diff --git a/CLA.md b/CLA.md deleted file mode 100644 index 6a8bf745..00000000 --- a/CLA.md +++ /dev/null @@ -1,88 +0,0 @@ -# Fizzy Contributor License Agreement (CLA) - -Thank you for your interest in contributing to fizzy (the "Project"), maintained -by Colton Franklin ("Maintainer"). This Contributor License Agreement ("CLA") -clarifies the intellectual property rights granted with each contribution. - -This CLA is adapted from the "inbound = outbound + relicense" pattern used by -many dual-licensed open-source projects. You retain ownership of your -contributions; this document only grants the Maintainer the rights needed to -distribute and dual-license the Project as a whole. - -**You** ("Contributor") agree to the following terms for any contribution you -submit (via pull request, patch, or any other means) to the Project. The -Maintainer accepts your contribution under these terms. - -## 1. Definitions - -- **"Contribution"** means any source code, documentation, asset, or other - work of authorship that you intentionally submit to the Project. -- **"Submit"** means any form of communication sent to the Maintainer or - Project, including pull requests, issues, patches, and electronic - discussion, but excluding communication explicitly marked "Not a - Contribution." - -## 2. Copyright License Grant - -You hereby grant to the Maintainer, and to recipients of software distributed -by the Maintainer, a perpetual, worldwide, non-exclusive, no-charge, -royalty-free, irrevocable copyright license to reproduce, prepare derivative -works of, publicly display, publicly perform, sublicense, and distribute your -Contribution and such derivative works **under any license terms, including -proprietary and commercial license terms.** This explicitly includes the -right to relicense your Contribution as part of the Project under different -terms (for example, alongside the Project's GNU GPL v3.0 license, under a -separate paid commercial license). - -You retain all right, title, and interest in your Contribution; this is a -license, not an assignment. - -## 3. Patent License Grant - -You hereby grant to the Maintainer and recipients of software distributed by -the Maintainer a perpetual, worldwide, non-exclusive, no-charge, royalty-free, -irrevocable (except as stated in this section) patent license to make, have -made, use, offer to sell, sell, import, and otherwise transfer your -Contribution, where such license applies only to those patent claims -licensable by you that are necessarily infringed by your Contribution alone -or by combination of your Contribution with the Project to which it was -submitted. - -If any entity institutes patent litigation against you or any other entity -(including a cross-claim or counterclaim in a lawsuit) alleging that your -Contribution, or the Project to which you have contributed, constitutes -direct or contributory patent infringement, then any patent licenses granted -to that entity under this CLA for that Contribution or Project shall -terminate as of the date such litigation is filed. - -## 4. Your Representations - -You represent that: - -1. Each of your Contributions is your original creation, or you have the - right to submit it under this CLA. -2. Your Contribution does not violate any third party's intellectual - property rights, contracts, or other obligations (including, if - applicable, any agreement with your employer). -3. If your employer has rights to intellectual property you create, you have - either (a) received permission to make Contributions on behalf of that - employer, (b) had your employer waive such rights for your Contributions, - or (c) had your employer also sign this CLA. - -You agree to notify the Maintainer if any of these representations becomes -inaccurate. - -## 5. No Obligation - -You are not expected to provide support for your Contributions, except to the -extent you desire to provide support. Unless required by applicable law or -agreed to in writing, you provide your Contributions on an "AS IS" basis, -without warranties or conditions of any kind, either express or implied. - -## 6. Acceptance - -You accept this CLA by submitting a pull request after this CLA is in place, -or by explicitly indicating agreement in a manner the Maintainer accepts -(for example, signing via [CLA Assistant](https://cla-assistant.io/) on a -pull request, or replying to an issue with the exact text "I have read the -CLA Document and I hereby sign the CLA"). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 5a48ddaa..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,36 +0,0 @@ -# Contributing to fizzy - -Thanks for your interest in contributing! - -## License & CLA - -fizzy is licensed under the [GNU General Public License v3.0](LICENSE). - -To keep the door open for the project to offer a separate commercial license -in addition to the GPL, **all contributors must sign the -[Contributor License Agreement](CLA.md)** before their pull request can be -merged. - -You retain copyright on your contributions — the CLA only grants the -maintainer (Colton Franklin) the rights needed to relicense the project as a -whole, including under future commercial terms. - -### How to sign - -A [CLA Assistant](https://cla-assistant.io/) bot is wired up to this -repository. The first time you open a pull request, it will post a comment -with a one-click sign-off link. After you sign once, subsequent PRs are -auto-checked against your signature — no further action needed. - -## Pull requests - -- Keep changes focused. One concern per PR. -- Match the style of the surrounding code. Run `zig build` locally before - pushing. -- Reference any related issue in the PR description. - -## Reporting issues - -Use the issue tracker for bug reports and feature requests. For bug reports, -include OS, fizzy version (visible in the title bar / `Help > About`), and -steps to reproduce. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..75f7222c --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,712 @@ +# Fizzy Modular-Plugin Refactor — Handoff (Phase 4 COMPLETE → Phase 5: runtime dylib plugins) + +## TL;DR + +We turned the monolithic editor into a **core shell + plugins** layout. **Phase 4 (compile-time +modular separation) is COMPLETE:** `core`, `pixelart`, and `workbench` are all decoupled build +modules; the shell imports plugins only via `@import("pixelart")` / `@import("workbench")` and +talks to them through the SDK vtable + `Host`/`EditorAPI` registries. All three configs green. + +**The next phase (Phase 5) is runtime dylib plugins** — desktop dynamic libraries +(macOS/Linux/Windows, `arm64` + `x86_64`), web static, built-ins bundled with the app. +See **"Phase 5 — Runtime dylib plugins"** below. Everything under "Phase 4 history" +further down is DONE reference material. + +--- + +### Phase 4 history (all DONE — reference) + +Phase 4 made `core` a standalone Zig module, then (Stages B–E) lifted the pixel-art editor fully +behind the plugin SDK, then (Stage W) did the same for workbench. + +**Stage A1–A3, B, C (full)** — `core` module; per-plugin settings, docs/tabs storage inversion, +save/pack/editor-action decoupling, platform detection, explorer pane lift, sprites bottom-panel lift. + +**Stage D — DONE** — module scaffold, `Globals` injection, Workspace decoupling, zero `fizzy.zig` +imports in plugin, `b.addModule("pixelart")` wired. + +**Stage E — polish complete** (see "Stage E polish — DONE" below): shell no longer imports +`pixelart.internal`; `pixelart_state` field access fully routed to lifecycle + vtable; +`Plugin.beginFrame` hook removes the last shell→`pixelart.render` poke; dead imports pruned. +**Sprite/atlas → `core` big rock: DONE** (verified — generic atlas type + sprite-draw +primitive + sprite-id index all in `core`; neither shell nor plugin reaches the other's atlas). + +**Dialog-registry lift — DONE** (see "Multi-plugin readiness"): the shell no longer names any +pixel-art dialog. `pixelart.dialogs` is gone from `src/editor` + `src/plugins/workbench`. + +**Workbench lift (Stage W1–W5) — DONE** (see "Stage W" below): workbench is now a real +`@import("workbench")` build module (`wireWorkbenchModule` in `build.zig`, native/web/test). +**Zero live `fizzy.*` refs in `src/plugins/workbench/**`** (was 225). Workspace/grouping/tab-drag +state moved onto the `Workbench` struct; doc-collection + folder/settings/etc. route through +`Globals.host` (EditorAPI) and `doc.owner`. Shell imports both plugins ONLY via +`@import("pixelart")` / `@import("workbench")`. + +> **Read this first if you're a fresh agent:** the **compile-time modular-separation phase is +> complete** — `core`, `pixelart`, `workbench` are all decoupled build modules; the only shell +> path-import into a plugin tree is the documented build-time `process_assets.zig → Atlas.zig`. +> Shell→plugin is now just the vtable/registry boundary plus the shell owning each plugin's +> state struct on `Editor` (`pixelart_state`, `workbench`) for lifecycle — the same arrangement +> for both. All three build configs are green. +> +> **Next big rock:** Phase 5 runtime dylib plugins — see **"Phase 5 — Runtime dylib plugins"** +> above. Optional polish first (5a): break workbench→pixelart compile-time link and route +> remaining `editor.workbench.*` field pokes (workbench Stage E). + +All three build configs are green: + +``` +zig build # native exe +zig build check-web # wasm +zig build test # unit/integration tests +``` + +Run all three after every stage. `zig build` for this repo currently needs to run outside +the sandbox (network/file access). + +--- + +## Phase 5 — Runtime dylib plugins (NEXT — not started) + +### Goal + +**One source, two link modes:** each plugin compiles from the same Zig sources, but the +link mode depends on the target: + +| Target | Link mode | Loader | +|--------|-----------|--------| +| macOS / Linux / Windows (`arm64` + `x86_64`) | **Dynamic** — plugin is a `.dylib` / `.so` / `.dll` | Host `dlopen`s at startup (built-ins) or on demand (3rd-party) | +| Web (`wasm32`) | **Static** — plugin is a Zig module linked into the exe | No runtime loader; same as today | + +Phase 4 proved the **vtable + `Host` registry boundary** is the right seam. Phase 5 makes +that boundary cross a real dynamic-library load on desktop without changing plugin logic. + +### Product decisions (locked for this phase) + +- **Built-in plugins always ship with Fizzy.** Pixelart, workbench, and future built-ins + (e.g. textedit) live in this repo under `src/plugins/`. We are **not** planning a + "shell-only" Fizzy distribution stripped of plugins. +- **Built-in dylibs are bundled, not separately versioned.** The release artifact is one + Velopack/update unit: the exe plus its built-in plugin dylibs at matching versions. + Velopack does **not** sign or distribute each plugin independently; plugin dylibs ride + inside the same app package the exe does. +- **3rd-party plugins are a later concern, but the architecture must allow them.** An + external Zig project should eventually be able to `@import` a published Fizzy plugin SDK, + write dvui-driven UI through the same `Plugin` vtable, build a dylib, and have Fizzy + load it at runtime — registering menus, sidebar views, bottom views, and doc handlers + through the same `Host` registries built-ins use today. A plugin store + hot-load path + is out of scope for the first Phase-5 milestones but should not be designed away. +- **Reference plugins to demonstrate complexity:** + - **pixelart** — full editor plugin: docs, save/dirty, explorer panes, bottom panel, + dialogs, pack jobs; consumes **workbench-api** for tabs/splits (inter-plugin service). + - **textedit** (future built-in) — lighter editor plugin for `.txt` / `.json` / `.atlas` + etc., coexisting in tabs beside pixel-art docs (see "Multi-plugin readiness"). + - **workbench** — infrastructure plugin (file tree, workspaces); likely stays a + built-in static or early-loaded dylib since it owns the center layout. + +### Dylib mechanism — Option 2: context injection (validated) + +The `spikes/shared-globals` spike ruled out **Mechanism A** (one shared `libdvui` / +`rdynamic` symbol interposition — globals are not auto-shared across the dylib boundary on +macOS two-level namespace, and the same applies on Linux/Windows). + +**Mechanism B (context injection) is the chosen approach:** + +- Host and plugin each compile their **own copy** of `dvui` + `sdk` + `core` (same pinned + Zig + source versions → identical struct layouts). +- Host owns the live `dvui.Window`, arena, backend, and GPU path. +- Before calling into a plugin's draw/tick hooks, the host **injects** the plugin-side + dvui globals (`current_window` per frame; `io` / `ft2lib` / `debug` at init — all + `pub var`, no dvui patch needed) with pointers into the host's live state. +- Cross-boundary vtable types (`Plugin`, `DocHandle`, `Host`, `EditorAPI`, workbench-api + `Api`, …) are normal Zig structs, not strict C-ABI — host and plugin are pinned to the + same SDK build. Only the **dlopen entry symbols** need `callconv(.c)`. +- Load-time **ABI version gate** rejects mismatched plugin builds before any vtable call. + +See `spikes/shared-globals/README.md` and `spikes/shared-globals/build.zig` for the +minimal host+plugin dylib harness. + +### What already exists (Phase 4 carry-over) + +| Piece | Location | Phase-5 role | +|-------|----------|--------------| +| Plugin vtable | `src/sdk/Plugin.zig` | Same shape static or dylib; hooks already optional fn pointers | +| Host registries | `src/sdk/Host.zig` | Menus / sidebar / bottom / center / settings — hot-load target | +| EditorAPI | `src/sdk/EditorAPI.zig` | Shell reach-through; plugins never import `fizzy.zig` | +| Globals injection | `src/plugins/*/src/Globals.zig` | Pattern for post-`dlopen` pointer wiring | +| Inter-plugin service | `Workbench.Api` in `src/plugins/workbench/src/Workbench.zig` | pixelart → workbench without compile-time coupling (goal) | +| Static registration | `Editor.postInit` | `workbench_mod.plugin.register` + `pixelart.plugin.register` — replace with loader on native | + +**No dylib build targets yet** — `build.zig` has no `addLibrary(.linkage = .dynamic)`. +Plugins are still compile-time modules on all targets. + +### Remaining Phase-4 polish (do before or alongside Phase-5a) + +These are not blockers for a spike, but should be cleared so built-in and 3rd-party +plugins share the same rules: + +1. **Break workbench → pixelart compile-time link (blocker for independent dylibs).** + - `build.zig` `wireWorkbenchModule` adds `pixelart` as a module dep. + - `workbench/src/files.zig` reads `pixelart.Globals.state.colors.palette` for file-row + tinting — the only live cross-plugin import in the workbench tree. + - Fix: register a file-row fill-color hook on **`Host`** (`registerFileRowFillColor`) that + pixelart contributes during `register()`; drop the `pixelart` import from the workbench + module. (Host registry chosen over workbench-api to avoid service init ordering and a + pixelart→workbench compile-time dep.) + +2. **Workbench "Stage E" — route shell `editor.workbench.*` field pokes.** + Pixelart Stage E is done (`pixelart_state` is lifecycle-only in `App.zig`). Workbench + still has ~24 direct `editor.workbench.` reaches in `Editor.zig` plus a few in + `Explorer.zig`, `Keybinds.zig`, `WebFileIo.zig`, `singleton_native.zig` (mostly + `open_workspace_grouping` — callers should use `editor.currentGroupingID()` instead). + Extend `EditorAPI` / thin `Editor` delegators so the shell never names workbench internals. + +3. **Minor hygiene** (non-blocking): `web_main.zig` force-imports `pixelart.widgets.FileWidget` + for wasm link; `fizzy.zig` globals (`app`, `editor`, `packer`) shrink as the loader owns + more lifecycle. + +### Phase-5 implementation plan (incremental; all three configs green after each step) + +Each step ends with `zig build`, `zig build check-web`, `zig build test`. + +#### 5a — Pre-dylib decoupling (Phase-4 tail) + +| Step | Work | Done when | +|------|------|-----------| +| **5a.1** | Break workbench→pixelart link (`Host.registerFileRowFillColor`; remove `pixelart` from `wireWorkbenchModule`) | `grep pixelart src/plugins/workbench` → 0; all configs green | +| **5a.2** | Workbench Stage E: route `editor.workbench.*` / `fizzy.editor.workbench.*` through EditorAPI | `grep 'editor\.workbench\.' src/` → lifecycle + delegators only | + +#### 5b — Dylib scaffolding (native only; web unchanged) + +| Step | Work | Done when | +|------|------|-----------| +| **5b.1** | SDK **export surface** — `src/sdk/dylib.zig` (`abi_version`, `RegisterStatus`, symbol names); `src/plugins/pixelart/dylib.zig` exports `fizzy_plugin_abi_version` / `fizzy_plugin_register`; `zig build pixelart-dylib` | ✅ Done | +| **5b.2** | **`build.zig` dual link** — add `addLibrary(.dynamic)` target for one plugin (start with pixelart or a minimal `plugins/hello` example); web root keeps static `@import("pixelart")` | Native builds `.dylib`/`.so`/`.dll` beside exe; web still static | +| **5b.3** | **Host loader** — `src/editor/PluginLoader.zig`; `Host.pluginById`; `FIZZY_PLUGIN_PATH`; `-Dstatic-pixelart` / `FIZZY_STATIC_PIXELART`; `zig build test-plugin-loader` | ✅ Done | +| **5b.4** | **Dvui context injection** — `sdk/dvui_context.zig`, `fizzy_plugin_set_dvui_context`, `Host.syncPluginDvuiContext` in frame loop | ✅ Done | + +Build all six native release triples (`x86_64`/`arm64` × macOS/Linux/Windows) once 5b.2 +lands; linkage suffixes differ (`.dylib` / `.so` / `.dll`) but the loader API is the same. + +#### 5c — Built-in plugins as bundled dylibs (desktop) + +| Step | Work | Done when | +|------|------|-----------| +| **5c.1** | Built-in pixelart dylib loaded by host on native; static on web; Editor routes via `pixelartPlugin()` / `host.pluginById` | ✅ Done | +| **5c.x** | dvui fingerprint gate (replaces version string) | ✅ Done — comptime FNV-1a over `@sizeOf` of boundary types (`Window`, `Debug`, `Vertex`, `Texture`, `TextureTarget`, `Rect.Physical`, `Id`) | +| **5c.2** | Built-in workbench dylib loaded by host on native; `workbenchPlugin()` / `workbench_files_view` routing | ✅ Done | +| **5c.3** | Install step bundles built-in dylibs next to exe (same `zig-out` / Velopack tree) | ✅ Done | + +Built-ins can remain **statically linked during 5b** and flip to dylib in 5c — the +`register()` path is identical either way. + +#### 5d — Reference plugins + 3rd-party path (later milestones) + +| Step | Work | Notes | +|------|------|-------| +| **5d.1** | **textedit** built-in plugin | Exercises multi-editor tabs, `fileTypePriority`, `registerBottomView`; forces "New > kind" chooser | +| **5d.2** | **Published plugin SDK** (`fizzy-plugin-sdk` or similar) | External Zig project: import SDK + dvui, implement vtable, `zig build` → dylib | +| **5d.3** | **User plugin directory** + discovery | ✅ Done — `Editor.loadUserPlugins` scans `/plugins//plugin.` on launch; ABI + dvui-fingerprint gated; built-in IDs always win; failures logged and skipped | +| **5d.4** | **Hot load** + plugin store | Reload dylib, refresh Host registries; trust/signing model TBD | + +### 3rd-party / distribution considerations (figure out later, don't block 5a–5c) + +- **Trust:** built-ins are co-signed with the app; 3rd-party plugins need a separate policy + (user opt-in, hash allowlist, dev-mode only, etc.) — not decided yet. +- **Velopack:** app updates replace the whole `zig-out` tree including built-in dylibs; no + per-plugin update channel for built-ins. +- **Version skew:** ABI gate + documented "built with Fizzy X.Y" requirement for 3rd-party + dylibs; plugin store would pin compatible versions. +- **Hot load:** `Host` registries already support append; unload needs vtable `deinit` + + registry removal + no dangling `DocHandle.owner` — design when approaching 5d.4. + +### Phase-5 sanity greps (add to the checklist) + +``` +# no cross-plugin compile-time imports (after 5a.1) +grep -rn '@import("pixelart")' src/plugins/workbench → 0 +grep -rn 'pixelart\.' src/plugins/workbench → 0 + +# shell workbench field pokes routed (after 5a.2) +grep -rn 'editor\.workbench\.' src/ → lifecycle/delegators only +grep -rn 'fizzy\.editor\.workbench\.' src/ → 0 + +# dylib entry exists (after 5b.1) +grep -rn 'fizzy_plugin_' src/sdk src/plugins → export symbols present + +# web stays static (always) +grep -rn 'DynLib\|dlopen' src/ → 0 on web code paths +``` + +### On-disk layout (locked) + +Fizzy already separates **install dir** from **user config** (`core/paths.zig` → +`configFolder()`; `App.zig` chdirs to the executable dir on native). Phase 5 keeps that +split and adds two plugin locations: + +| Kind | Path | Writable | Updated by | +|------|------|----------|------------| +| **Built-in dylibs** | `/plugins/.{dylib,so,dll}` | No (install tree) | Velopack / app update (same unit as exe) | +| **User / 3rd-party dylibs** | `/plugins//plugin.{dylib,so,dll}` | Yes | User / future plugin store | +| **Plugin settings** | `/settings.json` → `"plugins": { … }` | Yes | App (already via `Host.plugin_settings`) | + +`` is where the binary lives (and where the app chdirs on launch). `` +is the OS user config dir + `fizzy/` (e.g. `~/Library/Application Support/fizzy`, +`~/.config/fizzy`) — **not** beside the exe. + +**Loader search order (native):** + +1. Built-ins — fixed list from `{exe_dir}/plugins/.` +2. User plugins — scan `{config_folder}/plugins/*/plugin.` +3. Dev override — env var e.g. `FIZZY_PLUGIN_PATH` (optional, for local dylib hacking) + +Web: no loader; plugins stay statically linked into the wasm binary. + +Built-in dylibs ship inside the same Velopack package as the exe (no per-plugin signing or +update channel). User plugins survive app updates because they live under config, not install. + +Repo source tree `src/plugins/` is **build layout only** — unrelated to these runtime paths. + +### Where to begin (next session) + +**5c.1–5c.3** — done (built-in dylibs on native + Velopack packDir includes `plugins/`). **Next: 5d**. + +--- + +## Plugin directory layout (convention) + +Every plugin follows the same shape: + +``` +src/plugins// + module.zig # build module root / shell import surface + .zig # intra-plugin hub (sdk, core, Globals, shared types) + src/ # all implementation code +``` + +**pixelart** and **workbench** both use this layout now. + +| File | Role | +|------|------| +| `module.zig` | Compile-time module root; shell imports via `@import("pixelart")` / `@import("workbench")` | +| `pixelart.zig` / `workbench.zig` | Hub named after the plugin folder; files in `src/**` import as `../.zig` or `../../.zig` | +| `src/State.zig` (pixelart) / `src/Workbench.zig` (workbench) | Plugin runtime state struct (owned on `Editor`) | +| `src/Globals.zig` | Runtime injection — pixelart: `gpa`/`state`/`packer`; workbench: `gpa`/`host`/`workbench` | +| `src/plugin.zig` | Plugin registration + draw entry points | +| `src/deps/` | Third-party deps (`pixelart` only) | + +Both plugins keep their state struct on `Editor` (`editor.pixelart_state`, `editor.workbench`) +for lifecycle; plugin code reaches it + the Host through its `Globals`. + +### macOS case-insensitive rename protocol + +On APFS (default, case-insensitive), `PixelArt.zig` and `pixelart.zig` are the **same +file**. Never create `pixelart.zig` while `PixelArt.zig` is still in git — it silently +overwrites the state struct. + +**Two-step git rename (Option A):** + +```bash +git mv src/plugins/pixelart/PixelArt.zig src/plugins/pixelart/__legacy_remove__.zig +git rm -f src/plugins/pixelart/__legacy_remove__.zig +# now safe to add src/plugins/pixelart/pixelart.zig and State.zig +``` + +**Import paths inside `src/`:** + +- `src/foo.zig` → `@import("../pixelart.zig")` +- `src/widgets/bar.zig` → `@import("../../pixelart.zig")` +- View ids (`view_tools`, `view_sprites`) live in `src/plugin.zig` — import as + `@import("../plugin.zig")` from nested dirs, not through the hub. + +--- + +## What Stage C did (complete) + +### Part 1 — per-plugin settings (VSCode-style) + +Pixel-art-specific settings belong to the pixel-art plugin; the shell stores them opaquely. + +- **`SettingsSection`** in SDK; `Host` registry + `plugin_settings` blob store. +- **`EditorAPI`** vtable for shell reach-through (`arena`, `folder`, `paletteFolder`, …). +- **`Settings`** owns moved fields; `plugin.register` adds the "Pixel Art" section. +- Shell `Settings.serialize` splices `"plugins": { id: blob }` into settings.json. + +### Part 2 — docs/tabs storage inversion + +The shell no longer owns `Internal.File` values directly. + +- **`Docs.zig`**: plugin owns `files: HashMap(u64, Internal.File)`. +- **`Editor.open_files`**: `HashMap(u64, sdk.DocHandle)` — opaque handles with `ptr`/`id`/`owner`. +- **EditorAPI doc surface**: `activeDoc`, `docByIndex`, `docById`, `docIndex`, `openDocCount`, + `setActiveDocIndex`, `allocDocId`. +- Shell helpers: `fileFromDoc`, `docAt`, `fileAt`, `activeDoc`, `insertOpenDoc`, `closeDocumentResources`. +- Plugin repointed: `fizzy.pixelart.docs.activeFile(host)`, `host.docIndex` / `setActiveDocIndex`, + `host.allocDocId()`, `docs.fileById`, etc. +- **`State.docs`**: field + `docs.deinit` in teardown. + +### Part 3 — save/pack/editor-action decoupling + +Pixel-art dialogs and actions reach the shell through `host.*` / `EditorAPI`, not `fizzy.editor.*`. + +**EditorAPI additions** (all wired in `Editor.zig` shell vtable + `Host.zig` forwarders): + +`accept`, `cancel`, `copy`, `paste`, `transform`, `save`, `requestCompositeWarmup`, +`requestGridLayoutDialog`, `allocUntitledPath`, `createDocument`, `requestSaveAs`, +`requestWebSave`, `cancelPendingSaveDialog`, `setPendingCloseDocId`, `queueCloseAfterSave`, +`trackQuitSaveInFlight`, `resumeSaveAllQuit`, `abortSaveAllQuit`, `startPackProject`, +`isPackingActive`, `showSaveDialog`, `uiAtlas`, `explorerRect`, `explorerVirtualSize`, +`isMaximized`. + +### Part 4 — explorer pane + bottom-panel lift + +- **`tools_pane`**, **`sprites_pane`**, **`pinned_palettes`**, **`layers_ratio`** moved onto + `State` (were on shell `Explorer`). +- **`sprites_panel`** moved off `editor.panel.sprites` onto `State`; drawn via + `Globals.state.sprites_panel.draw()` from `plugin.zig`. + +### Part 5 — platform detection + +- **EditorAPI**: `isMacOS()`, `appliesNativeWindowOpacity()`. +- Plugin repointed: keybinds, window chrome opacity, `Settings.resolvedPanZoomScheme(settings, host)`. +- **Zero** live `fizzy.platform` / `builtin.os.tag` in `src/plugins/pixelart/**`. + +### Stage C sanity greps + +``` +grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (4 commented-out lines in Tools.zig, Project.zig) +grep -rn 'fizzy\.platform' src/plugins/pixelart → 0 +grep -rn 'fizzy\.backend\.' src/plugins/pixelart → check; native save dialogs go through host.showSaveDialog +``` + +--- + +## What Stage D has done so far + +### Module root — `src/plugins/pixelart/module.zig` + +Canonical export surface for the plugin tree. **`fizzy.zig`** re-exports through +`fizzy.pixelart_mod = @import("plugins/pixelart/module.zig")` instead of scattering +direct `@import("plugins/pixelart/…")` across the hub. + +Exports: `Globals`, `State`, `Settings`, `Docs`, `Tools`, `Transform`, `Project`, +`Colors`, `Packer`, `PackJob`, `plugin`, `dialogs.*`, `explorer.project`, `render`, +`sprite_render`, `algorithms`, on-disk types, `internal.*`. + +### Intra-plugin hub — `src/plugins/pixelart/pixelart.zig` + +Plugin files import this for `sdk`, `core`, `Globals`, shared types, and `internal.*`. +**Not** the build module root — that is `module.zig`. + +### Plugin state — `src/plugins/pixelart/State.zig` + +Renamed from `PixelArt.zig` / `PixelArt` struct → `State.zig` / `State`. + +### Globals injection — `src/plugins/pixelart/Globals.zig` + +Runtime pointers set once in `App.AppInit`: + +```zig +fizzy.pixelart_mod.Globals.gpa = allocator; +fizzy.pixelart_mod.Globals.state = fizzy.pixelart; +fizzy.pixelart_mod.Globals.packer = fizzy.packer; +``` + +Plugin tree now uses `Globals.allocator()` / `Globals.state` / `Globals.packer` — **zero** +remaining `fizzy.app.allocator` refs in `src/plugins/pixelart/**`. + +### Hub consolidation (partial) + +- **`fizzy.zig`**: `State`, `Packer`, `Internal`, on-disk types, `Tools`, `Transform`, + `PackJob`, `algorithms`, `render`, `sprite_render` all alias `pixelart_mod.*`. + Global `fizzy.pixelart: *State` kept for shell during migration. +- **`Editor.zig`**: removed public aliases `Colors`, `Project`, `Tools`, `Transform`; + uses `fizzy.Tools`, `fizzy.pixelart_mod.Project`, `fizzy.pixelart_mod.plugin.*`. +- **Shell imports rerouted** (via `fizzy.pixelart_mod`): + - `editor/dialogs/Dialogs.zig` → `dialogs.NewFile/Export/GridLayout/FlatRasterSaveWarning` + - `editor/dialogs/UnsavedClose.zig` → `dialogs.FlatRasterSaveWarning` + - `editor/explorer/Explorer.zig` → `explorer.project` +- **`Panel.zig`**: removed dead `Sprites` field/import. +- **Plugin import migration**: `bridge.zig` → `pixelart.zig`; `Globals.pixelart` → + `Globals.state`; subdirectory files use `../pixelart.zig`. + +### SDK module wired in `build.zig` + +`wireSdkModule` adds `@import("sdk")` to native, web, and test roots. `fizzy.zig` imports +sdk via `@import("sdk")` (not a duplicate file-path import). + +### SDK pane layout + workspace decoupling (done) + +- **`src/sdk/pane_layout.zig`** — shared `mainCanvasVbox` / `emptyStateCard` helpers. +- **`src/sdk/WorkbenchPane.zig`** — `WorkbenchPaneView { grouping, canvas_rect_physical }` + passed to sidebar `draw_workspace` hooks (plugins no longer cast back to `Workspace`). +- **`State.canvas_by_grouping`** — pixel-art owns per-pane `CanvasData`; `canvasForGrouping` / + `removeCanvasPane` replace the old `Workspace.plugin_view_state` opaque slot. +- **`plugin.zig`** — `drawDocument` uses `CanvasData.forGrouping`; `drawProjectView` uses + `sdk.WorkbenchPaneView` + `sdk.pane_layout`; no `fizzy` import. +- **`FileWidget.zig`** — `canvasData()` reads `Globals.state.canvas_by_grouping`; no `fizzy`. +- **`workbench/Workspace.zig`** — passes `WorkbenchPaneView` to `draw_workspace`; `deinit` + calls `fizzy.State.removeCanvasPane`; layout helpers delegate to `sdk.pane_layout`. + +### Runtime fixes (session) + +| Bug | Fix | +|-----|-----| +| Startup crash in `Tools.init` | Use `self.stroke_shape/size`; set `Globals` before `State.init` | +| Duplicate `Globals` module | `module.zig`: `pub const Globals = pixelart.Globals` | +| Crash opening multiple files | Resolve docs by `doc.id`, not cached `doc.ptr` | +| Crash on close with files open | `State.persistProject()` before `editor.deinit` | + +### Build module wired (done) + +- **`wirePixelartModule`** in `build.zig` — native, web, and test roots import + `@import("pixelart")` with deps: `core`, `sdk`, `dvui`, `assets`, `zip`, `zstbi`, + `msf_gif`, `icons`, `backend` (native/test only). +- **`fizzy.zig`** — `pixelart_mod = @import("pixelart")` (no path import). +- **Zero `@import("fizzy.zig")` in plugin** — last shell leaks removed: + - `dialogs/dimensions_label.zig` + `web_file_io.zig` (plugin-local helpers) + - `EditorAPI.setExplorerNewFilePath` (replaces `Explorer.files.new_file_path` touch) + - `web_main.zig` probes `FileWidget` via `@import("pixelart")` + +### Still direct-importing pixel-art files (shell) + +``` +process_assets.zig (repo root) → Atlas.zig (build-time, std-only — OK, separate compilation) +src/web_main.zig → FileWidget.zig force-import (wasm link — migrate later) +``` + +--- + +## Stage D — remaining work — DONE (historical) + +All items below were completed in Stage D/E/W. Kept for archaeology only. + +1. ~~Route straggler shell path imports through `pixelart_mod` / `@import("pixelart")`.~~ DONE +2. ~~Wire `b.addModule("workbench", …)`.~~ DONE (Stage W5) +3. ~~Stage E cleanup in shell `Editor.zig`.~~ DONE (pixelart); workbench Stage E → Phase 5a.2 + +Do **not** re-introduce a duplicate `@import("plugins/pixelart/module.zig")` from both +`App.zig` and `fizzy.zig` via a third path; shell code uses `@import("pixelart")` / +`@import("workbench")` build modules. + +--- + +## Stage E — strip pixel-art names from shell hubs — COMPLETE + +**Done this session:** +- **`Editor.pixelart_state`** — shell reaches plugin state through the editor, not scattered `fizzy.pixelart.*` (53 → 0 direct field accesses in shell code; `fizzy.pixelart` global remains only in `App.zig` lifecycle). +- **Plugin vtable hooks** — `tickKeybinds`, `processRadialMenuInput`, `radialMenuVisible`, `drawRadialMenu`; radial menu + tool keybind ticks moved to `pixelart/src/radial_menu.zig` and `keybind_ticks.zig`. +- **Shell `Keybinds.tick`** — pixel-art handlers removed (shell-only binds remain). +- **`editor/dialogs/Dialogs.zig`** — imports `@import("pixelart")` directly. +- **Explorer, UnsavedClose, files, Workspace** — use `fizzy.editor.pixelart_state` or `@import("pixelart")`. +- **`fizzy.zig` hub trimmed** — removed re-export aliases (`Tools`, `Internal`, `render`, `Packer`, on-disk types, …). Shell/workbench/tests/web probes now `@import("pixelart")` (or `fizzy.pixelart_mod` in integration tests). `fizzy.zig` keeps only `pixelart_mod` alias + lifecycle globals (`app`, `editor`, `packer`, `pixelart`). +- **`App.zig`** — wires `pixelart.Globals` directly (not `fizzy.pixelart_mod.Globals`). +- **Copy/paste + pack/project** — moved to `pixelart/src/clipboard.zig` and `pack_project.zig`; plugin vtable hooks (`copy`, `paste`, `startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`). Shell `Editor` delegates; `setProjectFolder` uses plugin `persistProjectFolder` / `reloadProjectFolder`. +- **Transform + doc registry** — `transform_op.zig` + `docs_registry.zig`; vtable hooks (`transform`, `registerOpenDocument`, `documentPtr`, `documentByPath`, `unregisterDocument`). Shell `fileFromDoc` / `insertOpenDoc` / `fileById` route through `doc.owner`; no direct `pixelart_state.docs` access in `Editor.zig`. +- **`fizzy.pixelart` global removed** — single ownership on `Editor.pixelart_state` + `Globals.state`; `App.zig` alloc/deinit via `fizzy.editor.pixelart_state` only. +- **DocHandle at workbench boundary** — `doc_bridge.zig` + plugin vtable metadata hooks (`bindDocumentToPane`, `documentGrouping`, `documentPath`, `setDocumentPath`, save/dirty indicators, …). `Workspace.zig` + `files.zig` use `DocHandle` + `doc.owner` only (no `Internal.File`). Shell helpers `docFromPath`, `docPath`, `setDocGrouping`, `bindDocToPane`; `fileFromDoc`/`fileById` are shell-internal. +- **Menu/Infobar off `activeFile()`** — `Menu.zig` + `Infobar.zig` route through `activeDoc()` + plugin hooks (`canUndo`/`canRedo`, `documentHasRecognizedSaveExtension`, `drawDocumentInfobar`). Active-doc infobar UI moved to `pixelart/src/infobar_status.zig`. Shell save/keybind paths (`save`, `saveAll`, quit-save-all, `UnsavedClose`) use `DocHandle` + owner hooks. +- **Shell `Internal.File` removed** — `Editor.zig` no longer types `*Internal.File` (removed `activeFile`, `fileFromDoc`, `fileById`, `getFile`, …). Document create/load/save-as routed through plugin vtable + `doc_lifecycle.zig` (`createDocument`, `saveDocumentAs`, `documentDefaultSaveAsFilename`, frame ticks, accept/cancel/delete). `insertOpenDoc` takes `*anyopaque` + id; `newFile` returns `DocHandle`; `openFileFromBytes` returns doc id. `FileLoadJob` uses opaque staging buffer via `Plugin.allocDocumentBuffer`. Save-queue worker owned by plugin (`initPlugin`/`deinit`). + +**Stage E polish — DONE:** +- ✅ Removed dead `Editor.closeReference` (referenced a non-existent `open_references` + field + `Internal.Reference` type; survived only via Zig lazy analysis). With it gone, + the `const Internal = pixelart.internal;` import is dropped — **shell no longer imports + `pixelart.internal` at all.** +- ✅ `editor.pixelart_state` direct field access already routed away: `pixelart_state` + now appears only as the `Editor` field declaration + `App.zig` lifecycle + (create/init/persist/deinit/destroy). No shell member access remains. +- ✅ **`Plugin.beginFrame` vtable hook** — shell no longer pokes `pixelart.render.frame_index` + directly. `Editor.frame` now calls `plugin.beginFrame()` for every registered plugin; the + pixel-art impl advances its own composite-cache frame clock. **No `pixelart.render` in shell.** +- ✅ Removed dead `pixelart`/`Packer` imports from `editor/panel/Panel.zig`. +- ✅ Removed dead `pixelart.explorer.project` re-export from `editor/explorer/Explorer.zig` + (the project view is contributed via `Host.registerSidebarView`, not the shell hub). +- ✅ Removed dead `Plugin.drawBottomPanel` / `drawExplorerPane` vtable hooks — superseded by + the `registerSidebarView` / `registerBottomView` registries (see "Multi-plugin readiness"). + +- ✅ **Dialog-registry lift** (see "Multi-plugin readiness"): all pixel-art dialogs lifted off + the shell hub onto plugin vtable hooks. `editor/dialogs/Dialogs.zig` no longer imports + `pixelart`; owns only shell-level dialogs (UnsavedClose, AppQuitUnsaved, AboutFizzy, Web*). + +**Shell → plugin surface now (grep `pixelart\.X` in `src/editor` + `src/plugins/workbench`):** +`pixelart.plugin` ×15 (the vtable boundary — intended), `pixelart.State` ×2, +`pixelart.Globals` ×2, `"pixelart.menu.edit"` ×1 (a registered-menu **id string**, not a +symbol ref). **No concrete pixel-art type (dialogs/render/explorer/Packer) is named in the +shell anymore** — only the plugin vtable boundary + lifecycle. + +--- + +## Multi-plugin readiness (context for the upcoming **textedit** plugin) + +> Direction (user, 2026-06-19): a textedit plugin will render `.txt`/`.atlas`/`.json` etc., +> coexisting in tabs/splits beside pixel-art docs. The bottom panel should likewise host +> per-plugin tabs (a console plugin one day). **This is NOT current scope** — captured here +> so the decoupling doesn't bake in single-plugin assumptions. + +**Audit result (this session): the architecture is already positioned for all of it.** + +| Concern | Mechanism today | textedit slots in by | +|---------|-----------------|----------------------| +| Which plugin owns an opened file | `Host.pluginForExtension(ext)` picks lowest `fileTypePriority` across **all** plugins (`Host.zig`) | registering `.txt/.atlas/.json` with a priority | +| Per-document ops (save/dirty/undo/path/grouping/…) | all route through `DocHandle.owner` vtable (opaque handle; shell never inspects `ptr`) | implementing the doc vtable hooks | +| Rendering a doc into a tab/split | `Workspace.zig` calls `doc.owner.drawDocument(doc)` — type-agnostic | implementing `drawDocument` | +| Sidebar/explorer panes | `Host.registerSidebarView(.{id,owner,title,draw[,draw_workspace]})`; shell renders the set (`Sidebar.zig`) | calling `registerSidebarView` | +| **Bottom panel tabs** | `Host.registerBottomView(.{id,owner,title,draw})`; `Panel.zig` draws a **tab strip when >1 view** + active-view get/set on `Host` | calling `registerBottomView` (a console is just another bottom view) | +| Menus | `Host.registerMenu` + `contributeMenu` | registering its menus | + +So tabs/splits and multi-plugin bottom panels are **already** registry-driven, not +pixelart-hardcoded. No corner-painting risk found. + +**Dialogs — lifted (was the one single-plugin seam, now DONE).** All pixel-art dialog launches +moved out of the shell hub onto the plugin; the shell never names a plugin dialog: + +- **Doc-scoped dialogs** route through `DocHandle.owner` vtable hooks (added to `sdk/Plugin.zig`): + - `requestGridLayoutDialog(doc)` — shell `Editor.requestGridLayoutDialog` resolves the active + doc and dispatches; launch + `presetFromFile` now live in `dialogs/GridLayout.request`. + Removed the old `prepareGridLayoutDialog` hook and the `EditorAPI.requestGridLayoutDialog` + round-trip (plugin `CanvasData` calls `GridLayout.request` directly now). + - `requestFlatRasterSaveWarning(doc, mode, from_save_all_quit)` — `mode` is the new SDK enum + `Plugin.FlatRasterSaveMode {editor_save, save_and_close}`. The save/quit flag is now captured + per-dialog in a `_flat_raster_from_quit` data slot instead of an externally-reset module var, + so `Editor.abortSaveAllQuit` no longer pokes dialog state. +- **Type-selecting dialog** (not doc-scoped): `Host.requestNewDocument(parent_path, id_extra)` + dispatches to the first plugin advertising `requestNewDocumentDialog` (vtable). Shell + `Editor.requestNewFileDialog` and `workbench/files.zig` "New File…" call the Host method; + launch lives in `dialogs/NewFile.request`. + **TODO(multi-plugin):** with textedit registered, "New File" is ambiguous — turn this into a + typed `New > ` chooser (each editor plugin contributes a new-doc kind) instead of + first-provider dispatch. The seam (shell decoupled from the dialog impl) is already in place. + +Dead dialog re-exports removed in the same pass: `Dialogs.Export`, `Dialogs.drawDimensionsLabel` +(both had zero shell callers). + +--- + +## Stage W — workbench lift — COMPLETE (signed off 2026-06-19) + +Workbench was the last "half-shell" plugin: it started this stage at **225 `fizzy` refs** +(163 `fizzy.editor`) across `files.zig`, `Workspace.zig`, `Workbench.zig`, `FileLoadJob.zig`, +`plugin.zig`, with no state-injection (`plugin.state = undefined`, draw hooks calling +`fizzy.editor.*`), the `Workbench` struct on `Editor`, and tab order living in +`Editor.open_files` (mutated in place via `std.mem.swap`). After W1–W5 below: +**zero live `fizzy.*` refs remain** (comments only), workbench is a `@import("workbench")` +build module, and all three configs are green. Verified 2026-06-19. + +**Plan (mirrored pixelart Stage C–E), each stage built all 3 configs green:** + +- **W1 — host-injection seam + doc-collection routing — DONE.** Added + `workbench/src/Globals.zig` (`host: *sdk.Host`, `gpa`), injected in `App.zig` (path import + until W5). Added `EditorAPI.swapDocs(a,b)` primitive (+ Host forwarder + shell impl) — the + only mutation of open-doc *order* plugins do; replaces workbench's in-place `std.mem.swap` + on `open_files`. Converted in `Workspace.zig` + `files.zig`: `open_files.count/.values().len` + → `Globals.host.openDocCount()`, `open_files.values()[i]`/`docAt` → `docByIndex`, + `open_files.getIndex` → `docIndex`, `setActiveFile` → `setActiveDocIndex`, + `fizzy.editor.host` → `Globals.host`. **Workbench `fizzy.editor` refs: 163 → 106.** +- **W2 — workspace/grouping ownership — DONE.** Moved `workspaces`, `open_workspace_grouping`, + `grouping_id_counter`, `tab_drag_from_tree_path`, `file_tree_data_id` onto `Workbench`; + added `Globals.workbench`, `workbench_layout.zig` (`rebuildWorkspaces`/`drawWorkspaces`), + and `Plugin.removeCanvasPane` (pixelart implements; `Workspace.deinit` iterates host plugins). + Shell `Editor` delegates `activeDoc`/`setActiveFile`/`rebuildWorkspaces`/`drawWorkspaces`/ + grouping helpers through `editor.workbench`. Workbench plugin code uses `Globals.workbench` + for workspace state; `setDocGrouping` → `doc.owner.setDocumentGrouping` in tab-drag paths. +- **W3 — remaining `fizzy.editor.*` → EditorAPI/Host — DONE.** Extended `EditorAPI`/`Host` + with doc/file ops (`docFromPath`, `openFilePath`, `openOrFocusFileAtGrouping`, + `closeDocById`), project folder (`setProjectFolder`, `closeProjectFolder`, `isPathIgnored`, + `recentFolderCount`/`recentFolderAt`, `openInFileBrowser`), explorer state + (`explorerViewportWidth`, `explorerBranchIsOpen`, `setExplorerBranchOpen`), and + `drawWorkspaces`. Workbench `files.zig`/`Workspace.zig`/`Workbench.zig`/`plugin.zig` + now route through `Globals.host` + `Globals.workbench`; zero runtime `fizzy.editor` + refs remain in workbench draw paths (comments only). +- **W4 — `fizzy.dvui`/`fizzy.app`/`fizzy.math`/`fizzy.backend` → sdk/core — DONE.** + Workbench hub (`workbench.zig`) re-exports `wdvui` (= `core.dvui`), `math`, `atlas`, + `platform`, `Sprite`, `perf`. Plugin sources use `Globals.allocator()` instead of + `fizzy.app`; native open dialogs via `host.showOpenFolderDialog`/`showOpenFileDialog`. + `workbench-api` service ctx is `*Host` (no `fizzy.Editor` in workbench). +- **W5 — `b.addModule("workbench")` + shell `@import("workbench")` — DONE.** + `wireWorkbenchModule` in `build.zig` (native, web, test). `Editor.zig`/`App.zig`/ + `Explorer.zig` import the module; path imports removed. + +--- + +## Next big rock: sprite / atlas → `core` — DONE + +End-state achieved. Verified this session: + +- **`core.Atlas`** (`src/core/Atlas.zig`) — generic atlas type, `loadSpritesFromBytes`. +- **`core.atlas`** (`src/core/generated/atlas.zig`) — generated sprite-id index + (`sprites.logo_default`, …). `fizzy.atlas = core.atlas`. +- **`core.Sprite.draw`** — the "draw sprite N" primitive. +- **Shell** holds its own static atlas instance (`editor.atlas`, loaded via + `core.Atlas.loadSpritesFromBytes`) for logo/icons and exposes it to plugins as + `EditorAPI.UiSprite`. Draws via `core.Sprite.draw`. +- **Plugin** consumes `core.Atlas`/`core.Sprite` for its own rendering (composites, + reflections, `water_surface`) and builds its own packed `internal/Atlas.zig` at pack time. +- **Neither side reaches the other's atlas** — `grep 'editor.atlas|fizzy.atlas' src/plugins/pixelart/src` → 0. +- Workbench draws the logo via `Globals.host.uiAtlas()` (not `fizzy.editor.atlas`). + +--- + +## What `core` is (Stage A3 — unchanged) + +`src/core/` is a standalone module; never imports `src/fizzy.zig`. See prior handoff +sections for allocator injection, trackpad hook, dialog chrome state, build wiring, and +the **build-script file-ownership trap** (`process_assets.zig` → std-only `Atlas.zig`). + +**macOS case-insensitive FS gotchas:** +- `sprite.zig` vs `Sprite.zig` → use `sprite_render.zig`. +- `pixelart.zig` vs `PixelArt.zig` / `State.zig` → use `module.zig` for the build module + root; use the two-step git rename when introducing `pixelart.zig` hub. + +--- + +## Key paths + +| Path | Role | +|------|------| +| `HANDOFF.md` | This file | +| `spikes/shared-globals/` | Dylib + dvui context-injection spike (Mechanism B) | +| `src/sdk/dvui_context.zig` | Mechanism B — inject host dvui globals into plugin dylib copy | +| `src/sdk/dylib.zig` | Dylib ABI version + entry symbol names (`fizzy_plugin_*`) | +| `src/plugins/pixelart/dylib.zig` | Pixelart dynamic-library root (exports only) | +| `src/plugins/workbench/dylib.zig` | Workbench dynamic-library root (exports only) | +| `src/sdk/Plugin.zig` | Plugin vtable; dylib entry wraps `register()` | +| `src/plugins/pixelart/module.zig` | Pixel-art build module root | +| `src/plugins/pixelart/pixelart.zig` | Pixel-art intra-plugin hub | +| `src/plugins/pixelart/src/` | Pixel-art implementation tree | +| `src/plugins/workbench/module.zig` | Workbench build module root | +| `src/plugins/workbench/workbench.zig` | Workbench intra-plugin hub | +| `src/plugins/workbench/src/` | Workbench implementation tree | +| `src/sdk/EditorAPI.zig`, `Host.zig` | Full shell API surface | +| `src/editor/Editor.zig` | Shell; `DocHandle`-only at UI boundary; no `Internal.File` | +| `src/fizzy.zig` | App hub; mid-migration to `pixelart_mod` re-exports | +| `process_assets.zig` | Build-time asset atlas generator (repo root, beside `build.zig`) | +| `src/backend/` | Platform backend: native/web stubs, singleton, auto-update, objc, MSVC shim | + +--- + +## State of the tree + +**Phase 4 committed** through the workbench lift (`stage w4` + follow-up). **Phase 5a +(5a.1–5a.2) complete** — plugins decoupled; shell workbench field pokes routed. + +Sanity greps (Phase-5 targets in **"Phase 5 sanity greps"** above): + +``` +# pixelart — fully decoupled from fizzy +grep -rn 'fizzy\.editor\.' src/plugins/pixelart → 0 live (comments only) +grep -rn '@import.*fizzy' src/plugins/pixelart → 0 + +# workbench — decoupled from fizzy and pixelart (5a.1 done) +grep -rn 'fizzy\.' src/plugins/workbench/src → comments only, 0 live +grep -rn 'pixelart' src/plugins/workbench → 0 +grep -rn '@import("workbench")' src/editor src/App.zig → module import (no path imports) + +# shell workbench field pokes routed (5a.2 done) +grep -rn 'fizzy\.editor\.workbench\.' src/ → 0 +grep -rn 'editor\.workbench\.' src/ → lifecycle + Editor delegators only (Editor.zig, App.zig Globals inject) + +# shell imports plugins only via build modules; only build-time exception: +grep -rn 'plugins/.*/src' src/ *.zig (excl. src/plugins) → process_assets.zig → Atlas.zig +``` + +All three configs green: `zig build`, `zig build check-web`, `zig build test`. diff --git a/build.zig b/build.zig index 48476dd6..61a6daf6 100644 --- a/build.zig +++ b/build.zig @@ -1,139 +1,16 @@ const std = @import("std"); -const zip = @import("src/deps/zip/build.zig"); +pub const plugin = @import("plugin_sdk.zig"); const dvui = @import("dvui"); const velopack = @import("velopack_zig"); -const content_dir = "assets/"; - -const ProcessAssetsStep = @import("src/tools/process_assets.zig"); - -const update = @import("update.zig"); -const GitDependency = update.GitDependency; -fn update_step(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { - const deps = &.{ - GitDependency{ - // zig_objc - .url = "https://github.com/foxnne/zig-objc", - .branch = "main", - }, - GitDependency{ - // zigwin32 (kristoff-it fork has the zig 0.16 fix branch) - .url = "https://github.com/kristoff-it/zigwin32", - .branch = "fix/zig16", - }, - GitDependency{ - // icons - .url = "https://github.com/foxnne/zig-lib-icons", - .branch = "dvui", - }, - GitDependency{ - // dvui - .url = "https://github.com/foxnne/dvui-dev", - .branch = "main", - }, - }; - try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps); -} - -/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other. -/// Uses `arm64` (not `aarch64`) for Apple Silicon / arm64 Linux and Windows to match the six release triples. -/// -/// Segment separator is `-` only: `vpk pack --channel` is merged into filenames that get parsed as NuGet -/// versions (e.g. `1.2.3--full.nupkg`), and NuGet prerelease labels must not contain `_`. -fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 { - const arch_name: []const u8 = switch (rt.result.cpu.arch) { - .x86_64 => "x86-64", - .aarch64 => "arm64", - else => @tagName(rt.result.cpu.arch), - }; - const os_name: []const u8 = switch (rt.result.os.tag) { - .windows => "windows", - .linux => "linux", - .macos => "macos", - else => @tagName(rt.result.os.tag), - }; - const base = b.fmt("{s}-{s}", .{ arch_name, os_name }); - if (std.mem.indexOfScalar(u8, base, '_') == null) - return base; - const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM"); - @memcpy(buf, base); - for (buf) |*byte| { - if (byte.* == '_') byte.* = '-'; - } - return buf; -} - -/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native" -/// (`target.query.isNative()` is false). Do not set the root `b.sysroot` for that: it skews -/// the main link (objc, libc paths). Forward include / framework / lib paths into dvui instead. -const MacosSdlPaths = struct { - include: std.Build.LazyPath, - framework: std.Build.LazyPath, - lib: std.Build.LazyPath, -}; - -fn resolveMacosSdkPath(b: *std.Build) ![]const u8 { - if (b.graph.environ_map.get("SDKROOT")) |sdk| { - const trimmed = std.mem.trim(u8, sdk, " \t\r\n"); - if (trimmed.len > 0) { - return b.dupePath(trimmed); - } - } - - const argv: []const []const u8 = &.{ - "xcrun", - "--sdk", - "macosx", - "--show-sdk-path", - }; - const run = try std.process.run(b.allocator, b.graph.io, .{ - .argv = argv, - .stdout_limit = std.Io.Limit.limited(4096), - .stderr_limit = std.Io.Limit.limited(4096), - }); - defer { - b.allocator.free(run.stdout); - b.allocator.free(run.stderr); - } - switch (run.term) { - .exited => |code| if (code != 0) { - std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code}); - return error.MacosSdkPath; - }, - else => { - std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{}); - return error.MacosSdkPath; - }, - } - const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n"); - if (path.len == 0) return error.MacosSdkPath; - return b.dupePath(path); -} - -fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths { - if (target.result.os.tag != .macos) return null; - if (b.graph.host.result.os.tag != .macos) return null; - if (target.query.isNative()) return null; - - const sdk = try resolveMacosSdkPath(b); - return MacosSdlPaths{ - .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) }, - .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }, - .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) }, - }; -} +const ProcessAssetsStep = @import("process_assets.zig"); pub fn build(b: *std.Build) !void { const windows_msvc_libc_opt = b.option([]const u8, "windows-msvc-libc", "zig libc manifest for *-windows-msvc when cross-compiling; forwarded by packageall for Windows children") orelse null; - // Default depends on host+target and is computed below once `target` is resolved. - // Pass `-Dfetch-msvc=false` on a Windows host to opt out of the auto-download and - // fall back to Zig's system-MSVC auto-detection (if you have Visual Studio installed). - const fetch_msvc_opt = b.option(bool, "fetch-msvc", "If *-windows-msvc libc is missing under .velopack-msvc/, run msvcup-setup first (downloads MSVC+SDK; requires network). Defaults to true on Windows hosts targeting *-windows-msvc."); + const fetch_msvc_opt = b.option(bool, "fetch-msvc", "If *-windows-msvc libc is missing under .velopack-msvc/, run msvcup-setup first (downloads MSVC+SDK; requires network). Defaults to true on Windows hosts targeting *-windows-msvc.") orelse null; - // macOS `vpk pack` codesigning / notarization. Optional: when omitted, packaging produces an - // unsigned bundle. Set all three to sign + notarize a release build. const macos_sign_app_identity = b.option([]const u8, "macos-sign-app", "macOS codesign identity for the app bundle (e.g. 'Developer ID Application: NAME (TEAMID)')") orelse b.graph.environ_map.get("FIZZY_MACOS_SIGN_APP"); const macos_sign_install_identity = b.option([]const u8, "macos-sign-installer", "macOS codesign identity for the installer pkg (e.g. 'Developer ID Installer: NAME (TEAMID)')") orelse @@ -142,1070 +19,23 @@ pub fn build(b: *std.Build) !void { b.graph.environ_map.get("FIZZY_MACOS_NOTARY_PROFILE"); const target = b.standardTargetOptions(.{}); - // Artifacts install to `zig-out/-/` (e.g. arm64-macos, x86-64-windows). Pass `-Dtarget=…` as usual. const optimize = b.standardOptimizeOption(.{}); - const macos_sdl_paths = try macosSdlPathsForExplicitTarget(b, target); - const zig_out_subdir = zigOutSubdirForTarget(b, target); - const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir }; - - const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc; - const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows; - - // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the - // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection - // of a system Visual Studio install picks up whatever's currently installed, which - // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is - // used on macOS/Linux cross-compile hosts, so all three triples land on the same - // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an - // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely. - const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null); - const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{ - .explicit_path = windows_msvc_libc_opt, - .install_dir_name = ".velopack-msvc", - .fetch_if_missing = fetch_msvc, - }); - - var effective_win_libc: ?[]const u8 = win_libc.libc_path; - if (effective_win_libc == null) { - if (cross_win_msvc) effective_win_libc = b.libc_file; - } - - // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release - // packaging (`zig build package`) still links Velopack when the ABI supports - // it via a second compile, so `zig build` / `run` / `test` never pull dotnet - // or the static Velopack lib unless you ask. Windows *-gnu targets are - // unchanged (no Velopack prebuilt for that ABI). - const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc); - const velopack_enabled = b.option( + const plugin_sdk = b.option( bool, - "velopack", - "Link Velopack runtime in the install/run exe (auto-update). Default: false. `package` still produces a Velopack-linked binary when supported.", + "plugin_sdk", + "Export core/sdk modules for third-party plugin builds; skips the fizzy app", ) orelse false; - - if (velopack_enabled and !velopack_supported_for_target) { - std.log.err( - "-Dvelopack=true is unsupported for target ABI {s}: Velopack on Windows requires -Dtarget=x86_64-windows-msvc or -Dtarget=aarch64-windows-msvc.", - .{@tagName(target.result.abi)}, - ); - return error.WindowsMsvcAbiRequired; - } - - // Fail loudly when the *-windows-msvc target has no headers/libs to compile against. - // On a non-Windows host this happens whenever `.velopack-msvc/` is missing and the - // user didn't pass `-Dfetch-msvc` or `-Dwindows-msvc-libc=…`. On a Windows host the - // auto-fetch default makes this unreachable unless the user explicitly opted out - // with `-Dfetch-msvc=false` — in which case Zig falls back to system Visual Studio - // auto-detection, which we can't validate here. - const velopack_required_fail: ?*std.Build.Step = if (cross_win_msvc and effective_win_libc == null) - &b.addFail( - \\*-windows-msvc needs MSVC + Windows SDK headers/libs. - \\ One-shot install (macOS/Linux/Windows): zig build msvcup-setup - \\ Then: zig build package -Dtarget=x86_64-windows-msvc (auto-uses .velopack-msvc/zig-libc-x64.ini) - \\ Or auto-download in this build: add -Dfetch-msvc (default on Windows hosts; forwards through packageall) - \\ Or pass: --libc path.ini / -Dwindows-msvc-libc=path.ini - ).step - else - null; - - const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false; - - const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file"); - - // GitHub repo URL baked into the binary so Velopack's auto-update can find - // the latest release via the GitHub Releases API. Override at build time - // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env - // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing. - const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy"; - - // Comma-separated fallback repo URLs checked (in order) after `app_repo_url` - // yields no update. Lets a build survive a repo move/rename: ship a binary - // whose primary points at the new home and whose fallback points at the old - // one (where the transitional release is published), then transfer the repo. - // Empty by default (no fallback). - const app_repo_url_fallback = b.option([]const u8, "repo-url-fallback", "Comma-separated fallback GitHub repo URLs for Velopack auto-update, tried after -Drepo-url") orelse ""; - - var version_owned: ?[]u8 = null; - defer if (version_owned) |buf| b.allocator.free(buf); - - const app_version: []const u8 = if (app_version_opt) |v| v else blk: { - const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); - version_owned = raw; - break :blk std.mem.trimEnd(u8, raw, "\r\n"); - }; - - const build_opts = b.addOptions(); - build_opts.addOption([]const u8, "app_version", app_version); - build_opts.addOption([]const u8, "app_repo_url", app_repo_url); - build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); - build_opts.addOption(bool, "velopack_enabled", velopack_enabled); - - const step = b.step("update", "update git dependencies"); - step.makeFn = update_step; - - const msvcup_before_compile = velopack.addMsvcupSetupStep(b, ".velopack-msvc"); - const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)"); - msvcup_setup_step.dependOn(&msvcup_before_compile.step); - - const zip_pkg = zip.package(b, .{}); - - const accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off; - - const assetpack = @import("assetpack"); - const assets_module = assetpack.pack(b, b.path("assets"), .{}); - - // Generated atlas / asset stubs (`src/generated/*.zig`) are imported - // unconditionally by `fizzy.zig`, so the process-assets step has to - // run before any target that touches fizzy.zig — exe, integration - // tests, etc. - const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/generated/"); - const process_assets_step = b.step("process-assets", "generates struct for all assets"); - process_assets_step.dependOn(&assets_processing.step); - - // --------------------------------------------------------------- - // Web (wasm) build — entirely separate from the native exe so it can't disturb - // packaging / SDL / Velopack paths. `zig build web` produces `zig-out/web/{web.wasm, - // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. - // - // Checkpoint A: minimal placeholder app, no fizzy editor code yet. Later checkpoints - // will incrementally pull fizzy modules in, gating each native-only path behind a - // `arch != .wasm32` check. - // --------------------------------------------------------------- - { - const web_target = b.resolveTargetQuery(.{ - .cpu_arch = .wasm32, - .os_tag = .freestanding, - .cpu_features_add = std.Target.wasm.featureSet(&.{ - .atomics, - .multivalue, - .bulk_memory, - }), - }); - - const dvui_web_dep = b.dependency("dvui", .{ - .target = web_target, - .optimize = optimize, - .backend = .web, - .freetype = false, - }); - - const web_exe = b.addExecutable(.{ - .name = "web", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/web_main.zig"), - .target = web_target, - .optimize = optimize, - .link_libc = false, - .single_threaded = true, - .strip = optimize == .ReleaseFast or optimize == .ReleaseSmall, - }), - }); - web_exe.entry = .disabled; - web_exe.root_module.addImport("dvui", dvui_web_dep.module("dvui_web")); - web_exe.root_module.addImport("web-backend", dvui_web_dep.module("web")); - - // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm - // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its - // own — without this line our trackpad pinch entry point would compile cleanly but - // be missing from `instance.exports`, and the JS bootstrap in `web/shell.html` - // would never be able to forward pinch deltas into the canvas widget. - web_exe.root_module.export_symbol_names = &[_][]const u8{ - "FizzyWebTrackpadMagnification", - }; - - // `icons` (pure-Zig icon data) is referenced at file scope in - // `src/dvui.zig` and `src/editor/Infobar.zig`. Wired in so any future - // wasm-reachable code that pulls those files in compiles cleanly. - if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| { - web_exe.root_module.addImport("icons", dep.module("icons")); - } - - // `assets` is generated at build time by assetpack (pure `@embedFile`s, - // target-independent). Same instance as native — no extra build cost. - web_exe.root_module.addImport("assets", assets_module); - - // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared - // with native. velopack_enabled is whatever was passed via `-Dvelopack`; - // wasm path is gated by `arch != .wasm32` in `auto_update.impl`. - web_exe.root_module.addOptions("build_opts", build_opts); - - // `zip` — Zig decls + miniz/zip.c compiled for wasm with `fizzy_zip_libc.c` - // (malloc → dvui_c_alloc). Enables `zip_stream_*` for .fiz open/save in browser. - web_exe.root_module.addImport("zip", zip_pkg.module); - zip.linkWasm(web_exe); - - // `known-folders` is referenced at file scope in a few editor files - // (AboutFizzy, Editor settings paths). It's a pure-Zig wrapper for - // OS-specific user-directory APIs — the file compiles fine on wasm even - // though runtime calls would fail (which we'll never reach on web). - const known_folders_web = b.dependency("known_folders", .{ - .target = web_target, - .optimize = optimize, - }).module("known-folders"); - web_exe.root_module.addImport("known-folders", known_folders_web); - - // Three editor files have `const sdl3 = @import("backend").c;` at file - // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references - // to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's - // lazy analysis skips file-scope consts that no reachable body uses. - // So no `backend` module is wired in for the web build. - - // `zstbi` for the web build. The C sources include `` / - // `` only when `STBI_NO_STDLIB` is undefined; with the flag - // set, `zstbi.c` routes alloc + qsort through `fizzy_stbi_libc.c` - // (which forwards to DVUI's `dvui_c_alloc` / `dvui_c_free`). Lets the - // Packer compile + run on wasm against the currently-open files. - const zstbi_web_lib = b.addLibrary(.{ - .name = "zstbi-web", - .root_module = b.addModule("zstbi_web", .{ - .target = web_target, - .optimize = optimize, - .root_source_file = b.path("src/deps/stbi/zstbi.zig"), - .link_libc = false, - .single_threaded = true, - }), - }); - const zstbi_web_cflags = [_][]const u8{ - "-DSTBI_NO_STDLIB=1", - "-DSTBI_NO_SIMD=1", - }; - zstbi_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/deps/stbi/zstbi.c"), - .flags = &zstbi_web_cflags, - }); - zstbi_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/deps/stbi/fizzy_stbi_libc.c"), - .flags = &zstbi_web_cflags, - }); - web_exe.root_module.addImport("zstbi", zstbi_web_lib.root_module); - - const msf_gif_web_lib = b.addLibrary(.{ - .name = "msf_gif-web", - .root_module = b.addModule("msf_gif_web", .{ - .target = web_target, - .optimize = optimize, - .root_source_file = b.path("src/deps/msf_gif/msf_gif.zig"), - .link_libc = false, - .single_threaded = true, - }), - }); - const msf_gif_wasm_cflags = [_][]const u8{"-Isrc/deps/msf_gif/wasm_shim"}; - msf_gif_web_lib.root_module.addCSourceFile(.{ - .file = std.Build.path(b, "src/deps/msf_gif/fizzy_msf_gif_wasm.c"), - .flags = &msf_gif_wasm_cflags, - }); - web_exe.root_module.addImport("msf_gif", msf_gif_web_lib.root_module); - - const web_install_dir: std.Build.InstallDir = .{ .custom = "web" }; - const install_wasm = b.addInstallArtifact(web_exe, .{ - .dest_dir = .{ .override = web_install_dir }, - }); - - // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so - // the browser picks up new wasm builds without manual hard-reloads. Re-implements - // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step. - const cb = b.addExecutable(.{ - .name = "cacheBuster", - .root_module = b.createModule(.{ - .root_source_file = dvui_web_dep.path("src/cacheBuster.zig"), - .target = b.graph.host, - }), - }); - const cb_run = b.addRunArtifact(cb); - cb_run.addFileArg(b.path("web/shell.html")); - cb_run.addFileArg(dvui_web_dep.path("src/backends/web.js")); - cb_run.addFileArg(web_exe.getEmittedBin()); - const index_html_with_hash = cb_run.captureStdOut(.{}); - - const web_step = b.step("web", "Build the fizzy web (wasm) app into zig-out/web/"); - web_step.dependOn(&install_wasm.step); - web_step.dependOn(&b.addInstallFileWithDir( - index_html_with_hash, - web_install_dir, - "index.html", - ).step); - web_step.dependOn(&b.addInstallFileWithDir( - dvui_web_dep.path("src/backends/web.js"), - web_install_dir, - "web.js", - ).step); - web_step.dependOn(&b.addInstallFileWithDir( - dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), - web_install_dir, - "NotoSansKR-Regular.ttf", - ).step); - - // Compile-only smoke check for the wasm target. Pairs with `check` (unit - // tests). Catches regressions where someone reaches a wasm-incompatible - // code path (thread spawn, std.posix surface, missing module import) - // from the wasm root. No install — just compile. - const check_web_step = b.step("check-web", "Compile fizzy web (wasm) without installing artifacts"); - check_web_step.dependOn(&web_exe.step); - - // Copy zig-out/web into web/app/ for local preview at the production - // `/app/` path: `cd web && python3 -m http.server` then open - // http://localhost:8000/app/. The landing page lives in fizzyedit/website. - const web_docs_step = b.step("web-docs", "Build web app and copy into web/app/ for local /app/ preview"); - web_docs_step.dependOn(web_step); - const cp_web_to_docs = b.addSystemCommand(&.{ "sh", "-c" }); - cp_web_to_docs.addArg("mkdir -p web/app && cp -R zig-out/web/. web/app/"); - cp_web_to_docs.step.dependOn(web_step); - web_docs_step.dependOn(&cp_web_to_docs.step); - - const serve_web_cmd = b.addSystemCommand(&.{ "sh", "scripts/serve-web.sh" }); - serve_web_cmd.step.dependOn(web_step); - _ = b.step( - "serve-web", - "Serve zig-out/web at http://127.0.0.1:8765/ (builds web first; frees stale :8765)", - ).dependOn(&serve_web_cmd.step); - } - - const main_fizzy = try addFizzyExecutableForTarget(b, target, optimize, accesskit, build_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, velopack_enabled); - const exe = main_fizzy.exe; - const zstbi_module = main_fizzy.zstbi_module; - const msf_gif_module = main_fizzy.msf_gif_module; - const known_folders = main_fizzy.known_folders; - - const exe_for_package: *std.Build.Step.Compile = package_blk: { - if (velopack_enabled) break :package_blk exe; - if (!velopack_supported_for_target) break :package_blk exe; - const pack_opts = b.addOptions(); - pack_opts.addOption([]const u8, "app_version", app_version); - pack_opts.addOption([]const u8, "app_repo_url", app_repo_url); - pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); - pack_opts.addOption(bool, "velopack_enabled", true); - const pack_fizzy = try addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true); - break :package_blk pack_fizzy.exe; - }; - - if (no_emit) { - b.getInstallStep().dependOn(&exe.step); - } else { - const install_artifact = b.addInstallArtifact(exe, .{ - .dest_dir = .{ .override = zig_out_install_dir }, - }); - - const run_cmd = b.addRunArtifact(exe); - const run_step = b.step("run", "Run the app (does not run Velopack)"); - - run_cmd.step.dependOn(&install_artifact.step); - run_step.dependOn(&run_cmd.step); - b.getInstallStep().dependOn(&install_artifact.step); - } - - const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run"); - // The default native target on a Windows host resolves to x86_64-windows-gnu, - // for which `velopack_supported_for_target` is false — exe_for_package falls - // back to the plain (Velopack-less) exe. vpk would still wrap it as a Velopack - // installer, but the install hook never runs: Setup.exe hangs with "the - // application install hook failed". Fail loudly instead of shipping that trap. - const windows_non_msvc = target.result.os.tag == .windows and target.result.abi != .msvc; - if (velopack_required_fail) |fail_step| { - package_step.dependOn(fail_step); - } else if (windows_non_msvc) { - package_step.dependOn(&b.addFail( - \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked. - \\The default native target resolves to x86_64-windows-gnu, which builds a binary - \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but - \\the install hook never runs and Setup.exe hangs ("the application install hook failed"). - \\ - \\Build with the MSVC target instead: - \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc - \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.) - ).step); - } else if (no_emit) { - package_step.dependOn(&b.addFail("cannot run `package` with -Dno-emit").step); - } else switch (target.result.os.tag) { - .linux, .macos, .windows => { - // Host strip can't process foreign object files when cross-compiling. - const cross_os = target.result.os.tag != b.graph.host.result.os.tag; - // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also - // breaks host strip — it errors with "Unable to recognise the format". - const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch; - // Windows hosts don't ship `strip` or `touch`. Skip the external strip - // step entirely there — Zig's linker already drops debug info in - // release builds. Use `cmd /c exit 0` as the no-op and keep the - // dependency on exe_for_package via the step graph. - const host_is_windows = b.graph.host.result.os.tag == .windows; - const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip; - const strip_release_sh = if (host_is_windows) blk: { - const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" }); - sh.step.dependOn(&exe_for_package.step); - break :blk sh; - } else blk: { - const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"}); - sh.addFileArg(exe_for_package.getEmittedBin()); - break :blk sh; - }; - - //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b); - //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b); - //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step); - - const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"}); - vpk_pkg_sh.addArg("vpk"); - // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`) - // because by default it auto-detects from the host OS. - if (cross_os) { - vpk_pkg_sh.addArg(switch (target.result.os.tag) { - .windows => "[win]", - .linux => "[linux]", - .macos => "[osx]", - else => unreachable, - }); - } - vpk_pkg_sh.addArg("pack"); - vpk_pkg_sh.addArg("--packId"); - vpk_pkg_sh.addArg("fizzy"); - vpk_pkg_sh.addArg("--packVersion"); - vpk_pkg_sh.addArg(app_version); - // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into - // the binary by vpk; the updater matches this to release assets. Distinct per triple - // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names. - vpk_pkg_sh.addArg("--channel"); - vpk_pkg_sh.addArg(zig_out_subdir); - vpk_pkg_sh.addArg("--mainExe"); - vpk_pkg_sh.addArg(switch (target.result.os.tag) { - .windows => "fizzy.exe", - else => "fizzy", - }); - - vpk_pkg_sh.addArg("--delta"); - vpk_pkg_sh.addArg("None"); - vpk_pkg_sh.addArg("--yes"); - - vpk_pkg_sh.addArg("--outputDir"); - // `addOutputDirectoryArg` takes a basename — Zig manages the actual - // path under the run step's cache dir. The `addInstallDirectory` - // below copies that into zig-out//. Previously this passed - // the full install path, which produced `.zig-cache\o\\C:\...` - // on Windows (BadPathName). - const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop"); - vpk_pkg_sh.addArg("--packDir"); - vpk_pkg_sh.addDirectoryArg(exe_for_package.getEmittedBin().dirname()); - switch (target.result.os.tag) { - .windows => { - // Sets the installer's icon and the Start Menu shortcut icon. The - // exe's own icon is already embedded via assets/windows/fizzy.rc. - vpk_pkg_sh.addArg("--icon"); - const ico_path = b.path("assets/windows/fizzy.ico").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("ico path: {}", .{e}); - vpk_pkg_sh.addArg(ico_path); - // Velopack's installer is silent (no shortcut-choice UI). Default is - // Desktop,StartMenu; restrict to StartMenu so we don't drop an - // unrequested icon on the user's desktop. - vpk_pkg_sh.addArg("--shortcuts"); - vpk_pkg_sh.addArg("StartMenu"); - }, - .macos => { - vpk_pkg_sh.addArg("--packTitle"); - vpk_pkg_sh.addArg("fizzy"); - // Bundle id / document types / versions: assets/macos/info.plist (vpk rejects --bundleId with --plist). - vpk_pkg_sh.addArg("--plist"); - const plist_path = b.path("assets/macos/info.plist").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("plist path: {}", .{e}); - vpk_pkg_sh.addArg(plist_path); - vpk_pkg_sh.addArg("--icon"); - const icns_path = b.path("assets/macos/fizzy.icns").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("icns path: {}", .{e}); - vpk_pkg_sh.addArg(icns_path); - - if (macos_sign_app_identity) |id| { - vpk_pkg_sh.addArg("--signAppIdentity"); - vpk_pkg_sh.addArg(id); - // Required for notarization: enables hardened runtime + secure timestamp on - // every nested binary (vpk forwards the file to `codesign --entitlements`). - // Without this, Apple's notary service rejects with "signature does not - // include a secure timestamp" / "hardened runtime not enabled". - vpk_pkg_sh.addArg("--signEntitlements"); - const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e}); - vpk_pkg_sh.addArg(entitlements_path); - } - if (macos_sign_install_identity) |id| { - vpk_pkg_sh.addArg("--signInstallIdentity"); - vpk_pkg_sh.addArg(id); - } - if (macos_notary_profile) |profile| { - vpk_pkg_sh.addArg("--notaryProfile"); - vpk_pkg_sh.addArg(profile); - } - }, - else => {}, - } - vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); - // Stream vpk's stdout/stderr live so failures surface their actual - // diagnostic instead of just an exit-code-N message from the build - // runner. With `addOutputDirectoryArg` in play, `infer_from_args` - // can otherwise capture+drop stdio on certain runner configs. - vpk_pkg_sh.stdio = .inherit; - try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target); - - //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); - vpk_pkg_sh.step.dependOn(&strip_release_sh.step); - - const build_package_install = b.addInstallDirectory(.{ - .source_dir = vpk_pkg_out_dir, - .install_dir = zig_out_install_dir, - .install_subdir = "", - }); - - package_step.dependOn(&build_package_install.step); - }, - else => { - package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step); - }, - } - - const desktop_step = b.step("desktop", "Alias for `zig build package`"); - desktop_step.dependOn(package_step); - - const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux"); - if (no_emit) { - packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step); - } else { - const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)}); - - // Build order is deliberately fail-fast: Windows first (most likely to - // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths, - // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage - // packaging quirks), then macOS last (native, lowest risk). When a - // release run is going to break, this ordering surfaces the failure - // 5-10 minutes sooner than the alphabetical order did. - const packageall_triples = [_][]const u8{ - "x86_64-windows-msvc", - "aarch64-windows-msvc", - "x86_64-linux-gnu", - "aarch64-linux-gnu", - "x86_64-macos", - "aarch64-macos", - }; - - var prev_step: ?*std.Build.Step = null; - for (packageall_triples) |triple| { - const zig_pkg_run = b.addSystemCommand(&.{ - b.graph.zig_exe, - "build", - "package", - packageall_optimize_arg, - b.fmt("-Dtarget={s}", .{triple}), - }); - if (std.mem.endsWith(u8, triple, "-windows-msvc")) { - if (windows_msvc_libc_opt) |libc_path| { - zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path})); - } - if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc"); - } - zig_pkg_run.setCwd(b.path(".")); - if (prev_step) |p| { - zig_pkg_run.step.dependOn(p); - } - prev_step = &zig_pkg_run.step; - } - packageall_step.dependOn(prev_step.?); - } - - // --------------------------------------------------------------- - // Tests - // --------------------------------------------------------------- - // - // Fizzy has two test layers (see tests/README.md): - // - // 1. Unit tests — pure-logic only (math, palette parsing, layer - // order). The test root imports nothing but std + the pure - // modules under test, so it compiles in well under a second - // and never needs dvui/SDL/assets. - // - // 2. Integration tests (added in Phase 2 of the testing plan) - // will use dvui's testing backend and exercise real fizzy - // drawing functions in a headless Window. - // - // Both share the same `zig build test` and `zig build check` - // entry points. - - const test_filters = b.option( - []const []const u8, - "test-filter", - "Skip tests that do not match any filter", - ) orelse &[0][]const u8{}; - - const tests_module = b.addModule("fizzy-tests", .{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("tests/root.zig"), - }); - - // Wire each pure-logic source file as a named module on the test - // target. Zig 0.15 disallows importing source files outside the test - // module's own directory via relative paths, so we expose them by - // name. Each of these files imports only `std`, so they remain free - // of dvui / SDL / globals. - inline for (.{ - .{ "fizzy-direction", "src/math/direction.zig" }, - .{ "fizzy-easing", "src/math/easing.zig" }, - .{ "fizzy-layer-order", "src/internal/layer_order.zig" }, - .{ "fizzy-palette-parse", "src/internal/palette_parse.zig" }, - .{ "fizzy-layout-anchor", "src/math/layout_anchor.zig" }, - .{ "fizzy-reduce", "src/algorithms/reduce.zig" }, - .{ "fizzy-grid-validate", "src/internal/grid_layout_validate.zig" }, - .{ "fizzy-animation", "src/Animation.zig" }, - .{ "fizzy-window-layout", "src/internal/window_layout.zig" }, - }) |entry| { - tests_module.addAnonymousImport(entry[0], .{ - .root_source_file = b.path(entry[1]), - .target = target, - .optimize = optimize, - }); - } - - const unit_tests = b.addTest(.{ - .name = "fizzy-unit-tests", - .root_module = tests_module, - .filters = test_filters, - }); - - // `zig build test` is the CI entry point and must stay self-contained: pure - // unit tests only, no dvui/SDL/Velopack/MSVC. Integration tests live under - // `zig build test-integration` (Velopack + dvui-testing + comctl32 on Windows - // → needs MSVC SDK on Windows hosts). `zig build test-all` runs both. - const test_step = b.step("test", "Run fizzy unit tests (pure-logic only, no dvui/SDL/Velopack)"); - test_step.dependOn(&b.addRunArtifact(unit_tests).step); - - // `check` mirrors the split so editor compile-error checking matches CI. - const check_step = b.step("check", "Compile fizzy unit tests without running them"); - check_step.dependOn(&unit_tests.step); - - // --------------------------------------------------------------- - // Layer 2: headless integration tests against dvui's testing - // backend. Wired under separate `test-integration` / `check-integration` - // steps so `zig build test` stays MSVC-free on Windows CI runners. Skipped - // when cross-compiling to *-windows-msvc without an MSVC libc INI. - // --------------------------------------------------------------- - const test_integration_step = b.step("test-integration", "Run fizzy headless integration tests (dvui-testing; needs MSVC on Windows)"); - const check_integration_step = b.step("check-integration", "Compile fizzy integration tests without running them"); - const test_all_step = b.step("test-all", "Run unit + integration tests"); - test_all_step.dependOn(test_step); - test_all_step.dependOn(test_integration_step); - - if (velopack_required_fail) |fail_step| { - test_integration_step.dependOn(fail_step); - check_integration_step.dependOn(fail_step); + if (plugin_sdk) { + try plugin.exportModules(b, target, optimize); return; } - const dvui_testing_dep = b.dependency("dvui", .{ - .target = target, - .optimize = optimize, - .backend = .testing, - .accesskit = accesskit, - }); - - // Build a module rooted at `src/fizzy.zig` carrying all the same - // imports the production exe carries. Because fizzy.zig's transitive - // imports (App.zig, Editor.zig, …) reference `dvui`, `assets`, - // `known-folders`, etc. by name, those names must be wired here. - // We point dvui at the *testing* backend so calling drawing - // functions doesn't try to open a real OS window. - const fizzy_test_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("src/fizzy.zig"), - }); - fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); - fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing")); - fizzy_test_module.addImport("assets", assets_module); - fizzy_test_module.addImport("known-folders", known_folders); - fizzy_test_module.addOptions("build_opts", build_opts); - fizzy_test_module.addImport("zstbi", zstbi_module); - fizzy_test_module.addImport("msf_gif", msf_gif_module); - fizzy_test_module.addImport("zip", zip_pkg.module); - if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { - fizzy_test_module.addImport("icons", dep.module("icons")); - } - if (target.result.os.tag == .macos) { - if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { - fizzy_test_module.addImport("objc", dep.module("objc")); - } - } else if (target.result.os.tag == .windows) { - if (b.lazyDependency("zigwin32", .{})) |dep| { - fizzy_test_module.addImport("win32", dep.module("win32")); - } - } - - const integration_module = b.addModule("fizzy-integration-tests", .{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("tests/integration.zig"), - }); - integration_module.addImport("fizzy", fizzy_test_module); - integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); - - const integration_tests = b.addTest(.{ - .name = "fizzy-integration-tests", - .root_module = integration_module, - .filters = test_filters, - }); - - if (target.result.os.tag == .windows) { - integration_tests.root_module.linkSystemLibrary("comctl32", .{}); - } - // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers from - // --libc (vcruntime_typeinfo.h vs libc++ type_info, etc.), so libc++ must be - // off for the msvc ABI regardless of host (cross or native Windows). - integration_tests.root_module.link_libcpp = !target_is_windows_msvc; - zip.link(integration_tests); - if (velopack_enabled) { - try velopack.linkVelopack(b, integration_tests, .{ .target = target, .optimize = optimize }); - } - - integration_tests.step.dependOn(process_assets_step); - - test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step); - check_integration_step.dependOn(&integration_tests.step); - - if (win_libc.needs_setup) { - exe.step.dependOn(&msvcup_before_compile.step); - if (!velopack_enabled and velopack_supported_for_target) { - exe_for_package.step.dependOn(&msvcup_before_compile.step); - } - integration_tests.step.dependOn(&msvcup_before_compile.step); - unit_tests.step.dependOn(&msvcup_before_compile.step); - } - - if (target.result.os.tag == .windows and target.result.abi == .msvc) { - var roots: [4]*std.Build.Step.Compile = undefined; - var n: usize = 0; - roots[n] = exe; - n += 1; - roots[n] = unit_tests; - n += 1; - roots[n] = integration_tests; - n += 1; - if (!velopack_enabled and velopack_supported_for_target) { - roots[n] = exe_for_package; - n += 1; - } - - // Always apply the translate-c shim + SIZE_MAX define for windows-msvc, regardless of - // whether we're using a downloaded SDK or the host's system MSVC. translate-c uses aro - // (not MSVC cl.exe), and aro rejects literals like `0xffffffffffffffffui64` from MSVC's - // . The shim shadows stdint.h via `-I` (search order beats `-isystem`); the - // defineCMacro adds belt-and-suspenders by predefining SIZE_MAX before any include so - // MSVC's stdint.h `#ifndef SIZE_MAX` skips its own definition entirely. - applyMsvcTranslateCShim(b, roots[0..n]) catch |e| { - std.debug.panic("MSVC translate-c shim wiring failed: {s}", .{@errorName(e)}); - }; - - if (effective_win_libc) |ini| { - if (cross_win_msvc) b.libc_file = null; - const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini }; - velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp); - - const ini_exists = blk: { - b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false; - break :blk true; - }; - if (ini_exists) { - // Adds explicit MSVC/UCRT/SDK `-isystem` paths from the libc INI to each reachable - // translate-c step. Only relevant when cross-compiling with .velopack-msvc/; on a - // Windows host with system MSVC, Zig auto-discovers these paths itself. - applyMsvcIncludesToReachableTranslateC(b, roots[0..n], ini) catch |e| { - std.debug.panic("MSVC translate-c include fixup failed: {s}", .{@errorName(e)}); - }; - } else { - // The INI is written by `msvcup-setup` (a make-phase step), but the translate-c - // `-isystem` paths embed the SDK version subdir, which is only known after the SDK - // is installed — so they must be wired at configure time, before that step runs. - // A one-shot `zig build package -Dfetch-msvc` against a clean .velopack-msvc can't - // satisfy that ordering. Fail only the compiles that need it (not `msvcup-setup`, - // which has no such dependency), so running setup first still works. - const fail = &b.addFail( - \\*-windows-msvc has no .velopack-msvc/zig-libc INI yet, so translate-c can't be wired. - \\The SDK install must run as its own step before packaging (it can't be done in one - \\pass — the translate-c include paths depend on the installed SDK version): - \\ zig build msvcup-setup - \\ zig build package -Dtarget=x86_64-windows-msvc - ).step; - for (roots[0..n]) |rc| rc.step.dependOn(fail); - } - } - } -} - -/// Apply the always-on translate-c fixups for windows-msvc targets: the stdint.h shim -/// (so aro doesn't choke on MSVC's `ui64` literal suffix) and a predefined SIZE_MAX. -/// Runs whether or not we have a downloaded SDK — the shim is purely an `-I` injection -/// and a `-D` flag, so it works equally on cross-compile and native windows-host builds. -fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile) !void { - var seen = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); - defer seen.deinit(); - for (roots) |root_compile| { - const graph = root_compile.root_module.getGraph(); - for (graph.modules) |mod| { - const root_src = mod.root_source_file orelse continue; - const gen = switch (root_src) { - .generated => |g| g, - else => continue, - }; - const dep_step = gen.file.step; - if (dep_step.id != .translate_c) continue; - const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); - const gop = try seen.getOrPut(tc); - if (gop.found_existing) continue; - const rt = tc.target.result; - if (rt.os.tag != .windows or rt.abi != .msvc) continue; - // `-I` searches before `-isystem`, so this shim wins over MSVC's . - tc.addIncludePath(b.path("src/tools/msvc_translatec_shim")); - // Pre-define SIZE_MAX so MSVC's stdint.h `#ifndef SIZE_MAX` block — which would - // otherwise install a `0xff…ui64` literal — skips itself. Belt-and-suspenders - // to the shim: covers the case where another header includes through - // a path that bypasses our shim. - tc.defineCMacro("SIZE_MAX", switch (rt.ptrBitWidth()) { - 32 => "4294967295U", - 64 => "18446744073709551615ULL", - else => "UINT_MAX", - }); - } - } -} - -/// Finds every `Step.TranslateC` reachable from each root compile's Zig module graph and adds -/// MSVC / Windows SDK `-isystem` paths from the zig-libc INI. We walk `Module.getGraph()` (imports) -/// rather than `Step.dependencies`: Zig wires `root_source_file` → `TranslateC` only in -/// `createModuleDependencies`, which runs after `build()` returns, so a step BFS from `Compile` -/// would miss DVUI's `dvui-c` / `sdl3-c` translate steps during Configure. -fn applyMsvcIncludesToReachableTranslateC( - b: *std.Build, - roots: []const *std.Build.Step.Compile, - libc_ini_path: []const u8, -) !void { - // `libc_ini_path` is absolute (resolved via `b.pathFromRoot`), so any Dir works as the base. - const data = try b.build_root.handle.readFileAlloc(b.graph.io, libc_ini_path, b.allocator, .unlimited); - - var include_dir: ?[]const u8 = null; - var sys_include_dir: ?[]const u8 = null; - var line_it = std.mem.splitScalar(u8, data, '\n'); - while (line_it.next()) |raw| { - const line = std.mem.trim(u8, raw, " \r\t"); - if (std.mem.startsWith(u8, line, "include_dir=")) { - include_dir = std.mem.trim(u8, line["include_dir=".len..], " \r\t"); - } else if (std.mem.startsWith(u8, line, "sys_include_dir=")) { - sys_include_dir = std.mem.trim(u8, line["sys_include_dir=".len..], " \r\t"); - } - } - if (include_dir == null or sys_include_dir == null) return; - - // `include_dir` points at `.../Windows Kits/10/Include//ucrt`. The Windows SDK's - // um/shared/winrt headers live as siblings of the `ucrt` directory. - const sdk_inc_root = std.fs.path.dirname(include_dir.?) orelse return; - const um_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "um" }); - const shared_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "shared" }); - const winrt_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "winrt" }); - - var seen_translate_c = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); - defer seen_translate_c.deinit(); - - for (roots) |root_compile| { - const graph = root_compile.root_module.getGraph(); - for (graph.modules) |mod| { - const root_src = mod.root_source_file orelse continue; - const gen = switch (root_src) { - .generated => |g| g, - else => continue, - }; - const dep_step = gen.file.step; - if (dep_step.id != .translate_c) continue; - - const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); - const gop = try seen_translate_c.getOrPut(tc); - if (gop.found_existing) continue; - - const rt = tc.target.result; - if (rt.os.tag == .windows and rt.abi == .msvc) { - // `translate-c` has no API to pass `--libc `, so `-lc` makes Zig - // auto-detect a system MSVC/SDK install — which fails on a Windows host - // that has no Visual Studio (we use the .velopack-msvc/ tree instead) with - // `WindowsSdkNotFound`. Drop `-lc` here: every MSVC/UCRT/SDK include dir is - // added explicitly below, so the headers still resolve, and the consuming - // exe links libc itself — the translated bindings don't need their own. - tc.link_libc = false; - // Shim + SIZE_MAX define are applied separately by `applyMsvcTranslateCShim`. - // Order matters: MSVC's own headers first (override Windows SDK declarations - // when both exist), then UCRT, then the Windows SDK trio. - tc.addSystemIncludePath(.{ .cwd_relative = sys_include_dir.? }); - tc.addSystemIncludePath(.{ .cwd_relative = include_dir.? }); - tc.addSystemIncludePath(.{ .cwd_relative = um_dir }); - tc.addSystemIncludePath(.{ .cwd_relative = shared_dir }); - tc.addSystemIncludePath(.{ .cwd_relative = winrt_dir }); - } - } - } -} - -const FizzyExecutable = struct { - exe: *std.Build.Step.Compile, - zstbi_module: *std.Build.Module, - msf_gif_module: *std.Build.Module, - known_folders: *std.Build.Module, -}; - -fn addFizzyExecutableForTarget( - b: *std.Build, - resolved_target: std.Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, - accesskit: dvui.AccesskitOptions, - build_opts: *std.Build.Step.Options, - zip_pkg: zip.Package, - assets_module: *std.Build.Module, - process_assets_step: *std.Build.Step, - macos_sdl_paths: ?MacosSdlPaths, - velopack_enabled: bool, -) !FizzyExecutable { - const dvui_dep = if (macos_sdl_paths) |p| - b.dependency("dvui", .{ - .target = resolved_target, - .optimize = optimize, - .backend = .sdl3, - .accesskit = accesskit, - .system_include_path = p.include, - .system_framework_path = p.framework, - .library_path = p.lib, - }) - else - b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); - - const zstbi_lib = b.addLibrary(.{ - .name = "zstbi", - .root_module = b.addModule("zstbi", .{ - .target = resolved_target, - .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/deps/stbi/zstbi.zig" }, - }), - }); - const zstbi_module = zstbi_lib.root_module; - zstbi_module.addCSourceFile(.{ .file = std.Build.path(b, "src/deps/stbi/zstbi.c") }); - - const msf_gif_lib = b.addLibrary(.{ - .name = "msf_gif", - .root_module = b.addModule("msf_gif", .{ - .target = resolved_target, - .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/deps/msf_gif/msf_gif.zig" }, - }), - }); - const msf_gif_module = msf_gif_lib.root_module; - msf_gif_module.addCSourceFile(.{ .file = std.Build.path(b, "src/deps/msf_gif/msf_gif.c") }); - - const exe = b.addExecutable(.{ - .name = "fizzy", - .root_module = b.addModule("App", .{ - .target = resolved_target, - .optimize = optimize, - .root_source_file = .{ .cwd_relative = "src/App.zig" }, - }), - }); - exe.root_module.strip = false; - - exe.root_module.addImport("assets", assets_module); - const known_folders = b.dependency("known_folders", .{ - .target = resolved_target, - .optimize = optimize, - }).module("known-folders"); - exe.root_module.addImport("known-folders", known_folders); - exe.root_module.addOptions("build_opts", build_opts); - exe.step.dependOn(process_assets_step); - - if (optimize != .Debug) { - switch (resolved_target.result.os.tag) { - .windows => { - exe.subsystem = .Windows; - // MSVC's libcmt links `WinMainCRTStartup` (needs `WinMain`) for /SUBSYSTEM:WINDOWS. - // Fizzy exposes `main`, so force the C `main` entry which works for either subsystem. - if (resolved_target.result.abi == .msvc) { - exe.entry = .{ .symbol_name = "mainCRTStartup" }; - } - }, - else => exe.subsystem = .Posix, - } - } - - exe.root_module.addImport("zstbi", zstbi_module); - exe.root_module.addImport("msf_gif", msf_gif_module); - exe.root_module.addImport("zip", zip_pkg.module); - exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); - exe.root_module.addImport("backend", dvui_dep.module("sdl3")); - - const singleton_app_dep = b.dependency("dvui_singleton_app", .{ - .target = resolved_target, - .optimize = optimize, + try @import("build/app.zig").build(b, target, optimize, .{ + .windows_msvc_libc_opt = windows_msvc_libc_opt, + .fetch_msvc_opt = fetch_msvc_opt, + .macos_sign_app_identity = macos_sign_app_identity, + .macos_sign_install_identity = macos_sign_install_identity, + .macos_notary_profile = macos_notary_profile, }); - exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app")); - - if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| { - exe.root_module.addImport("icons", dep.module("icons")); - } - - if (resolved_target.result.os.tag == .macos) { - if (macos_sdl_paths) |p| { - // Non-"native" macOS targets (`-Dtarget=aarch64-macos` on Apple Silicon, etc.) need the - // same SDK layout for Obj-C sources as for SDL; zig-objc paths do not always reach .m - // compiles (e.g. Security.framework → ). - exe.root_module.addSystemIncludePath(p.include); - exe.root_module.addSystemFrameworkPath(p.framework); - exe.root_module.addLibraryPath(p.lib); - } - if (b.lazyDependency("zig_objc", .{ - .target = resolved_target, - .optimize = optimize, - })) |dep| { - exe.root_module.addImport("objc", dep.module("objc")); - } - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyVisualEffectView.m") }); - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyMenuTarget.m") }); - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyTrackpadGesture.m") }); - exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/objc/FizzyWindowMonitor.m") }); - } else if (resolved_target.result.os.tag == .windows) { - if (b.lazyDependency("zigwin32", .{})) |dep| { - exe.root_module.addImport("win32", dep.module("win32")); - } - exe.root_module.linkSystemLibrary("comctl32", .{}); - - // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer, - // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all - // show the right icon without any runtime work. fizzy.ico must be a - // multi-resolution ICO with 16/32/48/256 px frames (see the README in - // that directory). - exe.root_module.addWin32ResourceFile(.{ - .file = b.path("assets/windows/fizzy.rc"), - }); - } - - // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers - // (vcruntime_typeinfo.h's ::type_info vs libc++'s own, redefined bad_cast, - // etc.). We always feed MSVC's own STL via --libc for *-windows-msvc — on a - // cross host and on a native Windows host using .velopack-msvc alike — so - // libc++ must be off for the msvc ABI regardless of host. - const exe_is_windows_msvc = resolved_target.result.os.tag == .windows and - resolved_target.result.abi == .msvc; - exe.root_module.link_libcpp = !exe_is_windows_msvc; - zip.link(exe); - if (velopack_enabled) { - try velopack.linkVelopack(b, exe, .{ .target = resolved_target, .optimize = optimize }); - } - - return .{ - .exe = exe, - .zstbi_module = zstbi_module, - .msf_gif_module = msf_gif_module, - .known_folders = known_folders, - }; } - -inline fn thisDir() []const u8 { - return comptime std.fs.path.dirname(@src().file) orelse "."; -} - -fn addImport( - compile: *std.Build.Step.Compile, - name: [:0]const u8, - module: *std.Build.Module, -) void { - compile.root_module.addImport(name, module); -} - diff --git a/build.zig.zon b/build.zig.zon index faf88e34..43ae1c5d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,8 +1,13 @@ .{ .paths = .{ "src", + "build", "build.zig", "build.zig.zon", + "process_assets.zig", + "update.zig", + "build", + "plugin_sdk.zig", "assets", "libs", }, @@ -27,8 +32,8 @@ .lazy = true, }, .dvui = .{ - .url = "https://github.com/foxnne/dvui-dev/archive/2f81423945d7076796023a7802f2680226dd9bd4.tar.gz", - .hash = "dvui-0.5.0-dev-AQFJmdw09wCp9ts4oaBV7Rkn7YuMKxDiaCLaweO-HPuS", + .url = "https://github.com/foxnne/dvui-dev/archive/3dec1c1b56f71aff41e36588a715dea085d307f5.tar.gz", + .hash = "dvui-0.5.0-dev-AQFJmZhu9wAmUMx9414LO75l0R69z3d8udYXbj72-q3R", //.path = "../dvui-dev", }, .assetpack = .{ @@ -47,5 +52,9 @@ .dvui_singleton_app = .{ .path = "libs/dvui-singleton-app", }, + // Built-in plugins are NOT package dependencies: the root build embeds them by + // importing each plugin's `static/integration.zig` directly (see build/plugins.zig), + // which owns the module graph. Each plugin's own `build.zig` is only for the + // standalone third-party-shape build under `src/plugins//`. }, } diff --git a/build/app.zig b/build/app.zig new file mode 100644 index 00000000..a2510ba6 --- /dev/null +++ b/build/app.zig @@ -0,0 +1,621 @@ +const std = @import("std"); + +const plugin = @import("../plugin_sdk.zig"); +const dvui = @import("dvui"); +const velopack = @import("velopack_zig"); +const ProcessAssetsStep = @import("../process_assets.zig"); + +pub const Options = struct { + windows_msvc_libc_opt: ?[]const u8 = null, + fetch_msvc_opt: ?bool = null, + macos_sign_app_identity: ?[]const u8 = null, + macos_sign_install_identity: ?[]const u8 = null, + macos_notary_profile: ?[]const u8 = null, +}; + +pub fn build(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, opts: Options) !void { + const windows_msvc_libc_opt = opts.windows_msvc_libc_opt; + const fetch_msvc_opt = opts.fetch_msvc_opt; + const macos_sign_app_identity = opts.macos_sign_app_identity; + const macos_sign_install_identity = opts.macos_sign_install_identity; + const macos_notary_profile = opts.macos_notary_profile; + + const common = @import("common.zig"); + const plugins = @import("plugins.zig"); + const sdk = @import("sdk.zig"); + const fizzy_exe = @import("exe.zig"); + const web = @import("web.zig"); + const package = @import("package.zig"); + const msvc = @import("msvc.zig"); + + const pixi_plugin = plugins.pixi; + const workbench_plugin = plugins.workbench; + const code_plugin = plugins.code; + const example_plugin = plugins.example; + const FizzyExecutable = fizzy_exe.FizzyExecutable; + + // Built-in plugins are embedded by importing their `static/integration.zig` directly + // (via build/plugins.zig); the root build owns the module graph, so there is no plugin + // package dependency to resolve here. Their canonical `build.zig` is only for the + // standalone (`cd src/plugins/ && zig build`) third-party-shape build. + + const macos_sdl_paths = try common.macosSdlPathsForExplicitTarget(b, target); + const zig_out_subdir = common.zigOutSubdirForTarget(b, target); + const zig_out_install_dir: std.Build.InstallDir = .{ .custom = zig_out_subdir }; + + const target_is_windows_msvc = target.result.os.tag == .windows and target.result.abi == .msvc; + const cross_win_msvc = target_is_windows_msvc and b.graph.host.result.os.tag != .windows; + + // Auto-fetch defaults: on Windows hosts targeting *-windows-msvc, downloading the + // MSVC SDK into .velopack-msvc/ is the deterministic path — Zig's auto-detection + // of a system Visual Studio install picks up whatever's currently installed, which + // makes packaged release builds non-reproducible. The same .velopack-msvc/ tree is + // used on macOS/Linux cross-compile hosts, so all three triples land on the same + // SDK headers + libs. Explicit `-Dfetch-msvc=false` opts out (use system VS); an + // explicit `-Dwindows-msvc-libc=...` overrides the discovery entirely. + const fetch_msvc = fetch_msvc_opt orelse (target_is_windows_msvc and windows_msvc_libc_opt == null); + + const win_libc = velopack.resolveWindowsMsvcLibc(b, target, .{ + .explicit_path = windows_msvc_libc_opt, + .install_dir_name = ".velopack-msvc", + .fetch_if_missing = fetch_msvc, + }); + + var effective_win_libc: ?[]const u8 = win_libc.libc_path; + if (effective_win_libc == null) { + if (cross_win_msvc) effective_win_libc = b.libc_file; + } + + // Velopack in the dev/install exe is opt-in (`-Dvelopack=true`). Release + // packaging (`zig build package`) still links Velopack when the ABI supports + // it via a second compile, so `zig build` / `run` / `test` never pull dotnet + // or the static Velopack lib unless you ask. Windows *-gnu targets are + // unchanged (no Velopack prebuilt for that ABI). + const velopack_supported_for_target = !(target.result.os.tag == .windows and target.result.abi != .msvc); + const velopack_enabled = b.option( + bool, + "velopack", + "Link Velopack runtime in the install/run exe (auto-update). Default: false. `package` still produces a Velopack-linked binary when supported.", + ) orelse false; + + if (velopack_enabled and !velopack_supported_for_target) { + std.log.err( + "-Dvelopack=true is unsupported for target ABI {s}: Velopack on Windows requires -Dtarget=x86_64-windows-msvc or -Dtarget=aarch64-windows-msvc.", + .{@tagName(target.result.abi)}, + ); + return error.WindowsMsvcAbiRequired; + } + + // Fail loudly when the *-windows-msvc target has no headers/libs to compile against. + // On a non-Windows host this happens whenever `.velopack-msvc/` is missing and the + // user didn't pass `-Dfetch-msvc` or `-Dwindows-msvc-libc=…`. On a Windows host the + // auto-fetch default makes this unreachable unless the user explicitly opted out + // with `-Dfetch-msvc=false` — in which case Zig falls back to system Visual Studio + // auto-detection, which we can't validate here. + const velopack_required_fail: ?*std.Build.Step = if (cross_win_msvc and effective_win_libc == null) + &b.addFail( + \\*-windows-msvc needs MSVC + Windows SDK headers/libs. + \\ One-shot install (macOS/Linux/Windows): zig build msvcup-setup + \\ Then: zig build package -Dtarget=x86_64-windows-msvc (auto-uses .velopack-msvc/zig-libc-x64.ini) + \\ Or auto-download in this build: add -Dfetch-msvc (default on Windows hosts; forwards through packageall) + \\ Or pass: --libc path.ini / -Dwindows-msvc-libc=path.ini + ).step + else + null; + + const no_emit = b.option(bool, "no-emit", "Check for compile errors without emitting any code") orelse false; + + const app_version_opt = b.option([]const u8, "app_version", "App version for vpk packVersion and startup log; defaults to VERSION file"); + + // GitHub repo URL baked into the binary so Velopack's auto-update can find + // the latest release via the GitHub Releases API. Override at build time + // with `-Drepo-url=...` (e.g. when shipping a fork). At runtime, the env + // var `FIZZY_AUTOUPDATE_URL` still overrides this for local feed testing. + const app_repo_url = b.option([]const u8, "repo-url", "GitHub repo URL used by Velopack auto-update (e.g. https://github.com/fizzyedit/fizzy)") orelse "https://github.com/fizzyedit/fizzy"; + + // Comma-separated fallback repo URLs checked (in order) after `app_repo_url` + // yields no update. Lets a build survive a repo move/rename: ship a binary + // whose primary points at the new home and whose fallback points at the old + // one (where the transitional release is published), then transfer the repo. + // Empty by default (no fallback). + const app_repo_url_fallback = b.option([]const u8, "repo-url-fallback", "Comma-separated fallback GitHub repo URLs for Velopack auto-update, tried after -Drepo-url") orelse ""; + + var version_owned: ?[]u8 = null; + defer if (version_owned) |buf| b.allocator.free(buf); + + const app_version: []const u8 = if (app_version_opt) |v| v else blk: { + const raw = b.build_root.handle.readFileAlloc(b.graph.io, "VERSION", b.allocator, std.Io.Limit.limited(256)) catch |e| std.debug.panic("read VERSION: {}", .{e}); + version_owned = raw; + break :blk std.mem.trimEnd(u8, raw, "\r\n"); + }; + + const build_opts = b.addOptions(); + build_opts.addOption([]const u8, "app_version", app_version); + build_opts.addOption([]const u8, "app_repo_url", app_repo_url); + build_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); + build_opts.addOption(bool, "velopack_enabled", velopack_enabled); + const static_pixi = b.option( + bool, + "static-pixi", + "Keep pixi statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_pixi", static_pixi); + const static_workbench = b.option( + bool, + "static-workbench", + "Keep workbench statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_workbench", static_workbench); + const static_code = b.option( + bool, + "static-code", + "Keep code plugin statically registered on native (skip built-in dylib load)", + ) orelse false; + build_opts.addOption(bool, "static_code", static_code); + const workbench_file_tree = b.option( + bool, + "workbench-file-tree", + "Register the workbench Files sidebar view (file tree)", + ) orelse true; + const workbench_opts = b.addOptions(); + workbench_opts.addOption(bool, "file_tree", workbench_file_tree); + + common.addUpdateStep(b); + + const msvcup_before_compile = velopack.addMsvcupSetupStep(b, ".velopack-msvc"); + const msvcup_setup_step = b.step("msvcup-setup", "Download MSVC SDK into .velopack-msvc/ via velopack-zig (writes zig-libc-*.ini)"); + msvcup_setup_step.dependOn(&msvcup_before_compile.step); + + const zip_pkg = pixi_plugin.zipPackage(b); + + const accesskit = b.option(dvui.AccesskitOptions, "accesskit", "Enable accesskit") orelse .off; + + const assetpack = @import("assetpack"); + const assets_module = assetpack.pack(b, b.path("assets"), .{}); + + // Generated atlas / asset stubs (`src/generated/*.zig`) are imported + // unconditionally by `fizzy.zig`, so the process-assets step has to + // run before any target that touches fizzy.zig — exe, integration + // tests, etc. + const assets_processing = try ProcessAssetsStep.init(b, "assets", "src/core/generated/"); + const process_assets_step = b.step("process-assets", "generates struct for all assets"); + process_assets_step.dependOn(&assets_processing.step); + + // --------------------------------------------------------------- + // Web (wasm) build — entirely separate from the native exe so it can't disturb + // packaging / SDL / Velopack paths. `zig build web` produces `zig-out/web/{web.wasm, + // web.js, index.html, NotoSansKR-Regular.ttf}`, deployable as-is to a static host. + // + // Checkpoint A: minimal placeholder app, no fizzy editor code yet. Later checkpoints + // will incrementally pull fizzy modules in, gating each native-only path behind a + // `arch != .wasm32` check. + // --------------------------------------------------------------- + + web.addSteps(b, optimize, build_opts, workbench_opts, zip_pkg, assets_module); + + const main_fizzy = try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, build_opts, workbench_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, velopack_enabled); + const exe = main_fizzy.exe; + const zstbi_module = main_fizzy.zstbi_module; + const msf_gif_module = main_fizzy.msf_gif_module; + const known_folders = main_fizzy.known_folders; + + const package_fizzy: FizzyExecutable = package_blk: { + if (velopack_enabled) break :package_blk main_fizzy; + if (!velopack_supported_for_target) break :package_blk main_fizzy; + const pack_opts = b.addOptions(); + pack_opts.addOption([]const u8, "app_version", app_version); + pack_opts.addOption([]const u8, "app_repo_url", app_repo_url); + pack_opts.addOption([]const u8, "app_repo_url_fallback", app_repo_url_fallback); + pack_opts.addOption(bool, "velopack_enabled", true); + pack_opts.addOption(bool, "static_pixi", static_pixi); + pack_opts.addOption(bool, "static_workbench", static_workbench); + pack_opts.addOption(bool, "static_code", static_code); + break :package_blk try fizzy_exe.addFizzyExecutableForTarget(b, target, optimize, accesskit, pack_opts, workbench_opts, zip_pkg, assets_module, process_assets_step, macos_sdl_paths, true); + }; + const exe_for_package = package_fizzy.exe; + + if (no_emit) { + b.getInstallStep().dependOn(&exe.step); + if (main_fizzy.pixi_dylib) |pixi_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), pixi_dylib, "pixi", plugins_install_dir); + } + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir); + } + if (main_fizzy.code_dylib) |code_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), code_dylib, "code", plugins_install_dir); + } + } else { + const install_artifact = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = zig_out_install_dir }, + }); + + const run_cmd = b.addRunArtifact(exe); + const run_step = b.step("run", "Run the app (does not run Velopack)"); + + run_cmd.step.dependOn(&install_artifact.step); + run_step.dependOn(&run_cmd.step); + b.getInstallStep().dependOn(&install_artifact.step); + + if (main_fizzy.pixi_dylib) |pixi_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), pixi_dylib, "pixi", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, pixi_dylib, "pixi", plugins_install_dir); + } + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), workbench_dylib, "workbench", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, workbench_dylib, "workbench", plugins_install_dir); + } + if (main_fizzy.code_dylib) |code_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + common.attachBuiltinPluginInstall(b, b.getInstallStep(), code_dylib, "code", plugins_install_dir); + common.attachBuiltinPluginInstall(b, &run_cmd.step, code_dylib, "code", plugins_install_dir); + } + } + + if (main_fizzy.workbench_dylib) |workbench_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_workbench = plugin.installBuiltinPlugin(b, workbench_dylib, "workbench", plugins_install_dir); + const workbench_dylib_step = b.step( + "workbench-dylib", + "Build the workbench plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + workbench_dylib_step.dependOn(&install_workbench.step); + } + + if (main_fizzy.pixi_dylib) |pixi_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_pixi = plugin.installBuiltinPlugin(b, pixi_dylib, "pixi", plugins_install_dir); + + const pixi_dylib_step = b.step( + "pixi-dylib", + "Build the pixi plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + pixi_dylib_step.dependOn(&install_pixi.step); + + const plugin_loader_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/editor/PluginLoader.zig"), + }); + plugin_loader_module.addImport("sdk", main_fizzy.sdk_module); + + const plugin_loader_test_opts = b.addOptions(); + plugin_loader_test_opts.addOptionPath("pixi_dylib", pixi_dylib.getEmittedBin()); + + const plugin_loader_test_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/plugin_loader_integration.zig"), + }); + plugin_loader_test_module.addImport("sdk", main_fizzy.sdk_module); + plugin_loader_test_module.addImport("plugin_loader", plugin_loader_module); + plugin_loader_test_module.addOptions("plugin_loader_test_opts", plugin_loader_test_opts); + + const plugin_loader_tests = b.addTest(.{ + .name = "plugin-loader-tests", + .root_module = plugin_loader_test_module, + }); + const run_plugin_loader_tests = b.addRunArtifact(plugin_loader_tests); + run_plugin_loader_tests.step.dependOn(&pixi_dylib.step); + + const test_plugin_loader_step = b.step( + "test-plugin-loader", + "Build pixi dylib and run dlopen/register integration test", + ); + test_plugin_loader_step.dependOn(&run_plugin_loader_tests.step); + } + + if (main_fizzy.code_dylib) |code_dylib| { + const plugins_install_dir: std.Build.InstallDir = .{ .custom = b.fmt("{s}/plugins", .{zig_out_subdir}) }; + const install_code = plugin.installBuiltinPlugin(b, code_dylib, "code", plugins_install_dir); + const code_dylib_step = b.step( + "code-dylib", + "Build the code plugin as a dynamic library into zig-out//plugins/ (native only)", + ); + code_dylib_step.dependOn(&install_code.step); + } + + _ = package.addSteps(.{ + .b = b, + .target = target, + .optimize = optimize, + .app_version = app_version, + .zig_out_subdir = zig_out_subdir, + .zig_out_install_dir = zig_out_install_dir, + .no_emit = no_emit, + .velopack_required_fail = velopack_required_fail, + .exe_for_package = exe_for_package, + .package_fizzy = package_fizzy, + .macos_sign_app_identity = macos_sign_app_identity, + .macos_sign_install_identity = macos_sign_install_identity, + .macos_notary_profile = macos_notary_profile, + .windows_msvc_libc_opt = windows_msvc_libc_opt, + .fetch_msvc = fetch_msvc, + }); + + // --------------------------------------------------------------- + // Tests + // --------------------------------------------------------------- + // + // Fizzy has two test layers (see tests/README.md): + // + // 1. Unit tests — pure-logic only (math, palette parsing, layer + // order). The test root imports nothing but std + the pure + // modules under test, so it compiles in well under a second + // and never needs dvui/SDL/assets. + // + // 2. Integration tests use dvui's testing backend and exercise + // real fizzy drawing functions in a headless Window. + // + // Both share the same `zig build test` and `zig build check` + // entry points. + + const test_filters = b.option( + []const []const u8, + "test-filter", + "Skip tests that do not match any filter", + ) orelse &[0][]const u8{}; + + const tests_module = b.addModule("fizzy-tests", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/root.zig"), + }); + + // Wire each pure-logic source file as a named module on the test + // target. Zig 0.15 disallows importing source files outside the test + // module's own directory via relative paths, so we expose them by + // name. Each of these files imports only `std`, so they remain free + // of dvui / SDL / globals. + inline for (.{ + .{ "fizzy-direction", "src/core/math/direction.zig" }, + .{ "fizzy-easing", "src/core/math/easing.zig" }, + .{ "fizzy-layer-order", "src/plugins/pixi/src/internal/layer_order.zig" }, + .{ "fizzy-palette-parse", "src/plugins/pixi/src/internal/palette_parse.zig" }, + .{ "fizzy-layout-anchor", "src/core/math/layout_anchor.zig" }, + .{ "fizzy-reduce", "src/plugins/pixi/src/algorithms/reduce.zig" }, + .{ "fizzy-grid-validate", "src/plugins/pixi/src/internal/grid_layout_validate.zig" }, + .{ "fizzy-animation", "src/plugins/pixi/src/Animation.zig" }, + .{ "fizzy-window-layout", "src/backend/window_layout.zig" }, + .{ "fizzy-plugin-dylib", "src/sdk/dylib.zig" }, + }) |entry| { + tests_module.addAnonymousImport(entry[0], .{ + .root_source_file = b.path(entry[1]), + .target = target, + .optimize = optimize, + }); + } + + const unit_tests = b.addTest(.{ + .name = "fizzy-unit-tests", + .root_module = tests_module, + .filters = test_filters, + }); + + // `zig build test` is the CI entry point and must stay self-contained: pure + // unit tests only, no dvui/SDL/Velopack/MSVC. Integration tests live under + // `zig build test-integration` (Velopack + dvui-testing + comctl32 on Windows + // → needs MSVC SDK on Windows hosts). `zig build test-all` runs both. + const test_step = b.step("test", "Run fizzy unit tests (pure-logic only, no dvui/SDL/Velopack)"); + test_step.dependOn(&b.addRunArtifact(unit_tests).step); + + // `check` mirrors the split so editor compile-error checking matches CI. + const check_step = b.step("check", "Compile fizzy unit tests without running them"); + check_step.dependOn(&unit_tests.step); + + // --------------------------------------------------------------- + // Layer 2: headless integration tests against dvui's testing + // backend. Wired under separate `test-integration` / `check-integration` + // steps so `zig build test` stays MSVC-free on Windows CI runners. Skipped + // when cross-compiling to *-windows-msvc without an MSVC libc INI. + // --------------------------------------------------------------- + const test_integration_step = b.step("test-integration", "Run fizzy headless integration tests (dvui-testing; needs MSVC on Windows)"); + const check_integration_step = b.step("check-integration", "Compile fizzy integration tests without running them"); + const test_all_step = b.step("test-all", "Run unit + integration tests"); + test_all_step.dependOn(test_step); + test_all_step.dependOn(test_integration_step); + + const test_sdk_version_step = b.step( + "test-sdk-version", + "Verify SDK version ↔ ABI fingerprint lock (compiles SDK + plugin dylib)", + ); + if (main_fizzy.pixi_dylib) |dylib| { + test_sdk_version_step.dependOn(&dylib.step); + } else { + test_sdk_version_step.dependOn(&exe.step); + } + test_all_step.dependOn(test_sdk_version_step); + + if (velopack_required_fail) |fail_step| { + test_integration_step.dependOn(fail_step); + check_integration_step.dependOn(fail_step); + return; + } + + const dvui_testing_dep = b.dependency("dvui", .{ + .target = target, + .optimize = optimize, + .backend = .testing, + .accesskit = accesskit, + }); + const dvui_test_proxy_bridge = sdk.addProxyBridgeModule(b, target, optimize, dvui_testing_dep, dvui_testing_dep.module("dvui_testing")); + + // Build a module rooted at `src/fizzy.zig` carrying all the same + // imports the production exe carries. Because fizzy.zig's transitive + // imports (App.zig, Editor.zig, …) reference `dvui`, `assets`, + // `known-folders`, etc. by name, those names must be wired here. + // We point dvui at the *testing* backend so calling drawing + // functions doesn't try to open a real OS window. + const fizzy_test_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/fizzy.zig"), + }); + fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing")); + fizzy_test_module.addImport("assets", assets_module); + fizzy_test_module.addImport("known-folders", known_folders); + fizzy_test_module.addOptions("build_opts", build_opts); + fizzy_test_module.addImport("zstbi", zstbi_module); + fizzy_test_module.addImport("msf_gif", msf_gif_module); + fizzy_test_module.addImport("zip", zip_pkg.module); + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + fizzy_test_module.addImport("icons", dep.module("icons")); + } + + // Shared `core` module for the test build (dvui testing backend variant). + const core_module_test = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + }); + core_module_test.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + core_module_test.addImport("known-folders", known_folders); + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + core_module_test.addImport("icons", dep.module("icons")); + } + fizzy_test_module.addImport("core", core_module_test); + const sdk_module_test = sdk.wireSdkModule(b, target, optimize, dvui_testing_dep.module("dvui_testing"), dvui_test_proxy_bridge, core_module_test, fizzy_test_module); + _ = pixi_plugin.addStaticModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_module, + .msf_gif = msf_gif_module, + .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = dvui_testing_dep.module("testing"), + }, fizzy_test_module); + _ = workbench_plugin.addStaticModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + .icons = if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = dvui_testing_dep.module("testing"), + }, workbench_opts, fizzy_test_module); + _ = code_plugin.addStaticModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + }, fizzy_test_module); + _ = example_plugin.addStaticModule(b, target, optimize, .{ + .dvui = dvui_testing_dep.module("dvui_testing"), + .core = core_module_test, + .sdk = sdk_module_test, + }, fizzy_test_module); + + if (target.result.os.tag == .macos) { + if (b.lazyDependency("zig_objc", .{ .target = target, .optimize = optimize })) |dep| { + fizzy_test_module.addImport("objc", dep.module("objc")); + } + } else if (target.result.os.tag == .windows) { + if (b.lazyDependency("zigwin32", .{})) |dep| { + fizzy_test_module.addImport("win32", dep.module("win32")); + } + } + + const integration_module = b.addModule("fizzy-integration-tests", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("tests/integration.zig"), + }); + integration_module.addImport("fizzy", fizzy_test_module); + integration_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); + + const integration_tests = b.addTest(.{ + .name = "fizzy-integration-tests", + .root_module = integration_module, + .filters = test_filters, + }); + + if (target.result.os.tag == .windows) { + integration_tests.root_module.linkSystemLibrary("comctl32", .{}); + } + // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers from + // --libc (vcruntime_typeinfo.h vs libc++ type_info, etc.), so libc++ must be + // off for the msvc ABI regardless of host (cross or native Windows). + integration_tests.root_module.link_libcpp = !target_is_windows_msvc; + pixi_plugin.linkZipNative(integration_tests); + if (velopack_enabled) { + try velopack.linkVelopack(b, integration_tests, .{ .target = target, .optimize = optimize }); + } + + integration_tests.step.dependOn(process_assets_step); + + test_integration_step.dependOn(&b.addRunArtifact(integration_tests).step); + check_integration_step.dependOn(&integration_tests.step); + + if (win_libc.needs_setup) { + exe.step.dependOn(&msvcup_before_compile.step); + if (!velopack_enabled and velopack_supported_for_target) { + exe_for_package.step.dependOn(&msvcup_before_compile.step); + } + integration_tests.step.dependOn(&msvcup_before_compile.step); + unit_tests.step.dependOn(&msvcup_before_compile.step); + } + + if (target.result.os.tag == .windows and target.result.abi == .msvc) { + var roots: [4]*std.Build.Step.Compile = undefined; + var n: usize = 0; + roots[n] = exe; + n += 1; + roots[n] = unit_tests; + n += 1; + roots[n] = integration_tests; + n += 1; + if (!velopack_enabled and velopack_supported_for_target) { + roots[n] = exe_for_package; + n += 1; + } + + // Always apply the translate-c shim + SIZE_MAX define for windows-msvc, regardless of + // whether we're using a downloaded SDK or the host's system MSVC. translate-c uses aro + // (not MSVC cl.exe), and aro rejects literals like `0xffffffffffffffffui64` from MSVC's + // . The shim shadows stdint.h via `-I` (search order beats `-isystem`); the + // defineCMacro adds belt-and-suspenders by predefining SIZE_MAX before any include so + // MSVC's stdint.h `#ifndef SIZE_MAX` skips its own definition entirely. + msvc.applyMsvcTranslateCShim(b, roots[0..n]) catch |e| { + std.debug.panic("MSVC translate-c shim wiring failed: {s}", .{@errorName(e)}); + }; + + if (effective_win_libc) |ini| { + if (cross_win_msvc) b.libc_file = null; + const libc_lp: std.Build.LazyPath = .{ .cwd_relative = ini }; + velopack.applyWindowsMsvcLibcRecursive(b, roots[0..n], libc_lp); + + const ini_exists = blk: { + b.build_root.handle.access(b.graph.io, ini, .{}) catch break :blk false; + break :blk true; + }; + if (ini_exists) { + // Adds explicit MSVC/UCRT/SDK `-isystem` paths from the libc INI to each reachable + // translate-c step. Only relevant when cross-compiling with .velopack-msvc/; on a + // Windows host with system MSVC, Zig auto-discovers these paths itself. + msvc.applyMsvcIncludesToReachableTranslateC(b, roots[0..n], ini) catch |e| { + std.debug.panic("MSVC translate-c include fixup failed: {s}", .{@errorName(e)}); + }; + } else { + // The INI is written by `msvcup-setup` (a make-phase step), but the translate-c + // `-isystem` paths embed the SDK version subdir, which is only known after the SDK + // is installed — so they must be wired at configure time, before that step runs. + // A one-shot `zig build package -Dfetch-msvc` against a clean .velopack-msvc can't + // satisfy that ordering. Fail only the compiles that need it (not `msvcup-setup`, + // which has no such dependency), so running setup first still works. + const fail = &b.addFail( + \\*-windows-msvc has no .velopack-msvc/zig-libc INI yet, so translate-c can't be wired. + \\The SDK install must run as its own step before packaging (it can't be done in one + \\pass — the translate-c include paths depend on the installed SDK version): + \\ zig build msvcup-setup + \\ zig build package -Dtarget=x86_64-windows-msvc + ).step; + for (roots[0..n]) |rc| rc.step.dependOn(fail); + } + } + } +} diff --git a/build/common.zig b/build/common.zig new file mode 100644 index 00000000..f0cad19c --- /dev/null +++ b/build/common.zig @@ -0,0 +1,125 @@ +const std = @import("std"); + +const plugin = @import("../plugin_sdk.zig"); +const update = @import("../update.zig"); +const GitDependency = update.GitDependency; + +/// Install `{id}.{ext}` flat under a `plugins/` directory (no `lib` prefix). +pub fn attachBuiltinPluginInstall( + b: *std.Build, + parent: *std.Build.Step, + dylib: *std.Build.Step.Compile, + id: []const u8, + plugins_dir: std.Build.InstallDir, +) void { + parent.dependOn(&plugin.installBuiltinPlugin(b, dylib, id, plugins_dir).step); +} + +pub fn addUpdateStep(b: *std.Build) void { + const step = b.step("update", "update git dependencies"); + step.makeFn = updateStep; +} + +fn updateStep(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { + const deps = &.{ + GitDependency{ + .url = "https://github.com/foxnne/zig-objc", + .branch = "main", + }, + GitDependency{ + .url = "https://github.com/kristoff-it/zigwin32", + .branch = "fix/zig16", + }, + GitDependency{ + .url = "https://github.com/foxnne/zig-lib-icons", + .branch = "dvui", + }, + GitDependency{ + .url = "https://github.com/foxnne/dvui-dev", + .branch = "main", + }, + }; + try update.update_dependency(step.owner.allocator, step.owner.graph.io, deps); +} + +/// Installed artifacts go under `zig-out//…` so `packageall` and parallel targets never clobber each other. +pub fn zigOutSubdirForTarget(b: *std.Build, rt: std.Build.ResolvedTarget) []const u8 { + const arch_name: []const u8 = switch (rt.result.cpu.arch) { + .x86_64 => "x86-64", + .aarch64 => "arm64", + else => @tagName(rt.result.cpu.arch), + }; + const os_name: []const u8 = switch (rt.result.os.tag) { + .windows => "windows", + .linux => "linux", + .macos => "macos", + else => @tagName(rt.result.os.tag), + }; + const base = b.fmt("{s}-{s}", .{ arch_name, os_name }); + if (std.mem.indexOfScalar(u8, base, '_') == null) + return base; + const buf = b.allocator.alloc(u8, base.len) catch @panic("OOM"); + @memcpy(buf, base); + for (buf) |*byte| { + if (byte.* == '_') byte.* = '-'; + } + return buf; +} + +/// SDL (via dvui → lazy `sdl3`) requires SDK layout when `-Dtarget=*-macos` is not "native". +pub const MacosSdlPaths = struct { + include: std.Build.LazyPath, + framework: std.Build.LazyPath, + lib: std.Build.LazyPath, +}; + +fn resolveMacosSdkPath(b: *std.Build) ![]const u8 { + if (b.graph.environ_map.get("SDKROOT")) |sdk| { + const trimmed = std.mem.trim(u8, sdk, " \t\r\n"); + if (trimmed.len > 0) { + return b.dupePath(trimmed); + } + } + + const argv: []const []const u8 = &.{ + "xcrun", + "--sdk", + "macosx", + "--show-sdk-path", + }; + const run = try std.process.run(b.allocator, b.graph.io, .{ + .argv = argv, + .stdout_limit = std.Io.Limit.limited(4096), + .stderr_limit = std.Io.Limit.limited(4096), + }); + defer { + b.allocator.free(run.stdout); + b.allocator.free(run.stderr); + } + switch (run.term) { + .exited => |code| if (code != 0) { + std.log.err("SDL on macOS: explicit -Dtarget=*-macos needs an SDK path. xcrun exited with code {d}. Install Xcode Command Line Tools or set SDKROOT.", .{code}); + return error.MacosSdkPath; + }, + else => { + std.log.err("SDL on macOS: xcrun --show-sdk-path failed", .{}); + return error.MacosSdkPath; + }, + } + const path = std.mem.trimEnd(u8, run.stdout, " \t\r\n"); + if (path.len == 0) return error.MacosSdkPath; + return b.dupePath(path); +} + +pub fn macosSdlPathsForExplicitTarget(b: *std.Build, target: std.Build.ResolvedTarget) !?MacosSdlPaths { + if (target.result.os.tag != .macos) return null; + if (b.graph.host.result.os.tag != .macos) return null; + if (target.query.isNative()) return null; + + const sdk = try resolveMacosSdkPath(b); + return MacosSdlPaths{ + .include = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) }, + .framework = .{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }, + .lib = .{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) }, + }; +} diff --git a/build/exe.zig b/build/exe.zig new file mode 100644 index 00000000..eea65f93 --- /dev/null +++ b/build/exe.zig @@ -0,0 +1,303 @@ +const std = @import("std"); +const dvui = @import("dvui"); +const velopack = @import("velopack_zig"); +const plugin = @import("../plugin_sdk.zig"); +const common = @import("common.zig"); +const plugins = @import("plugins.zig"); +const sdk = @import("sdk.zig"); + +const pixi_plugin = plugins.pixi; +const workbench_plugin = plugins.workbench; +const code_plugin = plugins.code; +const example_plugin = plugins.example; +const ZipPackage = plugins.ZipPackage; +const MacosSdlPaths = common.MacosSdlPaths; + +/// Install stripped exe + built-in plugin dylibs for `vpk pack --packDir`. +pub fn addVelopackPackDirInstall( + b: *std.Build, + exe: *std.Build.Step.Compile, + fizzy: FizzyExecutable, + pack_input_subdir: []const u8, + pack_plugins_subdir: []const u8, + after_step: *std.Build.Step, +) *std.Build.Step { + const pack_exe_install_dir: std.Build.InstallDir = .{ .custom = pack_input_subdir }; + const pack_plugins_install_dir: std.Build.InstallDir = .{ .custom = pack_plugins_subdir }; + + const install_pack_exe = b.addInstallArtifact(exe, .{ + .dest_dir = .{ .override = pack_exe_install_dir }, + }); + install_pack_exe.step.dependOn(after_step); + + var tail: *std.Build.Step = &install_pack_exe.step; + + if (fizzy.pixi_dylib) |dylib| { + const install_pixi = plugin.installBuiltinPlugin(b, dylib, "pixi", pack_plugins_install_dir); + install_pixi.step.dependOn(tail); + tail = &install_pixi.step; + } + if (fizzy.workbench_dylib) |dylib| { + const install_workbench = plugin.installBuiltinPlugin(b, dylib, "workbench", pack_plugins_install_dir); + install_workbench.step.dependOn(tail); + tail = &install_workbench.step; + } + if (fizzy.code_dylib) |dylib| { + const install_code = plugin.installBuiltinPlugin(b, dylib, "code", pack_plugins_install_dir); + install_code.step.dependOn(tail); + tail = &install_code.step; + } + + return tail; +} + +pub const FizzyExecutable = struct { + exe: *std.Build.Step.Compile, + zstbi_module: *std.Build.Module, + msf_gif_module: *std.Build.Module, + known_folders: *std.Build.Module, + sdk_module: *std.Build.Module, + /// Native-only; `null` on wasm targets. + pixi_dylib: ?*std.Build.Step.Compile = null, + workbench_dylib: ?*std.Build.Step.Compile = null, + code_dylib: ?*std.Build.Step.Compile = null, +}; + +pub fn addFizzyExecutableForTarget( + b: *std.Build, + resolved_target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + accesskit: dvui.AccesskitOptions, + build_opts: *std.Build.Step.Options, + workbench_opts: *std.Build.Step.Options, + zip_pkg: ZipPackage, + assets_module: *std.Build.Module, + process_assets_step: *std.Build.Step, + macos_sdl_paths: ?MacosSdlPaths, + velopack_enabled: bool, +) !FizzyExecutable { + const dvui_dep = if (macos_sdl_paths) |p| + b.dependency("dvui", .{ + .target = resolved_target, + .optimize = optimize, + .backend = .sdl3, + .accesskit = accesskit, + .system_include_path = p.include, + .system_framework_path = p.framework, + .library_path = p.lib, + }) + else + b.dependency("dvui", .{ .target = resolved_target, .optimize = optimize, .backend = .sdl3, .accesskit = accesskit }); + + const dvui_proxy_dep = b.dependency("dvui", .{ + .target = resolved_target, + .optimize = optimize, + .backend = .proxy, + .accesskit = .off, + }); + const dvui_proxy_mod = dvui_proxy_dep.module("dvui_proxy"); + const proxy_bridge_host_mod = sdk.addProxyBridgeModule(b, resolved_target, optimize, dvui_dep, dvui_dep.module("dvui_sdl3")); + const proxy_bridge_plugin_mod = dvui_proxy_dep.module("proxy_bridge"); + + const zstbi_module = pixi_plugin.addZstbiModule(b, resolved_target, optimize, false); + const msf_gif_module = pixi_plugin.addMsfGifModule(b, resolved_target, optimize, false); + + const exe = b.addExecutable(.{ + .name = "fizzy", + .root_module = b.addModule("App", .{ + .target = resolved_target, + .optimize = optimize, + .root_source_file = .{ .cwd_relative = "src/App.zig" }, + }), + }); + exe.root_module.strip = false; + + exe.root_module.addImport("assets", assets_module); + const known_folders = b.dependency("known_folders", .{ + .target = resolved_target, + .optimize = optimize, + }).module("known-folders"); + exe.root_module.addImport("known-folders", known_folders); + exe.root_module.addOptions("build_opts", build_opts); + exe.step.dependOn(process_assets_step); + + if (optimize != .Debug) { + switch (resolved_target.result.os.tag) { + .windows => { + exe.subsystem = .Windows; + // MSVC's libcmt links `WinMainCRTStartup` (needs `WinMain`) for /SUBSYSTEM:WINDOWS. + // Fizzy exposes `main`, so force the C `main` entry which works for either subsystem. + if (resolved_target.result.abi == .msvc) { + exe.entry = .{ .symbol_name = "mainCRTStartup" }; + } + }, + else => exe.subsystem = .Posix, + } + } + + exe.root_module.addImport("zstbi", zstbi_module); + exe.root_module.addImport("msf_gif", msf_gif_module); + exe.root_module.addImport("zip", zip_pkg.module); + exe.root_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); + exe.root_module.addImport("backend", dvui_dep.module("sdl3")); + + // Shared `core` module (gfx/math/fs/generated atlas/platform/paths/dvui hub + + // generic widgets). Imports only `dvui`, `icons`, and `known-folders`. + const core_module = b.createModule(.{ + .target = resolved_target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + }); + core_module.addImport("dvui", dvui_dep.module("dvui_sdl3")); + core_module.addImport("known-folders", known_folders); + exe.root_module.addImport("core", core_module); + + var icons_module: ?*std.Build.Module = null; + if (b.lazyDependency("icons", .{ .target = resolved_target, .optimize = optimize })) |dep| { + exe.root_module.addImport("icons", dep.module("icons")); + core_module.addImport("icons", dep.module("icons")); + icons_module = dep.module("icons"); + } + + const core_proxy_module = b.createModule(.{ + .target = resolved_target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + }); + core_proxy_module.addImport("dvui", dvui_proxy_mod); + core_proxy_module.addImport("known-folders", known_folders); + if (icons_module) |icons| core_proxy_module.addImport("icons", icons); + + const sdk_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_dep.module("dvui_sdl3"), proxy_bridge_host_mod, core_module, exe.root_module); + const sdk_proxy_module = sdk.wireSdkModule(b, resolved_target, optimize, dvui_proxy_mod, proxy_bridge_plugin_mod, core_proxy_module, null); + _ = pixi_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_module, + .msf_gif = msf_gif_module, + .icons = icons_module, + .backend = dvui_dep.module("sdl3"), + }, exe.root_module); + _ = workbench_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + .icons = icons_module, + .backend = dvui_dep.module("sdl3"), + }, workbench_opts, exe.root_module); + _ = code_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + }, exe.root_module); + _ = example_plugin.addStaticModule(b, resolved_target, optimize, .{ + .dvui = dvui_dep.module("dvui_sdl3"), + .core = core_module, + .sdk = sdk_module, + }, exe.root_module); + + const pixi_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + const dylib = pixi_plugin.addDylib(b, resolved_target, optimize, .{ + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_module, + .msf_gif = msf_gif_module, + .icons = icons_module, + .backend = null, + }); + pixi_plugin.linkZipNative(dylib); + break :blk dylib; + } else null; + + const workbench_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk workbench_plugin.addDylib(b, resolved_target, optimize, .{ + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, + .icons = icons_module, + .backend = null, + }, workbench_opts); + } else null; + + const code_dylib: ?*std.Build.Step.Compile = if (resolved_target.result.cpu.arch != .wasm32) blk: { + break :blk code_plugin.addDylib(b, resolved_target, optimize, .{ + .dvui = dvui_proxy_mod, + .core = core_proxy_module, + .sdk = sdk_proxy_module, + .proxy_bridge = proxy_bridge_plugin_mod, + }); + } else null; + + const singleton_app_dep = b.dependency("dvui_singleton_app", .{ + .target = resolved_target, + .optimize = optimize, + }); + exe.root_module.addImport("singleton_app", singleton_app_dep.module("singleton_app")); + + if (resolved_target.result.os.tag == .macos) { + if (macos_sdl_paths) |p| { + // Non-"native" macOS targets (`-Dtarget=aarch64-macos` on Apple Silicon, etc.) need the + // same SDK layout for Obj-C sources as for SDL; zig-objc paths do not always reach .m + // compiles (e.g. Security.framework → ). + exe.root_module.addSystemIncludePath(p.include); + exe.root_module.addSystemFrameworkPath(p.framework); + exe.root_module.addLibraryPath(p.lib); + } + if (b.lazyDependency("zig_objc", .{ + .target = resolved_target, + .optimize = optimize, + })) |dep| { + exe.root_module.addImport("objc", dep.module("objc")); + } + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyVisualEffectView.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyMenuTarget.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyTrackpadGesture.m") }); + exe.root_module.addCSourceFile(.{ .file = std.Build.path(b, "src/backend/objc/FizzyWindowMonitor.m") }); + } else if (resolved_target.result.os.tag == .windows) { + if (b.lazyDependency("zigwin32", .{})) |dep| { + exe.root_module.addImport("win32", dep.module("win32")); + } + exe.root_module.linkSystemLibrary("comctl32", .{}); + + // Embed assets/windows/fizzy.rc -> fizzy.ico into the exe so Explorer, + // Taskbar, Alt-Tab and the Velopack-generated Start Menu shortcut all + // show the right icon without any runtime work. fizzy.ico must be a + // multi-resolution ICO with 16/32/48/256 px frames (see the README in + // that directory). + exe.root_module.addWin32ResourceFile(.{ + .file = b.path("assets/windows/fizzy.rc"), + }); + } + + // Zig's bundled libc++/libcxxabi cannot compile against MSVC headers + // (vcruntime_typeinfo.h's ::type_info vs libc++'s own, redefined bad_cast, + // etc.). We always feed MSVC's own STL via --libc for *-windows-msvc — on a + // cross host and on a native Windows host using .velopack-msvc alike — so + // libc++ must be off for the msvc ABI regardless of host. + const exe_is_windows_msvc = resolved_target.result.os.tag == .windows and + resolved_target.result.abi == .msvc; + exe.root_module.link_libcpp = !exe_is_windows_msvc; + pixi_plugin.linkZipNative(exe); + if (velopack_enabled) { + try velopack.linkVelopack(b, exe, .{ .target = resolved_target, .optimize = optimize }); + } + + return .{ + .exe = exe, + .zstbi_module = zstbi_module, + .msf_gif_module = msf_gif_module, + .known_folders = known_folders, + .sdk_module = sdk_module, + .pixi_dylib = pixi_dylib, + .workbench_dylib = workbench_dylib, + .code_dylib = code_dylib, + }; +} diff --git a/build/msvc.zig b/build/msvc.zig new file mode 100644 index 00000000..a4ec853c --- /dev/null +++ b/build/msvc.zig @@ -0,0 +1,107 @@ +const std = @import("std"); + +pub fn applyMsvcTranslateCShim(b: *std.Build, roots: []const *std.Build.Step.Compile) !void { + var seen = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); + defer seen.deinit(); + for (roots) |root_compile| { + const graph = root_compile.root_module.getGraph(); + for (graph.modules) |mod| { + const root_src = mod.root_source_file orelse continue; + const gen = switch (root_src) { + .generated => |g| g, + else => continue, + }; + const dep_step = gen.file.step; + if (dep_step.id != .translate_c) continue; + const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); + const gop = try seen.getOrPut(tc); + if (gop.found_existing) continue; + const rt = tc.target.result; + if (rt.os.tag != .windows or rt.abi != .msvc) continue; + // `-I` searches before `-isystem`, so this shim wins over MSVC's . + tc.addIncludePath(b.path("src/backend/msvc_translatec_shim")); + // Pre-define SIZE_MAX so MSVC's stdint.h `#ifndef SIZE_MAX` block — which would + // otherwise install a `0xff…ui64` literal — skips itself. Belt-and-suspenders + // to the shim: covers the case where another header includes through + // a path that bypasses our shim. + tc.defineCMacro("SIZE_MAX", switch (rt.ptrBitWidth()) { + 32 => "4294967295U", + 64 => "18446744073709551615ULL", + else => "UINT_MAX", + }); + } + } +} + +/// Finds every `Step.TranslateC` reachable from each root compile's Zig module graph and adds +/// MSVC / Windows SDK `-isystem` paths from the zig-libc INI. We walk `Module.getGraph()` (imports) +/// rather than `Step.dependencies`: Zig wires `root_source_file` → `TranslateC` only in +/// `createModuleDependencies`, which runs after `build()` returns, so a step BFS from `Compile` +/// would miss DVUI's `dvui-c` / `sdl3-c` translate steps during Configure. +pub fn applyMsvcIncludesToReachableTranslateC( + b: *std.Build, + roots: []const *std.Build.Step.Compile, + libc_ini_path: []const u8, +) !void { + // `libc_ini_path` is absolute (resolved via `b.pathFromRoot`), so any Dir works as the base. + const data = try b.build_root.handle.readFileAlloc(b.graph.io, libc_ini_path, b.allocator, .unlimited); + + var include_dir: ?[]const u8 = null; + var sys_include_dir: ?[]const u8 = null; + var line_it = std.mem.splitScalar(u8, data, '\n'); + while (line_it.next()) |raw| { + const line = std.mem.trim(u8, raw, " \r\t"); + if (std.mem.startsWith(u8, line, "include_dir=")) { + include_dir = std.mem.trim(u8, line["include_dir=".len..], " \r\t"); + } else if (std.mem.startsWith(u8, line, "sys_include_dir=")) { + sys_include_dir = std.mem.trim(u8, line["sys_include_dir=".len..], " \r\t"); + } + } + if (include_dir == null or sys_include_dir == null) return; + + // `include_dir` points at `.../Windows Kits/10/Include//ucrt`. The Windows SDK's + // um/shared/winrt headers live as siblings of the `ucrt` directory. + const sdk_inc_root = std.fs.path.dirname(include_dir.?) orelse return; + const um_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "um" }); + const shared_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "shared" }); + const winrt_dir = try std.fs.path.join(b.allocator, &.{ sdk_inc_root, "winrt" }); + + var seen_translate_c = std.AutoHashMap(*std.Build.Step.TranslateC, void).init(b.allocator); + defer seen_translate_c.deinit(); + + for (roots) |root_compile| { + const graph = root_compile.root_module.getGraph(); + for (graph.modules) |mod| { + const root_src = mod.root_source_file orelse continue; + const gen = switch (root_src) { + .generated => |g| g, + else => continue, + }; + const dep_step = gen.file.step; + if (dep_step.id != .translate_c) continue; + + const tc: *std.Build.Step.TranslateC = @fieldParentPtr("step", dep_step); + const gop = try seen_translate_c.getOrPut(tc); + if (gop.found_existing) continue; + + const rt = tc.target.result; + if (rt.os.tag == .windows and rt.abi == .msvc) { + // `translate-c` has no API to pass `--libc `, so `-lc` makes Zig + // auto-detect a system MSVC/SDK install — which fails on a Windows host + // that has no Visual Studio (we use the .velopack-msvc/ tree instead) with + // `WindowsSdkNotFound`. Drop `-lc` here: every MSVC/UCRT/SDK include dir is + // added explicitly below, so the headers still resolve, and the consuming + // exe links libc itself — the translated bindings don't need their own. + tc.link_libc = false; + // Shim + SIZE_MAX define are applied separately by `applyMsvcTranslateCShim`. + // Order matters: MSVC's own headers first (override Windows SDK declarations + // when both exist), then UCRT, then the Windows SDK trio. + tc.addSystemIncludePath(.{ .cwd_relative = sys_include_dir.? }); + tc.addSystemIncludePath(.{ .cwd_relative = include_dir.? }); + tc.addSystemIncludePath(.{ .cwd_relative = um_dir }); + tc.addSystemIncludePath(.{ .cwd_relative = shared_dir }); + tc.addSystemIncludePath(.{ .cwd_relative = winrt_dir }); + } + } + } +} diff --git a/build/package.zig b/build/package.zig new file mode 100644 index 00000000..267b5241 --- /dev/null +++ b/build/package.zig @@ -0,0 +1,261 @@ +const std = @import("std"); +const velopack = @import("velopack_zig"); +const exe = @import("exe.zig"); + +pub const Options = struct { + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + app_version: []const u8, + zig_out_subdir: []const u8, + zig_out_install_dir: std.Build.InstallDir, + no_emit: bool, + velopack_required_fail: ?*std.Build.Step, + exe_for_package: *std.Build.Step.Compile, + package_fizzy: exe.FizzyExecutable, + macos_sign_app_identity: ?[]const u8, + macos_sign_install_identity: ?[]const u8, + macos_notary_profile: ?[]const u8, + windows_msvc_libc_opt: ?[]const u8, + fetch_msvc: bool, +}; + +pub fn addSteps(opts: Options) *std.Build.Step { + const b = opts.b; + const target = opts.target; + const optimize = opts.optimize; + const app_version = opts.app_version; + const zig_out_subdir = opts.zig_out_subdir; + const zig_out_install_dir = opts.zig_out_install_dir; + const no_emit = opts.no_emit; + const velopack_required_fail = opts.velopack_required_fail; + const exe_for_package = opts.exe_for_package; + const package_fizzy = opts.package_fizzy; + const macos_sign_app_identity = opts.macos_sign_app_identity; + const macos_sign_install_identity = opts.macos_sign_install_identity; + const macos_notary_profile = opts.macos_notary_profile; + const windows_msvc_libc_opt = opts.windows_msvc_libc_opt; + const fetch_msvc = opts.fetch_msvc; + + const package_step = b.step("package", "Velopack release artifacts (strip + vpk); not part of install or run"); + // The default native target on a Windows host resolves to x86_64-windows-gnu, + // for which `velopack_supported_for_target` is false — exe_for_package falls + // back to the plain (Velopack-less) exe. vpk would still wrap it as a Velopack + // installer, but the install hook never runs: Setup.exe hangs with "the + // application install hook failed". Fail loudly instead of shipping that trap. + const windows_non_msvc = target.result.os.tag == .windows and target.result.abi != .msvc; + if (velopack_required_fail) |fail_step| { + package_step.dependOn(fail_step); + } else if (windows_non_msvc) { + package_step.dependOn(&b.addFail( + \\`zig build package` for Windows requires the MSVC ABI so Velopack is linked. + \\The default native target resolves to x86_64-windows-gnu, which builds a binary + \\WITHOUT the Velopack runtime. vpk would still wrap it as a Velopack installer, but + \\the install hook never runs and Setup.exe hangs ("the application install hook failed"). + \\ + \\Build with the MSVC target instead: + \\ zig build package -Dtarget=x86_64-windows-msvc -Dfetch-msvc + \\(needs Windows SDK 10.0.26100+ for SDL's GameInput backend.) + ).step); + } else if (no_emit) { + package_step.dependOn(&b.addFail("cannot run `package` with -Dno-emit").step); + } else switch (target.result.os.tag) { + .linux, .macos, .windows => { + // Host strip can't process foreign object files when cross-compiling. + const cross_os = target.result.os.tag != b.graph.host.result.os.tag; + // Same-OS / different-arch (e.g. aarch64-linux from x86_64-linux) also + // breaks host strip — it errors with "Unable to recognise the format". + const cross_for_strip = cross_os or target.result.cpu.arch != b.graph.host.result.cpu.arch; + // Windows hosts don't ship `strip` or `touch`. Skip the external strip + // step entirely there — Zig's linker already drops debug info in + // release builds. Use `cmd /c exit 0` as the no-op and keep the + // dependency on exe_for_package via the step graph. + const host_is_windows = b.graph.host.result.os.tag == .windows; + const skip_strip = host_is_windows or optimize == .Debug or cross_for_strip; + const strip_release_sh = if (host_is_windows) blk: { + const sh = b.addSystemCommand(&.{ "cmd", "/c", "exit", "0" }); + sh.step.dependOn(&exe_for_package.step); + break :blk sh; + } else blk: { + const sh = b.addSystemCommand(&.{if (skip_strip) "touch" else "strip"}); + sh.addFileArg(exe_for_package.getEmittedBin()); + break :blk sh; + }; + + //const dotnet_tool_restore = velopack.addDotnetToolRestoreStep(b); + //const vpk_vendor_repair = velopack.addVpkVendorRepairStep(b); + //vpk_vendor_repair.step.dependOn(&dotnet_tool_restore.step); + + const vpk_pkg_sh = b.addSystemCommand(&.{"dotnet"}); + vpk_pkg_sh.addArg("vpk"); + // When packaging a foreign-OS bundle, vpk needs an OS directive (e.g. `vpk [win] pack ...`) + // because by default it auto-detects from the host OS. + if (cross_os) { + vpk_pkg_sh.addArg(switch (target.result.os.tag) { + .windows => "[win]", + .linux => "[linux]", + .macos => "[osx]", + else => unreachable, + }); + } + vpk_pkg_sh.addArg("pack"); + vpk_pkg_sh.addArg("--packId"); + vpk_pkg_sh.addArg("fizzy"); + vpk_pkg_sh.addArg("--packVersion"); + vpk_pkg_sh.addArg(app_version); + // Channel = zig-out subdir (`-`, NuGet-safe — no underscores). Baked into + // the binary by vpk; the updater matches this to release assets. Distinct per triple + // so parallel `vpk pack` runs don't collide on RELEASES / nupkg names. + vpk_pkg_sh.addArg("--channel"); + vpk_pkg_sh.addArg(zig_out_subdir); + vpk_pkg_sh.addArg("--mainExe"); + vpk_pkg_sh.addArg(switch (target.result.os.tag) { + .windows => "fizzy.exe", + else => "fizzy", + }); + + vpk_pkg_sh.addArg("--delta"); + vpk_pkg_sh.addArg("None"); + vpk_pkg_sh.addArg("--yes"); + + vpk_pkg_sh.addArg("--outputDir"); + // `addOutputDirectoryArg` takes a basename — Zig manages the actual + // path under the run step's cache dir. The `addInstallDirectory` + // below copies that into zig-out//. Previously this passed + // the full install path, which produced `.zig-cache\o\\C:\...` + // on Windows (BadPathName). + const vpk_pkg_out_dir = vpk_pkg_sh.addOutputDirectoryArg("desktop"); + // Stage exe + built-in plugin dylibs under zig-out//.pack-input/ + // so vpk ships plugins/ next to the main binary. + const pack_input_subdir = b.fmt("{s}/.pack-input", .{zig_out_subdir}); + const pack_plugins_subdir = b.fmt("{s}/.pack-input/plugins", .{zig_out_subdir}); + const pack_stage_tail = exe.addVelopackPackDirInstall( + b, + exe_for_package, + package_fizzy, + pack_input_subdir, + pack_plugins_subdir, + &strip_release_sh.step, + ); + vpk_pkg_sh.addArg("--packDir"); + vpk_pkg_sh.addArg(b.getInstallPath(.{ .custom = pack_input_subdir }, "")); + switch (target.result.os.tag) { + .windows => { + // Sets the installer's icon and the Start Menu shortcut icon. The + // exe's own icon is already embedded via assets/windows/fizzy.rc. + vpk_pkg_sh.addArg("--icon"); + const ico_path = b.path("assets/windows/fizzy.ico").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("ico path: {}", .{e}); + vpk_pkg_sh.addArg(ico_path); + // Velopack's installer is silent (no shortcut-choice UI). Default is + // Desktop,StartMenu; restrict to StartMenu so we don't drop an + // unrequested icon on the user's desktop. + vpk_pkg_sh.addArg("--shortcuts"); + vpk_pkg_sh.addArg("StartMenu"); + }, + .macos => { + vpk_pkg_sh.addArg("--packTitle"); + vpk_pkg_sh.addArg("fizzy"); + // Bundle id / document types / versions: assets/macos/info.plist (vpk rejects --bundleId with --plist). + vpk_pkg_sh.addArg("--plist"); + const plist_path = b.path("assets/macos/info.plist").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("plist path: {}", .{e}); + vpk_pkg_sh.addArg(plist_path); + vpk_pkg_sh.addArg("--icon"); + const icns_path = b.path("assets/macos/fizzy.icns").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("icns path: {}", .{e}); + vpk_pkg_sh.addArg(icns_path); + + if (macos_sign_app_identity) |id| { + vpk_pkg_sh.addArg("--signAppIdentity"); + vpk_pkg_sh.addArg(id); + // Required for notarization: enables hardened runtime + secure timestamp on + // every nested binary (vpk forwards the file to `codesign --entitlements`). + // Without this, Apple's notary service rejects with "signature does not + // include a secure timestamp" / "hardened runtime not enabled". + vpk_pkg_sh.addArg("--signEntitlements"); + const entitlements_path = b.path("assets/macos/Fizzy.entitlements").getPath3(b, &vpk_pkg_sh.step).toString(b.allocator) catch |e| std.debug.panic("entitlements path: {}", .{e}); + vpk_pkg_sh.addArg(entitlements_path); + } + if (macos_sign_install_identity) |id| { + vpk_pkg_sh.addArg("--signInstallIdentity"); + vpk_pkg_sh.addArg(id); + } + if (macos_notary_profile) |profile| { + vpk_pkg_sh.addArg("--notaryProfile"); + vpk_pkg_sh.addArg(profile); + } + }, + else => {}, + } + vpk_pkg_sh.setEnvironmentVariable("DOTNET_ROLL_FORWARD", "Major"); + // Stream vpk's stdout/stderr live so failures surface their actual + // diagnostic instead of just an exit-code-N message from the build + // runner. With `addOutputDirectoryArg` in play, `infer_from_args` + // can otherwise capture+drop stdio on certain runner configs. + vpk_pkg_sh.stdio = .inherit; + try velopack.attachMksquashfsToVpkRun(b, vpk_pkg_sh, target); + + //vpk_pkg_sh.step.dependOn(&vpk_vendor_repair.step); + vpk_pkg_sh.step.dependOn(pack_stage_tail); + + const build_package_install = b.addInstallDirectory(.{ + .source_dir = vpk_pkg_out_dir, + .install_dir = zig_out_install_dir, + .install_subdir = "", + }); + + package_step.dependOn(&build_package_install.step); + }, + else => { + package_step.dependOn(&b.addFail("Velopack packaging is only supported for Linux, macOS, and Windows targets").step); + }, + } + + const desktop_step = b.step("desktop", "Alias for `zig build package`"); + desktop_step.dependOn(package_step); + + const packageall_step = b.step("packageall", "Six zig build package runs; use -Dwindows-msvc-libc= or -Dfetch-msvc for Windows children from macOS/Linux"); + if (no_emit) { + packageall_step.dependOn(&b.addFail("cannot run `packageall` with -Dno-emit").step); + } else { + const packageall_optimize_arg = b.fmt("-Doptimize={s}", .{@tagName(optimize)}); + + // Build order is deliberately fail-fast: Windows first (most likely to + // fail on a fresh CI runner because of MSVC SDK setup, libc.ini paths, + // and cross-compile ABI surprises), then Linux (mksquashfs / AppImage + // packaging quirks), then macOS last (native, lowest risk). When a + // release run is going to break, this ordering surfaces the failure + // 5-10 minutes sooner than the alphabetical order did. + const packageall_triples = [_][]const u8{ + "x86_64-windows-msvc", + "aarch64-windows-msvc", + "x86_64-linux-gnu", + "aarch64-linux-gnu", + "x86_64-macos", + "aarch64-macos", + }; + + var prev_step: ?*std.Build.Step = null; + for (packageall_triples) |triple| { + const zig_pkg_run = b.addSystemCommand(&.{ + b.graph.zig_exe, + "build", + "package", + packageall_optimize_arg, + b.fmt("-Dtarget={s}", .{triple}), + }); + if (std.mem.endsWith(u8, triple, "-windows-msvc")) { + if (windows_msvc_libc_opt) |libc_path| { + zig_pkg_run.addArg(b.fmt("-Dwindows-msvc-libc={s}", .{libc_path})); + } + if (fetch_msvc) zig_pkg_run.addArg("-Dfetch-msvc"); + } + zig_pkg_run.setCwd(b.path(".")); + if (prev_step) |p| { + zig_pkg_run.step.dependOn(p); + } + prev_step = &zig_pkg_run.step; + } + packageall_step.dependOn(prev_step.?); + } + + return package_step; +} diff --git a/build/plugins.zig b/build/plugins.zig new file mode 100644 index 00000000..707ab413 --- /dev/null +++ b/build/plugins.zig @@ -0,0 +1,12 @@ +//! Built-in plugin build integration — the static-embed + bundled-dylib module graph. +//! +//! Each built-in plugin keeps its fizzy-internal static-embed glue self-contained in +//! `src/plugins//static/integration.zig`, separate from the canonical third-party files +//! at the plugin-folder root (the shell's `@import("")` resolves to the root +//! `.zig`). Fizzy root aggregates those integration files here. +pub const pixi = @import("../src/plugins/pixi/static/integration.zig"); +pub const workbench = @import("../src/plugins/workbench/static/integration.zig"); +pub const code = @import("../src/plugins/code/static/integration.zig"); +pub const example = @import("../src/plugins/example/static/integration.zig"); + +pub const ZipPackage = pixi.ZipPackage; diff --git a/build/sdk.zig b/build/sdk.zig new file mode 100644 index 00000000..82490947 --- /dev/null +++ b/build/sdk.zig @@ -0,0 +1,38 @@ +const std = @import("std"); + +pub fn addProxyBridgeModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui_dep: *std.Build.Dependency, + dvui_module: *std.Build.Module, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = dvui_dep.path("src/backends/proxy_bridge.zig"), + }); + mod.addImport("dvui", dvui_module); + return mod; +} + +pub fn wireSdkModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + dvui_module: *std.Build.Module, + proxy_bridge_module: *std.Build.Module, + core_module: *std.Build.Module, + consumer: ?*std.Build.Module, +) *std.Build.Module { + const sdk_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/sdk/sdk.zig"), + }); + sdk_module.addImport("dvui", dvui_module); + sdk_module.addImport("proxy_bridge", proxy_bridge_module); + sdk_module.addImport("core", core_module); + if (consumer) |c| c.addImport("sdk", sdk_module); + return sdk_module; +} diff --git a/build/web.zig b/build/web.zig new file mode 100644 index 00000000..5d2ab5e2 --- /dev/null +++ b/build/web.zig @@ -0,0 +1,210 @@ +const std = @import("std"); +const plugins = @import("plugins.zig"); +const sdk = @import("sdk.zig"); + +const pixi_plugin = plugins.pixi; +const workbench_plugin = plugins.workbench; +const code_plugin = plugins.code; +const example_plugin = plugins.example; + +pub fn addSteps( + b: *std.Build, + optimize: std.builtin.OptimizeMode, + build_opts: *std.Build.Step.Options, + workbench_opts: *std.Build.Step.Options, + zip_pkg: plugins.ZipPackage, + assets_module: *std.Build.Module, +) void { + const web_target = b.resolveTargetQuery(.{ + .cpu_arch = .wasm32, + .os_tag = .freestanding, + .cpu_features_add = std.Target.wasm.featureSet(&.{ + .atomics, + .multivalue, + .bulk_memory, + }), + }); + + const dvui_web_dep = b.dependency("dvui", .{ + .target = web_target, + .optimize = optimize, + .backend = .web, + .freetype = false, + }); + const dvui_web_proxy_bridge = sdk.addProxyBridgeModule(b, web_target, optimize, dvui_web_dep, dvui_web_dep.module("dvui_web")); + + const web_exe = b.addExecutable(.{ + .name = "web", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/web_main.zig"), + .target = web_target, + .optimize = optimize, + .link_libc = false, + .single_threaded = true, + .strip = optimize == .ReleaseFast or optimize == .ReleaseSmall, + }), + }); + web_exe.entry = .disabled; + web_exe.root_module.addImport("dvui", dvui_web_dep.module("dvui_web")); + web_exe.root_module.addImport("web-backend", dvui_web_dep.module("web")); + + // Extra wasm exports beyond dvui's own (`dvui_init`/`dvui_update`/etc.). The wasm + // linker only emits symbols listed here, so `export fn` in Zig isn't enough on its + // own — without this line our trackpad pinch entry point would compile cleanly but + // be missing from `instance.exports`, and the JS bootstrap in `web/shell.html` + // would never be able to forward pinch deltas into the canvas widget. + web_exe.root_module.export_symbol_names = &[_][]const u8{ + "FizzyWebTrackpadMagnification", + }; + + // `icons` (pure-Zig icon data) is referenced at file scope in + // `src/dvui.zig` and `src/editor/Infobar.zig`. Wired in so any future + // wasm-reachable code that pulls those files in compiles cleanly. + if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| { + web_exe.root_module.addImport("icons", dep.module("icons")); + } + + // `assets` is generated at build time by assetpack (pure `@embedFile`s, + // target-independent). Same instance as native — no extra build cost. + web_exe.root_module.addImport("assets", assets_module); + + // `build_opts` (app_version, app_repo_url, velopack_enabled) — shared + // with native. velopack_enabled is whatever was passed via `-Dvelopack`; + // wasm path is gated by `arch != .wasm32` in `auto_update.impl`. + web_exe.root_module.addOptions("build_opts", build_opts); + + // `zip` — Zig decls + miniz/zip.c compiled for wasm with `fizzy_zip_libc.c` + // (malloc → dvui_c_alloc). Enables `zip_stream_*` for .fiz open/save in browser. + web_exe.root_module.addImport("zip", zip_pkg.module); + pixi_plugin.linkZipWasm(web_exe); + + // `known-folders` is referenced at file scope in a few editor files + // (AboutFizzy, Editor settings paths). It's a pure-Zig wrapper for + // OS-specific user-directory APIs — the file compiles fine on wasm even + // though runtime calls would fail (which we'll never reach on web). + const known_folders_web = b.dependency("known_folders", .{ + .target = web_target, + .optimize = optimize, + }).module("known-folders"); + web_exe.root_module.addImport("known-folders", known_folders_web); + + // Shared `core` module for the wasm build (dvui web backend variant). + const core_module_web = b.createModule(.{ + .target = web_target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + .link_libc = false, + .single_threaded = true, + }); + core_module_web.addImport("dvui", dvui_web_dep.module("dvui_web")); + core_module_web.addImport("known-folders", known_folders_web); + if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| { + core_module_web.addImport("icons", dep.module("icons")); + } + web_exe.root_module.addImport("core", core_module_web); + const sdk_module_web = sdk.wireSdkModule(b, web_target, optimize, dvui_web_dep.module("dvui_web"), dvui_web_proxy_bridge, core_module_web, web_exe.root_module); + + // Three editor files have `const sdl3 = @import("backend").c;` at file + // scope. After refactoring all `sdl3.SDL_DialogFileFilter` references + // to `fizzy.backend.DialogFileFilter`, those decls became dead — Zig's + // lazy analysis skips file-scope consts that no reachable body uses. + // So no `backend` module is wired in for the web build. + + const zstbi_web_lib_module = pixi_plugin.addZstbiModule(b, web_target, optimize, true); + web_exe.root_module.addImport("zstbi", zstbi_web_lib_module); + + const msf_gif_web_lib_module = pixi_plugin.addMsfGifModule(b, web_target, optimize, true); + web_exe.root_module.addImport("msf_gif", msf_gif_web_lib_module); + + _ = pixi_plugin.addStaticModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + .assets = assets_module, + .zip = zip_pkg.module, + .zstbi = zstbi_web_lib_module, + .msf_gif = msf_gif_web_lib_module, + .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = null, + }, web_exe.root_module); + _ = workbench_plugin.addStaticModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + .icons = if (b.lazyDependency("icons", .{ .target = web_target, .optimize = optimize })) |dep| dep.module("icons") else null, + .backend = null, + }, workbench_opts, web_exe.root_module); + _ = code_plugin.addStaticModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + }, web_exe.root_module); + _ = example_plugin.addStaticModule(b, web_target, optimize, .{ + .dvui = dvui_web_dep.module("dvui_web"), + .core = core_module_web, + .sdk = sdk_module_web, + }, web_exe.root_module); + + const web_install_dir: std.Build.InstallDir = .{ .custom = "web" }; + const install_wasm = b.addInstallArtifact(web_exe, .{ + .dest_dir = .{ .override = web_install_dir }, + }); + + // Cache-buster: stamps a 64-char hash into the index.html / web.js placeholders so + // the browser picks up new wasm builds without manual hard-reloads. Re-implements + // upstream DVUI's `addWebExample` machinery so we don't have to invoke its step. + const cb = b.addExecutable(.{ + .name = "cacheBuster", + .root_module = b.createModule(.{ + .root_source_file = dvui_web_dep.path("src/cacheBuster.zig"), + .target = b.graph.host, + }), + }); + const cb_run = b.addRunArtifact(cb); + cb_run.addFileArg(b.path("web/shell.html")); + cb_run.addFileArg(dvui_web_dep.path("src/backends/web.js")); + cb_run.addFileArg(web_exe.getEmittedBin()); + const index_html_with_hash = cb_run.captureStdOut(.{}); + + const web_step = b.step("web", "Build the fizzy web (wasm) app into zig-out/web/"); + web_step.dependOn(&install_wasm.step); + web_step.dependOn(&b.addInstallFileWithDir( + index_html_with_hash, + web_install_dir, + "index.html", + ).step); + web_step.dependOn(&b.addInstallFileWithDir( + dvui_web_dep.path("src/backends/web.js"), + web_install_dir, + "web.js", + ).step); + web_step.dependOn(&b.addInstallFileWithDir( + dvui_web_dep.path("src/fonts/NotoSansKR-Regular.ttf"), + web_install_dir, + "NotoSansKR-Regular.ttf", + ).step); + + // Compile-only smoke check for the wasm target. Pairs with `check` (unit + // tests). Catches regressions where someone reaches a wasm-incompatible + // code path (thread spawn, std.posix surface, missing module import) + // from the wasm root. No install — just compile. + const check_web_step = b.step("check-web", "Compile fizzy web (wasm) without installing artifacts"); + check_web_step.dependOn(&web_exe.step); + + // Copy zig-out/web into web/app/ for local preview at the production + // `/app/` path: `cd web && python3 -m http.server` then open + // http://localhost:8000/app/. The landing page lives in fizzyedit/website. + const web_docs_step = b.step("web-docs", "Build web app and copy into web/app/ for local /app/ preview"); + web_docs_step.dependOn(web_step); + const cp_web_to_docs = b.addSystemCommand(&.{ "sh", "-c" }); + cp_web_to_docs.addArg("mkdir -p web/app && cp -R zig-out/web/. web/app/"); + cp_web_to_docs.step.dependOn(web_step); + web_docs_step.dependOn(&cp_web_to_docs.step); + + const serve_web_cmd = b.addSystemCommand(&.{ "sh", "scripts/serve-web.sh" }); + serve_web_cmd.step.dependOn(web_step); + _ = b.step( + "serve-web", + "Serve zig-out/web at http://127.0.0.1:8765/ (builds web first; frees stale :8765)", + ).dependOn(&serve_web_cmd.step); +} diff --git a/contributor.md b/contributor.md deleted file mode 100644 index 9bde4d85..00000000 --- a/contributor.md +++ /dev/null @@ -1,60 +0,0 @@ -

- -

-

- -## Contributing - -Hello and thank you so much for considering contributing to Fizzy! - -By suggestion, this document will hopefully serve as a good starting point for understanding Fizzy's internals and where things are. However, if you ever have any questions or would like -to have a conversation about Fizzy, please reach out to me on discord or add an issue. I'm "foxnne" on discord as well. - -### Overview - -Fizzy is built using several game development libraries by others in the Zig community, as well as a C library for handling zipped files. The dependencies are as follows: - - ***mach-core***: Handles windowing and input, and uses the new zig package manager. This library and dependencies will be downloaded to the cache on build. - - ***nfd_zig***: Native file dialogs wrapper, copied into the src/deps folder. - - ***zgui***: Wrapper for Dear Imgui, which is copied into the src/deps/zig-gamedev folder. - - ***zmath***: Math library, primarily using this for vector math and matrices. As above, this is copied into the src/deps/zig-gamedev folder. - - ***zstbi***: Wrapper for stbi provided by zig-gamedev. This handles loading and resizing images. As above, this is copied into the src/deps/zig-gamedev folder. - - ***zip***: Wrapper for the zip library, copied into the src/deps folder. - -Outside of the `src` folder, we have `assets` which contain all assets that we would like to be copied over next to the executable and used by Fizzy at runtime. - -`fizzy.zig` holds all the main loop information and init, update, and deinit functions. Mach-core handles the main entry point and calls these functions for us. Mach-core is multi-threaded in the sense that there are two update loops, one which is run on the main thread, and one that runs in a separate thread. For more information about mach-core please see [the mach-core website](https://machengine.org/core/). - -Please note that we need to handle native file dialogs from the main thread, which is currently how Fizzy handles it. I tried to set this up as a request/response. - -Inside of the `src` folder we have several subfolders. I tried to organize the project based on a few categories as follows: - -Outside of these subfolders, please note that `assets.zig` is generated so don't edit this file. - -- **algorithms**: This folder holds any generalized algorithms for use in pixel art operations. As of writing this, it only currently contains the brezenham algorithm used - by the stroke/pencil tool. This algorithm handles quick mouse movements when drawing and prevents broken lines, as each frame a line is drawn from the previous frame. - -- **deps**: This folder holds the previously outlined dependencies, except for those that are using the new zig package manager. -- **editor**: This folder holds individual files generally with simple *draw()* functions that mimic the layout of the editor itself. I tried to use subfolders and similar to - set the project up in a way that was easy to understand from looking at the editor itself. - - i.e. `editor/artboard/canvas.zig` is the file responsible for the canvas within the main artboard, while `editor/artboard/flipbook/canvas.zig` is the canvas within the flipbook. - - Note that `editor.zig` contains a bit more than just drawing of the editor panels, and contains many of the main *editor* related functions, like loading and opening files, setting the project folder, - saving files, and importing png files. - -- **gfx**: Fizzy is set up similar to a game, with the flipbook and main artboard having a camera. Each file actually has its own Camera, which allows u - to have individual views per file, and not a shared camera between all files. That means you can be working on two files and not have your camera move around as you switch. - - Other things in gfx are general things related to textures, atlases, quads, etc. Some of this is unused currently and can be removed. - -- **input**: Input holds hotkeys and mouse information. - - `Hotkeys.zig` is my attempt at trying to set up configurable hotkeys in the future. - -- **math**: General math functions I've written or picked up over time. -- **shaders**: Currently doesn't get used, but in the future if we support using the GPU for some operations, the wgsl files would live here. -- **storage**: This is where History, and the containers used to store information are. internal and external contain the structs used to describe a fizzy file internally, with additional information for the program to use, or externally, which should be easily exported as JSON. -- **tools**: A few helpful things such as font-awesome mapping, an example of the build step to process assets, and the Packer struct, which is responsible for packing all sprites to an atlas. - - - - - - - diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 00000000..a5c3b451 --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,684 @@ +# Fizzy Plugin System + +Fizzy is a near-empty **shell** that owns a window, a menu/sidebar/panel layout, and a +document model — but no features of its own. Everything the user sees (the pixel-art editor, +the file explorer, tabs/splits) is contributed by **plugins** that register against a stable +SDK. The same plugin source compiles two ways: statically into the app, or as a runtime +dynamic library. + +--- + +## 1. General structure + +``` + ┌─────────────────────────────────────────────────────────┐ + │ Shell (Editor) │ + │ window · frame loop · menu/sidebar/panel layout · docs │ + │ │ + │ ┌──────────────┐ ┌──────────────────────────┐ │ + │ │ Host │◄──────►│ EditorAPI │ │ + │ │ registries │ reach │ (shell read/util surface │ │ + │ │ + services │ back │ arena, folder, docs, …) │ │ + │ └──────┬───────┘ └──────────────────────────┘ │ + └──────────┼──────────────────────────────────────────────┘ + │ register(host) + vtable calls + ┌──────────┴───────────────┐ ┌────────────────────────┐ + │ workbench plugin │ │ pixelart plugin │ + │ file tree · tabs/splits │ │ canvas editor │ + └──────────────────────────┘ └────────────────────────┘ + plugins never import each other — they meet only at the SDK +``` + +The SDK (`src/sdk/`) is the entire contract between shell and plugins: + +| Type | Role | +|------|------| +| `Host` | What the shell hands every plugin. Holds the **registries** (the shell iterates these instead of hardcoding panes) + a **service locator** for inter-plugin APIs. | +| `Plugin` | A plugin's identity + **vtable** of optional hooks. The shell calls these; a plugin implements only what it needs. | +| `DocHandle` | Opaque handle to an open document: `{ ptr, id, owner: *Plugin }`. The shell stores these per tab and **routes every document operation to `owner`** — it never inspects `ptr`. | +| `EditorAPI` | The shell's read/utility surface a plugin reaches back through (`arena`, `folder`, open-doc collection, save dialogs, …). Reached via `Host`. | +| `regions` | The contribution structs a plugin registers: `SidebarView`, `BottomView`, `CenterProvider`, `MenuContribution`, `SettingsSection`. | +| `dylib` / `dvui_context` | The C-ABI entry contract + dvui-context injection used when a plugin is loaded as a runtime library. | + +**The shell owns no features.** Each frame it iterates the Host registries and draws whatever +plugins contributed. Adding a pane, panel tab, menu, document type, or settings section is a +`Host.register*` call from inside a plugin's `register` — never a shell edit. + +### Two link modes (one source) + +| Mode | Who | Targets | How it registers | +|------|-----|---------|------------------| +| **Static** | Built-in plugins (pixelart, workbench, …) — always shipped with the app | all, incl. web | shell calls `plugin.register(&host)` directly at startup | +| **Dynamic** | Third-party plugins | desktop only (no dlopen on web) | shell `dlopen`s the library and calls its `fizzy_plugin_register` C entry, which calls the same `register(&host)` | + +Built-in plugins live in this repo and ship inside the signed app bundle; they are never +distributed or versioned separately. The dynamic path exists so an external Zig project can +depend on the SDK, implement the same `Plugin` interface, and ship a loadable library. + +--- + +## 2. Anatomy of a plugin + +### Required files (checklist) + +A plugin is a small, fixed set of files. The SDK owns the boilerplate — the C entry symbols +and the allocator/`*Host` injection — so you really implement just one file. + +| File | Required? | You implement? | +|------|-----------|----------------| +| `build.zig` / `build.zig.zon` | **required** | yes — declare the `fizzy` dep, call `fizzy.plugin.create` + `.install` | +| `root.zig` | **required** | **no** — copy `fizzy/src/plugins/root.zig` (one `exportEntry` call) | +| `src/plugin.zig` | **required** | **yes** — `register(host)` + the `Plugin` vtable; owns your state | +| `src/State.zig`, … | as needed | yes — your feature code | + +**Minimum viable plugin:** `build.zig`, `build.zig.zon`, `root.zig` (copied), `src/plugin.zig`. +The host injects the allocator + `*Host` into the SDK itself (read via `sdk.allocator()` / +`sdk.host()`), so there is no storage file — everything else is optional structure around your +one implementation file. + +> **Built-in plugins use this exact same shape.** A built-in's folder is, file-for-file, a +> third-party plugin (`build.zig`, `build.zig.zon`, `root.zig`, `src/plugin.zig`, …) and it +> builds standalone the same way (`cd src/plugins/ && zig build`). The *only* extra is a +> small amount of fizzy-internal glue, separated out so it never clutters the plugin contract: +> a root `.zig` (the conventional package module + import hub) plus a `static/` subfolder. See [*How built-in plugins are wired*](#how-built-in-plugins-are-wired-fizzy-internal) +> at the end of this section. The in-repo [`example`](../src/plugins/example/) plugin is the +> canonical, always-compiling template — copy that folder to start a new plugin. + +### Layout + +``` +my-plugin/ + build.zig + build.zig.zon # fizzy dependency + .paths listing root.zig, src/, … + root.zig # dylib entry — copy from fizzy/src/plugins/root.zig (one exportEntry call) + src/ + plugin.zig # register(host) + Plugin vtable; owns its State + State.zig # optional but typical + … +``` + +No storage/`Globals` file: the host injects the allocator + `*Host` into the SDK, so plugin +code reads them through `sdk.allocator()` / `sdk.host()`. The in-repo +[`example`](../src/plugins/example/) plugin is a complete minimal example you can copy; +[markdown](https://github.com/fizzyedit/markdown) is an external one. + +### What each file must contain + +#### `root.zig` (third-party only — copy, don't invent) + +The entire dylib entry is one call to `sdk.dylib.exportEntry`, which emits the five C +symbols the host looks up: + +```zig +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} +``` + +| Export | Purpose | +|--------|---------| +| `fizzy_plugin_abi_fingerprint` | Must match host or load is rejected | +| `fizzy_plugin_register` | Calls your `src/plugin.zig` `register(host)` | +| `fizzy_plugin_set_dvui_context` | Host injects live dvui window/io before draw | +| `fizzy_plugin_set_render_bridge` | Host injects dvui proxy render bridge | +| `fizzy_plugin_set_globals` | Host injects allocator + `*Host` into the SDK (`sdk.allocator()` / `sdk.host()`) | + +Copy **`fizzy/src/plugins/root.zig`** into your project root; the `@import("src/plugin.zig")` +is relative to **your** tree (not fizzy's). The export bodies live in the SDK +(`sdk.dylib.exportEntry`), so there is nothing to maintain or keep in sync here. + +Built-in plugins use this **same** `root.zig` (their dylib build goes through it too); they no +longer carry a separate `dylib.zig` or typed `Globals.zig` — they read `sdk.allocator()` / +`sdk.host()` exactly like a third-party plugin. + +#### `src/plugin.zig` — **the contract you own** + +Must provide: + +1. A **`sdk.Plugin` value** — stable `id` (snake_case), `display_name`, `vtable`, and + `state` (set during `register`). +2. **`pub fn register(host: *sdk.Host) !void`** — wire `plugin.state`, call + `host.registerPlugin(&plugin)`, then any `host.registerSidebarView` / + `registerBottomView` / `registerCenterProvider` / `registerMenu` / + `registerSettingsSection` / `registerService` contributions. +3. A **`vtable: sdk.Plugin.VTable`** — only fill hooks your plugin needs; unset fields + stay `null`. + +Minimal skeleton (registers identity only — no documents, no panes): + +```zig +const sdk = @import("sdk"); + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "my_plugin", + .display_name = "My Plugin", +}; + +const vtable: sdk.Plugin.VTable = .{ + .deinit = deinit, +}; + +var plugin_state: State = .{}; // your own singleton; the SDK holds gpa/host for you + +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(&plugin_state); + try host.registerPlugin(&plugin); +} + +fn deinit(_: *anyopaque) void { plugin_state.deinit(sdk.allocator()); } +``` + +**Editor plugins** (open/save/draw files) also implement document vtable hooks — +`fileTypePriority`, `loadDocument`, `drawDocument`, `saveDocument`, `isDirty`, etc. +**Shell plugins** (workbench-style) skip document hooks and instead register a center +provider or sidebar views. See `Plugin.VTable` in [`src/sdk/Plugin.zig`](../src/sdk/Plugin.zig) +for the full hook list. + +#### Runtime access — **no storage file** + +The shell cannot be imported from plugin code, so the host pushes the allocator and the +`*Host` across the dylib boundary at load (`fizzy_plugin_set_globals`). `exportEntry` +catches them **into the SDK itself**, so plugin code just reads: + +- **`sdk.allocator()`** — the persistent host allocator (see *Memory* below). +- **`sdk.host()`** — the shell `*Host`: registries, services, and the `EditorAPI` read + surface (open folder, active doc, arena allocator, save dialogs). + +Your **own** state is just a variable you own. A singleton is a module-level `var`: + +```zig +var plugin_state: State = .{}; +// in register: plugin.state = @ptrCast(&plugin_state); +// in deinit: plugin_state.deinit(sdk.allocator()); +``` + +If your plugin uses `core`'s allocating helpers (most don't), sync that module's allocator +once in `register`: `core.gpa = sdk.allocator();`. + +Built-in plugins do the same — they call `register(&host)` directly at startup and read +`sdk.allocator()` / `sdk.host()`. (Earlier built-ins kept a typed `Globals.zig` poked from +`App.zig`; that is gone — there is one injection path for everyone now.) + +#### `build.zig` / `build.zig.zon` (third-party) + +`build.zig.zon` — declare **fizzy** as the only shell dependency (dvui arrives +transitively). List every shipped path in `.paths` (`root.zig`, `src`, …). + +`build.zig` — call `fizzy.plugin.create`, attach any extra libs on `lib.root_module`, then +`fizzy.plugin.install` (which renames Zig's `libplugin.dylib` output to the `plugin.` +the loader scans for — no shell `cp`/`mkdir`, works on every host): + +```zig +const lib = fizzy.plugin.create(b, .{ + .target = target, + .optimize = optimize, +}); +lib.root_module.linkLibrary(…); +lib.root_module.addIncludePath(…); +fizzy.plugin.install(b, lib, .{}); +``` + +Then `zig build install --prefix /` lands `plugin.` where the host +expects it, e.g. `~/.config/fizzy/plugins//plugin.dylib` (macOS: +`~/Library/Application Support/fizzy/plugins//plugin.dylib`). + +### Import discipline + +Files inside `src/**` must **not** `@import("fizzy")` or reach into the shell. Allowed: + +- `@import("sdk")`, `@import("core")`, `@import("dvui")` — wired on the dylib module by + `fizzy.plugin.create` +- `@import("State.zig")`, … — sibling files in your `src/` tree +- Built-in only: `@import("../.zig")` for an optional local hub file + +This is what lets the same sources compile as a standalone dylib. + +### The `register(host)` entry + +`register` wires the plugin into the shell. A minimal plugin just registers itself; a +real one adds contributions: + +```zig +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(&plugin_state); // adopt the plugin's runtime state + try host.registerPlugin(&plugin); // identity + vtable + try host.registerSidebarView(.{ … }); // a left-rail pane + try host.registerBottomView(.{ … }); // a bottom-panel tab + try host.registerSettingsSection(.{ … }); + // …whatever else it contributes +} +``` + +`Host.register*` methods: `registerPlugin`, `registerSidebarView`, `registerBottomView`, +`registerCenterProvider`, `registerMenu`, `registerSettingsSection`, `registerService`, +`registerFileRowFillColor`. Each takes a struct with a stable, namespaced `id`, the owning +`*Plugin`, and a `draw`/resolver fn. The shell renders the set (and shows a **tab strip** +automatically when more than one plugin contributes to a region). + +### The `Plugin` vtable — the universal editor protocol + +`Plugin.vtable` is the **universal editor contract**: every field is an optional fn pointer +taking the plugin's opaque `state`, and it holds only hooks that any editor plugin might need. +Group by purpose: + +- **Lifecycle** — `deinit`, `initPlugin`. +- **Document ownership** — `fileTypePriority(ext)` (claim file extensions), `loadDocument` / + `loadDocumentFromBytes` / `createDocument`, `saveDocument`, `closeDocument`, `isDirty`, + `undo`/`redo`/`canUndo`/`canRedo`, plus opaque document-buffer management for the async + load path. +- **Document metadata at the workbench boundary** — `bindDocumentToPane`, `documentGrouping`, + `documentPath`, `setDocumentPath`, dirty/save indicators. These keep `DocHandle` opaque so + the file-management plugin never sees a plugin-specific type. +- **Rendering** — `drawDocument(doc)` (the document's content in a tab/pane), + `drawDocumentInfobar(doc)`. +- **Per-frame phases** — generic frame callbacks (see the lifecycle table below for exactly + when each fires): `beginFrame`, `prepareFrame`, `tickKeybinds`, `tickOpenDocuments`, + `tickActiveDocument`, `drawOverlay`, `endFrame`, `needsContinuousRepaint`. A plugin does its + own domain work *inside* these generic phases. +- **Folder lifecycle** — `onFolderClose` / `onFolderOpen` (fired when the open root folder + changes/closes so a plugin can persist & reload state it keyed to that folder). +- **Save protocol** — `saveNeedsConfirmation(doc)` + `requestSaveConfirmation(doc, mode, …)` + (the owner may present a pre-save confirmation, e.g. a lossy-flatten warning). +- **Contributions** — `contributeMenu`, `contributeKeybinds`. +- **New document** — `requestNewDocumentDialog` (the shell dispatches; the plugin owns the dialog). + +Every hook here is generic — none names a domain feature. **Editing actions** (copy, paste, +transform, accept/cancel edit, delete selection) are deliberately *not* hooks: they are +user-invoked and mean different things per editor, so they are `Command`s (see below), not part +of this contract. A file-management plugin (workbench) implements none of the document hooks; an +editor plugin (pixelart) implements the document + rendering hooks but contributes no file tree. + +#### Required vs optional + +Every vtable field is an optional fn pointer, so the **type system requires nothing**. But to +function *as an editor* (open / draw / save files) you must implement the document cluster: + +> `fileTypePriority` · `documentStackSize` · `documentStackAlign` · `loadDocument` · +> `documentIdFromBuffer` · `registerOpenDocument` · `documentPtr` · `deinitDocumentBuffer` · +> `drawDocument` · `saveDocument` · `isDirty` + +Everything else is genuinely optional — implement only what your editor needs. (A non-editor +plugin like the workbench implements none of these and contributes panes + a center provider.) + +#### When & where each hook fires + +The model tag tells you how the shell invokes a hook: `[broadcast]` = called for every plugin +at that point; `[active-doc]` = called as `doc.owner.hook(doc)` only for the focused document; +`[requested]` = only fires after you call the paired `host.*` request. The call sites are in +`src/editor/Editor.zig` (verify with `grep` — line numbers drift): + +| Hook | Model | When / where | +|---|---|---| +| `beginFrame` | broadcast | top of the draw, before workspace rebuild (`renderFrame`) | +| `prepareFrame` | requested | after layout, before draw — only when `pending_composite_warmup` was set by `host.requestPrepareFrame()` | +| `needsContinuousRepaint` | broadcast | the shell's "should I keep repainting vs idle" decision | +| `tickOpenDocuments` | broadcast | early per-frame tick; return true → request a follow-up anim frame | +| `drawDocument(doc)` | active-doc | center region, when the workbench draws the focused tab | +| `tickActiveDocument(id)` | broadcast | inside the active document container (has the timer-anchor id) | +| `endFrame` | broadcast | `defer` at the end of the document-container block | +| `tickKeybinds` | broadcast | after the center draw, before the shell's global keybinds | +| `drawOverlay` | broadcast | right after `tickKeybinds`, on top of the frame | + +Outside the frame loop: `onFolderClose` / `onFolderOpen` fire `[broadcast]` from +`setProjectFolder` / `closeProjectFolder`; `saveNeedsConfirmation` / `requestSaveConfirmation` +fire `[active-doc]` from the `save` / close / quit-all paths; `loadDocument` runs on a +**background load-worker thread** (touch only the host allocator + the given buffer, no dvui). + +### Commands — how a plugin contributes its *own* features + +Anything a plugin **invokes** rather than implements as a shell callback — both plugin-specific +features (pixel-art's *Grid Layout*, *Pack Project*) and editing actions whose meaning varies per +editor (*Copy*, *Paste*, *Transform*, *Accept/Cancel Edit*, *Delete Selection*) — is a `Command`, +not a vtable hook. The plugin registers a named [`Command`](../src/sdk/regions.zig) with the Host, +and the shell triggers it by id via `host.runCommand("")` **without knowing what it does**: + +```zig +try host.registerCommand(.{ + .id = "pixelart.packProject", // plugin-namespaced + .owner = &plugin, + .title = "Pack Project", + .run = packProjectCommand, // fn(state) anyerror!void — resolves its own context + .isEnabled = packProjectEnabled, // optional gate +}); +``` + +This is the seam that keeps the SDK and shell free of any one plugin's vocabulary: the universal +`VTable` above is what *every* editor implements, and `Command`s are what each plugin adds on top. +A plugin's per-frame domain work (animation, atlas packing) runs inside the generic per-frame +phases; its invocable actions are commands. See `src/plugins/pixelart/src/plugin.zig`. + +**Per-owner action convention.** The shell's built-in actions on the active document — its Edit +menu / keybinds (*Copy* `copy`, *Paste* `paste`, *Transform* `transform`, accept `acceptEdit`, +cancel `cancelEdit`, delete `deleteSelection`) and *Grid Layout* (`gridLayout`) — dispatch to +`"."`. So focusing a pixel-art doc runs `"pixelart.copy"`; a second +editor answers the same shell actions by registering its own `".copy"`, `…transform`, +etc. An action the owner didn't register is simply a no-op for its documents. This keeps the +shell's standard editing UI while routing every action to whichever editor owns the focused tab. + +### Reaching the shell: SDK-held injection + +Plugin code can't import the shell, so the shell **injects pointers** into the plugin once at +startup — the allocator and the `*Host`. `exportEntry` catches them into the SDK, so plugin +code reads `sdk.allocator()` and `sdk.host()` directly (e.g. `sdk.host().` for the +open folder, active doc, arena allocator). Your own data is whatever variable you own. In a +dynamic build the host pushes these across the library boundary via the +`fizzy_plugin_set_globals` C export. + +### Memory: one allocator, one arena + +A plugin manages memory with the host through exactly two allocators, both reached from the +`*Host` it is handed in `register`: + +- **`host.allocator`** — the persistent heap allocator. Use it for anything that outlives a + frame (documents, caches, registry entries). You own every allocation and must free it. This + is the same allocator surfaced as `sdk.allocator()`; the two are interchangeable. +- **`host.arena()`** — a per-frame scratch allocator. It is reset at the end of every frame, so + never free from it and never hold a pointer into it past the current frame. + +**Do not capture `dvui.currentWindow().gpa` as "the allocator."** The shell deliberately creates +the dvui window with `host.allocator`, so today they are the same instance — but treat +`host.allocator` as the contract. Mixing allocators (allocate with one, free with another) is the +one memory bug the type system can't catch and it corrupts the heap. Pick `host.allocator` and +stay with it. + +### Building as a dynamic library + +Your `root.zig`'s `sdk.dylib.exportEntry` emits the C entry symbols the loader looks up +(defined in `src/sdk/dylib.zig`): + +- `fizzy_plugin_abi_fingerprint` → must equal the host's `dylib.abi_fingerprint` or the load is + rejected. +- `fizzy_plugin_register(*Host)` → calls the plugin's `register`. +- `fizzy_plugin_set_globals` / `fizzy_plugin_set_dvui_context` → host injects the allocator + + `*Host` (into the SDK) and its live dvui context into the plugin image (host and plugin each + compile their own `dvui`/`sdk`/`core`; the host's pointers are pushed in before draw/tick). + +There is **no ABI version to bump.** `dylib.abi_fingerprint` is a compile-time structural hash +over every type that crosses the boundary — the `Host`/`Plugin`/`DocHandle`/`EditorAPI` vtables, +the dvui types passed through them, and the C entry-symbol signatures (see `src/sdk/fingerprint.zig`). +Host and plugin each compute it from their own sources, so changing a vtable hook, a boundary +struct's layout, or the dvui dependency changes the hash automatically and stale plugins are +rejected at load. If you add a brand-new struct that crosses the boundary by value, add it to the +root list in `dylib.zig` so its layout is folded in. + +### Third-party quick start + +Fastest path: **copy the in-repo [`example`](../src/plugins/example/) plugin folder**, rename +the id/name, and replace `src/plugin.zig` with your feature. It is the canonical, always- +compiling template and already has every required file in the right place. See **Required +files**, **Layout**, and **What each file must contain** above. In short: + +1. Copy `fizzy/src/plugins/root.zig` (or `example/root.zig`) → `root.zig` (one `exportEntry` + call, never edited). +2. Implement `src/plugin.zig` (`register` + vtable). Read the host allocator + `*Host` via + `sdk.allocator()` / `sdk.host()`; own your state as a plain `var`. No storage file. +3. Add `build.zig` / `build.zig.zon` with a `fizzy` dependency, `fizzy.plugin.create`, and + `fizzy.plugin.install`. +4. `zig build install --prefix //` so the host finds `plugin.`. + +`fizzy.plugin.create` options: + +| Option | Default | When to override | +|--------|---------|------------------| +| `root_source_file` | `root.zig` | Dylib entry is not at project root or not named `root.zig` | +| `name` | `"plugin"` | Dylib artifact name (output is still `plugin.dylib` when installed) | + +Pin the **fizzy** dependency to the same revision as the host you run against; ABI +mismatch surfaces as a failed load at `fizzy_plugin_abi_fingerprint`, not a semver check. + +### How built-in plugins are wired (fizzy-internal) + +The in-tree plugins (pixi, workbench, code, example) ship inside the signed app and compile +**two ways** — statically into the native/web/test binaries *and* (for desktop) as a bundled +dylib. **Their folder is, file-for-file, the same canonical third-party shape** described +above (`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`, +`src/…`), and each builds standalone with `cd src/plugins/ && zig build`. There is no +embed-stub `build.zig` and no `build_standalone.zig` anymore. + +All the fizzy-internal glue is separated out so it never mixes into the plugin contract: + +``` +src/plugins// + build.zig # canonical third-party build (fizzy.plugin.create + install) + build.zig.zon + root.zig # exportEntry(@import("src/plugin.zig")) + .zig # package module root + intra-plugin import hub (see note below) + src/ + plugin.zig # register + Plugin vtable — identical shape to any third-party plugin + … + static/ # ← fizzy-internal: everything else the static embed needs + integration.zig # builds the static @import("") module + the bundled dylib +``` + +- **`static/integration.zig`** — defines `addStaticModule` (the `@import("")` module the + shell links in) and `addDylib` (the bundled dylib). The root build aggregates every plugin's + integration in [`build/plugins.zig`](../build/plugins.zig); `build/exe.zig`, `build/web.zig`, + and `build/app.zig` (tests) call `addStaticModule`. Shared helpers live in + [`src/plugins/shared/build/helpers.zig`](../src/plugins/shared/build/helpers.zig). Because + these only ever run from the fizzy build root, their paths are single fizzy-relative literals + — the old dual-root (`repo_paths`/`pkg_paths`) machinery is gone. +- **`.zig`** (e.g. `pixi.zig`) — the conventional package root: it is BOTH what the shell + resolves `@import("")` to (re-exporting `pub const plugin` + any types the shell reaches + into, e.g. `pixi.State`) AND the intra-plugin import hub that files under `src/` pull in as + `../.zig` for `sdk`/`core`/`dvui` + sibling types. It must sit at the **plugin root**, + not under `static/`: a Zig module cannot import files above its root file's directory, so it + has to be beside `src/` to re-export from it. A purely-dylib third-party plugin only needs it + if it embeds statically or wants a shared hub; a minimal one (`example`) keeps it tiny. +- **Vendored C deps** — a plugin with native deps builds them with `fizzy.plugin.addCModule` + (a Zig bindings module + its C sources), the same helper its `build.zig` and its + `static/integration.zig` both call. See pixi's `zstbi`/`msf_gif` wiring. + +A built-in is then registered statically in [`Editor.zig`](../src/editor/Editor.zig) +`postInit` with `try _mod.plugin.register(&editor.host)`. The pixi/workbench/code paths +additionally try a bundled-dylib load first and fall back to the static registration; the +`example` plugin keeps it simple (static registration only, but still builds as a dylib). + +The shared contract is exactly `src/plugin.zig` + the `Plugin` vtable; everything else above is +build-mode plumbing. See [`src/plugins/example/`](../src/plugins/example/) for the minimal +template and [`src/plugins/code/`](../src/plugins/code/) for an editor (document) plugin. + +## 3. How pixelart flows — and uses workbench + +**The crucial property: pixelart and workbench do not import each other.** They collaborate +entirely through the SDK. `grep` confirms zero cross-imports in either `src/` tree. + +### What each contributes + +`pixelart.register` (`src/plugins/pixelart/src/plugin.zig`): +- Claims its file types via the `fileTypePriority` vtable hook (`.fiz`, `.png`, …). +- `registerSidebarView` ×3 — **Tools**, **Sprites**, **Project**. (Project also sets + `draw_workspace`, letting it take over the center pane to show the packed atlas.) +- `registerBottomView` — the **Sprites** panel tab. +- `registerSettingsSection` — "Pixel Art". +- `registerFileRowFillColor` — a resolver the file tree calls to tint pixel-art file rows. +- Implements the document + rendering vtable hooks (load/save/undo/`drawDocument`/…). + +`workbench.register`: +- `registerSidebarView` — the **Files** tree. +- `registerCenterProvider` — owns the entire center region: the tabs/splits + canvas layout. +- `registerService("workbench", …)` — the file-management API (see below). + +### Opening and drawing a pixel-art document + +``` +user double-clicks foo.fiz in workbench's Files tree + │ + ▼ +host.pluginForExtension(".fiz") ──► pixelart (highest fileTypePriority) + │ + ▼ +pixelart.loadDocument(path) ──► builds its File, returns an opaque buffer + │ + ▼ +shell inserts DocHandle{ id, ptr=File, owner=pixelart } into Editor.open_files + │ + ▼ +workbench (center provider) draws a tab for it, and to render the body calls + doc.owner.drawDocument(doc) // Workspace.zig + │ + ▼ +pixelart draws its canvas inside the workbench tab/split +``` + +Every later action follows the same rule — the shell and workbench only ever call +`doc.owner.(doc)`. Save, dirty-dot, undo/redo, grouping, path, and the infobar status +all route to pixelart because it is the `owner`; workbench never knows it's a pixel-art file. +Reordering a tab is the one mutation of document order, done through `EditorAPI.swapDocs`. + +### The `workbench-api` service (inter-plugin file management) + +Workbench registers a service (`Workbench.Api`, key `"workbench"`) so any plugin can drive the +file explorer without importing workbench: + +```zig +const api: *Workbench.Api = @ptrCast(@alignCast(host.getService(Workbench.Api.service_name).?)); +_ = try api.open(path, api.currentGrouping()); // open a file into the focused tab group +``` + +Its vtable covers open/close/save, listing open docs by path/index (no plugin type crosses the +boundary), file-tree ops (create/rename/delete/move), and `registerBranchDecorator` for drawing +a per-row icon (the built-in "unsaved" dot is one). Pixelart doesn't need it today, but it's the +sanctioned way a second editor plugin would place documents into tabs and decorate file rows. + +### Why this is the model to copy + +A new editor plugin (e.g. textedit) drops in with **no shell or workbench changes**: register +its file types, implement the document + `drawDocument` hooks, and optionally contribute +sidebar/bottom/settings panes. Its documents then coexist in the same tabs/splits beside +pixel-art documents, because the whole system is keyed on `DocHandle.owner` and the Host +registries — not on any plugin knowing about another. + +--- + +### Key files + +| Path | Role | +|------|------| +| `src/sdk/sdk.zig` | SDK entry — re-exports everything below | +| `src/sdk/Host.zig` | Registries + service locator + `register*` methods | +| `src/sdk/Plugin.zig` | Plugin identity + the vtable of hooks | +| `src/sdk/DocHandle.zig` | Opaque document handle (`owner`-routed) | +| `src/sdk/EditorAPI.zig` | Shell read/utility surface plugins reach back through | +| `src/sdk/regions.zig` | Sidebar/bottom/center/menu/settings contribution structs | +| `src/sdk/dylib.zig`, `dvui_context.zig` | Runtime-library C entry contract + dvui injection | +| `src/plugins/root.zig` | Stock dylib entry template — copy to third-party projects as `root.zig` | +| `src/plugins/pixelart/` | Reference editor plugin (pixi id; owns documents, renders canvas) | +| `src/plugins/workbench/` | Reference file-management plugin (tree + tabs/splits + service) | +| `src/sdk/version.zig` | SDK version + ABI fingerprint CI lock | +| `src/sdk/manifest.zig` | `PluginManifest` embedded in dylibs | +| `src/sdk/document.zig` | Document staging helpers for editor plugins | +| `templates/` | Author starter templates (editor / utility profiles) | + +--- + +## Compatibility & versions + +Fizzy uses three independent **versions**: + +| Version | Owner | Purpose | +|---------|-------|---------| +| **App version** | Fizzy release (`build.zig.zon`) | User-facing editor release; does **not** gate plugin loading | +| **SDK version** | `src/sdk/version.zig` | ABI contract; bumps when the plugin boundary changes | +| **Plugin version** | Author `PluginManifest.version` | Plugin's own release semver | + +At load time the host checks, in order: + +1. **ABI fingerprint** (`fizzy_plugin_abi_fingerprint`) — hard reject on mismatch (memory safety) +2. **SDK version** — `host.sdk_version` must satisfy `plugin.min_sdk_version` +3. **Stale build warning** (debug) — optional soft warning when `built_with_sdk_version < host` + +CI enforces that any ABI fingerprint change updates `sdk_version` and `recorded_abi_fingerprint` together (`zig build test-sdk-version`). + +### Plugin dylib layout + +User and built-in plugins install as a **flat** file: + +``` +{config}/plugins/{id}.dylib # macOS +{config}/plugins/{id}.so # Linux +{config}/plugins/{id}.dll # Windows +{exe}/plugins/{id}.{ext} # bundled built-ins +``` + +The declared `manifest.id` must match the filename basename. There is no legacy `{id}/plugin.dylib` layout. + +### Config folders (lowercase) + +``` +{config}/plugins/ +{config}/palettes/ +{config}/themes/ +``` + +### Plugin manifest (dylib + optional sidecar) + +Each plugin embeds metadata via C exports from `PluginManifest`. Optional sidecar for store indexing: + +```json +{ + "id": "markdown", + "name": "Markdown Editor", + "version": "1.2.0", + "min_sdk_version": "0.1.0", + "abi_fingerprint": "0x05f167e314742930", + "author": "…", + "description": "…", + "homepage": "…" +} +``` + +Install with: + +```sh +zig build install --prefix ~/.config/fizzy/plugins +# → ~/.config/fizzy/plugins/markdown.dylib +``` + +### Store registry schema (future) + +Hosted registry JSON (Phase 2 Extensions UI): + +```json +{ + "sdk_version": "0.1.0", + "plugins": [ + { + "id": "markdown", + "name": "Markdown Editor", + "releases": [ + { + "version": "1.2.0", + "min_sdk_version": "0.1.0", + "abi_fingerprint": "0x…", + "published": "2026-06-01", + "downloads": { + "macos-aarch64": "https://…/markdown-1.2.0-macos-aarch64.dylib" + } + } + ] + } + ] +} +``` + +--- + +## Plugin profiles (IDE-shaped contract) + +The shell is **IDE-shaped**: sidebar rail + explorer, menubar, center (`CenterProvider`), bottom panel, infobar. Plugins contribute via `Host.register*` — the shell never hardcodes feature panes. + +| Profile | Implements | Example | +|---------|------------|---------| +| **Editor** | Document vtable cluster + optional panes/commands | `pixi`, `code` | +| **Shell** | Center provider + file tree, no documents | `workbench` | +| **Utility** | Menus/commands/settings only, no document hooks | external markdown menu plugin | + +Use `Plugin.assertEditorVTable(vtable)` / `Plugin.assertUtilityVTable(vtable)` at compile time to catch profile mistakes. + +Built-in plugin id renames (pre-release): runtime id **`pixi`** (was `pixelart`); dylib `pixi.dylib`; settings key `plugins.pixi`; env `FIZZY_STATIC_PIXI`. + +| `src/editor/Editor.zig` | The shell: frame loop, `postInit` plugin registration, dylib loading | diff --git a/docs/PLUGIN_ROUGH_EDGES.md b/docs/PLUGIN_ROUGH_EDGES.md new file mode 100644 index 00000000..1431d722 --- /dev/null +++ b/docs/PLUGIN_ROUGH_EDGES.md @@ -0,0 +1,232 @@ +# Plugin Author Rough Edges + +A punch list of friction points a third-party author hits when building a *complex* +editor plugin (a second real editor alongside pixelart). Ordered by pain, with file +references and fix sketches. Cheap correctness fixes (#4, #6, #7) are being done first; +the rest are tracked as backlog. + +Status legend: 🔴 not started · 🟡 in progress · 🟢 done + +--- + +## 1. 🟢 The "stable contract" is pixel-art-shaped — *large* — DONE + +The intermediate `canvas_ext` (a relocated grab-bag that still *named* pixelart concepts) was +replaced with two clean mechanisms, so the SDK names zero domain features: + +1. **Command registry** ([`regions.Command`](../src/sdk/regions.zig) + `Host.registerCommand` / + `runCommand` / `commandEnabled`). Invocable features register as namespaced commands the shell + triggers by id (`"pixelart.transform"`, `"pixelart.gridLayout"`, `"pixelart.packProject"`) + without knowing what they do. Folded into the ABI fingerprint. +2. **Generic per-frame / lifecycle / save protocol** on `Plugin.VTable`, renamed from the + pixelart-flavored hooks: `prepareFrame`, `tickActiveDocument`, `drawOverlay`, `endFrame`, + `needsContinuousRepaint`, `persistProjectState`/`restoreProjectState`, and + `saveNeedsConfirmation`/`requestSaveConfirmation` (mode enum `SaveConfirmMode`). + +Pixelart's pack lifecycle (`tickPackJobs`/`runPackWorkers`) folded into its own `beginFrame` +(the plugin self-drives background work); its pack-status check reads its own state instead of +round-tripping through the host. Dead pack plumbing removed from `EditorAPI`/`Host`/`Editor`. +`EditorAPI.requestCompositeWarmup` → `requestPrepareFrame` to match the new phase name. +`Plugin.CanvasEditorExt` deleted. Verified: native build, `test`, `test-plugin-loader`, `check-web` +all green; a grep of `src/sdk/` shows no residual domain vocabulary on the typed surface. + +Follow-up pass (hook honesty + docs): audited each renamed hook against its real call site — +9/10 are genuinely generic across editor types; `prepareFrame` is borderline and is now +documented as an opt-in `[requested]` pre-draw pass (only fires after `host.requestPrepareFrame`). +Found & fixed a real generality bug: `tickKeybinds` was invoked only on `pixelartPlugin(editor)`, +so a second plugin's per-frame keybinds would never fire — now broadcast to all plugins. Added a +**required-vs-optional** map (the document cluster you must implement to be an editor) and a +`[broadcast]`/`[active-doc]`/`[requested]` invocation tag + call-site/timing table to +[`Plugin.zig`](../src/sdk/Plugin.zig) and [`PLUGINS.md`](PLUGINS.md). This also closes the +original "no map of which of N hooks to implement" complaint. + +Active-doc owner dispatch + verbs-as-commands (done): a design review concluded the editing +actions (`copy`/`paste`/`transform`/`acceptEdit`/`cancelEdit`/`deleteSelection`) are *not* +universal — they're user-invoked and mean different things per editor — so they were **removed +from `Plugin.VTable` and registered as `Command`s** (`"pixelart.copy"`, …). The shell's Edit +menu / keybinds and *Grid Layout* dispatch to `"."` via +`Editor.runActiveDocCommand`, so every editing action routes to whichever editor owns the focused +tab; an owner that registered none is a clean no-op. The `EditorAPI` verb reach-backs are +unchanged (they funnel through `editor.()`, now per-owner command dispatch). + +Folder lifecycle rename (done): the pixelart-flavored `persistProjectState`/`restoreProjectState` +became the shell-event-named `onFolderClose` / `onFolderOpen` (the shell has a *folder* concept; +"project" was pixelart's layer on top). + +**Still open (smaller follow-ups):** +- **New File chooser** — with multiple `requestNewDocumentDialog` providers, present a typed "New > \" chooser (rough-edge #9 / existing `Plugin.zig` TODO). Single-provider dispatch via `Host.requestNewDocument` is done. + +**Resolved in SDK hardening pass:** +- ~~**New File is single-owner**~~ — `Editor.requestNewFileDialog` dispatches via `Host.requestNewDocument`. +- ~~**`initPlugin` not broadcast**~~ — `postInit` calls `initPlugin` on every registered plugin. +- ~~**Menu enablement by owner**~~ — Edit menu gates on `commandEnabled` for active-doc owner commands. +- ~~**No comptime editor profile check**~~ — `Plugin.assertEditorVTable` / `assertUtilityVTable` + templates. + +--- + +### Original note + +[`Plugin.VTable`](../src/sdk/Plugin.zig) is ~60 optional hooks; a large fraction are +pixel-art concepts presented as the neutral SDK: `transform`, `copy`, `paste`, +`startPackProject`, `isPackingActive`, `tickPackJobs`, `runPackWorkers`, +`persistProjectFolder`, `reloadProjectFolder`, `requestGridLayoutDialog`, +`requestFlatRasterSaveWarning`, `shouldConfirmFlatRasterSave`, +`warmupActiveDocumentComposites`, `resetDocumentPeekLayers`, `removeCanvasPane`, +`radialMenu*`, `tickActiveDocumentPlayback`. [`EditorAPI`](../src/sdk/EditorAPI.zig) does +the same (`transform`, `startPackProject`, `isPackingActive`, `requestCompositeWarmup`). + +Every hook is `?`-optional, so the compiler gives zero guidance — a missing hook surfaces +at runtime as a feature silently doing nothing. There is no delineated "minimal editor +plugin" subset. + +**Fix sketch:** split the vtable into a core *editor protocol* (the ~8 hooks every editor +needs) and an optional *pixelart extension* surface; or at minimum document the required +subset and add a comptime check that flags an editor plugin missing a core hook. + +## 2. 🔴 Document-load staging protocol is intricate and thread-unsafe-by-comment — *medium* + +Opening one file requires a correctly-ordered cluster of cooperating hooks whose contract +lives only in field comments: `documentStackSize`/`documentStackAlign` → shell allocates a +raw buffer → `loadDocument(path, out_doc)` constructs in place into shell-owned memory **on +a worker thread** → `documentIdFromBuffer` → `registerOpenDocument` to move to a stable +pointer → plus a separate `loadDocumentFromBytes` for web. Wrong size/align or touching +dvui/globals from the worker thread is UB with no compile-time protection. + +**Fix sketch:** provide an SDK helper that owns the happy path (size/align from the doc +type via comptime), and lift the threading rule out of a field comment into a documented +contract / debug assertion. + +## 3. 🔴 ABI compatibility is all-or-nothing, opaque, pins to an exact commit — *large* + +The structural fingerprint ([`dylib.zig`](../src/sdk/dylib.zig)) rejects every third-party +plugin on *any* dvui bump / boundary-struct tweak / new vtable hook, with a bare +`error.AbiMismatch`. No version range, no skew tolerance, no tool telling the author what +changed or which fizzy build their `.dylib` matches. A plugin is dead the instant the user +updates fizzy. + +**Fix sketch:** keep the fingerprint as the hard gate but layer a human-readable +(fizzy-version, dvui-version) tuple alongside it so diagnostics can say *why* and *what to +rebuild against*; consider a documented "compatible host build" stamp. + +## 4. 🟢 Failure is invisible to the user — *cheap* — DONE + +Implemented: `Editor.loadUserPlugins` now records each failure into `editor.failed_user_plugins` +(`{id, reason}`, owned strings, freed in `unloadPluginLibs`), logs at `.err` with an +actionable reason (`pluginLoadFailureReason` maps each `LoadError` — e.g. AbiMismatch → +"rebuild against this Fizzy build"), and a one-shot startup dialog +(`dialogs/PluginLoadFailures.zig`) lists them so the author isn't left reading logs. + +--- + +### Original note + +[`Editor.loadUserPlugins`](../src/editor/Editor.zig) logs `dvui.log.warn` and silently +skips on every failure (open failed, ABI mismatch, register rejected, OOM). A user whose +plugin doesn't load sees nothing in the UI. ABI mismatch — the most common case — surfaces +only as a log line. + +**Fix sketch:** record `{plugin_id, path, error}` for each failed load on the Editor/Host, +and surface it (settings panel section and/or a startup notice). At minimum keep a +queryable list so the UI can show "N plugins failed to load." + +## 5. 🔴 No hot-reload / unload — brutal dev loop — *large* + +[`PluginLoader.loadAndRegister`](../src/editor/PluginLoader.zig) keeps the DynLib open for +the app lifetime; `registerPlugin` only appends; `deinit` is never called mid-session. Plugin +development means quit + relaunch (and reopen project/files) on every change. + +**Fix sketch:** an unregister path (drop registry entries owned by a plugin id, call +`deinit`, close the lib) + a dev "reload plugin" affordance. Non-trivial because open +documents may be owned by the plugin being unloaded. + +## 6. 🟢 `set_globals` slot overload is a latent footgun — *cheap* — DONE + +Implemented: the two post-`gpa` slots are renamed `arg_b`/`arg_c` across `sdk.dylib.SetGlobalsFn`, +`PluginLoader.PreRegister`, and all `Editor.zig` call sites (matching the existing +`syncLoadedPluginGlobals` vocabulary), each with a doc comment + inline comment stating the +per-plugin convention (third-party: `arg_b` = `*Host`). No more field literally named `.state` +carrying the host. + +--- + +### Original note + +The C entry `set_globals(gpa, state, packer)` has three positional `*anyopaque` slots whose +meaning differs per plugin. Third-party [`exportEntry`](../src/sdk/dylib.zig) reads them as +`(gpa, host, state-ignored)`, so [`Editor.zig`](../src/editor/Editor.zig) smuggles `&host` +through the field named `.state` and `.packer` is dead. Built-ins use the slots differently +again. Works only by convention; it's a raw pointer reinterpret. + +**Fix sketch:** rename `PreRegister`/`SetGlobalsFn`/`installRuntime`/`exportEntry` params to +a single clear contract — `gpa`, `host`, `plugin_state` — and update all call sites. Naming +only; no behavior change. + +## 7. 🟢 Plugin identity vs folder name conflated; no dedup — *cheap* — DONE + +Implemented: `Host.registerPlugin` now rejects a duplicate declared `id` with +`error.DuplicatePluginId` (built-ins register first, so they always win). The dylib loader +turns that into a failed load surfaced via #4, and the declared `id` — not the folder name — +is the source of truth for routing. + +--- + +### Original note + +[`Editor.loadUserPlugins`](../src/editor/Editor.zig) derives `plugin_id` from the directory +name and keys its collision guard on `pluginById(entry.name)`, but plugins register under +their own declared `plugin.id`, and [`registerPlugin`](../src/sdk/Host.zig) does no dedup. A +plugin in folder `foo` declaring `id = "pixelart"` passes the folder guard then +double-registers `"pixelart"`; routing (`pluginById`/`pluginForExtension`) becomes +ambiguous. + +**Fix sketch:** make `registerPlugin` reject a duplicate id (return an error the loader +treats as a failed load — feeds #4), and treat the declared id as the source of truth. + +## 8. 🔴 Service discovery is stringly-typed and unversioned — *medium* + +[`Host.getService(name) -> ?*anyopaque`](../src/sdk/Host.zig) then +`@ptrCast(@alignCast(...))`. The author must know the magic string and the exact cast type, +with nothing binding the two, and the service struct's layout is not in the ABI fingerprint — +so a shape change silently corrupts. Only workbench's service is documented. + +**Fix sketch:** a typed service helper (`getService(T)` keyed on `T.service_name`) and fold +registered service struct layouts into the fingerprint, or attach a per-service version. + +## 9. 🔴 Smaller items — *cheap-ish, batched* + +- **`core.gpa` global** — docs say "sync `core.gpa = sdk.allocator()` if you use core + helpers," but `core` is a first-class import a complex plugin will use; forgetting is UB + with no reminder. Consider asserting/initializing it at load. +- **"New File" is single-owner** — existing TODO in [`Plugin.zig`](../src/sdk/Plugin.zig): + `requestNewDocumentDialog` dispatches to "a plugin that provides one"; a second editor + collides. Needs a typed "New > \" chooser. +- **Install ergonomics / no manifest** — `zig build install --prefix /plugins//` is hand-assembled; no `fizzy install-plugin`, no manifest declaring + name/version/author/min-fizzy-version. Identity comes from the folder the user drops it in. +- **dvui globals across the boundary** — context is re-injected each frame + ([`syncLoadedPluginDvuiContexts`](../src/editor/Editor.zig)); a plugin caching + `currentWindow()`, a font, or an ft2 handle across frames is in undocumented territory. + +## 10. 🟢 Built-in plugins didn't look like third-party plugins — *medium* — DONE + +A built-in's folder used to carry files a third-party plugin never has (an embed-stub +`build.zig` + a separate `build_standalone.zig`, `module.zig`, `dylib.zig`, `Globals.zig`) +and its `build/integration.zig` ran from two roots via dual-path (`repo_paths`/`pkg_paths`) +machinery — so "what files does a plugin need?" had two different answers. + +Now every plugin folder — the built-ins (pixi/workbench/code), the new in-repo `example` +template, and external plugins like markdown — is the **same canonical third-party shape** +(`build.zig` via `fizzy.plugin.create`, `build.zig.zon`, `root.zig` → `src/plugin.zig`, +`src/…`) and builds standalone with `cd src/plugins/ && zig build`. The only +fizzy-internal extras are a root `.zig` (the conventional package module + import hub, +forced to the root by Zig's module-import boundary) and a self-contained `static/` subfolder +(`static/integration.zig`) holding the static-embed + bundled-dylib build graph; the embed stub, +`build_standalone.zig`, `module.zig`, `src/hub.zig`, `dylib.zig`, `Globals.zig`, and the +dual-root path machinery are all gone. Vendored C deps use the reusable `fizzy.plugin.addCModule` +helper. The [`example`](../src/plugins/example/) plugin is the always-compiling copy-me +template. See [PLUGINS.md](PLUGINS.md) §2. + +**Caveat (monorepo only):** building a built-in that vendors C deps shared with fizzy's own +build graph (pixi's `build/deps.zig`) standalone from *inside* the repo would put one file in +two build modules, so pixi's `build.zig` inlines its vendored-dep wiring. A genuine +third-party plugin in its own repo has no such overlap. diff --git a/plugin_sdk.zig b/plugin_sdk.zig new file mode 100644 index 00000000..e0e838f4 --- /dev/null +++ b/plugin_sdk.zig @@ -0,0 +1,243 @@ +//! Build helpers for third-party Fizzy plugin dylibs. +//! +//! Required in your project (see `docs/PLUGINS.md` §2): +//! - `root.zig` — copy from `fizzy/src/plugins/root.zig` (one `sdk.dylib.exportEntry` call) +//! - `src/plugin.zig` — `register(host)` + `Plugin` vtable + `manifest`; read `sdk.allocator()` / `sdk.host()` +//! - `build.zig` / `build.zig.zon` — declare `fizzy`, call `fizzy.plugin.create` + `.install` below +const std = @import("std"); + +/// C-ABI entry symbols every plugin dylib must export. +pub const dylib_exports = [_][]const u8{ + "fizzy_plugin_abi_fingerprint", + "fizzy_plugin_sdk_version", + "fizzy_plugin_min_sdk_version", + "fizzy_plugin_version", + "fizzy_plugin_id", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", + "fizzy_plugin_set_globals", +}; + +pub const Modules = struct { + core: *std.Build.Module, + sdk: *std.Build.Module, + dvui: *std.Build.Module, + proxy_bridge: *std.Build.Module, +}; + +pub const ModulesOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +}; + +pub const ModuleOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + root_source_file: std.Build.LazyPath, + link_libc: bool = true, +}; + +pub const CreateOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + /// Dylib artifact name and installed filename stem (e.g. `"markdown"` → `markdown.dylib`). + name: []const u8, + link_libc: bool = true, + root_source_file: ?std.Build.LazyPath = null, +}; + +fn fizzyDep(b: *std.Build, opts: ModulesOptions) *std.Build.Dependency { + return b.dependency("fizzy", .{ + .target = opts.target, + .optimize = opts.optimize, + .plugin_sdk = true, + }); +} + +fn modulesFromDep(fizzy_dep: *std.Build.Dependency) Modules { + return .{ + .core = fizzy_dep.module("core"), + .sdk = fizzy_dep.module("sdk"), + .dvui = fizzy_dep.module("dvui"), + .proxy_bridge = fizzy_dep.module("proxy_bridge"), + }; +} + +pub fn modules(b: *std.Build, opts: ModulesOptions) Modules { + return modulesFromDep(fizzyDep(b, opts)); +} + +pub fn addImports(mod: *std.Build.Module, plugin_modules: Modules) void { + mod.addImport("core", plugin_modules.core); + mod.addImport("sdk", plugin_modules.sdk); + mod.addImport("dvui", plugin_modules.dvui); + mod.addImport("proxy_bridge", plugin_modules.proxy_bridge); +} + +fn module( + b: *std.Build, + plugin_modules: Modules, + opts: ModuleOptions, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = opts.link_libc, + }); + addImports(mod, plugin_modules); + return mod; +} + +pub fn createModule(b: *std.Build, opts: ModuleOptions) *std.Build.Module { + return module(b, modules(b, .{ + .target = opts.target, + .optimize = opts.optimize, + }), opts); +} + +pub const InstallOptions = struct { + /// Install under `/{name}.{ext}`. Defaults to `lib` compile artifact name. + name: ?[]const u8 = null, +}; + +/// Install `lib` as `{name}.{dylib,dll,so}` under the install prefix. +/// +/// const lib = fizzy.plugin.create(b, .{ .name = "markdown", .target = target, .optimize = optimize }); +/// fizzy.plugin.install(b, lib, .{}); +pub fn install(b: *std.Build, lib: *std.Build.Step.Compile, opts: InstallOptions) void { + const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; + const name = opts.name orelse lib.name; + const dest = b.fmt("{s}.{s}", .{ name, ext }); + const install_step = b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = .prefix }, + .dest_sub_path = dest, + }); + b.getInstallStep().dependOn(&install_step.step); +} + +/// A C source file + its compile flags, for `addCModule`. +pub const CSourceFile = struct { + file: std.Build.LazyPath, + flags: []const []const u8 = &.{}, +}; + +pub const CModuleOptions = struct { + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + /// Zig bindings root (e.g. `zstbi.zig`). + root_source_file: std.Build.LazyPath, + /// C translation units compiled into the module. + c_sources: []const CSourceFile = &.{}, + /// `-I` include dirs for the C sources. + include_paths: []const std.Build.LazyPath = &.{}, + link_libc: bool = true, + single_threaded: bool = false, +}; + +/// Build a Zig module backed by vendored C sources (an image/codec/archive lib, etc.) and +/// return it for `mod.addImport(...)`. The C compiles into whatever artifact imports the +/// returned module. All inputs are caller-supplied `LazyPath`s, so this works unchanged whether +/// invoked from the fizzy build root (static embed / bundled dylib) or a standalone plugin +/// build — there is no shared, location-bound build file to collide between the two graphs. +pub fn addCModule(b: *std.Build, opts: CModuleOptions) *std.Build.Module { + const mod = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = opts.link_libc, + .single_threaded = opts.single_threaded, + }); + for (opts.include_paths) |path| mod.addIncludePath(path); + for (opts.c_sources) |c| mod.addCSourceFile(.{ .file = c.file, .flags = c.flags }); + return mod; +} + +pub fn create(b: *std.Build, opts: CreateOptions) *std.Build.Step.Compile { + const root_source = opts.root_source_file orelse b.path("root.zig"); + const mod = module(b, modules(b, .{ + .target = opts.target, + .optimize = opts.optimize, + }), .{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = root_source, + .link_libc = opts.link_libc, + }); + + const lib = b.addLibrary(.{ + .name = opts.name, + .linkage = .dynamic, + .root_module = mod, + }); + lib.linker_allow_shlib_undefined = true; + lib.root_module.export_symbol_names = &dylib_exports; + return lib; +} + +pub fn exportModules( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, +) !void { + const dvui_dep = b.dependency("dvui", .{ + .target = target, + .optimize = optimize, + .backend = .proxy, + .accesskit = .off, + }); + const dvui_proxy_mod = dvui_dep.module("dvui_proxy"); + const proxy_bridge_mod = dvui_dep.module("proxy_bridge"); + + const known_folders = b.dependency("known_folders", .{ + .target = target, + .optimize = optimize, + }).module("known-folders"); + + const core_mod = b.addModule("core", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/core/core.zig"), + .link_libc = true, + }); + core_mod.addImport("dvui", dvui_proxy_mod); + core_mod.addImport("known-folders", known_folders); + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + core_mod.addImport("icons", dep.module("icons")); + } + + const sdk_mod = b.addModule("sdk", .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/sdk/sdk.zig"), + }); + sdk_mod.addImport("dvui", dvui_proxy_mod); + sdk_mod.addImport("proxy_bridge", proxy_bridge_mod); + sdk_mod.addImport("core", core_mod); + + b.modules.put(b.graph.arena, b.dupe("dvui"), dvui_proxy_mod) catch @panic("OOM"); + b.modules.put(b.graph.arena, b.dupe("proxy_bridge"), proxy_bridge_mod) catch @panic("OOM"); +} + +/// Install a built-in plugin dylib as `{name}.{ext}` under `plugins/`. +pub fn installBuiltinPlugin( + b: *std.Build, + lib: *std.Build.Step.Compile, + name: []const u8, + plugins_install_dir: std.Build.InstallDir, +) *std.Build.Step.InstallArtifact { + const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; + return b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = plugins_install_dir }, + .dest_sub_path = b.fmt("{s}.{s}", .{ name, ext }), + }); +} diff --git a/src/tools/process_assets.zig b/process_assets.zig similarity index 99% rename from src/tools/process_assets.zig rename to process_assets.zig index 3597b0eb..08a10636 100644 --- a/src/tools/process_assets.zig +++ b/process_assets.zig @@ -3,7 +3,7 @@ const path = std.fs.path; const Step = std.Build.Step; const Io = std.Io; -const Atlas = @import("../Atlas.zig"); +const Atlas = @import("src/plugins/pixi/src/Atlas.zig"); const ProcessAssetsStep = @This(); step: Step, diff --git a/spikes/shared-globals/README.md b/spikes/shared-globals/README.md new file mode 100644 index 00000000..8c3b635a --- /dev/null +++ b/spikes/shared-globals/README.md @@ -0,0 +1,40 @@ +# Spike: driving host dvui state from a prebuilt plugin dylib + +Validates the load-bearing mechanism for fizzy's runtime native-plugin architecture +(see `~/.claude/plans/i-would-like-to-glowing-stroustrup.md`): can a **prebuilt +plugin dynamic library**, compiling its **own copy** of the dvui-like code, render +into the **host's** dvui state across the `dlopen` boundary? + +`core.zig` stands in for dvui (a `current_window` global, an `ft2lib` global, a +`Window` carrying a per-frame arena, and a `label()` "widget" that uses all three). +The host exe and the plugin dylib each compile `core.zig` independently. + +Run: `zig build run` + +## Findings (macOS/arm64, Zig 0.16.0) + +- **Globals are NOT auto-shared.** Even with `rdynamic` + `allow_shlib_undefined`, + the host and plugin each get their own `current_window` (different addresses). + macOS two-level namespace ⇒ no automatic interposition. So the "one shared + `libdvui`" idea is out. +- **Mechanism B (context injection) works.** The host owns the dvui state; before + invoking the plugin's draw it sets the plugin's `current_window` + `ft2lib`. The + plugin's own statically-compiled `label()` then: + - mutates the **host's** `Window` (`widget_count` 1→4), + - allocates strings in the **host's** arena (round-tripped), + - uses the **host's** `FreeType` handle (`shape_calls` 1→4). +- Works because struct layout is identical (same pinned source/version) and it's + pure pointer-passing — so it ports to Linux/Windows unchanged, and the shared + allocator means **no cross-allocator free hazard**. + +## Design consequence + +Plugins statically compile dvui + the SDK; the host injects its handful of dvui +globals each frame (`current_window` per-frame; `io`/`ft2lib`/`debug` at init — all +public `pub var`, so no dvui patch needed). Pinned Zig + SDK version + a load-time +ABI gate keep struct layouts compatible. + +## Not covered here (validate in-fizzy at Phase 4) + +Real GPU rendering with a live backend — but that's the host's job; the plugin only +records draw commands into the shared Window's render list. diff --git a/spikes/shared-globals/build.zig b/spikes/shared-globals/build.zig new file mode 100644 index 00000000..2f469a9a --- /dev/null +++ b/spikes/shared-globals/build.zig @@ -0,0 +1,47 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // The shared "dvui-like" source. Imported by both artifacts; each compiles + // its own copy (as dvui would be compiled into host and plugin alike). + const core_mod = b.createModule(.{ + .root_source_file = b.path("core.zig"), + .target = target, + .optimize = optimize, + }); + + // Plugin: a dynamic library, prebuilt and dlopen'd at runtime. + const plugin = b.addLibrary(.{ + .name = "plugin", + .linkage = .dynamic, + .root_module = b.createModule(.{ + .root_source_file = b.path("plugin.zig"), + .target = target, + .optimize = optimize, + }), + }); + plugin.root_module.addImport("core", core_mod); + // Allow symbols to be resolved at load time (needed if we test Mechanism A). + plugin.linker_allow_shlib_undefined = true; + b.installArtifact(plugin); + + // Host: the near-empty exe that owns the Window and loads the plugin. + const host = b.addExecutable(.{ + .name = "host", + .root_module = b.createModule(.{ + .root_source_file = b.path("host.zig"), + .target = target, + .optimize = optimize, + }), + }); + host.root_module.addImport("core", core_mod); + // Export the host's dynamic symbols so a plugin could interpose (Mechanism A). + host.rdynamic = true; + b.installArtifact(host); + + const run = b.addRunArtifact(host); + run.step.dependOn(b.getInstallStep()); + b.step("run", "build everything and run the host").dependOn(&run.step); +} diff --git a/spikes/shared-globals/build.zig.zon b/spikes/shared-globals/build.zig.zon new file mode 100644 index 00000000..d4fa5fdb --- /dev/null +++ b/spikes/shared-globals/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .shared_globals_spike, + .version = "0.0.0", + .fingerprint = 0xc23fd395f515e0c8, + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "core.zig", + "host.zig", + "plugin.zig", + }, + .dependencies = .{}, +} diff --git a/spikes/shared-globals/core.zig b/spikes/shared-globals/core.zig new file mode 100644 index 00000000..a0d43feb --- /dev/null +++ b/spikes/shared-globals/core.zig @@ -0,0 +1,50 @@ +//! Stand-in for dvui: a global immediate-mode context pointer plus a "widget" +//! call that reads the global and mutates the shared Window. Both the host exe +//! and the plugin dylib compile THIS source independently (as dvui would be +//! compiled into each), so each binary gets its own copy of these globals. +//! The spike answers: can the plugin still drive the host's dvui state — its +//! Window, its per-frame arena allocator, and its FreeType handle? +const std = @import("std"); + +/// Stand-in for dvui's FT_Library handle (`dvui.ft2lib`, dvui.zig:346): a host- +/// owned resource the plugin must use, not reinitialize. +pub const FreeType = struct { + shape_calls: u32 = 0, +}; + +pub const Window = struct { + widget_count: u32 = 0, + magic: u64 = 0xDEADBEEF, + /// Stand-in for dvui's per-frame arena, which lives in the Window. Plugins + /// allocate widget data through this — i.e. the HOST's allocator. + arena: ?std.mem.Allocator = null, +}; + +/// Mirrors `dvui.current_window` (dvui.zig:416) — the shared immediate-mode context. +pub var current_window: ?*Window = null; +/// Mirrors `dvui.ft2lib` — a global library handle that must be injected too. +pub var ft2lib: ?*FreeType = null; + +/// Mirrors a dvui widget constructor: reads the global, allocates label text in +/// the Window's arena, shapes it via the FreeType handle, mutates the Window. +pub fn label(text: []const u8) ![]u8 { + const w = current_window orelse return error.NoCurrentWindow; + std.debug.assert(w.magic == 0xDEADBEEF); // layout/pointer sanity across boundary + const ft = ft2lib orelse return error.NoFreeType; + + const arena = w.arena orelse return error.NoArena; + const copy = try arena.dupe(u8, text); // allocate via the HOST's allocator + ft.shape_calls += 1; // touch the HOST's FreeType handle + w.widget_count += 1; + return copy; +} + +pub fn setCurrentWindow(w: ?*Window) void { + current_window = w; +} +pub fn setFreeType(ft: ?*FreeType) void { + ft2lib = ft; +} +pub fn currentWindowAddr() usize { + return @intFromPtr(¤t_window); +} diff --git a/spikes/shared-globals/host.zig b/spikes/shared-globals/host.zig new file mode 100644 index 00000000..646744f6 --- /dev/null +++ b/spikes/shared-globals/host.zig @@ -0,0 +1,55 @@ +//! The near-empty host exe. It owns the dvui state (Window + per-frame arena + +//! FreeType handle), then dlopens the plugin and lets it draw into that state — +//! modelling fizzy's shell driving a plugin's render across the dylib boundary. +const std = @import("std"); +const builtin = @import("builtin"); +const core = @import("core"); + +pub fn main() !void { + // The host owns the per-frame arena (as dvui's Window owns its arena). + var arena_inst = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_inst.deinit(); + + var ft = core.FreeType{}; // host owns the FreeType handle + var win = core.Window{ .arena = arena_inst.allocator() }; + core.setCurrentWindow(&win); + core.setFreeType(&ft); + _ = try core.label("host-drawn"); // host renders 1 widget itself + std.debug.print("[host] after host label(): widget_count={d} shape_calls={d}\n", .{ win.widget_count, ft.shape_calls }); + + const ext = switch (builtin.os.tag) { + .macos => "dylib", + .windows => "dll", + else => "so", + }; + var buf: [256]u8 = undefined; + const path = try std.fmt.bufPrint(&buf, "zig-out/lib/libplugin.{s}", .{ext}); + + var lib = try std.DynLib.open(path); + defer lib.close(); + + const set_ctx = lib.lookup(*const fn (?*core.Window, ?*core.FreeType) callconv(.c) void, "plugin_set_context") orelse return error.SymMissing; + const draw = lib.lookup(*const fn () callconv(.c) usize, "plugin_draw") orelse return error.SymMissing; + const plugin_global_addr = lib.lookup(*const fn () callconv(.c) usize, "plugin_current_window_addr") orelse return error.SymMissing; + + std.debug.print("[host] host current_window @ {x}, plugin current_window @ {x} ({s})\n", .{ + core.currentWindowAddr(), + plugin_global_addr(), + if (core.currentWindowAddr() == plugin_global_addr()) "SHARED" else "SEPARATE → inject", + }); + + // Mechanism B: inject the host's dvui state into the plugin. + set_ctx(&win, &ft); + const last_len = draw(); // plugin renders 3 labels via host arena + host FreeType + + std.debug.print("[host] plugin allocated last string len={d} (expect 9 for \"readme.md\")\n", .{last_len}); + std.debug.print("[host] after plugin draw: widget_count={d} (expect 4) shape_calls={d} (expect 4)\n", .{ win.widget_count, ft.shape_calls }); + + const ok = win.widget_count == 4 and ft.shape_calls == 4 and last_len == 9 and win.magic == 0xDEADBEEF; + if (ok) { + std.debug.print("\n[host] ✅ SUCCESS: plugin drove the host's Window, allocated in the host's arena, and used the host's FreeType handle — across the dylib boundary.\n", .{}); + } else { + std.debug.print("\n[host] ❌ FAIL: count={d} shape={d} len={d} magic={x}\n", .{ win.widget_count, ft.shape_calls, last_len, win.magic }); + return error.SpikeFailed; + } +} diff --git a/spikes/shared-globals/plugin.zig b/spikes/shared-globals/plugin.zig new file mode 100644 index 00000000..54d7d65b --- /dev/null +++ b/spikes/shared-globals/plugin.zig @@ -0,0 +1,28 @@ +//! A prebuilt plugin dylib. It imports `core` (the dvui stand-in) and compiles +//! its OWN copy of that code. It draws by calling core.label(), exactly as a real +//! fizzy plugin would call dvui.label() to render into the host's window — using +//! the host's Window, the host's arena allocator, and the host's FreeType handle. +const std = @import("std"); +const core = @import("core"); + +/// Mechanism B: the host injects its dvui state into the plugin's own globals +/// before asking it to draw. (current_window per-frame; ft2lib at init.) +export fn plugin_set_context(w: ?*core.Window, ft: ?*core.FreeType) callconv(.c) void { + core.setCurrentWindow(w); + core.setFreeType(ft); +} + +/// The plugin "renders" three labels into the current Window. Returns the length +/// of the last allocated string (proving it allocated via the host's arena). +export fn plugin_draw() callconv(.c) usize { + const a = core.label("file.fiz") catch return 0; + const b = core.label("sprite.png") catch return 0; + const c = core.label("readme.md") catch return 0; + _ = a; + _ = b; + return c.len; +} + +export fn plugin_current_window_addr() callconv(.c) usize { + return core.currentWindowAddr(); +} diff --git a/spikes/ts_highlight_test b/spikes/ts_highlight_test new file mode 100755 index 00000000..374e8764 Binary files /dev/null and b/spikes/ts_highlight_test differ diff --git a/src/App.zig b/src/App.zig index 65e64e30..c1c1e5e8 100644 --- a/src/App.zig +++ b/src/App.zig @@ -8,15 +8,17 @@ const assets = @import("assets"); const icon = assets.files.@"icon.png"; const fizzy = @import("fizzy.zig"); -const auto_update = @import("auto_update.zig"); -const update_notify = @import("update_notify.zig"); -const singleton = @import("singleton.zig"); -const paths = @import("paths.zig"); +const workbench = @import("workbench"); +const pixi = @import("pixi"); +const code = @import("code"); +const auto_update = @import("backend/auto_update.zig"); +const update_notify = @import("backend/update_notify.zig"); +const singleton = @import("backend/singleton.zig"); +const paths = fizzy.paths; const App = @This(); const Editor = fizzy.Editor; -const Packer = fizzy.Packer; -//const Assets = fizzy.Assets; +const Packer = pixi.Packer; // App fields allocator: std.mem.Allocator = undefined, @@ -59,7 +61,14 @@ const start_options_base: dvui.App.StartOptions = .{ fn startOptions() dvui.App.StartOptions { var opts = start_options_base; + // Create the dvui window with the *same* allocator the host hands to plugins + // (`fizzy.app.allocator`). Without this, dvui defaults the window to the runtime's + // `main_init.gpa`, a different allocator instance — so `dvui.currentWindow().gpa` + // and `host.allocator` would be distinct, and a plugin that allocated with one and + // freed with the other would corrupt the heap. Unifying them makes every allocator a + // plugin can reach the same instance. (No-op on wasm, which uses the page allocator.) if (comptime builtin.target.cpu.arch != .wasm32) { + opts.gpa = appAllocator(); const main_init = dvui.App.main_init orelse return opts; if (paths.configFolderZ(&pref_path_buf, main_init.io, fizzy.processEnviron(), ".")) |pref_path| { pref_path_len = pref_path.len; @@ -130,6 +139,11 @@ pub fn AppInit(win: *dvui.Window) !void { const allocator = appAllocator(); + // Inject shared infrastructure context into `core` so it stays decoupled from + // the App hub (allocator for gfx, trackpad input for the canvas widget). + fizzy.core.gpa = allocator; + fizzy.core.takeTrackpadPinchRatio = fizzy.backend.takeTrackpadPinchRatio; + const resolved_argv = singleton.consumeStartupArgv(); defer singleton.freeResolvedArgv(allocator, resolved_argv); @@ -161,12 +175,27 @@ pub fn AppInit(win: *dvui.Window) !void { fizzy.editor = try allocator.create(Editor); fizzy.editor.* = Editor.init(fizzy.app) catch unreachable; + // Workbench + pixi shell-owned state: wire before plugin `register`. + workbench.runtime.setWorkbench(&fizzy.editor.workbench); + + const pixi_state = try allocator.create(pixi.State); + pixi.runtime.adoptShellState(pixi_state); + pixi_state.* = pixi.State.init(allocator, &fizzy.editor.host) catch unreachable; + fizzy.editor.pixi_state = pixi_state; + + // Second-stage init that needs the editor at its final heap address (e.g. + // registering the workbench-api service whose `ctx` is this pointer). + fizzy.editor.postInit() catch unreachable; + // `Packer` works on web now that `zstbi.c` compiles for wasm32-freestanding // (`STBI_NO_STDLIB` + the `fizzy_stbi_libc.c` shims). The web pack flow // packs the currently-open files instead of walking a project directory. fizzy.packer = try allocator.create(Packer); fizzy.packer.* = Packer.init(allocator) catch unreachable; + pixi.runtime.setPacker(fizzy.packer); + fizzy.editor.syncLoadedPixiGlobals(); + // Hand the window to the listener thread and queue our own argv so the // first frame opens any files / project folder supplied on the command line. singleton.registerWindow(win, resolved_argv); @@ -212,7 +241,13 @@ pub fn AppInit(win: *dvui.Window) !void { pub fn AppDeinit() void { // Persist the current windowed frame while the window still exists. No-op off macOS. fizzy.backend.saveWindowGeometry(fizzy.app.window); + // Persist `.fizproject` while `editor.host` and `editor.folder` are still live. + pixi.State.persistProject(fizzy.editor.pixi_state); fizzy.editor.deinit() catch unreachable; + // Pixel-art teardown (persists the .fizproject, frees tools/palettes/pack jobs). + // After the editor so any editor teardown that still reads pixel-art state runs first. + fizzy.editor.pixi_state.deinit(fizzy.app.allocator); + fizzy.app.allocator.destroy(fizzy.editor.pixi_state); // Tear down the singleton listener after the editor so any callback // currently in flight finishes before we free state it touches. singleton.deinit(); diff --git a/src/Assets.zig b/src/Assets.zig deleted file mode 100644 index a8cd63af..00000000 --- a/src/Assets.zig +++ /dev/null @@ -1,276 +0,0 @@ -const std = @import("std"); -const zstbi = @import("zstbi"); -const mach = @import("mach"); -const builtin = @import("builtin"); -const fizzy = @import("fizzy.zig"); - -const Assets = @This(); - -pub const AssetType = enum { - texture, - atlas, - unsupported, -}; - -// Mach module, systems, and main -pub const mach_module = .assets; -pub const mach_systems = .{ .init, .listen, .deinit }; -pub const mach_tags = .{ .auto_reload, .path }; - -const log = std.log.scoped(.watcher); -const ListenerFn = fn (self: *Assets, path: []const u8, name: []const u8) void; -const Watcher = switch (builtin.target.os.tag) { - .linux => @import("tools/watcher/LinuxWatcher.zig"), - .macos => @import("tools/watcher/MacosWatcher.zig"), - .windows => @import("tools/watcher/WindowsWatcher.zig"), - else => @compileError("unsupported platform"), -}; - -paths: mach.Objects(.{ .track_fields = false }, struct { value: [:0]const u8 }), -textures: mach.Objects(.{ .track_fields = false }, fizzy.gfx.Texture), -atlases: mach.Objects(.{ .track_fields = false }, fizzy.Atlas), - -allocator: std.mem.Allocator, -watcher: Watcher = undefined, -thread: std.Thread = undefined, -watching: bool = false, - -var gpa: std.heap.DebugAllocator(.{}) = .init; - -pub fn init(assets: *Assets) !void { - const allocator = gpa.allocator(); - - zstbi.init(allocator); - assets.* = .{ - .textures = assets.textures, - .atlases = assets.atlases, - .paths = assets.paths, - .allocator = allocator, - }; -} - -pub fn loadTexture(assets: *Assets, path: []const u8, options: fizzy.gfx.Texture.SamplerOptions) !?mach.ObjectID { - assets.textures.lock(); - defer assets.textures.unlock(); - - const term_path = try assets.allocator.dupeZ(u8, path); - - if (fizzy.gfx.Texture.loadFromFile(term_path, options) catch null) |texture| { - const texture_id = try assets.textures.new(texture); - const path_id = try assets.paths.new(.{ .value = term_path }); - - try assets.textures.setTag(texture_id, Assets, .path, path_id); - - return texture_id; - } - - return null; -} - -pub fn loadAtlas(assets: *Assets, path: []const u8) !?mach.ObjectID { - assets.atlases.lock(); - defer assets.atlases.unlock(); - - const term_path = try assets.allocator.dupeZ(u8, path); - - if (fizzy.Atlas.loadFromFile(assets.allocator, term_path) catch null) |atlas| { - const atlas_id = try assets.atlases.new(atlas); - const path_id = try assets.paths.new(.{ .value = term_path }); - - try assets.atlases.setTag(atlas_id, Assets, .path, path_id); - - return atlas_id; - } - - return null; -} - -pub fn reload(assets: *Assets, id: mach.ObjectID) !void { - if (assets.textures.is(id)) { - var old_texture = assets.textures.getValue(id); - defer old_texture.deinitWithoutClear(); - - if (assets.textures.getTag(id, Assets, .path)) |path_id| { - const path = assets.paths.get(path_id, .value); - - if (fizzy.gfx.Texture.loadFromFile(path, .{ - .address_mode = old_texture.address_mode, - .copy_dst = old_texture.copy_dst, - .copy_src = old_texture.copy_src, - .filter = old_texture.filter, - .format = old_texture.format, - .render_attachment = old_texture.render_attachment, - .storage_binding = old_texture.storage_binding, - .texture_binding = old_texture.texture_binding, - }) catch null) |texture| { - assets.textures.setValueRaw(id, texture); - } - } - } else if (assets.atlases.is(id)) { - var old_atlas = assets.atlases.getValue(id); - defer old_atlas.deinit(assets.allocator); - - if (assets.atlases.getTag(id, Assets, .path)) |path_id| { - const path = assets.paths.get(path_id, .value); - - if (fizzy.Atlas.loadFromFile(assets.allocator, path) catch null) |atlas| { - assets.atlases.setValueRaw(id, atlas); - } - } - } -} - -pub fn getTexture(assets: *Assets, id: mach.ObjectID) fizzy.gfx.Texture { - return assets.textures.getValue(id); -} - -pub fn getAtlas(assets: *Assets, id: mach.ObjectID) fizzy.Atlas { - return assets.atlases.getValue(id); -} - -/// Returns the watch paths for the currently loaded assets. -/// Caller owns the memory. -pub fn getWatchPaths(assets: *Assets, allocator: std.mem.Allocator) ![]const []const u8 { - var out_paths = std.ArrayList([]const u8).init(allocator); - - var paths = assets.paths.slice(); - while (paths.next()) |id| { - const path = paths.objs.get(id, .value); - for (out_paths.items) |out_path| { - if (std.mem.eql(u8, path, out_path)) { - continue; - } - } - try out_paths.append(path); - } - - return out_paths.toOwnedSlice(); -} - -/// Returns the watch directories for the currently loaded assets. -/// Caller owns the memory. -pub fn getWatchDirs(assets: *Assets, allocator: std.mem.Allocator) ![]const []const u8 { - var out_dirs = std.ArrayList([]const u8).init(allocator); - - var paths = assets.paths.slice(); - path_blk: while (paths.next()) |id| { - if (std.fs.path.dirname(paths.objs.get(id, .value))) |new_dir| { - for (out_dirs.items) |dir| { - if (std.mem.eql(u8, dir, new_dir)) { - continue :path_blk; - } - } - - try out_dirs.append(new_dir); - } - } - - return out_dirs.toOwnedSlice(); -} - -/// Spawns a watch thread for all of the currently registered assets -/// If you add or change assets, you need to call stopWatch and then watch again to reset the background thread -pub fn watch(assets: *Assets) !void { - if (!assets.watching) - try spawnWatchThread(assets); -} - -/// Stops the asset watching thread -pub fn stopWatching(assets: *Assets) void { - assets.stopWatchThread(); -} - -fn spawnWatchThread(assets: *Assets) !void { - assets.watcher = try Watcher.init(assets.allocator); - assets.thread = try std.Thread.spawn(.{}, listen, .{assets}); - assets.thread.detach(); - assets.watching = true; -} - -fn stopWatchThread(assets: *Assets) void { - assets.watching = false; - assets.watcher.stop(); - //assets.thread.join(); - //assets.thread = undefined; -} - -/// Kicks off the listening loop, this will not return -pub fn listen(assets: *Assets) !void { - try assets.watcher.listen(assets); -} - -fn comparePaths(allocator: std.mem.Allocator, path1: []const u8, path2: []const u8) !bool { - const rel_1 = try std.fs.path.relative(allocator, fizzy.app.root_path, path1); - const rel_2 = try std.fs.path.relative(allocator, fizzy.app.root_path, path2); - - defer allocator.free(rel_1); - defer allocator.free(rel_2); - - return std.mem.eql(u8, rel_1, rel_2); -} - -/// Called from the watchers when assets change, this is where we reload our assets based on path. -pub fn onAssetChange(assets: *Assets, path: []const u8, name: []const u8) void { - const changed_path = std.fs.path.join(assets.allocator, &.{ path, name }) catch return; - defer assets.allocator.free(changed_path); - - const extension = std.fs.path.extension(name); - - var asset_type: AssetType = .unsupported; - - if (std.mem.eql(u8, extension, ".png") or std.mem.eql(u8, extension, ".jpg")) - asset_type = .texture - else if (std.mem.eql(u8, extension, ".atlas")) - asset_type = .atlas; - - switch (asset_type) { - .texture => { - var textures = assets.textures.slice(); - while (textures.next()) |texture_id| { - if (!assets.textures.hasTag(texture_id, Assets, .auto_reload)) continue; - - if (assets.textures.getTag(texture_id, Assets, .path)) |path_id| { - if (comparePaths(assets.allocator, changed_path, assets.paths.get(path_id, .value)) catch false) { - assets.reload(texture_id) catch log.debug("Texture failed to reload: {s}", .{changed_path}); - } - } - } - }, - .atlas => { - var atlases = assets.atlases.slice(); - while (atlases.next()) |atlas_id| { - if (!assets.atlases.hasTag(atlas_id, Assets, .auto_reload)) continue; - - if (assets.atlases.getTag(atlas_id, Assets, .path)) |path_id| { - if (comparePaths(assets.allocator, changed_path, assets.paths.get(path_id, .value)) catch false) { - assets.reload(atlas_id) catch log.debug("Atlas failed to reload: {s}", .{changed_path}); - } - } - } - }, - .unsupported => {}, - } -} - -pub fn deinit(assets: *Assets) void { - assets.stopWatching(); - - var textures = assets.textures.slice(); - while (textures.next()) |id| { - var t = assets.textures.getValue(id); - t.deinit(); - } - - var atlases = assets.atlases.slice(); - while (atlases.next()) |id| { - var a = assets.atlases.getValue(id); - a.deinit(assets.allocator); - } - - var paths = assets.paths.slice(); - while (paths.next()) |id| { - assets.allocator.free(assets.paths.get(id, .value)); - } - - zstbi.deinit(); -} diff --git a/src/auto_update.zig b/src/backend/auto_update.zig similarity index 100% rename from src/auto_update.zig rename to src/backend/auto_update.zig diff --git a/src/backend_native.zig b/src/backend/backend_native.zig similarity index 99% rename from src/backend_native.zig rename to src/backend/backend_native.zig index 93d7c6ba..e785e85d 100644 --- a/src/backend_native.zig +++ b/src/backend/backend_native.zig @@ -1,5 +1,5 @@ // These are functions specific to the backend, which is currently SDL3 -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); @@ -7,7 +7,7 @@ const sdl3 = @import("backend").c; const objc = @import("objc"); const win32 = @import("win32"); const singleton = @import("singleton.zig"); -const window_layout = @import("internal/window_layout.zig"); +const window_layout = @import("window_layout.zig"); // AppKit geometry types for NSView frame/bounds (same layout as Foundation). const NSPoint = extern struct { x: f64, y: f64 }; diff --git a/src/backend_web.zig b/src/backend/backend_web.zig similarity index 98% rename from src/backend_web.zig rename to src/backend/backend_web.zig index 0bf57f11..95299263 100644 --- a/src/backend_web.zig +++ b/src/backend/backend_web.zig @@ -8,7 +8,7 @@ const dvui = @import("dvui"); const builtin = @import("builtin"); const WebFileIo = if (builtin.target.cpu.arch == .wasm32) - @import("editor/WebFileIo.zig") + @import("../editor/WebFileIo.zig") else struct {}; @@ -151,7 +151,7 @@ pub fn showOpenFolderDialog( _: ?[]const u8, ) void { if (comptime builtin.target.cpu.arch == .wasm32) { - const Dialogs = @import("editor/dialogs/Dialogs.zig"); + const Dialogs = @import("../editor/dialogs/Dialogs.zig"); Dialogs.WebFolderUnavailable.request(); } } diff --git a/src/file_assoc.zig b/src/backend/file_assoc.zig similarity index 100% rename from src/file_assoc.zig rename to src/backend/file_assoc.zig diff --git a/src/tools/msvc_translatec_shim/stdint.h b/src/backend/msvc_translatec_shim/stdint.h similarity index 100% rename from src/tools/msvc_translatec_shim/stdint.h rename to src/backend/msvc_translatec_shim/stdint.h diff --git a/src/objc/FizzyMenuTarget.m b/src/backend/objc/FizzyMenuTarget.m similarity index 100% rename from src/objc/FizzyMenuTarget.m rename to src/backend/objc/FizzyMenuTarget.m diff --git a/src/objc/FizzyTrackpadGesture.m b/src/backend/objc/FizzyTrackpadGesture.m similarity index 100% rename from src/objc/FizzyTrackpadGesture.m rename to src/backend/objc/FizzyTrackpadGesture.m diff --git a/src/objc/FizzyVisualEffectView.m b/src/backend/objc/FizzyVisualEffectView.m similarity index 100% rename from src/objc/FizzyVisualEffectView.m rename to src/backend/objc/FizzyVisualEffectView.m diff --git a/src/objc/FizzyWindowMonitor.m b/src/backend/objc/FizzyWindowMonitor.m similarity index 99% rename from src/objc/FizzyWindowMonitor.m rename to src/backend/objc/FizzyWindowMonitor.m index 707a841a..ca6e034b 100644 --- a/src/objc/FizzyWindowMonitor.m +++ b/src/backend/objc/FizzyWindowMonitor.m @@ -8,11 +8,11 @@ * Green-button maximize uses a native fullscreen Space (menu bar hidden). * SDL3 ignores resize notifications while a Space transition animates, so a * 60Hz NSTimer pump renders live frames during the morph. The Zig side - * (src/backend_native.zig) pushes live contentView bounds into SDL before each + * (src/backend/backend_native.zig) pushes live contentView bounds into SDL before each * frame so the Metal drawable and layout stay paired. * * The fizzy_macos_window_* callbacks below are exported from - * src/backend_native.zig; everything else is self-contained. */ + * src/backend/backend_native.zig; everything else is self-contained. */ extern void fizzy_macos_window_resize_cb(void); extern void fizzy_macos_window_pump_frame(void); @@ -20,7 +20,7 @@ extern void fizzy_macos_window_request_clear_frames(int frames); extern void fizzy_macos_window_commit_steady_state(void); /* Pure window-frame decisions live in window_layout.zig (unit-tested); see - * backend_native.zig for the C-ABI wrappers. */ + * backend/backend_native.zig for the C-ABI wrappers. */ extern int fizzy_macos_constrain_is_menu_bar_nudge(double rx, double ry, double rw, double rh, double cx, double cy, double cw, double ch, double visible_top); diff --git a/src/singleton.zig b/src/backend/singleton.zig similarity index 100% rename from src/singleton.zig rename to src/backend/singleton.zig diff --git a/src/singleton_native.zig b/src/backend/singleton_native.zig similarity index 98% rename from src/singleton_native.zig rename to src/backend/singleton_native.zig index d52a071e..7e7d6044 100644 --- a/src/singleton_native.zig +++ b/src/backend/singleton_native.zig @@ -15,7 +15,7 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); const singleton_app = @import("singleton_app"); -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const log = std.log.scoped(.singleton); @@ -197,7 +197,7 @@ fn dispatchPath(path: []const u8) !void { return err; }; file.close(io); - _ = try fizzy.editor.openFilePath(path, fizzy.editor.open_workspace_grouping); + _ = try fizzy.editor.openFilePath(path, fizzy.editor.currentGroupingID()); } /// Walk upward from `file_path`'s parent directory, returning the first diff --git a/src/singleton_web.zig b/src/backend/singleton_web.zig similarity index 100% rename from src/singleton_web.zig rename to src/backend/singleton_web.zig diff --git a/src/update_install.zig b/src/backend/update_install.zig similarity index 100% rename from src/update_install.zig rename to src/backend/update_install.zig diff --git a/src/update_notify.zig b/src/backend/update_notify.zig similarity index 99% rename from src/update_notify.zig rename to src/backend/update_notify.zig index 7644f846..1c8de6a5 100644 --- a/src/update_notify.zig +++ b/src/backend/update_notify.zig @@ -7,7 +7,7 @@ const std = @import("std"); const dvui = @import("dvui"); const auto_update = @import("auto_update.zig"); const update_install = @import("update_install.zig"); -const fizzy = @import("fizzy.zig"); +const fizzy = @import("../fizzy.zig"); const Phase = enum(u8) { pending, diff --git a/src/web_io.zig b/src/backend/web_io.zig similarity index 100% rename from src/web_io.zig rename to src/backend/web_io.zig diff --git a/src/internal/window_layout.zig b/src/backend/window_layout.zig similarity index 98% rename from src/internal/window_layout.zig rename to src/backend/window_layout.zig index dd15f2f3..af3ec513 100644 --- a/src/internal/window_layout.zig +++ b/src/backend/window_layout.zig @@ -2,7 +2,8 @@ //! (`backend_native.zig` + `objc/FizzyWindowMonitor.m`), so the "+/- titlebar //! height" math is testable without a window. std-only — pulled in by //! `tests/root.zig` and called from `backend_native.zig` (which keeps the -//! AppKit/SDL plumbing). See `src/internal/window_layout` notes in the plan. +//! AppKit/SDL plumbing). Shell/native-windowing infra (not pixel-art), so it lives at +//! `src/backend/window_layout.zig` beside `backend_native.zig` rather than under `internal/`. const std = @import("std"); diff --git a/src/core/Atlas.zig b/src/core/Atlas.zig new file mode 100644 index 00000000..060995f9 --- /dev/null +++ b/src/core/Atlas.zig @@ -0,0 +1,34 @@ +//! A loaded spritesheet: GPU `source` texture + indexed sprite metadata. +//! +//! The shell's `editor.atlas` uses this minimal type for UI icons. The pixel-art +//! plugin's packed output uses the richer `Internal.Atlas` instead. +const std = @import("std"); +const dvui = @import("dvui"); + +const Sprite = @import("Sprite.zig"); + +const Atlas = @This(); + +source: dvui.ImageSource, +sprites: []Sprite, + +const SpritesOnly = struct { + sprites: []Sprite, +}; + +/// Parse a `.atlas` JSON blob and return a duped sprite table. Animations and +/// other fields are ignored (`ignore_unknown_fields`). +pub fn loadSpritesFromBytes(allocator: std.mem.Allocator, bytes: []const u8) ![]Sprite { + const options: std.json.ParseOptions = .{ + .ignore_unknown_fields = true, + .allocate = .alloc_if_needed, + }; + var parsed = try std.json.parseFromSlice(SpritesOnly, allocator, bytes, options); + defer parsed.deinit(); + return try allocator.dupe(Sprite, parsed.value.sprites); +} + +pub fn deinit(self: *Atlas, allocator: std.mem.Allocator) void { + allocator.free(self.sprites); + self.sprites = &.{}; +} diff --git a/src/editor/Fling.zig b/src/core/Fling.zig similarity index 100% rename from src/editor/Fling.zig rename to src/core/Fling.zig diff --git a/src/core/Sprite.zig b/src/core/Sprite.zig new file mode 100644 index 00000000..e71d8c49 --- /dev/null +++ b/src/core/Sprite.zig @@ -0,0 +1,91 @@ +//! A sub-rect within an atlas texture: pixel `source` rect + optional `origin`. +//! +//! Used by the shell for UI icons and by the pixel-art renderer as the sprite-rect +//! type. Distinct from the plugin's build-time `Atlas.zig` (JSON loader with animations). +const std = @import("std"); +const dvui = @import("dvui"); + +const Sprite = @This(); + +origin: [2]f32 = .{ 0.0, 0.0 }, +source: [4]u32, + +/// Draw this sprite from `atlas_source` as a dvui widget (static textured quad). +pub fn draw( + self: Sprite, + src: std.builtin.SourceLocation, + atlas_source: dvui.ImageSource, + scale: f32, + opts: dvui.Options, +) dvui.WidgetData { + const source_size: dvui.Size = dvui.imageSize(atlas_source) catch .{ .w = 0, .h = 0 }; + + const uv = dvui.Rect{ + .x = @as(f32, @floatFromInt(self.source[0])) / source_size.w, + .y = @as(f32, @floatFromInt(self.source[1])) / source_size.h, + .w = @as(f32, @floatFromInt(self.source[2])) / source_size.w, + .h = @as(f32, @floatFromInt(self.source[3])) / source_size.h, + }; + + const options = (dvui.Options{ .name = "sprite" }).override(opts); + + const size: dvui.Size = if (options.min_size_content) |msc| msc else .{ + .w = @as(f32, @floatFromInt(self.source[2])) * scale, + .h = @as(f32, @floatFromInt(self.source[3])) * scale, + }; + + var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size })); + wd.register(); + + const cr = wd.contentRect(); + const ms = wd.options.min_size_contentGet(); + + var too_big = false; + if (ms.w > cr.w or ms.h > cr.h) too_big = true; + + var e = wd.options.expandGet(); + const g = wd.options.gravityGet(); + var rect = dvui.placeIn(cr, ms, e, g); + + if (too_big and e != .ratio) { + if (ms.w > cr.w and !e.isHorizontal()) { + rect.w = ms.w; + rect.x -= g.x * (ms.w - cr.w); + } + if (ms.h > cr.h and !e.isVertical()) { + rect.h = ms.h; + rect.y -= g.y * (ms.h - cr.h); + } + } + + wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet()); + + if (wd.options.rotationGet() == 0.0) { + wd.borderAndBackground(.{}); + } else if (wd.options.borderGet().nonZero()) { + dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id}); + } + + const rs = wd.contentRectScale(); + dvui.renderImage(atlas_source, rs, .{ + .uv = uv, + .fade = 0.0, + }) catch { + dvui.log.err("Failed to render sprite", .{}); + }; + + if (opts.color_border) |border| { + var path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + defer path.deinit(); + const r = wd.contentRectScale().r; + path.addPoint(r.topLeft()); + path.addPoint(r.topRight()); + path.addPoint(r.bottomRight()); + path.addPoint(r.bottomLeft()); + path.build().stroke(.{ .color = border, .thickness = 1.0, .closed = true }); + } + + wd.minSizeSetAndRefresh(); + wd.minSizeReportToParent(); + return wd; +} diff --git a/src/core/core.zig b/src/core/core.zig new file mode 100644 index 00000000..2fd4cd4b --- /dev/null +++ b/src/core/core.zig @@ -0,0 +1,45 @@ +//! Core module root: shared infrastructure (gfx, math, fs, generated atlas, +//! platform, paths, the generic dvui hub + generic widgets) that both the shell +//! and the plugins depend on. Core never imports the `fizzy` app hub. +//! +//! Cross-cutting app resources (the allocator, platform input) are injected at +//! startup via the context fields below so core stays decoupled from the App. +const std = @import("std"); + +/// Process allocator, set once at startup by the shell (`App`/`web_main`). +/// Core infrastructure (e.g. `gfx.image`) allocates through this instead of +/// reaching into the App hub. +pub var gpa: std.mem.Allocator = undefined; + +/// Trackpad pinch-zoom accessor, wired at startup by the platform backend +/// (native/web). Defaults to a no-op so headless/test builds work without it. +pub var takeTrackpadPinchRatio: *const fn () f32 = defaultTrackpadPinchRatio; + +fn defaultTrackpadPinchRatio() f32 { + return 1.0; +} + +// Shared infrastructure re-exports. +pub const image = @import("gfx/image.zig"); +pub const perf = @import("gfx/perf.zig"); +pub const water_surface = @import("gfx/water_surface.zig"); +pub const math = @import("math/math.zig"); +pub const fs = @import("fs.zig"); +pub const platform = @import("platform.zig"); +pub const paths = @import("paths.zig"); + +/// Generated atlas index (named sprite lookups). Written by the build's +/// process-assets step into `src/core/generated/`. +pub const atlas = @import("generated/atlas.zig"); + +/// Generic dvui hub: dialog framework, helpers, and the generic widgets. +pub const dvui = @import("dvui.zig"); + +/// Generic momentum/fling helper (pan, scrub, cover-flow). +pub const Fling = @import("Fling.zig"); + +/// Generic sprite sub-rect within an atlas texture. +pub const Sprite = @import("Sprite.zig"); + +/// Generic loaded spritesheet (`source` texture + sprite table). +pub const Atlas = @import("Atlas.zig"); diff --git a/src/dvui.zig b/src/core/dvui.zig similarity index 56% rename from src/dvui.zig rename to src/core/dvui.zig index 9966490b..be10dfeb 100644 --- a/src/dvui.zig +++ b/src/core/dvui.zig @@ -1,19 +1,22 @@ const std = @import("std"); -const fizzy = @import("fizzy.zig"); const dvui = @import("dvui"); const builtin = @import("builtin"); const icons = @import("icons"); -const Widgets = @import("editor/widgets/Widgets.zig"); - -pub const FileWidget = Widgets.FileWidget; -pub const TabsWidget = Widgets.TabsWidget; -pub const ImageWidget = Widgets.ImageWidget; -pub const CanvasWidget = Widgets.CanvasWidget; -pub const ReorderWidget = Widgets.ReorderWidget; -pub const PanedWidget = Widgets.PanedWidget; -pub const FloatingWindowWidget = Widgets.FloatingWindowWidget; -pub const TreeWidget = Widgets.TreeWidget; -pub const TreeSelection = Widgets.TreeSelection; +const platform = @import("platform.zig"); + +pub const CanvasWidget = @import("widgets/CanvasWidget.zig"); +pub const ReorderWidget = @import("widgets/ReorderWidget.zig"); +pub const PanedWidget = @import("widgets/PanedWidget.zig"); +pub const FloatingWindowWidget = @import("widgets/FloatingWindowWidget.zig"); +pub const TreeWidget = @import("widgets/TreeWidget.zig"); +pub const TreeSelection = @import("widgets/TreeSelection.zig"); + +/// Core-owned dialog chrome state, set by the dialog framework and read by the +/// shell so core stays decoupled from the editor. When a modal is open the shell +/// dims the titlebar; the optional close-rect overrides the dialog's close +/// animation origin (e.g. the New File flow animating from the tree row). +pub var modal_dim_titlebar: bool = false; +pub var dialog_close_rect_override: ?dvui.Rect.Physical = null; /// Currently this is specialized for the layers paned widget, just includes icon and dragging flag so we know when the pane is dragging pub fn paned(src: std.builtin.SourceLocation, init_opts: PanedWidget.InitOptions, opts: dvui.Options) *PanedWidget { @@ -101,17 +104,10 @@ pub const DialogOptions = struct { }; pub fn defaultDialogDisplay(id: dvui.Id) anyerror!bool { - const valid: bool = true; - + // Placeholder body; every real dialog supplies its own `displayFn`. Kept free + // of plugin (atlas/sprite) draws so the core dialog code stays plugin-agnostic. _ = id; - - _ = fizzy.dvui.sprite(@src(), .{ - .source = fizzy.editor.atlas.source, - .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.fox_default], - .scale = 2.0, - }, .{ .gravity_y = 0.5, .gravity_x = 0.5, .background = false }); - - return valid; + return true; } pub fn defaultDialogCallAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void { @@ -193,7 +189,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { }; if (modal) { - fizzy.editor.dim_titlebar = true; + modal_dim_titlebar = true; } const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { @@ -221,7 +217,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { const maxSize = dvui.dataGet(null, id, "_max_size", dvui.Options.MaxSize); const hide_footer = dvui.dataGet(null, id, "_hide_footer", bool) orelse false; - var win = fizzy.dvui.floatingWindow(@src(), .{ + var win = floatingWindow(@src(), .{ .modal = modal, .center_on = center_on, .window_avoid = .nudge, @@ -245,12 +241,12 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { if (dvui.animationGet(win.data().id, "_close_x")) |a| { if (a.done()) { - fizzy.Editor.Explorer.files.new_file_close_rect = null; + dialog_close_rect_override = null; dvui.dialogRemove(id); } - } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| { + } else if (dialog_close_rect_override) |close_rect| { dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - fizzy.Editor.Explorer.files.new_file_close_rect = null; + dialog_close_rect_override = null; } else { win.autoSize(); } @@ -268,7 +264,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { }; var header_openflag = true; - win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind)); + win.dragAreaSet(windowHeader(title, "", &header_openflag, header_kind)); if (!header_openflag) { if (callafter) |ca| { ca(id, .cancel) catch { @@ -413,6 +409,26 @@ pub fn windowHeaderCloseInnerSide() f32 { return (row_inner + cap_inner) * 0.5; } +/// Padding around the close / dirty / save indicator in workspace tabs (fixed every frame). +pub const tab_status_inset = dvui.Rect{ .x = 4, .y = 2, .w = 4, .h = 2 }; + +/// Workspace tab close control: fixed size, no margin/shadow (unlike dialog header close). +pub fn tabCloseButtonOptions(over: dvui.Options) dvui.Options { + return windowHeaderCloseButtonOptions(over.override(.{ + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .border = dvui.Rect.all(0), + .box_shadow = null, + .background = false, + .color_fill = .transparent, + .color_fill_hover = .transparent, + .color_fill_press = .transparent, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + })); +} + /// Base `Options` for the dialog header close button. Tabs pass `.override(.{ .expand = .none, .min_size_content = …, .id_extra = … })`. pub fn windowHeaderCloseButtonOptions(over: dvui.Options) dvui.Options { const base: dvui.Options = .{ @@ -949,690 +965,6 @@ pub fn saveCompleteToastDisplay(id: dvui.Id) !void { } } -pub const SpriteInitOptions = struct { - source: dvui.ImageSource, - file: ?*fizzy.Internal.File = null, - alpha_source: ?dvui.ImageSource = null, - sprite: fizzy.Atlas.Sprite, - scale: f32 = 1.0, - depth: f32 = 0.0, // -1.0 is front, 1.0 is back - reflection: bool = false, - overlap: f32 = 0.0, - /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out - /// toward the background the further they sit from the focus. - opacity: f32 = 1.0, - /// Vertical shift (logical px, positive = down) applied to the reflection - /// only. Lets the reflection slide away from the card — e.g. as a card flies - /// up out of view, its reflection sinks down, like peeling off a waterline. - reflection_offset: f32 = 0.0, - /// Depth-lagged reflection grid (logical px); rows shear while scrolling and ripple on settle. - reflection_lag: ?ReflectionLagSample = null, - /// Reflection mesh density multiplier in (0, 1]. 1.0 = full per-zoom density; - /// lower values coarsen the (O(n²)) mesh. Callers pass <1 for distant/skewed - /// cards so only the head-on focus cards pay for a fine, high-res reflection. - reflection_detail: f32 = 1.0, -}; - -/// Columns the reflection mesh samples across a card's width (waterline strip). -/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card. -pub const reflection_surface_cols = fizzy.water_surface.reflection_surface_cols; - -/// Reflection-only waterline sample across the card width (logical px). `cols_dx` -/// is horizontal refraction from surface slope; `cols_dy` is vertical height at -/// the seam (positive = down). The card itself stays flat — only the reflection -/// mesh pins its top edge and propagates ripples downward. -pub const ReflectionLagSample = struct { - cols_dx: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, - cols_dy: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, -}; - -pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData { - const source_size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 }; - - const overlap: f32 = 1.0 - init_opts.overlap; - - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(init_opts.sprite.source[0])) / source_size.w, - .y = @as(f32, @floatFromInt(init_opts.sprite.source[1])) / source_size.h, - .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) / source_size.w, - .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) / source_size.h, - }; - - const options = (dvui.Options{ .name = "sprite" }).override(opts); - - var size = dvui.Size{}; - if (options.min_size_content) |msc| { - // user gave us a min size, use it - size = msc; - } else { - // user didn't give us one, use natural size - size = .{ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) * init_opts.scale * overlap, .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) * init_opts.scale * overlap }; - } - - var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size })); - wd.register(); - - const cr = wd.contentRect(); - const ms = wd.options.min_size_contentGet(); - - var too_big = false; - if (ms.w > cr.w or ms.h > cr.h) { - too_big = true; - } - - var e = wd.options.expandGet(); - const g = wd.options.gravityGet(); - var rect = dvui.placeIn(cr, ms, e, g); - - if (too_big and e != .ratio) { - if (ms.w > cr.w and !e.isHorizontal()) { - rect.w = ms.w; - rect.x -= g.x * (ms.w - cr.w); - } - - if (ms.h > cr.h and !e.isVertical()) { - rect.h = ms.h; - rect.y -= g.y * (ms.h - cr.h); - } - } - - // rect is the content rect, so expand to the whole rect - wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet()); - - var renderBackground: ?dvui.Color = if (wd.options.backgroundGet()) wd.options.color(.fill) else null; - - if (wd.options.rotationGet() == 0.0) { - wd.borderAndBackground(.{}); - renderBackground = null; - } else { - if (wd.options.borderGet().nonZero()) { - dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id}); - } - } - - var path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - defer path.deinit(); - - var top_left = wd.contentRectScale().r.topLeft(); - var top_right = wd.contentRectScale().r.topRight(); - var bottom_right = wd.contentRectScale().r.bottomRight(); - var bottom_left = wd.contentRectScale().r.bottomLeft(); - - if (init_opts.depth > 0) { - top_left = top_left.plus(bottom_right.diff(top_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); - bottom_left = bottom_left.plus(top_right.diff(bottom_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); - } else { - top_right = top_right.plus(bottom_right.diff(top_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); - bottom_right = bottom_right.plus(top_right.diff(bottom_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); - } - - const lag_active = init_opts.reflection_lag != null; - const reflection_lag_phys: ?ReflectionLagSample = if (lag_active) reflectionLagSamplePhysical( - init_opts.reflection_lag.?, - wd.contentRectScale().s, - ) else null; - - path.addPoint(top_left); - path.addPoint(top_right); - path.addPoint(bottom_right); - path.addPoint(bottom_left); - - // Distance fade toward transparent: `fade_white` tints textured draws by the - // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1. - const op = std.math.clamp(init_opts.opacity, 0.0, 1.0); - const fade_white = dvui.Color.white.opacity(op); - - // Cover-flow fast path: when a file's layer stack is fully flattenable, the - // checker + layers + selection + temp are baked into one texture once per - // frame, so each card (front and reflection) is a single textured pass - // instead of several overlapping alpha-blended fills. Null → multi-pass path. - const preview_tex: ?dvui.Texture = if (init_opts.file) |f| fizzy.render.spritePreviewComposite(f) else null; - - if (init_opts.reflection) { - var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - defer path2.deinit(); - - // Direct vertical mirror: reflect each (already skewed) top corner straight - // down through its bottom corner, so the reflection is a true flip of the - // card — same width and skew at every height, sharing the bottom edge — - // rather than a trapezoid that flares outward. pathToSubdividedQuad reads - // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top - // and the near edge (br, bl) the sprite bottom, giving the mirrored uv. - // `refl_off` slides the whole reflection down independently of the card. - const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s }; - path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off)); - path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off)); - path2.addPoint(bottom_right.plus(refl_off)); - path2.addPoint(bottom_left.plus(refl_off)); - - const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h); - // Subdivide in proportion to on-screen size so the *physical* ripple density - // stays constant across zoom — a big (zoomed-in) card gets many more verts, - // rendering the fine field detail instead of undersampling it into coarse - // waves. (The field already carries dense ripples at `cols_per_slot`.) - const base_subdivisions_f = std.math.clamp(preview_extent / 13.0, 14.0, 44.0); - // The mesh is O(subdivisions²) and is rebuilt + rendered per layer for every - // card. Only the head-on focus cards need the fine, high-res ripple; skewed - // shelf cards pass a low `reflection_detail` so they fall to the coarse floor - // and stay cheap, which is what keeps the shelf affordable on slower GPUs. - const detail = std.math.clamp(init_opts.reflection_detail, 0.0, 1.0); - const subdivisions_f = @max(6.0, base_subdivisions_f * detail); - const subdivisions: usize = @intFromFloat(subdivisions_f); - - if (init_opts.alpha_source) |alpha_source| preview: { - const reflection_path = path2.build(); - - const reflection_lag = reflection_lag_phys orelse ReflectionLagSample{}; - const displacement_max = wd.contentRectScale().r.h * 0.52; - const refl_lag = if (lag_active) reflection_lag else null; - - if (preview_tex) |ptex| { - // Single textured pass: checker + layers + selection + temp are - // pre-flattened into the preview composite, so the reflection is one - // draw instead of replaying the whole stack per card. - var refl = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ - .subdivisions = subdivisions, - .uv = uv, - .vertical_fade = true, - .color_mod = fade_white, - .reflection_lag = refl_lag, - .waterline_propagate = true, - .displacement_max = displacement_max, - }) catch unreachable; - defer refl.deinit(dvui.currentWindow().arena()); - dvui.renderTriangles(refl, ptex) catch { - dvui.log.err("Failed to render reflection preview composite", .{}); - }; - break :preview; - } - - // Build two meshes from the same path so vertex positions match (shared - // ripple) but UVs differ: bg uses the full quad for checkerboard alpha, - // layers use the sprite atlas rect. - var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ - .subdivisions = subdivisions, - .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op), - .vertical_fade = true, - .reflection_lag = refl_lag, - .waterline_propagate = true, - .displacement_max = displacement_max, - }) catch unreachable; - defer reflection_triangles_bg.deinit(dvui.currentWindow().arena()); - - var reflection_triangles_layers = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ - .subdivisions = subdivisions, - .uv = uv, - .vertical_fade = true, - .color_mod = fade_white, - .reflection_lag = refl_lag, - .waterline_propagate = true, - .displacement_max = displacement_max, - }) catch unreachable; - defer reflection_triangles_layers.deinit(dvui.currentWindow().arena()); - - var reflection_triangles_layers_dimmed = reflection_triangles_layers.dupe(dvui.currentWindow().arena()) catch unreachable; - defer reflection_triangles_layers_dimmed.deinit(dvui.currentWindow().arena()); - reflection_triangles_layers_dimmed.color(.gray); - - dvui.renderTriangles(reflection_triangles_bg, alpha_source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - - if (init_opts.file) |file| { - const preview_opts = fizzy.render.RenderFileOptions{ - .file = file, - .rs = .{ - .r = wd.contentRectScale().r, - .s = wd.contentRectScale().s, - }, - .uv = uv, - .corner_radius = .all(0), - }; - fizzy.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { - dvui.log.err("Failed to render reflection layer stack: {any}", .{err}); - }; - - dvui.renderTriangles(reflection_triangles_layers, file.editor.selection_layer.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - - // Match renderLayers: use cached GPU texture when the canvas has already uploaded this frame. - // Avoids getTexture() on .pixelsPMA sources (would upload when invalidation is .always). - if (file.editor.temp_layer_has_content or file.editor.temp_gpu_dirty_rect != null) { - const temp_src = file.editor.temporary_layer.source; - const temp_key = temp_src.hash(); - if (dvui.textureGetCached(temp_key)) |tex| { - dvui.renderTriangles(reflection_triangles_layers, tex) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } else { - dvui.renderTriangles(reflection_triangles_layers, temp_src.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - } else { - dvui.renderTriangles(reflection_triangles_layers, init_opts.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - } - - // The preview composite already bakes the content-fill base + checkerboard, - // so skip the separate base/checker passes when it's in use. - if (preview_tex == null) { - if (init_opts.alpha_source) |alpha_source| { - if (init_opts.depth != 0.0) { - // Skew the opaque base along with the art so no axis-aligned sliver - // of fill colour pokes out past the receding edge. - var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .color_mod = dvui.themeGet().color(.content, .fill).opacity(op), - }) catch unreachable; - defer base_triangles.deinit(dvui.currentWindow().arena()); - dvui.renderTriangles(base_triangles, null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } else { - wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 }); - } - - const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op), - }) catch unreachable; - dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - } - - if (preview_tex) |ptex| { - // Front card: one textured pass from the baked preview composite. Skewed - // cards build a subdivided quad so the art tilts like a record on a shelf; - // head-on cards use the plain quad. - const front_path = if (init_opts.depth != 0.0) blk: { - var q: dvui.Path.Builder = .init(dvui.currentWindow().arena()); - q.addPoint(top_left); - q.addPoint(top_right); - q.addPoint(bottom_right); - q.addPoint(bottom_left); - break :blk q.build(); - } else path.build(); - var tris = pathToSubdividedQuad(front_path, dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .uv = uv, - .color_mod = fade_white, - }) catch unreachable; - defer tris.deinit(dvui.currentWindow().arena()); - dvui.renderTriangles(tris, ptex) catch { - dvui.log.err("Failed to render sprite preview composite", .{}); - }; - } else if (init_opts.file) |file| { - fizzy.render.renderLayers(.{ - .file = file, - .rs = .{ - .r = wd.contentRectScale().r, - .s = wd.contentRectScale().s, - }, - .uv = uv, - .corner_radius = .all(0), - .color_mod = fade_white, - // When skewed, render the layer stack into the same quad as the - // background so the art tilts like a record on a shelf. - .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null, - }) catch { - dvui.log.err("Failed to render layers", .{}); - }; - } else { - const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ - .subdivisions = 8, - .uv = uv, - .color_mod = fade_white, - }) catch unreachable; - - dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch { - dvui.log.err("Failed to render triangles", .{}); - }; - } - - path.build().stroke(.{ .color = opts.color_border orelse .transparent, .thickness = 1.0, .closed = true }); - - wd.minSizeSetAndRefresh(); - wd.minSizeReportToParent(); - - return wd; -} - -pub const PathToSubdividedQuadOptions = struct { - subdivisions: usize = 4, - uv: ?dvui.Rect = null, - vertical_fade: bool = false, - color_mod: dvui.Color = .white, - reflection_lag: ?ReflectionLagSample = null, - /// When true, reflection meshes refract ripples deeper below the seam. - waterline_propagate: bool = true, - /// Cap vertex offset (physical px) so ripples stay inside the reflection. - displacement_max: f32 = 0.0, -}; - -fn reflectionLagSamplePhysical(sample: ReflectionLagSample, scale: f32) ReflectionLagSample { - var out = sample; - for (&out.cols_dx) |*c| c.* *= scale; - for (&out.cols_dy) |*c| c.* *= scale; - return out; -} - -/// Linear interpolation across the column strip by horizontal fraction `t_x`. -/// Per-row reflection factors, hoisted out of the per-vertex loop. The two `pow` -/// calls (depth lag + seam pin) depend only on the row (`t_y`), so computing them -/// once per row instead of per vertex removes thousands of `pow` calls per frame. -const ReflectionRow = struct { - low_submerge: bool, - lag: f32, - lag_mix: f32, // already × 0.55 - submerge_scale: f32, // lerp(1, 1.25, submerge) - dx_pin: f32, -}; - -fn reflectionRowFactors(t_y: f32) ReflectionRow { - const submerge = 1.0 - std.math.clamp(t_y, 0, 1); - const seam_t = std.math.clamp(t_y, 0, 1); - return .{ - .low_submerge = submerge <= 0.001, - .lag = std.math.pow(f32, submerge, 1.55) * 0.74, - .lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1) * 0.55, - .submerge_scale = std.math.lerp(1.0, 1.25, submerge), - .dx_pin = 1.0 - std.math.pow(f32, seam_t, 4.5), - }; -} - -/// Horizontal refraction for one vertex using precomputed row factors. Equivalent -/// to `reflectionMeshDisplacement(.x)`, just with the row-constant work hoisted. -fn reflectionRowDx(t_x: f32, dx_seam: f32, row: ReflectionRow, sample: ReflectionLagSample) f32 { - // `dx_seam` (the column's refraction at the seam) is supplied precomputed — it - // depends only on t_x, so the caller resolves it once per column. Only the - // depth-lagged sample, which shifts t_x by the row's phase lag, needs an interp. - const t_lag = if (row.low_submerge) - t_x - else - std.math.clamp(t_x - (if (dx_seam >= 0) row.lag else -row.lag), 0, 1); - const dx_lag = if (row.low_submerge) dx_seam else interpolateReflectionCols(&sample.cols_dx, t_lag); - return std.math.lerp(dx_seam, dx_lag, row.lag_mix) * row.submerge_scale * row.dx_pin; -} - -fn interpolateReflectionCols(cols: []const f32, t_x: f32) f32 { - if (cols.len == 0) return 0; - if (cols.len == 1) return cols[0]; - const f = std.math.clamp(t_x, 0, 1) * @as(f32, @floatFromInt(cols.len - 1)); - const idx0: usize = @intFromFloat(@floor(f)); - const idx1 = @min(idx0 + 1, cols.len - 1); - const t = f - @as(f32, @floatFromInt(idx0)); - return std.math.lerp(cols[idx0], cols[idx1], t); -} - -fn clampDisplacement(d: dvui.Point.Physical, max_mag: f32) dvui.Point.Physical { - if (max_mag <= 0.0001) return d; - const mag = @sqrt(d.x * d.x + d.y * d.y); - if (mag <= max_mag) return d; - const s = max_mag / mag; - return .{ .x = d.x * s, .y = d.y * s }; -} - -/// Depth into the reflection body (0 at the waterline seam, 1 at the far edge). -fn reflectionSubmergeDepth(t_y: f32) f32 { - return 1.0 - std.math.clamp(t_y, 0, 1); -} - -/// Expanding ripple: larger displacement toward the reflection bottom. Rises -/// quickly just below the seam (so the effect is still strong in the upper region -/// that stays on-screen when zoomed in and the reflection's bottom is clipped), -/// then keeps growing toward the far edge for the full zoomed-out slosh. -fn reflectionDepthAmplitude(submerge: f32) f32 { - const d = std.math.clamp(submerge, 0, 1); - return 1.0 + d * (1.8 + 1.4 * d); -} - -/// Phase lag vs depth — deeper rows follow the same wave, slower and larger. -fn reflectionDepthLag(submerge: f32) f32 { - const d = std.math.clamp(submerge, 0, 1); - return std.math.pow(f32, d, 1.55) * 0.74; -} - -/// Sample the surface field with increasing horizontal phase lag at depth. -fn reflectionLaggedTx(t_x: f32, cols_dx: []const f32, submerge: f32) f32 { - if (submerge <= 0.001) return t_x; - const lag = reflectionDepthLag(submerge); - const slope = interpolateReflectionCols(cols_dx, t_x); - const dir: f32 = if (slope >= 0) 1 else -1; - return std.math.clamp(t_x - dir * lag, 0, 1); -} - -/// Reflection mesh: seam pinned at the waterline; the body carries horizontal -/// refraction ripples that phase-lag with depth. cols_dy is not applied. -fn reflectionMeshDisplacement(t_x: f32, t_y: f32, sample: ReflectionLagSample) dvui.Point.Physical { - const submerge = reflectionSubmergeDepth(t_y); - const t_lag = reflectionLaggedTx(t_x, &sample.cols_dx, submerge); - const lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1); - - const seam_t = std.math.clamp(t_y, 0, 1); - // Peak refraction just under the card base (not mid-body / far edge); seam - // corners stay pinned so the base width still matches the card. - const dx_pin = std.math.pow(f32, seam_t, 1.4) * (1.0 - std.math.pow(f32, seam_t, 12.0)); - const dx_seam = interpolateReflectionCols(&sample.cols_dx, t_x); - const dx_lag = interpolateReflectionCols(&sample.cols_dx, t_lag); - const dx = std.math.lerp(dx_seam, dx_lag, lag_mix * 0.55) * std.math.lerp(1.0, 1.25, submerge) * dx_pin; - - return .{ .x = dx, .y = 0 }; -} - -fn waterlineMeshDisplacement( - t_x: f32, - t_y: f32, - sample: ReflectionLagSample, - propagate: bool, -) dvui.Point.Physical { - if (propagate) return reflectionMeshDisplacement(t_x, t_y, sample); - const s = std.math.clamp(t_y, 0, 1); - const strength = s * (0.1 + 0.9 * s); - return .{ - .x = interpolateReflectionCols(&sample.cols_dx, t_x) * strength, - .y = 0, - }; -} - -fn reflectionCombinedDisplacement(t_x: f32, t_y: f32, options: PathToSubdividedQuadOptions) dvui.Point.Physical { - var d: dvui.Point.Physical = .{ .x = 0, .y = 0 }; - if (options.reflection_lag) |sample| { - d = d.plus(waterlineMeshDisplacement(t_x, t_y, sample, options.waterline_propagate)); - } - return clampDisplacement(d, options.displacement_max); -} - -pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, options: PathToSubdividedQuadOptions) std.mem.Allocator.Error!dvui.Triangles { - if (path.points.len != 4) { - return .empty; - } - - const subdivs = options.subdivisions; - const vtx_count = (subdivs + 1) * (subdivs + 1); - const idx_count = 2 * subdivs * subdivs * 3; - - var builder = try dvui.Triangles.Builder.init(allocator, vtx_count, idx_count); - errdefer comptime unreachable; - - // Four quad corners in order: tl, tr, br, bl - const tl = path.points[0]; - const tr = path.points[1]; - const br = path.points[2]; - const bl = path.points[3]; - - // Use given UV or default to (0,0,1,1) - const base_uv = options.uv orelse dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; - - { - // The seam refraction for a reflection mesh depends only on the column - // (t_x), so precompute it once per column and reuse it down every row - // instead of re-interpolating cols_dx per vertex. Guarded by the buffer - // size; non-reflection meshes and any unusually fine mesh fall back to the - // inline interp below (`seam_cache` stays false). - var dx_seam_col: [64]f32 = undefined; - const seam_cache = options.reflection_lag != null and options.waterline_propagate and subdivs + 1 <= dx_seam_col.len; - if (seam_cache) { - const sample = options.reflection_lag.?; - var x: usize = 0; - while (x <= subdivs) : (x += 1) { - const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); - dx_seam_col[x] = interpolateReflectionCols(&sample.cols_dx, t_x); - } - } - - var y: usize = 0; - while (y <= subdivs) : (y += 1) { // vertical - const t_y = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(subdivs)); - // Interpolate between tl/bl for left and tr/br for right - const left = dvui.Point.Physical{ - .x = tl.x + (bl.x - tl.x) * t_y, - .y = tl.y + (bl.y - tl.y) * t_y, - }; - const right = dvui.Point.Physical{ - .x = tr.x + (br.x - tr.x) * t_y, - .y = tr.y + (br.y - tr.y) * t_y, - }; - // Keep each row monotonic in x so a steep ripple pinches instead of - // folding back over itself. Overlapping triangles double-blend the - // semi-transparent reflection, which reads as a too-bright seam where - // the verts cross (most visible on the fly-in splash). - const row_increasing = right.x >= left.x; - // Hoist the per-row (pow-heavy) refraction factors out of the x-loop. - const refl_row: ?ReflectionRow = if (options.reflection_lag != null and options.waterline_propagate) - reflectionRowFactors(t_y) - else - null; - // Vertex tint only depends on the row (vertical fade), so resolve the - // colour and its PMA conversion once per row, not per vertex. - var row_col: dvui.Color = options.color_mod; - if (options.vertical_fade) row_col = row_col.opacity(0.5 * t_y); - const row_col_pma = dvui.Color.PMA.fromColor(row_col); - var prev_x: f32 = 0; - var x: usize = 0; - while (x <= subdivs) : (x += 1) { // horizontal - const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); - var pos = dvui.Point.Physical{ - .x = left.x + (right.x - left.x) * t_x, - .y = left.y + (right.y - left.y) * t_x, - }; - if (options.reflection_lag) |sample| { - if (refl_row) |row| { - const dx_seam = if (seam_cache) dx_seam_col[x] else interpolateReflectionCols(&sample.cols_dx, t_x); - var dx = reflectionRowDx(t_x, dx_seam, row, sample); - // The reflection offset is purely horizontal (dy = 0), so the - // magnitude clamp is just |dx| — no Point/​sqrt needed. - const dmax = options.displacement_max; - if (dmax > 0.0001 and @abs(dx) > dmax) dx = std.math.sign(dx) * dmax; - pos.x += dx; - } else { - pos = pos.plus(reflectionCombinedDisplacement(t_x, t_y, options)); - } - if (x > 0) { - if (row_increasing) { - pos.x = @max(pos.x, prev_x); - } else { - pos.x = @min(pos.x, prev_x); - } - } - prev_x = pos.x; - } - - const uv = .{ - base_uv.x + base_uv.w * t_x, - base_uv.y + base_uv.h * t_y, - }; - - builder.appendVertex(.{ - .pos = pos, - .col = row_col_pma, - .uv = uv, - }); - } - } - } - - // Generate indices for quads in row-major order - for (0..subdivs) |j| { - for (0..subdivs) |i| { - const row_stride = subdivs + 1; - const idx0 = j * row_stride + i; - const idx1 = idx0 + 1; - const idx2 = idx0 + row_stride; - const idx3 = idx2 + 1; - // 0---1 - // | / | - // 2---3 - // first triangle (idx0, idx2, idx1) - builder.appendTriangles(&.{ - @intCast(idx0), - @intCast(idx2), - @intCast(idx1), - }); - // second triangle (idx1, idx2, idx3) - builder.appendTriangles(&.{ - @intCast(idx1), - @intCast(idx2), - @intCast(idx3), - }); - } - } - - return builder.build(); -} - -pub fn renderSprite(source: dvui.ImageSource, s: fizzy.Sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { - const atlas_size = dvui.imageSize(source) catch { - std.log.err("Failed to get atlas size", .{}); - return; - }; - - var opt = opts; - - const uv = dvui.Rect{ - .x = (@as(f32, @floatFromInt(s.source[0])) / atlas_size.w), - .y = (@as(f32, @floatFromInt(s.source[1])) / atlas_size.h), - .w = (@as(f32, @floatFromInt(s.source[2])) / atlas_size.w), - .h = (@as(f32, @floatFromInt(s.source[3])) / atlas_size.h), - }; - - opt.uv = uv; - - const origin = dvui.Point{ - .x = @as(f32, @floatFromInt(s.origin[0])) * 1 / scale, - .y = @as(f32, @floatFromInt(s.origin[1])) * 1 / scale, - }; - - const position = data_point.diff(origin); - - const box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .rect = .{ - .x = position.x, - .y = position.y, - .w = @as(f32, @floatFromInt(s.source[2])) * scale, - .h = @as(f32, @floatFromInt(s.source[3])) * scale, - }, - .border = dvui.Rect.all(0), - .corner_radius = .{ .x = 0, .y = 0 }, - .padding = .{ .x = 0, .y = 0 }, - .margin = .{ .x = 0, .y = 0 }, - .background = false, - .color_fill = dvui.themeGet().color(.err, .fill), - }); - defer box.deinit(); - - const rs = box.data().rectScale(); - - try dvui.renderImage(source, rs, opt); -} pub fn labelWithKeybind(label_str: []const u8, hotkey: dvui.enums.Keybind, enabled: bool, label_opts: dvui.Options, opts: dvui.Options) void { const box = dvui.box(@src(), .{ .dir = .horizontal }, opts); @@ -1671,7 +1003,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui. var second_opts = opts.strip(); second_opts.color_text = color; - second_opts.font = dvui.Font.theme(.mono).larger(-2.0); + second_opts.font = dvui.Font.theme(.mono); second_opts.gravity_y = 0.5; var needs_space = false; @@ -1692,7 +1024,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui. if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()); //if (needs_plus) dvui.labelNoFmt(@src(), "+", .{}, opts.strip()) else needs_plus = true; //if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()) else needs_space = true; - if (fizzy.platform.isMacOS()) { + if (platform.isMacOS()) { dvui.icon(@src(), "cmd", icons.tvg.lucide.command, .{ .stroke_color = color }, .{ .gravity_y = 0.5 }); } else { dvui.labelNoFmt(@src(), "cmd", .{}, second_opts); @@ -1706,7 +1038,7 @@ pub fn keybindLabels(self: *const dvui.enums.Keybind, enabled: bool, opts: dvui. if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()); //if (needs_plus) dvui.labelNoFmt(@src(), "+", .{}, opts.strip()) else needs_plus = true; //if (needs_space) dvui.labelNoFmt(@src(), " ", .{}, opts.strip()) else needs_space = true; - if (fizzy.platform.isMacOS()) { + if (platform.isMacOS()) { dvui.icon(@src(), "option", icons.tvg.lucide.option, .{ .stroke_color = color }, .{ .gravity_y = 0.5 }); } else { dvui.labelNoFmt(@src(), "alt", .{}, second_opts); @@ -1789,6 +1121,19 @@ fn drawGradientRect(r: dvui.Rect.Physical, corner_radius: dvui.Rect.Physical, op }; } +/// Active workspace tab indicator: one snapped physical pixel along the tab bottom edge. +pub fn drawTabActiveIndicator(tab: dvui.RectScale, color: dvui.Color) void { + if (tab.r.empty()) return; + const scale = tab.s; + var line = tab.r; + line.h = scale; + line.y = @floor(tab.r.y + tab.r.h - scale); + line.x = @floor(line.x); + line.w = @ceil(line.w); + if (line.w <= 0) return; + line.fill(.{}, .{ .color = color }); +} + pub fn drawEdgeShadow(container: dvui.RectScale, shadow: Shadow, opts: ShadowOptions) void { var rs = container; switch (shadow) { diff --git a/src/tools/fs.zig b/src/core/fs.zig similarity index 100% rename from src/tools/fs.zig rename to src/core/fs.zig diff --git a/src/generated/atlas.zig b/src/core/generated/atlas.zig similarity index 100% rename from src/generated/atlas.zig rename to src/core/generated/atlas.zig diff --git a/src/gfx/image.zig b/src/core/gfx/image.zig similarity index 90% rename from src/gfx/image.zig rename to src/core/gfx/image.zig index b39c0110..124a7ee8 100644 --- a/src/gfx/image.zig +++ b/src/core/gfx/image.zig @@ -1,12 +1,13 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); +const core = @import("../core.zig"); +const fs = @import("../fs.zig"); +const math = @import("../math/math.zig"); const dvui = @import("dvui"); -const zip = @import("zip"); pub fn init(width: u32, height: u32, default_color: dvui.Color.PMA, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { const num_pixels = width * height; if (num_pixels == 0) return error.InvalidImageSize; - const p = fizzy.app.allocator.alloc(dvui.Color.PMA, num_pixels) catch return error.MemoryAllocationFailed; + const p = core.gpa.alloc(dvui.Color.PMA, num_pixels) catch return error.MemoryAllocationFailed; @memset(p, default_color); @@ -34,7 +35,7 @@ pub fn fromImageFileBytes(name: []const u8, file_bytes: []const u8, invalidation return .{ .pixelsPMA = .{ - .rgba = dvui.Color.PMA.sliceFromRGBA(fizzy.app.allocator.dupe(u8, data[0..@intCast(w * h * @sizeOf(dvui.Color.PMA))]) catch return error.MemoryAllocationFailed), + .rgba = dvui.Color.PMA.sliceFromRGBA(core.gpa.dupe(u8, data[0..@intCast(w * h * @sizeOf(dvui.Color.PMA))]) catch return error.MemoryAllocationFailed), .width = @as(u32, @intCast(w)), .height = @as(u32, @intCast(h)), .interpolation = .nearest, @@ -44,15 +45,15 @@ pub fn fromImageFileBytes(name: []const u8, file_bytes: []const u8, invalidation } pub fn fromImageFilePath(name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { - const file_byes = try fizzy.fs.read(fizzy.app.allocator, dvui.io, path); - defer fizzy.app.allocator.free(file_byes); + const file_byes = try fs.read(core.gpa, dvui.io, path); + defer core.gpa.free(file_byes); return fromImageFileBytes(name, file_byes, invalidation); } pub fn fromPixelsPMA(pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { return .{ .pixelsPMA = .{ - .rgba = fizzy.app.allocator.dupe(dvui.Color.PMA, pixel_data) catch return error.MemoryAllocationFailed, + .rgba = core.gpa.dupe(dvui.Color.PMA, pixel_data) catch return error.MemoryAllocationFailed, .interpolation = .nearest, .invalidation = invalidation, .width = width, @@ -64,7 +65,7 @@ pub fn fromPixelsPMA(pixel_data: []dvui.Color.PMA, width: u32, height: u32, inva pub fn fromPixels(pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !dvui.ImageSource { return .{ .pixels = .{ - .rgba = fizzy.app.allocator.dupe(u8, pixel_data) catch return error.MemoryAllocationFailed, + .rgba = core.gpa.dupe(u8, pixel_data) catch return error.MemoryAllocationFailed, .interpolation = .nearest, .invalidation = invalidation, .width = width, @@ -75,7 +76,7 @@ pub fn fromPixels(pixel_data: []u8, width: u32, height: u32, invalidation: dvui. pub fn fromTexture(name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) dvui.ImageSource { return .{ - .name = fizzy.app.allocator.dupe(u8, name) catch name, + .name = core.gpa.dupe(u8, name) catch name, .texture = texture, .invalidation = invalidation, .interpolation = .nearest, @@ -92,7 +93,7 @@ pub fn checkerboardTile(width: u32, height: u32, even: [4]u8, odd: [4]u8) ?dvui. const size_f: dvui.Size = .{ .w = @floatFromInt(width), .h = @floatFromInt(height) }; for (buf, 0..) |*p, i| { - const rgba = if (fizzy.math.checker(size_f, i)) even else odd; + const rgba = if (math.checker(size_f, i)) even else odd; p.* = @bitCast(rgba); } @@ -312,7 +313,7 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi const bot_c = dvui.Color{ .r = bot_px[0], .g = bot_px[1], .b = bot_px[2], .a = bot_px[3] }; const tpm = dvui.Color.PMA.fromColor(top_c); const bpm = dvui.Color.PMA.fromColor(bot_c); - const out_pma = fizzy.Internal.Layer.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm)); + const out_pma = math.blendPmaSrcOver(@bitCast(tpm), @bitCast(bpm)); top_px.* = @as(dvui.Color.PMA, @bitCast(out_pma)).toColor().toRGBA(); } } @@ -329,32 +330,12 @@ pub fn blitData(src_pixels: [][4]u8, src_width: usize, src_height: usize, dst_pi } } -fn ensurePngWriterBuffer(writer: *std.Io.Writer) !void { +pub fn ensurePngWriterBuffer(writer: *std.Io.Writer) !void { if (writer.buffer.len < dvui.PNGEncoder.min_buffer_size) { try writer.rebase(0, dvui.PNGEncoder.min_buffer_size); } } -pub fn writeToZip( - source: dvui.ImageSource, - zip_file: ?*anyopaque, - resolution: u32, -) !void { - const s: dvui.Size = dvui.imageSize(source) catch .{ .w = 0, .h = 0 }; - - const w = @as(c_int, @intFromFloat(s.w)); - const h = @as(c_int, @intFromFloat(s.h)); - - var writer = std.Io.Writer.Allocating.init(fizzy.editor.arena.allocator()); - - try ensurePngWriterBuffer(&writer.writer); - try dvui.PNGEncoder.writeWithResolution(&writer.writer, fizzy.image.bytes(source), @intCast(w), @intCast(h), resolution); - - if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| { - _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len)); - } -} - pub fn writePngToWriter(source: dvui.ImageSource, writer: *std.Io.Writer, resolution: u32) !void { const flat = try flatRgbaForEncode(source); try ensurePngWriterBuffer(writer); diff --git a/src/gfx/perf.zig b/src/core/gfx/perf.zig similarity index 100% rename from src/gfx/perf.zig rename to src/core/gfx/perf.zig diff --git a/src/gfx/water_surface.zig b/src/core/gfx/water_surface.zig similarity index 100% rename from src/gfx/water_surface.zig rename to src/core/gfx/water_surface.zig diff --git a/src/math/color.zig b/src/core/math/color.zig similarity index 80% rename from src/math/color.zig rename to src/core/math/color.zig index 76f2a011..6e6f8888 100644 --- a/src/math/color.zig +++ b/src/core/math/color.zig @@ -1,6 +1,21 @@ //const zm = @import("zmath"); const imgui = @import("zig-imgui"); +/// Porter-Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout). +/// `top` is composited over `bottom`. Generic byte math, no pixel-art types. +pub fn blendPmaSrcOver(top: [4]u8, bottom: [4]u8) [4]u8 { + const sa: u32 = @intCast(top[3]); + const inv: u32 = 255 - sa; + var out: [4]u8 = undefined; + inline for (0..3) |c| { + const v: u32 = @as(u32, @intCast(top[c])) + @as(u32, @intCast(bottom[c])) * inv / 255; + out[c] = @intCast(@min(255, v)); + } + const a: u32 = sa + @as(u32, @intCast(bottom[3])) * inv / 255; + out[3] = @intCast(@min(255, a)); + return out; +} + pub const Color = struct { value: [4]f32, diff --git a/src/math/direction.zig b/src/core/math/direction.zig similarity index 100% rename from src/math/direction.zig rename to src/core/math/direction.zig diff --git a/src/math/easing.zig b/src/core/math/easing.zig similarity index 100% rename from src/math/easing.zig rename to src/core/math/easing.zig diff --git a/src/math/layout_anchor.zig b/src/core/math/layout_anchor.zig similarity index 100% rename from src/math/layout_anchor.zig rename to src/core/math/layout_anchor.zig diff --git a/src/math/math.zig b/src/core/math/math.zig similarity index 96% rename from src/math/math.zig rename to src/core/math/math.zig index 82589dcc..bc64c5b7 100644 --- a/src/math/math.zig +++ b/src/core/math/math.zig @@ -39,6 +39,7 @@ pub const Direction = @import("direction.zig").Direction; const color = @import("color.zig"); pub const Color = color.Color; pub const Colors = color.Colors; +pub const blendPmaSrcOver = color.blendPmaSrcOver; pub const Point = struct { x: i32, y: i32 }; diff --git a/src/paths.zig b/src/core/paths.zig similarity index 100% rename from src/paths.zig rename to src/core/paths.zig diff --git a/src/platform.zig b/src/core/platform.zig similarity index 95% rename from src/platform.zig rename to src/core/platform.zig index 575b8fa6..0c809af7 100644 --- a/src/platform.zig +++ b/src/core/platform.zig @@ -33,7 +33,7 @@ pub fn cacheFromWindow(win: *dvui.Window) void { cached_is_macos = kb.command orelse false; } -/// True iff the running platform is macOS. Use this anywhere fizzy previously +/// True if the running platform is macOS. Use this anywhere fizzy previously /// had `builtin.os.tag == .macos` and the check needs to be right on web. pub inline fn isMacOS() bool { return cached_is_macos; diff --git a/src/editor/widgets/CanvasWidget.zig b/src/core/widgets/CanvasWidget.zig similarity index 95% rename from src/editor/widgets/CanvasWidget.zig rename to src/core/widgets/CanvasWidget.zig index c478bbce..59a2a0f0 100644 --- a/src/editor/widgets/CanvasWidget.zig +++ b/src/core/widgets/CanvasWidget.zig @@ -1,6 +1,7 @@ const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); +const core = @import("../core.zig"); +const Fling = @import("../Fling.zig"); pub const CanvasWidget = @This(); @@ -74,8 +75,6 @@ fade_pending: bool = false, // Saved between `install` and `deinit` so the parent alpha is restored exactly. prev_alpha: f32 = 1.0, hovered: bool = false, -/// `.dialog` for embedded previews (Grid Layout); uses `dialogCanvasPointerInputSuppressed`. -pointer_scope: enum { main, dialog } = .main, // Last frame's scroll viewport in physical pixels (latched in `deinit`). Used when the // scroll container is not installed yet this frame (e.g. UI chrome before `FileWidget`). sample_viewport_physical: ?dvui.Rect.Physical = null, @@ -136,8 +135,8 @@ scroll_pan_end_pending: bool = false, // Momentum for the drag-pan (middle button, or a left/touch drag starting off the // artboard). One coast per axis so a flick keeps gliding after release; see Fling. -pan_fling_x: fizzy.Fling = .{}, -pan_fling_y: fizzy.Fling = .{}, +pan_fling_x: Fling = .{}, +pan_fling_y: Fling = .{}, // Pinch / two-finger pan input accumulated during this frame's `updateTouchGesture`. // Mutating `scale` / `scroll_info.viewport` mid-frame jitters the canvas because the @@ -188,7 +187,7 @@ const touch_eval_duration_ns: i128 = 80 * std.time.ns_per_ms; /// units `scroll_info.viewport.x/y` move in — so the feel scales naturally with zoom. /// Release velocity is measured over a wall-clock position/time window /// (`releaseWindowed`) -const pan_fling: fizzy.Fling.Tuning = .{ +const pan_fling: Fling.Tuning = .{ .decay = 4.0, .min_start = 40.0, .stop = 10.0, @@ -253,10 +252,38 @@ pub fn trackpadPinching(self: *const CanvasWidget) bool { return (dvui.currentWindow().frame_time_ns - self.trackpad_pinch_last_ns) < window_ns; } +/// How wheel/scroll input maps to pan vs. zoom. The owner resolves its own user +/// preference (mouse vs. trackpad) and passes the result; the canvas stays unaware of +/// any settings system. +pub const PanZoomScheme = enum { mouse, trackpad }; + +/// Owner-supplied reactions to viewport gestures the canvas itself has no opinion about. +/// Every field is optional: a plain pan/zoom viewport (e.g. an image preview) supplies +/// none, while an editor supplies hooks that act on its own document/tool state. `ctx` is +/// passed back to each callback so a plugin can reach its state without globals. +pub const Hooks = struct { + ctx: ?*anyopaque = null, + /// An off-artboard press that released without moving or holding (a "tap" on empty + /// space). Pixel art uses this to clear the current selection. + onEmptyTap: ?*const fn (ctx: ?*anyopaque) void = null, + /// An off-artboard press held in place past the hold-menu duration. Pixel art opens + /// its radial tool menu at `press_p`. + onEmptyHold: ?*const fn (ctx: ?*anyopaque, press_p: dvui.Point.Physical) void = null, + /// Whether a modified (ctrl/cmd or shift) off-artboard press should be yielded to the + /// owner instead of starting a viewport pan. Pixel art yields it to the selection + /// marquee when the pointer tool is active. + yieldModifiedEmptyPress: ?*const fn (ctx: ?*anyopaque) bool = null, + /// Whether pointer input to this canvas is currently suppressed (e.g. a modal overlay + /// owns input this frame). Replaces the old built-in main/dialog scope switch. + pointerInputSuppressed: ?*const fn (ctx: ?*anyopaque) bool = null, +}; + pub const InitOptions = struct { id: dvui.Id, data_size: dvui.Size, center: bool = false, + pan_zoom_scheme: PanZoomScheme = .mouse, + hooks: Hooks = .{}, }; pub fn recenter(self: *CanvasWidget) void { @@ -695,10 +722,10 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { dvui.captureMouse(null, 0); } - // Quick off-artboard tap: finger lifted during the eval window. Resolve as - // clear-selection here so we never arm hold state from the replayed press. + // Quick off-artboard tap: finger lifted during the eval window. Hand it to the + // owner (pixel art clears selection) so we never arm hold state from the replayed press. if (released and !self.pointerOverDrawable(press_p)) { - fizzy.editor.cancel() catch {}; + if (self.init_opts.hooks.onEmptyTap) |f| f(self.init_opts.hooks.ctx); } // `addEventPointer` uses `win.mouse_pt` for the event position. Push the press @@ -731,7 +758,7 @@ pub fn updateTouchGesture(self: *CanvasWidget) void { // scale-around-point math used by wheel/touch zoom. Focal point is the cursor position // (macOS does not move the cursor during a trackpad gesture, so it represents intent). // No-op on Windows/Linux/web (`takeTrackpadPinchRatio` returns 1.0 there). - const trackpad_ratio = fizzy.backend.takeTrackpadPinchRatio(); + const trackpad_ratio = core.takeTrackpadPinchRatio(); if (trackpad_ratio != 1.0) { const cursor_phys = dvui.currentWindow().mouse_pt; // Only honor the gesture when the cursor is over the canvas viewport — otherwise a @@ -906,10 +933,8 @@ pub fn mouse(self: *CanvasWidget) ?dvui.Event.Mouse { } fn pointerInputSuppressed(self: *const CanvasWidget) bool { - return switch (self.pointer_scope) { - .main => fizzy.dvui.canvasPointerInputSuppressed(), - .dialog => fizzy.dvui.dialogCanvasPointerInputSuppressed(), - }; + const hooks = self.init_opts.hooks; + return if (hooks.pointerInputSuppressed) |f| f(hooks.ctx) else false; } pub fn processEvents(self: *CanvasWidget) void { @@ -1042,15 +1067,19 @@ pub fn processEvents(self: *CanvasWidget) void { // same scrub-the-viewport feel as the middle-button pan. // // Exception: a left/touch off-artboard press holding ctrl/cmd (add) - // or shift (subtract) while the pointer tool is active belongs to the - // sprite-selection marquee — it already claimed the press earlier in - // FileWidget.processSpriteSelection. Yielding it here keeps our + // or shift (subtract) that the owner wants to claim (pixel art: the + // sprite-selection marquee, which already claimed the press earlier in + // FileWidget.processSpriteSelection). Yielding it here keeps our // `dragPreStart("scroll_drag")` from clobbering the marquee's drag, so // the hotkey draws a selection box instead of panning. Middle-button // pans are never affected. + const owner_yields = if (self.init_opts.hooks.yieldModifiedEmptyPress) |f| + f(self.init_opts.hooks.ctx) + else + false; const sel_marquee_press = me.button.pointer() and me.button != .middle and (me.mod.matchBind("ctrl/cmd") or me.mod.matchBind("shift")) and - fizzy.editor.tools.current == .pointer; + owner_yields; if (me.action == .press and !sel_marquee_press and (me.button == .middle or (me.button.pointer() and !self.pointerOverDrawable(me.p)))) { e.handle(@src(), self.scroll_container.data()); dvui.captureMouse(self.scroll_container.data(), e.num); @@ -1114,7 +1143,7 @@ pub fn processEvents(self: *CanvasWidget) void { } } } else if (me.action == .wheel_y or me.action == .wheel_x) { - switch (fizzy.Editor.Settings.resolvedPanZoomScheme(&fizzy.editor.settings)) { + switch (self.init_opts.pan_zoom_scheme) { .mouse => { const base: f32 = if (me.mod.matchBind("shift")) 1.005 else 1.005; if ((me.mod.matchBind("shift") and me.mod.matchBind("ctrl/cmd")) or !me.mod.matchBind("shift") and !me.mod.matchBind("ctrl/cmd")) { @@ -1182,20 +1211,15 @@ pub fn processEvents(self: *CanvasWidget) void { switch (self.empty) { .pending => { if (!still_down) { - // Lifted without moving or holding → a tap: clear the selection. - fizzy.editor.cancel() catch {}; + // Lifted without moving or holding → a tap: hand to the owner (pixel + // art clears the selection). + if (self.init_opts.hooks.onEmptyTap) |f| f(self.init_opts.hooks.ctx); self.empty = .idle; } else if (dvui.frameTimeNS() - self.empty_press_ns >= dvui.currentWindow().hold_menu_duration_ns) { - // Held in place past the hold duration → open the radial tool menu and - // release our capture so its buttons can be hovered. Editor keeps it - // open until a tool is chosen or the user taps outside. - const rm = &fizzy.editor.tools.radial_menu; - rm.mouse_position = self.empty_press_p; - rm.center = self.empty_press_p; - rm.visible = true; - rm.opened_by_press = true; - rm.suppress_next_pointer_release = true; - rm.outside_click_press_p = null; + // Held in place past the hold duration → tell the owner (pixel art opens + // its radial tool menu at the press point) and release our capture so its + // buttons can be hovered. + if (self.init_opts.hooks.onEmptyHold) |f| f(self.init_opts.hooks.ctx, self.empty_press_p); self.empty = .holding; if (dvui.captured(self.scroll_container.data().id)) { dvui.captureMouse(null, 0); diff --git a/src/editor/widgets/FloatingWindowWidget.zig b/src/core/widgets/FloatingWindowWidget.zig similarity index 100% rename from src/editor/widgets/FloatingWindowWidget.zig rename to src/core/widgets/FloatingWindowWidget.zig diff --git a/src/editor/widgets/PanedWidget.zig b/src/core/widgets/PanedWidget.zig similarity index 100% rename from src/editor/widgets/PanedWidget.zig rename to src/core/widgets/PanedWidget.zig diff --git a/src/editor/widgets/ReorderWidget.zig b/src/core/widgets/ReorderWidget.zig similarity index 100% rename from src/editor/widgets/ReorderWidget.zig rename to src/core/widgets/ReorderWidget.zig diff --git a/src/editor/widgets/TreeSelection.zig b/src/core/widgets/TreeSelection.zig similarity index 100% rename from src/editor/widgets/TreeSelection.zig rename to src/core/widgets/TreeSelection.zig diff --git a/src/editor/widgets/TreeWidget.zig b/src/core/widgets/TreeWidget.zig similarity index 100% rename from src/editor/widgets/TreeWidget.zig rename to src/core/widgets/TreeWidget.zig diff --git a/src/editor/Brushes.zig b/src/editor/Brushes.zig deleted file mode 100644 index fbc7566c..00000000 --- a/src/editor/Brushes.zig +++ /dev/null @@ -1,24 +0,0 @@ -const std = @import("std"); -const fizzy = @import("../fizzy.zig"); -const dvui = @import("dvui"); - -pub const Brushes = @This(); - -pub const Brush = struct { - name: []const u8, - source: dvui.ImageSource, - origin: dvui.Point, -}; - -brushes: std.ArrayList(Brush) = undefined, -selected_brush_index: usize = 0, - -pub fn init() !Brushes { - return .{ - .brushes = std.ArrayList(Brush).init(fizzy.app.allocator), - }; -} - -pub fn deinit(self: *Brushes) void { - self.brushes.deinit(); -} diff --git a/src/editor/Colors.zig b/src/editor/Colors.zig deleted file mode 100644 index 531f1cb4..00000000 --- a/src/editor/Colors.zig +++ /dev/null @@ -1,10 +0,0 @@ -const std = @import("std"); -const fizzy = @import("../fizzy.zig"); - -const Self = @This(); - -primary: [4]u8 = .{ 255, 255, 255, 255 }, -secondary: [4]u8 = .{ 0, 0, 0, 255 }, -height: u8 = 0, -palette: ?fizzy.Internal.Palette = null, -file_tree_palette: ?fizzy.Internal.Palette = null, diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 1efd2106..9545a298 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -13,32 +13,46 @@ const comfortaa_bold_ttf = assets.files.fonts.@"Comfortaa-Bold.ttf"; const plus_jakarta_sans_ttf = assets.files.fonts.@"PlusJakartaSans-Regular.ttf"; const plus_jakarta_sans_bold_ttf = assets.files.fonts.@"PlusJakartaSans-Bold.ttf"; +const build_opts = @import("build_opts"); + const fizzy = @import("../fizzy.zig"); +const pixi = @import("pixi"); const dvui = @import("dvui"); -const update_notify = @import("../update_notify.zig"); +const update_notify = @import("../backend/update_notify.zig"); const App = fizzy.App; const Editor = @This(); -pub const Colors = @import("Colors.zig"); -pub const Project = @import("Project.zig"); pub const Recents = @import("Recents.zig"); pub const Settings = @import("Settings.zig"); -pub const Tools = @import("Tools.zig"); pub const Dialogs = @import("dialogs/Dialogs.zig"); -pub const Transform = @import("Transform.zig"); pub const Keybinds = @import("Keybinds.zig"); -pub const Workspace = @import("Workspace.zig"); +const workbench_mod = @import("workbench"); +const code_mod = @import("code"); +const example_mod = @import("example"); +const PluginLoader = if (builtin.target.cpu.arch == .wasm32) + @import("PluginLoader_stub.zig") +else + @import("PluginLoader.zig"); +const InstalledPlugins = @import("InstalledPlugins.zig"); + +pub const Workspace = workbench_mod.Workspace; pub const Explorer = @import("explorer/Explorer.zig"); pub const IgnoreRules = @import("explorer/IgnoreRules.zig"); pub const Panel = @import("panel/Panel.zig"); pub const Sidebar = @import("Sidebar.zig"); pub const Infobar = @import("Infobar.zig"); pub const Menu = @import("Menu.zig"); -pub const FileLoadJob = @import("FileLoadJob.zig"); -pub const PackJob = @import("PackJob.zig"); +pub const FileLoadJob = workbench_mod.FileLoadJob; + +pub const sdk = fizzy.sdk; +pub const Host = sdk.Host; + +/// Workbench: the file-management home — file tree, open/load flow, and the +/// workspace/tabs/splits system, plus the per-branch explorer decoration registry. +pub const Workbench = workbench_mod.Workbench; /// This arena is for small per-frame editor allocations, such as path joins, null terminations and labels. /// Do not free these allocations, instead, this allocator will be .reset(.retain_capacity) each frame @@ -47,7 +61,26 @@ arena: std.heap.ArenaAllocator, config_folder: []const u8, palette_folder: []const u8, -atlas: fizzy.Internal.Atlas, +atlas: fizzy.core.Atlas, + +/// Plugin registry + service locator exposed to plugins +host: Host, + +/// Pixel-art plugin runtime state (owned by App; wired into `Globals.state`). +pixi_state: *pixi.State, + +/// File-management workbench (per-branch explorer decorations, …) +workbench: Workbench, + +/// Keeps plugin dylibs mapped while their vtables are live (native only). +loaded_plugin_libs: std.ArrayListUnmanaged(PluginLoader.LoadedLib) = .empty, + +/// User plugins that failed to load this session, so the UI can tell the author what +/// went wrong instead of failing silently into the log. Populated by `loadUserPlugins`; +/// strings are owned here and freed in `deinit`. +failed_user_plugins: std.ArrayListUnmanaged(FailedPlugin) = .empty, +/// One-shot guard so the startup "plugin load failures" dialog is raised only once. +plugin_failures_dialog_shown: bool = false, settings: Settings = undefined, recents: Recents = undefined, @@ -56,57 +89,32 @@ explorer: *Explorer, panel: *Panel, last_titlebar_color: dvui.Color, -dim_titlebar: bool = false, -/// Workspaces stored by their grouping ID -workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, sidebar: Sidebar, infobar: Infobar, /// The root folder that will be searched for files and a .fizproject file folder: ?[]const u8 = null, -project: ?Project = null, /// From `.fizignore` (preferred) or `.gitignore` at the project root; used by the Files explorer. ignore: IgnoreRules = .{}, themes: std.ArrayList(dvui.Theme) = .empty, -open_files: std.AutoArrayHashMapUnmanaged(u64, fizzy.Internal.File) = .empty, +open_files: std.AutoArrayHashMapUnmanaged(u64, sdk.DocHandle) = .empty, -/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread runs -/// `Internal.File.fromPath` off the main thread; the main thread polls via `processLoadingJobs` +/// Background file-load jobs in flight. Keyed by absolute path. Each job's worker thread loads +/// the document bytes off the main thread; the main thread polls via `processLoadingJobs` /// and moves completed results into `open_files`. The map owns its key strings via each job's /// `path` allocation; the StringHashMap stores key slices that point into job memory. loading_jobs: std.StringHashMapUnmanaged(*FileLoadJob) = .empty, -/// Background project-pack jobs. Each `startPackProject` cancels any predecessors and pushes a -/// new job; only the newest job's result is installed. Cancelled jobs are still kept here -/// until their worker observes the flag and publishes `done`, at which point -/// `processPackJob` reaps them. This way rapid Pack-Project clicks (or future per-save -/// repacks) coalesce: only the most recent request produces a visible atlas update. -pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, /// True iff a loading job should set its target file as the active file once it lands. /// `setActiveFile`-on-completion respects the most recent open request — multiple in-flight /// loads only auto-focus the most recently requested one. last_load_request_path: ?[]const u8 = null, -// The actively focused workspace grouping ID -// This will contain tabs for all open files with a matching grouping ID -open_workspace_grouping: u64 = 0, - -/// Files tree cross-workspace drag (`tab_drag`): heap copy of absolute path. See `files.zig`. -tab_drag_from_tree_path: ?[]u8 = null, -/// `drawFiles` data id for `removed_path`; clear after drop on workspace canvas. -file_tree_data_id: ?dvui.Id = null, - -tools: Tools, -colors: Colors = .{}, - -grouping_id_counter: u64 = 0, file_id_counter: u64 = 0, -sprite_clipboard: ?SpriteClipboard = null, - window_opacity: f32 = 1.0, /// Animated window-background opacity multiplier. Eases toward the windowed @@ -163,11 +171,6 @@ settings_save_deadline_ns: i128 = 0, /// to open the hold-to-context menu on touch-only hardware. last_touch_press_ns: ?i128 = null, -pub const SpriteClipboard = struct { - source: dvui.ImageSource, - offset: dvui.Point, -}; - const embedded_fonts: []const dvui.Font.Source = &.{ .{ .family = dvui.Font.array("CozetteVector"), @@ -243,7 +246,7 @@ pub fn init( } } } - const palette_folder = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "Palettes" }) catch config_folder; + const palette_folder = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "palettes" }) catch config_folder; var editor: Editor = .{ .config_folder = config_folder, @@ -255,19 +258,27 @@ pub fn init( .arena = .init(std.heap.page_allocator), .last_titlebar_color = dvui.themeGet().color(.control, .fill), .atlas = .{ - .data = try .loadFromBytes(app.allocator, assets.files.@"fizzy.atlas"), + .sprites = try fizzy.core.Atlas.loadSpritesFromBytes(app.allocator, assets.files.@"fizzy.atlas"), .source = try fizzy.image.fromImageFileBytes("fizzy.png", assets.files.@"fizzy.png", .ptr), }, - .tools = try .init(app.allocator), .themes = .empty, + .host = .init(app.allocator), + .pixi_state = undefined, + .workbench = .init(app.allocator), }; - editor.settings = try Settings.load(app.allocator, try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" })); + try editor.workbench.registerBuiltins(); + + { + const settings_path = try std.fs.path.join(app.allocator, &.{ editor.config_folder, "settings.json" }); + editor.settings = try Settings.load(app.allocator, settings_path); + // Load the opaque per-plugin settings blobs into the Host so plugins (created + // right after this `Editor.init` returns) can read their own settings. Runs a + // one-time migration of legacy flat settings; see `Settings.loadPluginStore`. + Settings.loadPluginStore(app.allocator, settings_path, &editor.host.plugin_settings); + } - // Start the long-lived save-queue worker. All .fiz async saves get - // serialized through this single thread (see `File.SaveQueue`); concurrent - // worker spawns were causing one save to wedge under contention. - try fizzy.Internal.File.initSaveQueue(); + // Save-queue worker is owned by the pixel-art plugin (`initPlugin` in `postInit`). { // Setup themes var fizzy_dark = dvui.themeGet(); @@ -428,26 +439,763 @@ pub fn init( editor.explorer.* = .init(); editor.panel.* = .init(); editor.open_files = .empty; - editor.workspaces = .empty; - editor.workspaces.put(fizzy.app.allocator, 0, .init(0)) catch |err| { - std.log.err("Failed to create workspace: {s}", .{@errorName(err)}); - return err; - }; + try editor.workbench.initDefaultWorkspace(); - editor.colors.file_tree_palette = fizzy.Internal.Palette.loadFromBytes(app.allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; - editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(app.allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + // Pixel-art tools/colors/palettes now init in `State.init` (App allocates + // `editor.pixi_state` just after this `Editor.init` returns). try Keybinds.register(); - // Collect the initial settings json - editor.settings_last_saved_json = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{}); + // Collect the initial settings json (shell fields + per-plugin blobs) for autosave dedup. + editor.settings_last_saved_json = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator); return editor; } -/// Ensures `{config}/Themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). +/// Second-stage init that needs the editor at its FINAL heap address. `init` +/// builds an `Editor` by value and the caller copies it to the heap, so anything +/// that captures `&editor.*` (e.g. a service whose `ctx` is the editor pointer) +/// must run here — not in `init`, where it would point at the stack temporary. +/// Called from `App.AppInit` right after the heap copy. (The built-in branch +/// decorators registered in `init` are exempt: they store fn pointers, not `&editor`.) +/// Stable shell-builtin contribution id. +pub const view_settings = "shell.settings"; + +fn loadPixiFromDylibEnabled() bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime build_opts.static_pixi) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_PIXI")) |v| { + defer fizzy.app.allocator.free(v); + return v.len == 0 or v[0] == '0'; + } else |_| {} + return true; +} + +fn loadWorkbenchFromDylibEnabled() bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime build_opts.static_workbench) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_WORKBENCH")) |v| { + defer fizzy.app.allocator.free(v); + return v.len == 0 or v[0] == '0'; + } else |_| {} + return true; +} + +fn loadCodeFromDylibEnabled() bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + if (comptime build_opts.static_code) return false; + if (std.process.Environ.getAlloc(fizzy.processEnviron(), fizzy.app.allocator, "FIZZY_STATIC_CODE")) |v| { + defer fizzy.app.allocator.free(v); + return v.len == 0 or v[0] == '0'; + } else |_| {} + return true; +} + +/// Stable workbench sidebar view id (matches `workbench.plugin.view_files`). +pub const workbench_files_view = workbench_mod.plugin.view_files; + +/// Registered workbench plugin (dylib or static). Panics if missing after `postInit`. +pub fn workbenchPlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("workbench") orelse @panic("workbench plugin not registered"); +} + +/// Registered pixelart plugin (dylib or static). Panics if missing after `postInit`. +pub fn pixiPlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("pixi") orelse @panic("pixelart plugin not registered"); +} + +/// Registered code plugin (dylib or static). Panics if missing after `postInit`. +pub fn codePlugin(editor: *Editor) *sdk.Plugin { + return editor.host.pluginById("code") orelse @panic("code plugin not registered"); +} + +/// Push host dvui state into every loaded plugin dylib image. +pub fn syncLoadedPluginDvuiContexts(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |loaded| { + sdk.dvui_context.syncHostIntoPlugin(loaded.set_dvui_context); + } +} + +/// Inject the host render bridge into every loaded plugin dylib (proxy backend). +pub fn syncLoadedPluginRenderBridge(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |loaded| { + sdk.render_bridge.syncHostIntoPlugin(loaded.set_render_bridge); + } +} + +fn syncLoadedPluginGlobals(editor: *Editor, plugin_id: []const u8, arg_b: *anyopaque, arg_c: ?*anyopaque) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |loaded| { + if (!std.mem.eql(u8, loaded.plugin_id, plugin_id)) continue; + loaded.set_globals(@ptrCast(&fizzy.app.allocator), arg_b, arg_c); + } +} + +/// Re-inject host + state into a loaded pixi dylib (e.g. after packer init on shared state). +pub fn syncLoadedPixiGlobals(editor: *Editor) void { + syncLoadedPluginGlobals(editor, "pixi", @ptrCast(&editor.host), @ptrCast(editor.pixi_state)); +} + +/// Re-inject host-owned Globals into a loaded workbench dylib. +pub fn syncLoadedWorkbenchGlobals(editor: *Editor) void { + syncLoadedPluginGlobals(editor, "workbench", @ptrCast(&editor.host), @ptrCast(&editor.workbench)); +} + +fn appendLoadedPluginLib(editor: *Editor, loaded: PluginLoader.LoadedLib) !void { + const id_owned = try fizzy.app.allocator.dupe(u8, loaded.plugin_id); + var stored = loaded; + stored.plugin_id = id_owned; + try editor.loaded_plugin_libs.append(fizzy.app.allocator, stored); +} + +/// Load `{exe_dir}/plugins/workbench.{ext}` and register via dylib entry. +pub fn loadWorkbenchDylib(editor: *Editor, exe_dir: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "workbench"); + errdefer fizzy.app.allocator.free(path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "workbench", .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), // workbench convention: arg_b = *Host + .arg_c = @ptrCast(&editor.workbench), // arg_c = *Workbench + }); + try appendLoadedPluginLib(editor, loaded); + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); +} + +/// Load `{exe_dir}/plugins/pixi.{ext}` (or `FIZZY_PLUGIN_PATH`) and register via dylib entry. +pub fn loadPixiDylib(editor: *Editor, exe_dir: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const path = try PluginLoader.resolvePluginPath(fizzy.app.allocator, exe_dir, "pixi"); + errdefer fizzy.app.allocator.free(path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "pixi", .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), + .arg_c = @ptrCast(editor.pixi_state), + }); + try appendLoadedPluginLib(editor, loaded); + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); +} + +/// Load `{exe_dir}/plugins/code.{ext}` and register via dylib entry. +pub fn loadCodeDylib(editor: *Editor, exe_dir: []const u8) !void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const path = try PluginLoader.builtinPluginPath(fizzy.app.allocator, exe_dir, "code"); + errdefer fizzy.app.allocator.free(path); + const loaded = try PluginLoader.loadAndRegister(&editor.host, path, "code", .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), + .arg_c = null, + }); + try appendLoadedPluginLib(editor, loaded); + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); +} + +/// Scan `/plugins/` for user-installed plugin dylibs and load each one. +/// +/// Each sub-directory that contains `plugin.` is attempted in iteration order. +/// Failures are logged and skipped — a bad plugin never prevents the others from loading. +/// Built-in plugin IDs ("pixi", "workbench", "code") are never overridden; any +/// user directory whose name collides with an already-registered plugin is skipped. +/// +/// On success each loaded lib is appended to `loaded_plugin_libs` and the dvui context +/// + render bridge are synced once at the end. On wasm this is a no-op. +/// +/// The user plugin directory does not need to exist; a missing directory is silently ignored. +/// A user plugin that failed to load, retained so the UI can surface it. `id` and `reason` +/// are heap-owned (app allocator) and freed in `deinit`. +pub const FailedPlugin = struct { + id: []const u8, + reason: []const u8, + /// Optional version / SDK detail when the dylib could be opened for probing. + detail: ?[]const u8 = null, +}; + +/// Record a failed user-plugin load so the UI can surface it. `id` and `reason` are copied +/// (the caller keeps ownership of its arguments). Best-effort: on OOM the failure is dropped +/// after being logged at the call site. +fn recordPluginFailure(editor: *Editor, id: []const u8, reason: []const u8, detail: ?[]const u8) void { + const id_owned = fizzy.app.allocator.dupe(u8, id) catch return; + const reason_owned = fizzy.app.allocator.dupe(u8, reason) catch { + fizzy.app.allocator.free(id_owned); + return; + }; + const detail_owned: ?[]const u8 = if (detail) |d| fizzy.app.allocator.dupe(u8, d) catch null else null; + if (detail_owned == null and detail != null) { + fizzy.app.allocator.free(id_owned); + fizzy.app.allocator.free(reason_owned); + return; + } + editor.failed_user_plugins.append(fizzy.app.allocator, .{ + .id = id_owned, + .reason = reason_owned, + .detail = detail_owned, + }) catch { + fizzy.app.allocator.free(id_owned); + fizzy.app.allocator.free(reason_owned); + if (detail_owned) |d| fizzy.app.allocator.free(d); + }; +} + +fn formatPluginProbeDetail(allocator: std.mem.Allocator, info: PluginLoader.PluginVersionInfo) ![]const u8 { + return std.fmt.allocPrint(allocator, "plugin {d}.{d}.{d}, min SDK {d}.{d}.{d}", .{ + info.plugin_version.major, + info.plugin_version.minor, + info.plugin_version.patch, + info.min_sdk_version.major, + info.min_sdk_version.minor, + info.min_sdk_version.patch, + }); +} + +/// Human-readable, actionable explanation for a `PluginLoader.LoadError`. +fn pluginLoadFailureReason(err: PluginLoader.LoadError) []const u8 { + return switch (err) { + error.AbiMismatch => "built against an incompatible Fizzy SDK — rebuild the plugin against this Fizzy build", + error.SdkVersionMismatch => "requires a newer Fizzy SDK — update Fizzy or install a matching plugin build", + error.PluginIdMismatch => "plugin id in the dylib does not match its filename — rename the file or fix manifest.id", + error.DylibOpenFailed => "the plugin library could not be opened (missing file, wrong architecture, or unresolved symbols)", + error.RegisterRejected => "the plugin's register() was rejected (often a duplicate plugin id — a built-in or another plugin already claims it)", + error.AbiFingerprintSymbolMissing, + error.RegisterSymbolMissing, + error.SetGlobalsSymbolMissing, + error.SetDvuiContextSymbolMissing, + error.SetRenderBridgeSymbolMissing, + error.SdkVersionSymbolMissing, + => "the plugin is missing required entry symbols — rebuild it from a current root.zig template", + }; +} + +pub fn loadUserPlugins(editor: *Editor, config_folder: []const u8) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + + const plugins_dir = std.fs.path.join(fizzy.app.allocator, &.{ config_folder, "plugins" }) catch return; + defer fizzy.app.allocator.free(plugins_dir); + + var dir = std.Io.Dir.cwd().openDir(dvui.io, plugins_dir, .{ .iterate = true }) catch return; + defer dir.close(dvui.io); + + const ext_suffix: []const u8 = switch (builtin.os.tag) { + .windows => ".dll", + .macos => ".dylib", + else => ".so", + }; + var loaded_any = false; + + var iter = dir.iterate(); + while (iter.next(dvui.io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ext_suffix)) continue; + + const dot = std.mem.lastIndexOf(u8, entry.name, ".") orelse continue; + const plugin_id = entry.name[0..dot]; + if (plugin_id.len == 0) continue; + + if (editor.host.pluginById(plugin_id) != null) { + dvui.log.err("user plugin '{s}': id already registered by a built-in; skipped", .{plugin_id}); + editor.recordPluginFailure(plugin_id, "id already registered by a built-in plugin", null); + continue; + } + + const path = std.fs.path.join(fizzy.app.allocator, &.{ plugins_dir, entry.name }) catch continue; + + const loaded = PluginLoader.loadAndRegister(&editor.host, path, plugin_id, .{ + .gpa = &fizzy.app.allocator, + .arg_b = @ptrCast(&editor.host), + .arg_c = null, + }) catch |err| { + const reason = pluginLoadFailureReason(err); + const probe = PluginLoader.probeVersionInfo(path); + const detail_owned: ?[]const u8 = if (probe) |info| + formatPluginProbeDetail(fizzy.app.allocator, info) catch null + else + null; + dvui.log.err("user plugin '{s}' ({s}): load failed: {s} — {s}", .{ plugin_id, path, @errorName(err), reason }); + editor.recordPluginFailure(plugin_id, reason, detail_owned); + fizzy.app.allocator.free(path); + continue; + }; + + appendLoadedPluginLib(editor, loaded) catch { + dvui.log.err("user plugin '{s}': out of memory storing LoadedLib", .{plugin_id}); + editor.recordPluginFailure(plugin_id, "ran out of memory while loading", null); + continue; + }; + dvui.log.info("user plugin '{s}' loaded from {s}", .{ plugin_id, path }); + loaded_any = true; + } + + if (loaded_any) { + syncLoadedPluginDvuiContexts(editor); + syncLoadedPluginRenderBridge(editor); + } +} + +fn unloadPluginLibs(editor: *Editor) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + for (editor.loaded_plugin_libs.items) |*entry| { + entry.lib.close(); + fizzy.app.allocator.free(entry.plugin_id); + fizzy.app.allocator.free(entry.path); + } + editor.loaded_plugin_libs.deinit(fizzy.app.allocator); + + for (editor.failed_user_plugins.items) |f| { + fizzy.app.allocator.free(f.id); + fizzy.app.allocator.free(f.reason); + if (f.detail) |d| fizzy.app.allocator.free(d); + } + editor.failed_user_plugins.deinit(fizzy.app.allocator); +} + +pub fn postInit(editor: *Editor) !void { + sdk.installRuntime(&fizzy.app.allocator, &editor.host, null); + + // Install the shell's read/utility surface so plugins reach shared shell state + // (per-frame arena, project folder, content opacity, settings dirty-mark) through + // the Host instead of importing the concrete Editor. + editor.host.installShell(.{ .ctx = editor, .vtable = &shell_api_vtable }); + + // The shell's own settings section, registered first so "Editor" leads the list; + // plugins append theirs in their `register` (the Settings view renders each grouped + // by owner, VSCode-style). + try editor.host.registerSettingsSection(.{ + .id = "shell.settings.editor", + .title = "Editor", + .draw = drawShellSettingsSection, + }); + + // Register plugin contributions (sidebar/bottom/center/menus). These are the + // near-empty shell's content: it iterates the Host registries rather than + // hardcoding panes. Web-safe — the draw fns reach the same inline code the + // editor tick already runs on wasm. Order = sidebar order. + if (loadWorkbenchFromDylibEnabled()) { + editor.loadWorkbenchDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("workbench dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try workbench_mod.plugin.register(&editor.host); + }; + } else { + try workbench_mod.plugin.register(&editor.host); + } + if (loadPixiFromDylibEnabled()) { + editor.loadPixiDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("pixi dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try pixi.plugin.register(&editor.host); + }; + } else { + try pixi.plugin.register(&editor.host); + } + if (loadCodeFromDylibEnabled()) { + editor.loadCodeDylib(fizzy.app.root_path) catch |err| { + dvui.log.warn("code dylib load failed ({s}); falling back to static plugin", .{@errorName(err)}); + try code_mod.plugin.register(&editor.host); + }; + } else { + try code_mod.plugin.register(&editor.host); + } + // Example plugin: the minimal built-in / template. Registered statically here; it also + // builds standalone as a dylib (`cd src/plugins/example && zig build`), so it exercises + // both link modes. See docs/PLUGINS.md. + try example_mod.plugin.register(&editor.host); + + // User-installed plugins from `/plugins/{id}.{dylib,so,dll}`. + editor.loadUserPlugins(editor.config_folder); + + try InstalledPlugins.register(&editor.host); + + for (editor.host.plugins.items) |p| try p.initPlugin(); + + // Shell built-in: Settings (owner = null; not a plugin). + try editor.host.registerSidebarView(.{ + .id = view_settings, + .icon = dvui.entypo.cog, + .title = "Settings", + .draw = drawSettingsPane, + }); + + // Menu bar contributions (non-macOS in-app bar). The File/Edit draw bodies still live + // in the shell's `Menu.zig`; a later step could move them into the workbench / pixel-art + // plugins so those self-register. Order = bar order. + try editor.host.registerMenu(.{ .id = "workbench.menu.file", .draw = Menu.drawFileMenu }); + try editor.host.registerMenu(.{ .id = "pixi.menu.edit", .draw = Menu.drawEditMenu }); + try editor.host.registerMenu(.{ .id = "shell.menu.view", .draw = Menu.drawViewMenu }); + try editor.host.registerMenu(.{ .id = "shell.menu.help", .draw = Menu.drawHelpMenu }); + + // Keybind contributions: each plugin registers its own binds into the window's + // keybind map. The shell already registered its global/navigation/region binds + // in `Keybinds.register` (during `init`, before this runs), so the two halves + // are disjoint — no `putNoClobber` clash. Runs on all targets (web included). + syncLoadedPluginDvuiContexts(editor); + const window = dvui.currentWindow(); + for (editor.host.plugins.items) |plugin| try plugin.contributeKeybinds(window); + + // The workbench-api is the file explorer's programmatic surface and drives OS + // file management (open/create/rename/delete/move on disk). The web build has + // no filesystem API, so the workbench *service* is left out there for now. + // Keeping it behind a comptime gate also keeps its native-only fn bodies out of + // wasm analysis entirely (the codebase's dead-branch convention; see + // `web_main.zig`). + if (comptime builtin.target.cpu.arch != .wasm32) { + editor.workbench.initService(&editor.host); + try editor.host.registerService(Workbench.Api.service_name, &editor.workbench.api); + } +} + +/// The Settings sidebar view: render every registered settings section under its title +/// heading, grouped by owner (VSCode-style). The shell registers its own "Editor" +/// section; plugins add theirs. +fn drawSettingsPane(_: ?*anyopaque) anyerror!void { + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + for (fizzy.editor.host.settings_sections.items, 0..) |*section, i| { + var sbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, .id_extra = i }); + defer sbox.deinit(); + + dvui.labelNoFmt(@src(), section.title, .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .x = 2, .y = 6, .w = 2, .h = 2 }, + }); + try section.draw(section.ctx); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 12 } }); + } +} + +/// Shell-owned settings controls (theme, fonts, window/content opacity, input timing, +/// debugging). Pixel-art-specific controls live in the pixel-art plugin's own section. +fn drawShellSettingsSection(_: ?*anyopaque) anyerror!void { + try Explorer.settings.draw(); +} + +// ---- EditorAPI: the shell-provided read/utility surface for plugins ---------- +// Installed on the Host in `postInit`; `ctx` is this `*Editor`. + +const shell_api_vtable: sdk.EditorAPI.VTable = .{ + .arena = shellArena, + .folder = shellFolder, + .paletteFolder = shellPaletteFolder, + .markSettingsDirty = shellMarkSettingsDirty, + .contentOpacity = shellContentOpacity, + .isMaximized = shellIsMaximized, + .isMacOS = shellIsMacOS, + .appliesNativeWindowOpacity = shellAppliesNativeWindowOpacity, + .explorerRect = shellExplorerRect, + .explorerVirtualSize = shellExplorerVirtualSize, + .showSaveDialog = shellShowSaveDialog, + .uiAtlas = shellUiAtlas, + .activeDoc = shellActiveDoc, + .docByIndex = shellDocByIndex, + .docById = shellDocById, + .docIndex = shellDocIndex, + .openDocCount = shellOpenDocCount, + .setActiveDocIndex = shellSetActiveDocIndex, + .swapDocs = shellSwapDocs, + .allocDocId = shellAllocDocId, + .explorerViewportWidth = shellExplorerViewportWidth, + .docFromPath = shellDocFromPath, + .openFilePath = shellOpenFilePath, + .openOrFocusFileAtGrouping = shellOpenOrFocusFileAtGrouping, + .closeDocById = shellCloseDocById, + .setProjectFolder = shellSetProjectFolder, + .closeProjectFolder = shellCloseProjectFolder, + .recentFolderCount = shellRecentFolderCount, + .recentFolderAt = shellRecentFolderAt, + .openInFileBrowser = shellOpenInFileBrowser, + .isPathIgnored = shellIsPathIgnored, + .explorerBranchIsOpen = shellExplorerBranchIsOpen, + .setExplorerBranchOpen = shellSetExplorerBranchOpen, + .drawWorkspaces = shellDrawWorkspaces, + .showOpenFolderDialog = shellShowOpenFolderDialog, + .showOpenFileDialog = shellShowOpenFileDialog, + .save = shellSave, + .requestPrepareFrame = shellRequestCompositeWarmup, + .refresh = shellRefresh, + .allocUntitledPath = shellAllocUntitledPath, + .createDocument = shellCreateDocument, + .setExplorerNewFilePath = shellSetExplorerNewFilePath, + .requestSaveAs = shellRequestSaveAs, + .requestWebSave = shellRequestWebSave, + .cancelPendingSaveDialog = shellCancelPendingSaveDialog, + .setPendingCloseDocId = shellSetPendingCloseDocId, + .queueCloseAfterSave = shellQueueCloseAfterSave, + .trackQuitSaveInFlight = shellTrackQuitSaveInFlight, + .resumeSaveAllQuit = shellResumeSaveAllQuit, + .abortSaveAllQuit = shellAbortSaveAllQuit, +}; + +fn shellCtx(ctx: *anyopaque) *Editor { + return @ptrCast(@alignCast(ctx)); +} +fn shellArena(ctx: *anyopaque) std.mem.Allocator { + return shellCtx(ctx).arena.allocator(); +} +fn shellFolder(ctx: *anyopaque) ?[]const u8 { + return shellCtx(ctx).folder; +} +fn shellPaletteFolder(ctx: *anyopaque) ?[]const u8 { + return shellCtx(ctx).palette_folder; +} +fn shellMarkSettingsDirty(ctx: *anyopaque) void { + shellCtx(ctx).markSettingsDirty(); +} +fn shellContentOpacity(ctx: *anyopaque) f32 { + return shellCtx(ctx).settings.content_opacity; +} +fn shellIsMaximized(ctx: *anyopaque) bool { + _ = ctx; + return fizzy.backend.isMaximized(dvui.currentWindow()); +} +fn shellIsMacOS(_: *anyopaque) bool { + return fizzy.platform.isMacOS(); +} +fn shellAppliesNativeWindowOpacity(_: *anyopaque) bool { + if (comptime builtin.target.cpu.arch == .wasm32) return false; + return builtin.os.tag == .macos or builtin.os.tag == .windows; +} +fn shellExplorerRect(ctx: *anyopaque) dvui.Rect { + return shellCtx(ctx).explorer.rect; +} +fn shellExplorerVirtualSize(ctx: *anyopaque) dvui.Size { + return shellCtx(ctx).explorer.scroll_info.virtual_size; +} +fn shellShowSaveDialog( + ctx: *anyopaque, + cb: sdk.EditorAPI.SaveDialogCallback, + filters: []const sdk.EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + _ = ctx; + // `SaveDialogFilter` shares `DialogFileFilter`'s layout, so the slice forwards as-is. + const native_filters: [*]const fizzy.backend.DialogFileFilter = @ptrCast(filters.ptr); + fizzy.backend.showSaveFileDialog(cb, native_filters[0..filters.len], default_filename, default_folder); +} +fn shellUiAtlas(ctx: *anyopaque) sdk.EditorAPI.UiAtlasView { + const atlas = &shellCtx(ctx).atlas; + return .{ + .source = atlas.source, + .sprites = @as([]const sdk.EditorAPI.UiSprite, @ptrCast(atlas.sprites)), + }; +} +fn shellActiveDoc(ctx: *anyopaque) ?sdk.DocHandle { + return shellCtx(ctx).activeDoc(); +} +fn shellDocByIndex(ctx: *anyopaque, index: usize) ?sdk.DocHandle { + return shellCtx(ctx).docAt(index); +} +fn shellDocById(ctx: *anyopaque, id: u64) ?sdk.DocHandle { + return shellCtx(ctx).docById(id); +} +fn shellDocIndex(ctx: *anyopaque, id: u64) ?usize { + return shellCtx(ctx).open_files.getIndex(id); +} +fn shellOpenDocCount(ctx: *anyopaque) usize { + return shellCtx(ctx).open_files.count(); +} +fn shellSetActiveDocIndex(ctx: *anyopaque, index: usize) void { + shellCtx(ctx).setActiveFile(index); +} +fn shellSwapDocs(ctx: *anyopaque, a: usize, b: usize) void { + const editor = shellCtx(ctx); + std.mem.swap(sdk.DocHandle, &editor.open_files.values()[a], &editor.open_files.values()[b]); + std.mem.swap(u64, &editor.open_files.keys()[a], &editor.open_files.keys()[b]); +} +fn shellAllocDocId(ctx: *anyopaque) u64 { + return shellCtx(ctx).newFileID(); +} +fn shellExplorerViewportWidth(ctx: *anyopaque) f32 { + return shellCtx(ctx).explorer.scroll_info.viewport.w; +} +fn shellDocFromPath(ctx: *anyopaque, path: []const u8) ?sdk.DocHandle { + return shellCtx(ctx).docFromPath(path); +} +fn shellOpenFilePath(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { + return shellCtx(ctx).openFilePath(path, grouping); +} +fn shellOpenOrFocusFileAtGrouping(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!?usize { + return shellCtx(ctx).openOrFocusFileAtGrouping(path, grouping); +} +fn shellCloseDocById(ctx: *anyopaque, id: u64) anyerror!void { + return shellCtx(ctx).closeFileID(id); +} +fn shellSetProjectFolder(ctx: *anyopaque, path: []const u8) anyerror!void { + return shellCtx(ctx).setProjectFolder(path); +} +fn shellCloseProjectFolder(ctx: *anyopaque) void { + shellCtx(ctx).closeProjectFolder(); +} +fn shellRecentFolderCount(ctx: *anyopaque) usize { + return shellCtx(ctx).recents.folders.items.len; +} +fn shellRecentFolderAt(ctx: *anyopaque, index: usize) ?[]const u8 { + const editor = shellCtx(ctx); + if (index >= editor.recents.folders.items.len) return null; + return editor.recents.folders.items[index]; +} +fn shellOpenInFileBrowser(ctx: *anyopaque, path: []const u8) anyerror!void { + return shellCtx(ctx).openInFileBrowser(path); +} +fn shellIsPathIgnored( + ctx: *anyopaque, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, +) bool { + return shellCtx(ctx).ignore.isIgnored(project_root, abs_path, name, kind); +} +fn shellExplorerBranchIsOpen(ctx: *anyopaque, branch_id: dvui.Id) bool { + return shellCtx(ctx).explorer.open_branches.contains(branch_id); +} +fn shellSetExplorerBranchOpen(ctx: *anyopaque, branch_id: dvui.Id, open: bool) void { + const editor = shellCtx(ctx); + if (open) { + editor.explorer.open_branches.put(branch_id, {}) catch {}; + } else { + _ = editor.explorer.open_branches.remove(branch_id); + } +} +fn shellDrawWorkspaces(ctx: *anyopaque, index: usize) anyerror!dvui.App.Result { + return drawWorkspaces(shellCtx(ctx), index); +} +fn shellShowOpenFolderDialog(ctx: *anyopaque, cb: sdk.EditorAPI.OpenPathsCallback, default_folder: ?[]const u8) void { + _ = ctx; + fizzy.backend.showOpenFolderDialog(cb, default_folder); +} +fn shellShowOpenFileDialog( + ctx: *anyopaque, + cb: sdk.EditorAPI.OpenPathsCallback, + filters: []const sdk.EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + _ = ctx; + const native_filters: [*]const fizzy.backend.DialogFileFilter = @ptrCast(filters.ptr); + fizzy.backend.showOpenFileDialog(cb, native_filters[0..filters.len], default_filename, default_folder); +} +fn shellSave(ctx: *anyopaque) anyerror!void { + return shellCtx(ctx).save(); +} +fn shellRequestCompositeWarmup(ctx: *anyopaque) void { + shellCtx(ctx).requestPrepareFrame(); +} +fn shellRefresh(ctx: *anyopaque) void { + _ = ctx; + const w = fizzy.app.window; + if (w.extra_frames_needed == 0) w.extra_frames_needed = 1; + w.backend.refresh(); +} +fn shellAllocUntitledPath(ctx: *anyopaque) anyerror![]u8 { + return shellCtx(ctx).allocNextUntitledPath(); +} +fn shellCreateDocument(ctx: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) anyerror!sdk.DocHandle { + return shellCtx(ctx).newFile(path, grid); +} +fn shellSetExplorerNewFilePath(ctx: *anyopaque, path: []const u8) anyerror!void { + const Files = fizzy.Explorer.files; + if (Files.new_file_path) |old| { + fizzy.app.allocator.free(old); + } + Files.new_file_path = try fizzy.app.allocator.dupe(u8, path); + _ = ctx; +} +fn shellRequestSaveAs(ctx: *anyopaque) void { + shellCtx(ctx).requestSaveAs(); +} +fn shellRequestWebSave(ctx: *anyopaque, kind: sdk.EditorAPI.WebSaveKind) void { + const native_kind: Dialogs.WebSaveAs.Kind = switch (kind) { + .save => .save, + .save_as => .save_as, + }; + shellCtx(ctx).requestWebSaveDialog(native_kind); +} +fn shellCancelPendingSaveDialog(ctx: *anyopaque) void { + shellCtx(ctx).cancelPendingSaveDialog(); +} +fn shellSetPendingCloseDocId(ctx: *anyopaque, id: u64) void { + shellCtx(ctx).pending_close_file_id = id; +} +fn shellQueueCloseAfterSave(ctx: *anyopaque, id: u64) anyerror!void { + try shellCtx(ctx).pending_close_after_save.put(fizzy.app.allocator, id, {}); +} +fn shellTrackQuitSaveInFlight(ctx: *anyopaque, id: u64) anyerror!void { + try shellCtx(ctx).quit_saves_in_flight.put(fizzy.app.allocator, id, {}); +} +fn shellResumeSaveAllQuit(ctx: *anyopaque) void { + shellCtx(ctx).pending_quit_continue = true; +} +fn shellAbortSaveAllQuit(ctx: *anyopaque) void { + shellCtx(ctx).abortSaveAllQuit(); +} + +/// Store a loaded/created document in the plugin registry and register its handle. +pub fn insertOpenDoc(editor: *Editor, doc_buf: *anyopaque, owner: *sdk.Plugin, id: u64) !void { + const ptr = try owner.registerOpenDocument(doc_buf); + try editor.open_files.put(fizzy.app.allocator, id, .{ + .ptr = ptr, + .owner = owner, + .id = id, + }); +} +pub fn docAt(editor: *Editor, index: usize) ?sdk.DocHandle { + if (index >= editor.open_files.values().len) return null; + return editor.open_files.values()[index]; +} + +pub fn docById(editor: *Editor, id: u64) ?sdk.DocHandle { + return editor.open_files.get(id); +} + +pub fn activeDoc(editor: *Editor) ?sdk.DocHandle { + return editor.workbench.activeDoc(); +} + +pub fn clearFileTreeDataId(editor: *Editor) void { + editor.workbench.clearFileTreeDataId(); +} + +/// Files sidebar inactive — drop tree dvui stash and tab-drag state. +pub fn resetFileTreeWhenFilesHidden(editor: *Editor) void { + editor.clearFileTreeDataId(); + editor.clearFileTreeTabDragDropState(); +} + +pub fn clearAllWorkspaceCenter(editor: *Editor) void { + editor.workbench.clearAllWorkspaceCenter(); +} + +/// Workbench routing helpers (type-agnostic; dispatch through `doc.owner`). +pub fn docGrouping(_: *Editor, doc: sdk.DocHandle) u64 { + return doc.owner.documentGrouping(doc); +} + +pub fn setDocGrouping(_: *Editor, doc: sdk.DocHandle, grouping: u64) void { + doc.owner.setDocumentGrouping(doc, grouping); +} + +pub fn docPath(_: *Editor, doc: sdk.DocHandle) []const u8 { + return doc.owner.documentPath(doc); +} + +pub fn docFromPath(editor: *Editor, path: []const u8) ?sdk.DocHandle { + for (editor.open_files.values()) |doc| { + if (doc.owner.documentByPath(path) != null) return doc; + } + return null; +} + +pub fn bindDocToPane(_: *Editor, doc: sdk.DocHandle, canvas_id: dvui.Id, workspace: *anyopaque, center: bool) void { + doc.owner.bindDocumentToPane(doc, canvas_id, workspace, center); +} + +/// Ensures `{config}/themes` exists and scans `*.json` for future user themes (loaded entries are prepended before Fizzy themes). fn appendUserThemes(gpa: std.mem.Allocator, editor: *Editor) !void { - const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "Themes" }); + const themes_dir = try std.fs.path.join(gpa, &.{ editor.config_folder, "themes" }); if (!std.fs.path.isAbsolute(themes_dir)) { gpa.free(themes_dir); @@ -552,12 +1300,11 @@ pub fn applyHoldMenuDuration(editor: *Editor) void { } pub fn currentGroupingID(editor: *Editor) u64 { - return editor.open_workspace_grouping; + return editor.workbench.currentGroupingID(); } pub fn newGroupingID(editor: *Editor) u64 { - editor.grouping_id_counter += 1; - return editor.grouping_id_counter; + return editor.workbench.newGroupingID(); } pub fn newFileID(editor: *Editor) u64 { @@ -571,8 +1318,8 @@ pub fn markSettingsDirty(editor: *Editor) void { } fn activelyDrawing(editor: *Editor) bool { - for (editor.open_files.values()) |*file| { - if (file.editor.active_drawing) return true; + for (editor.host.plugins.items) |plugin| { + if (plugin.needsContinuousRepaint()) return true; } return false; } @@ -589,7 +1336,7 @@ fn saveSettingsGuarded(editor: *Editor) !void { if (editor.activelyDrawing()) return; - const serialized = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{}); + const serialized = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator); defer fizzy.app.allocator.free(serialized); if (editor.settings_last_saved_json) |old| { @@ -602,7 +1349,7 @@ fn saveSettingsGuarded(editor: *Editor) !void { const settings_path = try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "settings.json" }); defer fizzy.app.allocator.free(settings_path); - try Settings.save(&editor.settings, fizzy.app.allocator, settings_path); + try Settings.save(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator, settings_path); if (editor.settings_last_saved_json) |blob| { fizzy.app.allocator.free(blob); @@ -614,7 +1361,7 @@ fn saveSettingsGuarded(editor: *Editor) !void { /// Flush to disk regardless of idle/drawing deferral — used during shutdown only. fn saveSettingsRaw(editor: *Editor) !void { - const serialized = try std.json.Stringify.valueAlloc(fizzy.app.allocator, &editor.settings, .{}); + const serialized = try Settings.serialize(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator); defer fizzy.app.allocator.free(serialized); const need_disk = blk: { @@ -628,7 +1375,7 @@ fn saveSettingsRaw(editor: *Editor) !void { defer fizzy.app.allocator.free(settings_path); if (need_disk) - try Settings.save(&editor.settings, fizzy.app.allocator, settings_path); + try Settings.save(&editor.settings, &editor.host.plugin_settings, fizzy.app.allocator, settings_path); if (need_disk) { if (editor.settings_last_saved_json) |blob| { @@ -666,9 +1413,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Drain any "Save and Close" requests whose async save has settled. editor.tickPendingSaveCloses(); var needs_save_status_anim_tick = false; - for (editor.open_files.values()) |*f| { - f.tickSaveDoneFlash(); - if (f.showsSaveStatusIndicator()) needs_save_status_anim_tick = true; + for (editor.host.plugins.items) |plugin| { + if (plugin.tickOpenDocuments()) needs_save_status_anim_tick = true; } // Re-poll the quit walker while saves are in flight on worker threads. if (editor.quit_saves_in_flight.count() > 0) editor.pending_quit_continue = true; @@ -691,8 +1437,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (!want_quit) continue; var dirty_n: usize = 0; - for (editor.open_files.values()) |f| { - if (f.dirty()) dirty_n += 1; + for (editor.open_files.values()) |doc| { + if (doc.owner.isDirty(doc)) dirty_n += 1; } if (dirty_n == 0) continue; @@ -706,11 +1452,12 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.queueNativeMenuAction(action); } - defer editor.dim_titlebar = false; + defer fizzy.dvui.modal_dim_titlebar = false; editor.setTitlebarColor(); editor.setWindowStyle(); - fizzy.render.frame_index +%= 1; + syncLoadedPluginDvuiContexts(editor); + for (editor.host.plugins.items) |plugin| plugin.beginFrame(); if (fizzy.perf.record) fizzy.perf.beginFrame(); defer if (fizzy.perf.record) fizzy.perf.endFrameAndMaybeLog(); @@ -718,7 +1465,6 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // workspace/file iteration so that a just-loaded file is visible to the rest of this frame. editor.processLoadingJobs(); if (comptime builtin.target.cpu.arch == .wasm32) fizzy.backend.pollWebFileIo(editor); - editor.processPackJob(); // Build workspaces AFTER reaping load jobs so a freshly-loaded file with a new grouping // (e.g. "Open to the side") gets its workspace created on the same frame it lands. @@ -730,30 +1476,14 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (editor.pending_composite_warmup) { editor.pending_composite_warmup = false; - if (editor.activeFile()) |file| { - const w = file.width(); - const h = file.height(); - if (w > 0 and h > 0) { - const area = @as(u64, w) * @as(u64, h); - // Skip tiny canvases; large docs benefit most from moving split-target work off the first stroke. - if (area >= 512 * 512) { - fizzy.render.warmupDrawingComposites(file) catch |err| { - dvui.log.err("Composite warmup failed: {any}", .{err}); - }; - } - } - } + for (editor.host.plugins.items) |plugin| plugin.prepareFrame(); } { var any_drawing = false; - fizzy.perf.draw_stroke_buf_count = 0; // no active stroke → 0; else first active file's map size - for (editor.open_files.values()) |*file| { - if (file.editor.active_drawing) { - any_drawing = true; - fizzy.perf.draw_stroke_buf_count = file.buffers.stroke.pixels.count(); - break; - } + fizzy.perf.draw_stroke_buf_count = 0; + for (editor.host.plugins.items) |plugin| { + if (plugin.needsContinuousRepaint()) any_drawing = true; } fizzy.perf.drawFrameBegin(any_drawing); } @@ -940,37 +1670,13 @@ pub fn tick(editor: *Editor) !dvui.App.Result { ); defer base_box.deinit(); - // Advance the animation frame if we are in play mode - if (editor.activeFile()) |file| { - if (file.editor.playing) { - if (file.selected_animation_index) |index| { - const animation = file.animations.get(index); - - if (animation.frames.len > 0) { - if (dvui.timerDoneOrNone(base_box.data().id)) { - if (file.selected_animation_frame_index >= animation.frames.len - 1) { - file.selected_animation_frame_index = 0; - } else { - file.selected_animation_frame_index += 1; - } - const millis_per_frame = animation.frames[file.selected_animation_frame_index].ms; - - dvui.timer(base_box.data().id, @intCast(millis_per_frame * 1000)); - } - } - } - } + for (editor.host.plugins.items) |plugin| { + plugin.tickActiveDocument(base_box.data().id); } // Always reset the peek layer index back, but we need to do this outside of the file widget so // other editor windows can use it - defer for (editor.open_files.values()) |*file| { - if (file.editor.isolate_layer) { - file.peek_layer_index = file.selected_layer_index; - } else { - file.peek_layer_index = null; - } - }; + defer for (editor.host.plugins.items) |plugin| plugin.endFrame(); // Sidebar area // Since sidebar is drawn before the explorer, and we want to allow expanding the explorer @@ -1112,7 +1818,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { defer editor.panel.paned.deinit(); if (!editor.panel.paned.dragging) { - if (editor.activeFile()) |_| { + const show_panel = editor.activeDoc() != null or editor.host.hasPersistentBottomView(); + if (show_panel) { if ((editor.panel.paned.split_ratio.* == 1.0 and !editor.panel.paned.collapsed()) and fizzy.editor.settings.panel_ratio > 0.0) { editor.panel.paned.animateSplit(1.0 - fizzy.editor.settings.panel_ratio, dvui.easing.outQuint); } @@ -1141,30 +1848,32 @@ pub fn tick(editor: *Editor) !dvui.App.Result { } if (editor.panel.paned.showFirst()) { - const result = try editor.drawWorkspaces(0); - if (result != .ok) { - return result; + if (editor.host.activeCenter()) |center| { + const result = try center.draw(center.ctx); + if (result != .ok) { + return result; + } } } } else { // Explorer peek/collapse hides the workspace subtree, so `drawWorkspaces` does not // run and `workspace.center` would otherwise stay latched from a prior panel animation. - for (editor.workspaces.values()) |*ws| { - ws.center = false; - } + editor.clearAllWorkspaceCenter(); } - { // Radial Menu - + { // Plugin keybinds + per-frame overlays (e.g. pixel-art's radial menu) + for (editor.host.plugins.items) |plugin| { + plugin.tickKeybinds() catch |err| { + dvui.log.err("Plugin keybind tick failed: {s}", .{@errorName(err)}); + }; + } Keybinds.tick() catch { dvui.log.err("Failed to tick hotkeys", .{}); }; - processHoldOpenRadialMenu(editor); - - if (editor.tools.radial_menu.visible) { - editor.drawRadialMenu() catch { - dvui.log.err("Failed to draw radial menu", .{}); + for (editor.host.plugins.items) |plugin| { + plugin.drawOverlay() catch |err| { + dvui.log.err("Plugin overlay draw failed: {s}", .{@errorName(err)}); }; } } @@ -1195,14 +1904,17 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // out and removes itself when the timer expires. editor.drawSaveToasts(); + // First frame after startup: if any user plugin failed to load, tell the user once + // (otherwise the only trace is a log line they'll never see). + if (!editor.plugin_failures_dialog_shown and editor.failed_user_plugins.items.len > 0) { + editor.plugin_failures_dialog_shown = true; + Dialogs.PluginLoadFailures.request(); + } + editor.saveSettingsGuarded() catch |err| { dvui.log.err("Failed to autosave settings ({s})", .{@errorName(err)}); }; - if (comptime builtin.target.cpu.arch == .wasm32) { - runWasmPackWorkers(editor); - } - _ = editor.arena.reset(.retain_capacity); if (editor.pending_app_close) { @@ -1260,7 +1972,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" }, })) |files| { for (files) |file| { - _ = editor.openFilePath(file, editor.open_workspace_grouping) catch { + _ = editor.openFilePath(file, editor.currentGroupingID()) catch { std.log.err("Failed to open file: {s}", .{file}); }; } @@ -1283,42 +1995,42 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA editor.requestSaveAs(); }, .copy => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.copy() catch { std.log.err("Failed to copy", .{}); }; } }, .paste => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.paste() catch { std.log.err("Failed to paste", .{}); }; } }, .undo => { - if (editor.activeFile()) |file| { - file.history.undoRedo(file, .undo) catch { + if (editor.activeDoc()) |doc| { + doc.owner.undo(doc) catch { std.log.err("Failed to undo", .{}); }; } }, .redo => { - if (editor.activeFile()) |file| { - file.history.undoRedo(file, .redo) catch { + if (editor.activeDoc()) |doc| { + doc.owner.redo(doc) catch { std.log.err("Failed to redo", .{}); }; } }, .transform => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.transform() catch { std.log.err("Failed to transform", .{}); }; } }, .grid_layout => { - if (editor.activeFile() != null) { + if (editor.activeDoc() != null) { editor.requestGridLayoutDialog(); } }, @@ -1348,7 +2060,7 @@ pub fn handleNativeMenuAction(editor: *Editor, action: fizzy.backend.NativeMenuA } pub fn setTitlebarColor(editor: *Editor) void { - const color = if (editor.dim_titlebar) dvui.themeGet().color(.control, .fill).lerp(.black, if (dvui.themeGet().dark) 60.0 / 255.0 else 80.0 / 255.0) else dvui.themeGet().color(.control, .fill); + const color = if (fizzy.dvui.modal_dim_titlebar) dvui.themeGet().color(.control, .fill).lerp(.black, if (dvui.themeGet().dark) 60.0 / 255.0 else 80.0 / 255.0) else dvui.themeGet().color(.control, .fill); if (!std.mem.eql(u8, &editor.last_titlebar_color.toRGBA(), &color.toRGBA())) { editor.last_titlebar_color = color; @@ -1360,400 +2072,20 @@ pub fn setWindowStyle(_: *Editor) void { fizzy.backend.setWindowStyle(dvui.currentWindow()); } -/// Dismiss rules for the hold-opened radial menu (empty workspace area): stay open after -/// the opening finger lifts; close on tool button click or a non-drag click outside. -fn processHoldOpenRadialMenu(editor: *Editor) void { - const rm = &editor.tools.radial_menu; - if (!rm.visible or !rm.opened_by_press) { - rm.outside_click_press_p = null; - return; - } - - const dismiss_move_threshold: f32 = dvui.Dragging.threshold; - - for (dvui.events()) |*e| { - if (e.evt != .mouse) continue; - const me = e.evt.mouse; - rm.mouse_position = me.p; - - const primary = me.button.pointer() or me.button.touch(); - if (!primary) continue; - - switch (me.action) { - .press => { - if (!rm.containsPhysical(me.p)) { - rm.outside_click_press_p = me.p; - } else { - rm.outside_click_press_p = null; - } - }, - .motion => { - if (rm.outside_click_press_p) |press_p| { - if (me.p.diff(press_p).length() > dismiss_move_threshold) { - rm.outside_click_press_p = null; - } - } - }, - .release => { - if (rm.suppress_next_pointer_release) { - rm.suppress_next_pointer_release = false; - rm.outside_click_press_p = null; - continue; - } - if (rm.outside_click_press_p) |press_p| { - const moved = me.p.diff(press_p).length() > dismiss_move_threshold; - if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) { - rm.close(); - } - rm.outside_click_press_p = null; - } - }, - else => {}, - } - } -} - -pub fn drawRadialMenu(editor: *Editor) !void { - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .cast(dvui.windowRect()), - }); - defer fw.deinit(); - - const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0); - - // `center` is set when the menu opens (Space down or hold on empty workspace) and stays - // fixed until close so tool buttons remain hoverable/clickable. - const center = fw.data().rectScale().pointFromPhysical(editor.tools.radial_menu.center); - - const tool_count: usize = std.meta.fields(Editor.Tools.Tool).len; - - const radius: f32 = 50.0; - const width: f32 = radius * 2.0; - const height: f32 = radius * 2.0; - const step: f32 = (2.0 * std.math.pi) / @as(f32, @floatFromInt(tool_count)); - - var angle: f32 = 180.0; - - var outer_anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{}); - - const temp_radius: f32 = 3.0 * radius * (outer_anim.val orelse 1.0); - - var outer_rect = dvui.Rect.fromPoint(center); - outer_rect.w = temp_radius; - outer_rect.h = temp_radius; - outer_rect.x -= outer_rect.w / 2.0; - outer_rect.y -= outer_rect.h / 2.0; - - var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .rect = outer_rect, - .expand = .none, - .background = true, - .corner_radius = dvui.Rect.all(100000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -4.0, .y = 4.0 }, - .fade = 8.0, - .alpha = 0.35, - }, - .color_fill = menu_color.opacity(0.75), - .border = dvui.Rect.all(0.0), - }); - - box.deinit(); - - outer_anim.deinit(); - - for (0..tool_count) |i| { - var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{ - .id_extra = i, - }); - defer anim.deinit(); - - if (anim.val) |val| { - angle += ((1 - val) * 100.0) * 0.015; - } - - var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { - color = palette.getDVUIColor(i); - } - - const x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle) - width / 2.0); - const y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle) - height / 2.0); - - const new_center = center.plus(.{ .x = x, .y = y }); - - { // Draw line along pie slice - // const line_x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle + step / 2.0) - width / 2.0); - // const line_y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle + step / 2.0) - height / 2.0); - - // const new_line_center = center.plus((dvui.Point{ .x = line_x, .y = line_y }).normalize().scale(radius * 1.5, dvui.Point)); - - // dvui.Path.stroke(.{ .points = &.{ center.scale(scale, dvui.Point.Physical), new_line_center.scale(scale, dvui.Point.Physical) } }, .{ - // .color = dvui.themeGet().color(.control, .text), - // .thickness = 1.0, - // }); - } - - var rect = dvui.Rect.fromPoint(new_center); - - rect.w = 40.0; - rect.h = 40.0; - rect.x -= rect.w / 2.0; - rect.y -= rect.h / 2.0; - - const tool = @as(Editor.Tools.Tool, @enumFromInt(i)); - - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{}, .{ - .rect = rect, - .id_extra = i, - .corner_radius = dvui.Rect.all(1000.0), - .color_fill = if (tool == editor.tools.current) dvui.themeGet().color(.content, .fill) else .transparent, - .box_shadow = if (tool == editor.tools.current) .{ - .color = .black, - .offset = .{ .x = -2.5, .y = 2.5 }, - .fade = 4.0, - .alpha = 0.25, - .corner_radius = dvui.Rect.all(1000), - } else null, - .padding = .all(0), - .margin = .all(0), - }); - - { - editor.tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; - } - - const selection_sprite = switch (editor.tools.selection_mode) { - .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], - }; - - const sprite = switch (@as(Editor.Tools.Tool, @enumFromInt(i))) { - .pointer => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.cursor_default], - .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], - .selection => selection_sprite, - }; - const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 1, .h = 1 }; - const atlas_w = if (size.w > 0) size.w else 1; - const atlas_h = if (size.h > 0) size.h else 1; - - const uv = dvui.Rect{ - .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_w, - .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_h, - .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_w, - .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_h, - }; - - button.processEvents(); - button.drawBackground(); - - var rs = button.data().contentRectScale(); - - const w = @as(f32, @floatFromInt(sprite.source[2])) * rs.s; - const h = @as(f32, @floatFromInt(sprite.source[3])) * rs.s; - - rs.r.x += (rs.r.w - w) / 2.0; - rs.r.y += (rs.r.h - h) / 2.0; - rs.r.w = w; - rs.r.h = h; - - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ - .uv = uv, - .fade = 0.0, - }) catch { - std.log.err("Failed to render image", .{}); - }; - angle += step; - - if (button.hovered()) { - editor.tools.set(tool); - } - if (button.clicked()) { - editor.tools.set(tool); - editor.tools.radial_menu.close(); - } - - button.deinit(); - } - - { // Center play/pause button - - var anim = dvui.animate(@src(), .{ .duration = 100_000, .kind = .alpha, .easing = dvui.easing.linear }, .{ - .id_extra = tool_count + 1, - }); - defer anim.deinit(); - - var rect = dvui.Rect.fromPoint(center); - - rect.w = 40.0; - rect.h = 40.0; - rect.x -= rect.w / 2.0; - rect.y -= rect.h / 2.0; - - { - if (editor.activeFile()) |file| { - if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{ - .expand = .none, - .corner_radius = dvui.Rect.all(1000), - .box_shadow = .{ - .color = .black, - .offset = .{ .x = -2.5, .y = 2.5 }, - .fade = 4.0, - .alpha = 0.25, - .corner_radius = dvui.Rect.all(1000), - }, - .color_fill = dvui.themeGet().color(.control, .fill_hover), - .rect = rect, - })) { - file.editor.playing = !file.editor.playing; - if (editor.tools.radial_menu.opened_by_press) { - editor.tools.radial_menu.close(); - } - } - } - } - } -} - -pub fn rebuildWorkspaces(editor: *Editor) !void { - - // Create workspaces for each grouping ID - for (editor.open_files.values()) |*file| { - if (!editor.workspaces.contains(file.editor.grouping)) { - var workspace: fizzy.Editor.Workspace = .init(file.editor.grouping); - for (editor.open_files.values()) |*f| { - if (f.editor.grouping == file.editor.grouping) { - workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0; - } - } - - editor.workspaces.put(fizzy.app.allocator, file.editor.grouping, workspace) catch |err| { - std.log.err("Failed to create workspace: {s}", .{@errorName(err)}); - return err; - }; - } - } - - // Remove workspaces that are no longer needed - for (editor.workspaces.values()) |*workspace| { - if (editor.workspaces.count() == 1) { - break; - } - - var contains: bool = false; - for (editor.open_files.values()) |*file| { - if (file.editor.grouping == workspace.grouping) { - contains = true; - break; - } - } - - if (!contains) { - if (editor.open_workspace_grouping == workspace.grouping) { - for (editor.workspaces.values()) |*w| { - if (w.grouping != workspace.grouping) { - editor.open_workspace_grouping = w.grouping; - break; - } - } - } - - _ = editor.workspaces.orderedRemove(workspace.grouping); - break; - } - } - - // Ensure the selected file for each workspace is still valid - for (editor.workspaces.values()) |*workspace| { - if (editor.getFile(workspace.open_file_index)) |file| { - if (file.editor.grouping == workspace.grouping) { - continue; - } - } - - var i: usize = editor.open_files.count(); - while (i > 0) { - i -= 1; - - if (editor.getFile(i)) |file| { - if (file.editor.grouping == workspace.grouping) { - workspace.open_file_index = i; - break; - } - } - } - } -} +pub fn rebuildWorkspaces(editor: *Editor) !void { + try editor.workbench.rebuildWorkspaces(); +} pub fn drawWorkspaces(editor: *Editor, index: usize) !dvui.App.Result { - if (index >= editor.workspaces.count()) return .ok; - - var s = fizzy.dvui.paned(@src(), .{ - .direction = .horizontal, - .collapsed_size = if (index == editor.workspaces.count() - 1) std.math.floatMax(f32) else 0, - .handle_size = handle_size, - .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist }, - }, .{ - .expand = .both, - .background = false, - }); - defer s.deinit(); - - const dragging = editor.panel.paned.dragging or s.dragging; - - if (!dragging) { - const should_center = (s.animating and s.split_ratio.* < 1.0) or - (editor.panel.paned.animating and editor.panel.paned.split_ratio.* < 1.0); - if (index + 1 < editor.workspaces.count()) { - editor.workspaces.values()[index + 1].center = should_center; - } else if (editor.workspaces.count() == 1) { - editor.workspaces.values()[index].center = should_center; - } - } - - // Ens - if (s.collapsing and s.split_ratio.* < 0.5) { - s.animateSplit(1.0, dvui.easing.outBack); - } - - if (!s.dragging and !s.animating and !s.collapsing and !s.collapsed_state) { - if (index == editor.workspaces.count() - 1) { - if (s.split_ratio.* != 1.0) { - s.animateSplit(1.0, dvui.easing.outBack); - } - } else { - if (dvui.firstFrame(s.wd.id)) { - s.split_ratio.* = 1.0; - s.animateSplit(0.5, dvui.easing.outBack); - } - } - } - - if (s.showFirst()) { - const result = try editor.workspaces.values()[index].draw(); - if (result != .ok) { - return result; - } - } - - if (s.showSecond()) { - const result = try drawWorkspaces(editor, index + 1); - if (result != .ok) { - return result; - } - } - - return .ok; + const panel = editor.panel.paned; + return editor.workbench.drawWorkspaces(.{ + .dragging = panel.dragging, + .animating = panel.animating, + .split_ratio = panel.split_ratio, + }, index); } pub fn abortSaveAllQuit(editor: *Editor) void { - Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = false; editor.quit_save_all_ids.clearAndFree(fizzy.app.allocator); editor.quit_saves_in_flight.clearRetainingCapacity(); editor.quit_in_progress = false; @@ -1774,9 +2106,8 @@ fn tickPendingSaveCloses(editor: *Editor) void { var i: usize = 0; while (i < editor.pending_close_after_save.count()) { const id = editor.pending_close_after_save.keys()[i]; - const file_ptr = editor.open_files.getPtr(id); - if (file_ptr) |f| { - if (f.isSaving()) { + if (editor.docById(id)) |doc| { + if (doc.owner.isDocumentSaving(doc)) { i += 1; continue; } @@ -1805,16 +2136,16 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { // Pass 1: kick off any queued saves we haven't started yet. while (editor.quit_save_all_ids.items.len > 0) { const id = editor.quit_save_all_ids.items[0]; - const file_ptr = editor.open_files.getPtr(id) orelse { + const doc = editor.docById(id) orelse { _ = editor.quit_save_all_ids.swapRemove(0); continue; }; - if (!file_ptr.dirty()) { + if (!doc.owner.isDirty(doc)) { _ = editor.quit_save_all_ids.swapRemove(0); continue; } - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file_ptr.path)) { + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) { // Save As dialog needs a single active file — bail out of the parallel // kickoff for this one and let the existing Save As + pending_close_file_id // flow handle it. Next frame, pending_quit_continue will re-enter us. @@ -1824,17 +2155,16 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { editor.requestSaveAs(); return; } - if (file_ptr.shouldConfirmFlatRasterSave()) { + if (doc.owner.saveNeedsConfirmation(doc)) { // Flat-raster prompt is a modal dialog — same reason as Save As, do // it serially and rejoin afterwards. if (editor.open_files.getIndex(id)) |idx| editor.setActiveFile(idx); - Dialogs.FlatRasterSaveWarning.pending_from_save_all_quit = true; - Dialogs.FlatRasterSaveWarning.request(id, .save_and_close); + doc.owner.requestSaveConfirmation(doc, .save_and_close, true); return; } // Async-safe path: kick off, move to in-flight, drop from queue. - file_ptr.saveAsync() catch |err| { + doc.owner.saveDocumentAsync(doc) catch |err| { dvui.log.err("Save all quit kickoff: {s}", .{@errorName(err)}); editor.abortSaveAllQuit(); return; @@ -1854,9 +2184,8 @@ pub fn advanceSaveAllQuit(editor: *Editor) void { var i: usize = 0; while (i < editor.quit_saves_in_flight.count()) { const id = editor.quit_saves_in_flight.keys()[i]; - const file_ptr = editor.open_files.getPtr(id); - if (file_ptr) |f| { - if (f.isSaving()) { + if (editor.docById(id)) |doc| { + if (doc.owner.isDocumentSaving(doc)) { i += 1; continue; } @@ -1886,8 +2215,8 @@ pub fn close(app: *App, editor: *Editor) void { return; } var dirty_n: usize = 0; - for (editor.open_files.values()) |f| { - if (f.dirty()) dirty_n += 1; + for (editor.open_files.values()) |doc| { + if (doc.owner.isDirty(doc)) dirty_n += 1; } if (dirty_n > 0) { Dialogs.AppQuitUnsaved.request(); @@ -1899,24 +2228,31 @@ pub fn close(app: *App, editor: *Editor) void { pub fn setProjectFolder(editor: *Editor, path: []const u8) !void { if (editor.folder) |folder| { editor.ignore.deinit(fizzy.app.allocator); - if (editor.project) |*project| { - project.save() catch { - dvui.log.err("Failed to save project", .{}); - }; - } + for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); fizzy.app.allocator.free(folder); } editor.folder = try fizzy.app.allocator.dupe(u8, path); try editor.recents.appendFolder(try fizzy.app.allocator.dupe(u8, path)); - editor.explorer.pane = .files; + if (editor.host.firstVisibleSidebarView()) |view| { + editor.host.setActiveSidebarView(view.id); + } - editor.project = Project.load(fizzy.app.allocator) catch null; + for (editor.host.plugins.items) |plugin| plugin.onFolderOpen(fizzy.app.allocator); editor.ignore = try IgnoreRules.load(fizzy.app.allocator, path); } +pub fn closeProjectFolder(editor: *Editor) void { + if (editor.folder) |folder| { + editor.ignore.deinit(fizzy.app.allocator); + for (editor.host.plugins.items) |plugin| plugin.onFolderClose(); + fizzy.app.allocator.free(folder); + editor.folder = null; + } +} + pub fn saving(editor: *Editor) bool { - for (editor.open_files.values()) |file| { - if (file.saving) return true; + for (editor.open_files.values()) |doc| { + if (doc.owner.isDocumentSaving(doc)) return true; } return false; } @@ -1931,9 +2267,9 @@ pub fn saving(editor: *Editor) bool { /// worker hasn't landed it yet and there is no valid `open_files` index to act on. The async /// load will auto-focus once the worker completes (see `processLoadingJobs`). pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u64) !?usize { - if (editor.getFileFromPath(path)) |file| { - const idx = editor.open_files.getIndex(file.id) orelse return error.Unexpected; - editor.open_files.values()[idx].editor.grouping = grouping; + if (editor.docFromPath(path)) |doc| { + const idx = editor.open_files.getIndex(doc.id) orelse return error.Unexpected; + editor.setDocGrouping(doc, grouping); editor.setActiveFile(idx); return idx; } @@ -1943,11 +2279,8 @@ pub fn openOrFocusFileAtGrouping(editor: *Editor, path: []const u8, grouping: u6 /// After a workspace drop from the Files tree or when `tab_drag` ends; frees path and clears tree reorder stash. pub fn clearFileTreeTabDragDropState(editor: *Editor) void { - if (editor.tab_drag_from_tree_path) |p| { - fizzy.app.allocator.free(p); - editor.tab_drag_from_tree_path = null; - } - if (editor.file_tree_data_id) |id| { + editor.workbench.clearFileTreeTabDragDropState(); + if (editor.workbench.file_tree_data_id) |id| { dvui.dataRemove(null, id, "removed_path"); } // `file_tree_data_id` is reassigned each `drawFiles` frame; do not clear the id here so @@ -1956,8 +2289,8 @@ pub fn clearFileTreeTabDragDropState(editor: *Editor) void { pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { // Already open? Just focus it. - for (editor.open_files.values(), 0..) |*file, i| { - if (std.mem.eql(u8, file.path, path)) { + for (editor.open_files.values(), 0..) |doc, i| { + if (std.mem.eql(u8, editor.docPath(doc), path)) { editor.setActiveFile(i); return false; } @@ -1969,8 +2302,16 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { return false; } + // Resolve the owning plugin from the file-type registry before spawning. No owner + // means no plugin claims this extension — reject here rather than spawning a worker + // that would only fail with InvalidFile. + const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse { + dvui.log.warn("No plugin handles file: {s}", .{path}); + return false; + }; + // Spawn a worker. The job owns the path string we'll key the map by. - const job = try FileLoadJob.create(fizzy.app.allocator, path, grouping); + const job = try FileLoadJob.create(fizzy.app.allocator, path, owner, grouping); errdefer job.destroy(); try editor.loading_jobs.put(fizzy.app.allocator, job.path, job); @@ -1995,28 +2336,37 @@ pub fn openFilePath(editor: *Editor, path: []const u8, grouping: u64) !bool { return true; } -/// Synchronous open from browser file-picker bytes. Caller owns `path` on success (stored in `File.path`). -pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !fizzy.Internal.File { - for (editor.open_files.values()) |*file| { - if (std.mem.eql(u8, file.path, path)) { - if (editor.open_files.getIndex(file.id)) |idx| { - editor.setActiveFile(idx); - } - fizzy.app.allocator.free(path); - return error.AlreadyOpen; +/// Synchronous open from browser file-picker bytes. Registers the document and returns its id. +pub fn openFileFromBytes(editor: *Editor, path: []u8, bytes: []const u8, grouping: u64) !u64 { + if (editor.docFromPath(path)) |existing| { + if (editor.open_files.getIndex(existing.id)) |idx| { + editor.setActiveFile(idx); } + fizzy.app.allocator.free(path); + return error.AlreadyOpen; } - const loaded = fizzy.Internal.File.fromBytes(path, bytes) catch |err| { + const owner = editor.host.pluginForExtension(std.fs.path.extension(path)) orelse { + fizzy.app.allocator.free(path); + return error.InvalidExtension; + }; + + const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); + defer fizzy.app.allocator.free(staging.backing); + + const handled = owner.loadDocumentFromBytes(path, bytes, staging.buf.ptr) catch |err| { fizzy.app.allocator.free(path); return err; }; - var file = loaded orelse { + if (!handled) { fizzy.app.allocator.free(path); return error.InvalidFile; - }; - file.editor.grouping = grouping; - return file; + } + + owner.setDocumentGroupingOnBuffer(staging.buf.ptr, grouping); + const id = owner.documentIdFromBuffer(staging.buf.ptr); + try editor.insertOpenDoc(staging.buf.ptr, owner, id); + return id; } /// Per-frame sweep called from `tick`. Moves completed load jobs into `open_files`, cleans up @@ -2041,39 +2391,33 @@ pub fn processLoadingJobs(editor: *Editor) void { const phase = job.currentPhase(); switch (phase) { .ready => { - if (job.result) |result| { - var file = result; - file.editor.grouping = job.target_grouping; - - editor.open_files.put(fizzy.app.allocator, file.id, file) catch { - dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path}); - // We still own `file` here — clean it up. - var f = file; - f.deinit(); - job.destroy(); - continue; - }; + const owner = job.owner; + owner.setDocumentGroupingOnBuffer(job.doc_buf.ptr, job.target_grouping); + const id = owner.documentIdFromBuffer(job.doc_buf.ptr); + + editor.insertOpenDoc(job.doc_buf.ptr, owner, id) catch { + dvui.log.err("Failed to insert loaded file into open_files: {s}", .{job.path}); + owner.deinitDocumentBuffer(job.doc_buf.ptr); + job.destroy(); + continue; + }; - // Focus this file iff it's the most recently requested load. Multiple - // simultaneous loads only auto-focus the latest; others land silently. - const should_focus = editor.last_load_request_path != null and - std.mem.eql(u8, editor.last_load_request_path.?, job.path); - if (should_focus) { - if (editor.open_files.getIndex(file.id)) |idx| { - editor.setActiveFile(idx); - editor.last_load_request_path = null; - } - editor.pending_composite_warmup = true; + const should_focus = editor.last_load_request_path != null and + std.mem.eql(u8, editor.last_load_request_path.?, job.path); + if (should_focus) { + if (editor.open_files.getIndex(id)) |idx| { + editor.setActiveFile(idx); + editor.last_load_request_path = null; } - } else { - dvui.log.err("Load job reported ready but result was null: {s}", .{job.path}); + editor.pending_composite_warmup = true; } }, .failed => { dvui.log.err("Failed to open file: {s} ({any})", .{ job.path, job.err }); + job.owner.deinitDocumentBuffer(job.doc_buf.ptr); }, .cancelled => { - // No-op: result already discarded by the worker. + job.owner.deinitDocumentBuffer(job.doc_buf.ptr); }, else => { dvui.log.err("Load job finished in unexpected phase {s}: {s}", .{ @tagName(phase), job.path }); @@ -2084,265 +2428,8 @@ pub fn processLoadingJobs(editor: *Editor) void { } } -/// Kick off an async project-pack. Walks the project directory once on the main thread to -/// gather inputs: open files contribute a thread-isolated snapshot (so unsaved edits make it -/// into the pack); unopened files just contribute their paths and the worker reads them. Once -/// inputs are gathered the heavy work — pixel reduction, rect packing, atlas blit — runs on a -/// worker thread. -/// -/// Rapid re-triggers (e.g. save-all-then-repack, or rapid button clicks) coalesce: any -/// in-flight jobs are cancelled before the new one spawns. The cancelled workers continue -/// running long enough to observe the flag and exit cleanly; their results are discarded by -/// `processPackJob`. Only the most recently-started job's result is installed. -pub fn startPackProject(editor: *Editor) !void { - var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty; - errdefer { - for (inputs.items) |*input| input.deinit(fizzy.app.allocator); - inputs.deinit(fizzy.app.allocator); - } - - if (comptime builtin.target.cpu.arch == .wasm32) { - // Web: no project folder to walk — pack every open document (fiz, pixi, png, - // jpg, in-memory untitled, etc.). Saved-path tracking is not available in the - // browser, so the open tab set is the only source of truth. - try appendOpenPackInputs(editor, &inputs); - } else { - const root = editor.folder orelse return; - // Snapshot open files first so unsaved edits are included and gather can skip - // duplicates when it walks the project tree. - try appendOpenPackInputs(editor, &inputs); - try gatherPackInputs(editor, &inputs, root); - } - - if (inputs.items.len == 0) { - const msg = if (comptime builtin.target.cpu.arch == .wasm32) - "No open files to pack" - else - "No .fiz or .pixi files to pack"; - showPackToast(msg, null); - return; - } - - // `owned_inputs` is nulled out once ownership transfers into the job, so the errdefer - // below is a no-op on the success path and avoids the double-free of letting both this - // and `job.destroy()` reclaim the same allocations. - var owned_inputs: ?[]PackJob.PackInput = try inputs.toOwnedSlice(fizzy.app.allocator); - errdefer if (owned_inputs) |o| { - for (o) |*input| input.deinit(fizzy.app.allocator); - fizzy.app.allocator.free(o); - }; - - // Cancel every predecessor BEFORE appending the new job. This avoids a race where a - // predecessor publishes `done` between append and cancel: `processPackJob` walks the list - // newest-first and would otherwise see an old non-cancelled ready job and install its - // (stale) atlas. Cancelled predecessors are skipped during install selection. - for (editor.pack_jobs.items) |old| { - old.cancelled.store(true, .monotonic); - } - - const job = try PackJob.create(fizzy.app.allocator, owned_inputs.?); - owned_inputs = null; - errdefer job.destroy(); - - try editor.pack_jobs.append(fizzy.app.allocator, job); - errdefer _ = editor.pack_jobs.pop(); - - if (comptime builtin.target.cpu.arch == .wasm32) { - // Worker runs at end of `tick` (after the explorer draws) so the Pack - // button can show a spinner for at least one frame before work starts. - dvui.refresh(dvui.currentWindow(), @src(), null); - } else { - const thread = try std.Thread.spawn(.{}, PackJob.workerMain, .{job}); - thread.detach(); - } -} - -/// True while a pack is queued, running, or finished but not yet installed into -/// `fizzy.packer.atlas`. Drives the explorer Pack button spinner. -pub fn isPackingActive(editor: *const Editor) bool { - for (editor.pack_jobs.items) |job| { - if (job.cancelled.load(.monotonic)) continue; - if (!job.done.load(.acquire)) return true; - if (!job.result_consumed) return true; - } - return false; -} - -/// Run queued wasm pack workers after UI has drawn so `isPackingActive` can show feedback. -fn runWasmPackWorkers(editor: *Editor) void { - for (editor.pack_jobs.items) |job| { - if (job.cancelled.load(.monotonic)) continue; - if (job.done.load(.acquire)) continue; - PackJob.workerMain(job); - return; - } -} - -fn appendOpenPackInputs(editor: *Editor, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void { - for (editor.open_files.values()) |*open_file| { - const snapshot = try PackJob.PackFile.fromOpenFile(fizzy.app.allocator, open_file); - try inputs.append(fizzy.app.allocator, .{ .open = snapshot }); - } -} - -fn gatherPackInputs( - editor: *Editor, - inputs: *std.ArrayListUnmanaged(PackJob.PackInput), - directory: []const u8, -) !void { - const io = dvui.io; - var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }); - defer dir.close(io); - - var iter = dir.iterate(); - while (try iter.next(io)) |entry| { - if (entry.kind == .file) { - const ext = std.fs.path.extension(entry.name); - if (!fizzy.Internal.File.isFizzyExtension(ext)) continue; - - const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); - - // Open files were snapshotted in `appendOpenPackInputs` (including unsaved edits). - if (findOpenFileForPackPath(editor, abs_path) != null) continue; - - const owned_path = try fizzy.app.allocator.dupe(u8, abs_path); - try inputs.append(fizzy.app.allocator, .{ .path = owned_path }); - } else if (entry.kind == .directory) { - const abs_path = try std.fs.path.join(fizzy.app.allocator, &.{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); - try gatherPackInputs(editor, inputs, abs_path); - } - } -} - -/// Match a project-tree path to an open file (`file.path` may differ in normalization from `join` vs `joinZ`). -fn findOpenFileForPackPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.File { - if (editor.getFileFromPath(path)) |file| return file; - - const basename = std.fs.path.basename(path); - for (editor.open_files.values()) |*file| { - if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue; - if (std.mem.eql(u8, file.path, path)) return file; - if (editor.folder) |folder| { - const joined = std.fs.path.join(fizzy.app.allocator, &.{ folder, basename }) catch continue; - defer fizzy.app.allocator.free(joined); - if (std.mem.eql(u8, file.path, joined)) return file; - } - } - return null; -} - -fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { - const anchor = canvas_id orelse blk: { - if (fizzy.editor.activeWorkspaceCanvasRectPhysical()) |r| { - if (fizzy.editor.activeFile()) |file| break :blk file.editor.canvas.id; - _ = r; - } - break :blk dvui.currentWindow().data().id; - }; - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, fizzy.dvui.toastDisplay, 2_500_000); - const id = id_mutex.id; - const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message; - dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy); - id_mutex.mutex.unlock(dvui.io); -} - -/// Per-frame sweep called from `tick`. Reaps any pack jobs whose worker has published `done`, -/// installs the result of the newest non-cancelled job (and only that one), and discards the -/// rest. Older or cancelled jobs' results — even successful ones — are freed without affecting -/// `fizzy.packer.atlas` so coalesced re-triggers can't briefly flicker stale atlases. -pub fn processPackJob(editor: *Editor) void { - if (editor.pack_jobs.items.len == 0) return; - - // Identify the newest (last appended) job that finished with a `.ready` result and was - // not cancelled. Only its result is installed; older successful results are stale and - // get discarded along with cancelled / failed ones. - var install_index: ?usize = null; - { - var i = editor.pack_jobs.items.len; - while (i > 0) { - i -= 1; - const job = editor.pack_jobs.items[i]; - if (!job.done.load(.acquire)) continue; - if (job.cancelled.load(.monotonic)) continue; - if (job.currentPhase() == .ready and job.result_atlas != null) { - install_index = i; - break; - } - } - } - - if (install_index) |idx| { - const job = editor.pack_jobs.items[idx]; - const new_atlas = job.result_atlas.?; - // Free the previously-installed atlas's allocations so the new one can take its - // place — matches the synchronous `packAndClear` cleanup ordering. - if (fizzy.packer.atlas) |*current_atlas| { - current_atlas.deinitCheckerboardTile(); - for (current_atlas.data.animations) |*anim| fizzy.app.allocator.free(anim.name); - fizzy.app.allocator.free(current_atlas.data.sprites); - fizzy.app.allocator.free(current_atlas.data.animations); - fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source)); - - current_atlas.source = new_atlas.source; - current_atlas.data = new_atlas.data; - current_atlas.initCheckerboardTile(); - } else { - fizzy.packer.atlas = new_atlas; - fizzy.packer.atlas.?.initCheckerboardTile(); - } - fizzy.packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); - job.result_consumed = true; - editor.explorer.pane = .project; - const toast_canvas: ?dvui.Id = if (editor.activeFile()) |file| file.editor.canvas.id else null; - showPackToast("Project packed", toast_canvas); - } else blk: { - // Newest finished job had no atlas (empty inputs / no packable frames). Tell the user - // so the Pack button doesn't look like it silently did nothing. - var i = editor.pack_jobs.items.len; - while (i > 0) { - i -= 1; - const job = editor.pack_jobs.items[i]; - if (!job.done.load(.acquire)) continue; - if (job.cancelled.load(.monotonic)) continue; - if (job.currentPhase() == .ready and job.result_atlas == null) { - showPackToast("Nothing to pack in the selected files", null); - break :blk; - } - } - } - - // Reap everything that has published `done`. Successful-but-superseded jobs leave their - // `result_atlas` un-consumed; `destroy()` frees those allocations for us. - var write: usize = 0; - for (editor.pack_jobs.items) |job| { - if (!job.done.load(.acquire)) { - editor.pack_jobs.items[write] = job; - write += 1; - continue; - } - const phase = job.currentPhase(); - switch (phase) { - .ready, .cancelled => {}, - .failed => { - dvui.log.err("Pack project failed: {any}", .{job.err}); - showPackToast("Pack failed", null); - }, - else => dvui.log.err("Pack job finished in unexpected phase {s}", .{@tagName(phase)}), - } - job.destroy(); - } - editor.pack_jobs.shrinkRetainingCapacity(write); -} - -/// Returns the active workspace's canvas content rect (physical pixels) captured from the -/// previous frame's draw, if available. Falls back to `null` before the first workspace draw. -/// Used by `drawLoadingOverlay` / `drawSaveToasts` to center their cards over the canvas area -/// the user is currently looking at, instead of the raw OS window rect. pub fn activeWorkspaceCanvasRectPhysical(editor: *Editor) ?dvui.Rect.Physical { - const workspace = editor.workspaces.getPtr(editor.open_workspace_grouping) orelse return null; - return workspace.canvas_rect_physical; + return editor.workbench.activeWorkspaceCanvasRectPhysical(); } /// Cancel every in-flight load. Workers exit at the next cancellation checkpoint (after @@ -2424,7 +2511,7 @@ pub fn drawLoadingOverlay(editor: *Editor) void { // unrelated input (mouse move, etc.) ticks a frame. Schedule a wakeup at the threshold // boundary so the overlay shows on time even with the cursor parked. if (earliest_pending_start_ns) |start_ns| { - const elapsed_ms = @divTrunc(@import("../gfx/perf.zig").nanoTimestamp() - start_ns, std.time.ns_per_ms); + const elapsed_ms = @divTrunc(fizzy.perf.nanoTimestamp() - start_ns, std.time.ns_per_ms); const remaining_ms: i64 = toast_threshold_ms - @as(i64, @intCast(elapsed_ms)); if (remaining_ms > 0) { dvui.timer(dvui.currentWindow().data().id, @intCast(remaining_ms * std.time.us_per_ms)); @@ -2526,32 +2613,38 @@ pub fn drawLoadingOverlay(editor: *Editor) void { } } -pub fn requestCompositeWarmup(editor: *Editor) void { +pub fn requestPrepareFrame(editor: *Editor) void { editor.pending_composite_warmup = true; } -pub fn newFile(editor: *Editor, path: []const u8, options: fizzy.Internal.File.InitOptions) !*fizzy.Internal.File { - if (editor.getFileFromPath(path)) |_| { +pub fn newFile(editor: *Editor, path: []const u8, grid: sdk.EditorAPI.NewDocGrid) !sdk.DocHandle { + if (editor.docFromPath(path) != null) { return error.FileAlreadyExists; } - const file = fizzy.Internal.File.init(path, options) catch { + const owner = editor.host.pluginWithCreateDocument() orelse return error.NoEditorPlugin; + const staging = try owner.allocDocumentBuffer(fizzy.app.allocator); + defer fizzy.app.allocator.free(staging.backing); + + owner.createDocument(path, grid, staging.buf.ptr) catch { + owner.deinitDocumentBuffer(staging.buf.ptr); dvui.log.err("Failed to create file: {s}", .{path}); return error.FailedToCreateFile; }; - try editor.open_files.put(fizzy.app.allocator, file.id, file); + const id = owner.documentIdFromBuffer(staging.buf.ptr); + try editor.insertOpenDoc(staging.buf.ptr, owner, id); editor.setActiveFile(editor.open_files.count() - 1); editor.pending_composite_warmup = true; - return editor.open_files.getPtr(file.id) orelse return error.FailedToCreateFile; + return editor.docById(id) orelse return error.FailedToCreateFile; } -/// Heap-owned path like `untitled-1`, unique among `open_files` basenames. +/// Heap-owned path like `untitled-1`, unique among open-document basenames. pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { var max_n: u32 = 0; - for (editor.open_files.values()) |f| { - const base = std.fs.path.basename(f.path); + for (editor.open_files.values()) |doc| { + const base = std.fs.path.basename(editor.docPath(doc)); if (std.mem.startsWith(u8, base, "untitled-")) { const suffix = base["untitled-".len..]; const n = std.fmt.parseUnsigned(u32, suffix, 10) catch continue; @@ -2563,481 +2656,97 @@ pub fn allocNextUntitledPath(editor: *Editor) ![]u8 { return std.fmt.allocPrint(fizzy.app.allocator, "untitled-{d}", .{max_n + 1}); } -/// Opens the Grid Layout dialog for the active file. Uses a custom `windowFn` that matches -/// `dialogWindow`'s open animation while capping the window to half the main window size; the -/// dialog can still be resized afterward. -/// The dialog rebinds the active file via the `_grid_layout_file_id` data slot so the form and -/// preview can survive frames where `fizzy.editor.activeFile()` momentarily returns null. +/// Runs the active document owner's grid-layout command (`.gridLayout`). Dispatched by +/// the focused doc's owner — never a hardcoded plugin; a no-op when the owner has no such command. pub fn requestGridLayoutDialog(editor: *Editor) void { - const file = editor.activeFile() orelse return; - - Dialogs.GridLayout.presetFromFile(file); - - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = Dialogs.GridLayout.dialog, - .callafterFn = Dialogs.GridLayout.callAfter, - .windowFn = Dialogs.GridLayout.windowFn, - .title = "Grid Layout...", - .ok_label = "Apply", - .cancel_label = "Cancel", - .resizeable = true, - .header_kind = .info, - .default = .ok, - }); - dvui.dataSet(null, mutex.id, "_grid_layout_file_id", file.id); - // Let `GridLayout.windowFn` run `autoSize` only until the open animation finishes; otherwise - // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks). - dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false); - mutex.mutex.unlock(dvui.io); -} - -/// Opens the New File dimensions dialog; on confirm, creates an in-memory `untitled-n` document (or on-disk from explorer when `_parent_path` is set). -pub fn requestNewFileDialog(_: *Editor) void { - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = Dialogs.NewFile.dialog, - .callafterFn = Dialogs.NewFile.callAfter, - .title = "New File...", - .ok_label = "Create", - .cancel_label = "Cancel", - .resizeable = false, - .header_kind = .info, - .default = .ok, - }); - mutex.mutex.unlock(dvui.io); -} - -pub fn setActiveFile(editor: *Editor, index: usize) void { - if (index >= editor.open_files.values().len) return; - const file = editor.open_files.values()[index]; - const grouping = file.editor.grouping; - - if (editor.workspaces.getPtr(grouping)) |workspace| { - editor.open_workspace_grouping = grouping; - workspace.open_file_index = index; - } + editor.runActiveDocCommand("gridLayout") catch |err| { + dvui.log.err("Grid layout command failed: {s}", .{@errorName(err)}); + }; } -/// Returns the actively focused file, through workspace grouping. -pub fn activeFile(editor: *Editor) ?*fizzy.Internal.File { - if (editor.workspaces.get(editor.open_workspace_grouping)) |workspace| { - return editor.getFile(workspace.open_file_index); - } - - return null; +/// Opens the New File dialog via the plugin that provides one (dispatched by `Host`); on confirm +/// the owner creates an in-memory `untitled-n` document (or on-disk when a parent folder is set). +pub fn requestNewFileDialog(editor: *Editor) void { + editor.host.requestNewDocument(null, 0); } -pub fn getFile(editor: *Editor, index: usize) ?*fizzy.Internal.File { - if (editor.open_files.values().len == 0) return null; - if (index >= editor.open_files.values().len) return null; - - return &editor.open_files.values()[index]; +pub fn setActiveFile(editor: *Editor, index: usize) void { + editor.workbench.setActiveDocIndex(index); } -pub fn getFileFromPath(editor: *Editor, path: []const u8) ?*fizzy.Internal.File { - if (editor.open_files.values().len == 0) return null; - - for (editor.open_files.values()) |*file| { - if (std.mem.eql(u8, file.path, path)) { - return file; - } +pub fn forceCloseFile(editor: *Editor, index: usize) !void { + if (editor.docAt(index) != null) { + return editor.rawCloseFile(index); } +} - return null; +/// Dispatch a generic shell action to the active document owner's command (`.`). +/// No active doc, or an owner that registered no such command, is a clean no-op. This is how the +/// shell's Edit menu / keybinds reach per-editor actions without naming any plugin. +fn runActiveDocCommand(editor: *Editor, action: []const u8) !void { + const doc = editor.activeDoc() orelse return; + const id = try std.fmt.allocPrint(editor.arena.allocator(), "{s}.{s}", .{ doc.owner.id, action }); + try editor.host.runCommand(id); } -pub fn forceCloseFile(editor: *Editor, index: usize) !void { - if (editor.getFile(index) != null) { - return editor.rawCloseFile(index); - } +/// Whether the active document's owner registered `action` as a command. +pub fn activeDocCommandEnabled(editor: *Editor, action: []const u8) bool { + const doc = editor.activeDoc() orelse return false; + var buf: [128]u8 = undefined; + const id = std.fmt.bufPrint(&buf, "{s}.{s}", .{ doc.owner.id, action }) catch return false; + return editor.host.commandEnabled(id); } pub fn accept(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform) |*t| { - t.accept(); - } - } + try editor.runActiveDocCommand("acceptEdit"); } pub fn cancel(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform) |*t| { - t.cancel(); - } - - if (file.editor.selected_sprites.count() > 0) { - file.clearSelectedSprites(); - } - - if (file.selected_animation_index != null) { - file.selected_animation_index = null; - } - } + try editor.runActiveDocCommand("cancelEdit"); } pub fn copy(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform != null) return; - - if (editor.sprite_clipboard) |*clipboard| { - fizzy.app.allocator.free(fizzy.image.bytes(clipboard.source)); - editor.sprite_clipboard = null; - } - - file.editor.transform_layer.clear(); - - var selected_layer = file.layers.get(file.selected_layer_index); - switch (editor.tools.current) { - .selection => { - // We are in the selection tool, so we should assume that the user has painted a selection - // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing - var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (pixel_iterator.next()) |pixel_index| { - @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); - file.editor.transform_layer.mask.set(pixel_index); - } - }, - else => { - if (file.editor.selected_sprites.count() > 0) { - var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (sprite_iterator.next()) |index| { - const source_rect = file.spriteRect(index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - source_rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - source_rect, - .{ .transparent = true, .mask = true }, - ); - } - } - } else { - if (file.editor.canvas.hovered) { - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { - const rect = file.spriteRect(sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - rect, - .{ .transparent = true, .mask = true }, - ); - } - } - } else if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - if (file.selected_animation_frame_index < animation.frames.len) { - const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - rect, - .{ .transparent = true, .mask = true }, - ); - } - } - } - } - }, - } - - const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); - if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { - const sprite_tl = file.spritePoint(reduced_data_rect.topLeft()); - - editor.sprite_clipboard = .{ - .source = fizzy.image.fromPixelsPMA( - @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)), - @intFromFloat(reduced_data_rect.w), - @intFromFloat(reduced_data_rect.h), - .ptr, - ) catch return error.MemoryAllocationFailed, - .offset = reduced_data_rect.topLeft().diff(sprite_tl), - }; - - // Show a toast so its evident a copy action was completed - { - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, fizzy.dvui.toastDisplay, 2_000_000); - const id = id_mutex.id; - const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection."; - dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message); - id_mutex.mutex.unlock(dvui.io); - } - } - } + try editor.runActiveDocCommand("copy"); } pub fn paste(editor: *Editor) !void { - if (editor.sprite_clipboard) |*clipboard| { - if (editor.activeFile()) |file| { - const active_layer = file.layers.get(file.selected_layer_index); - - var dst_rect: dvui.Rect = .fromSize(fizzy.image.size(clipboard.source)); - - var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - while (sprite_iterator.next()) |sprite_index| { - const sprite_rect = file.spriteRect(sprite_index); - - dst_rect.x = sprite_rect.x + clipboard.offset.x; - dst_rect.y = sprite_rect.y + clipboard.offset.y; - - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = active_layer.id, - .data_points = .{ - dst_rect.topLeft(), - dst_rect.topRight(), - dst_rect.bottomRight(), - dst_rect.bottomLeft(), - dst_rect.center(), - dst_rect.center(), - }, - .source = clipboard.source, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - - return; - } - - dst_rect.x = clipboard.offset.x; - dst_rect.y = clipboard.offset.y; - - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { - const rect = file.spriteRect(sprite_index); - dst_rect.x = rect.x + clipboard.offset.x; - dst_rect.y = rect.y + clipboard.offset.y; - } else if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - - if (file.selected_animation_frame_index < animation.frames.len) { - const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - dst_rect.x = rect.x + clipboard.offset.x; - dst_rect.y = rect.y + clipboard.offset.y; - - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = active_layer.id, - .data_points = .{ - dst_rect.topLeft(), - dst_rect.topRight(), - dst_rect.bottomRight(), - dst_rect.bottomLeft(), - dst_rect.center(), - dst_rect.center(), - }, - .source = clipboard.source, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - - return; - } - } - - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = active_layer.id, - .data_points = .{ - dst_rect.topLeft(), - dst_rect.topRight(), - dst_rect.bottomRight(), - dst_rect.bottomLeft(), - dst_rect.center(), - dst_rect.center(), - }, - .source = clipboard.source, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - } - } + try editor.runActiveDocCommand("paste"); } pub fn deleteSelectedContents(editor: *Editor) void { - if (editor.activeFile()) |file| { - file.deleteSelectedContents(); - } + editor.runActiveDocCommand("deleteSelection") catch |err| { + dvui.log.err("deleteSelection command failed: {s}", .{@errorName(err)}); + }; } -/// Begins a transform operation on the currently active file. pub fn transform(editor: *Editor) !void { - if (editor.activeFile()) |file| { - if (file.editor.transform) |*t| { - t.cancel(); - } - - var selected_layer = file.layers.get(file.selected_layer_index); - - switch (editor.tools.current) { - .selection => { - file.editor.transform_layer.clear(); - // We are in the selection tool, so we should assume that the user has painted a selection - // into the selection layer mask, we need to copy the pixels into the transform layer itself for reducing - var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); - while (pixel_iterator.next()) |pixel_index| { - @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); - selected_layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 }; - file.editor.transform_layer.mask.set(pixel_index); - } - selected_layer.invalidate(); - }, - else => { - // Current tool is the pointer, so we potentially have a sprite selection in - // selected sprites that we need to copy to the selection layer. - file.editor.transform_layer.clear(); - - if (file.editor.selected_sprites.count() > 0) { - var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - - while (sprite_iterator.next()) |index| { - const source_rect = file.spriteRect(index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - source_rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - source_rect, - .{ .transparent = true, .mask = true }, - ); - selected_layer.clearRect(source_rect); - } - } - } else { - if (file.editor.canvas.hovered) { - if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { - const rect = file.spriteRect(sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - rect, - .{ .transparent = true, .mask = true }, - ); - selected_layer.clearRect(rect); - } - } - } else if (file.selected_animation_index) |animation_index| { - const animation = file.animations.get(animation_index); - if (file.selected_animation_frame_index < animation.frames.len) { - const source_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); - if (selected_layer.pixelsFromRect( - dvui.currentWindow().arena(), - source_rect, - )) |source_pixels| { - file.editor.transform_layer.blit( - source_pixels, - source_rect, - .{ .transparent = true, .mask = true }, - ); - selected_layer.clearRect(source_rect); - } - } - } - } - }, - } - - // We now have a transform layer that contains: - // 1. the unaltered colored pixels of the active transform - // 2. a mask containing bits for the pixels of the selection being transformed - const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); - if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { - defer file.editor.selection_layer.clearMask(); - file.editor.transform = .{ - .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { - dvui.log.err("Failed to create target texture", .{}); - return; - }, - .file_id = file.id, - .layer_id = selected_layer.id, - .data_points = .{ - reduced_data_rect.topLeft(), - reduced_data_rect.topRight(), - reduced_data_rect.bottomRight(), - reduced_data_rect.bottomLeft(), - reduced_data_rect.center(), - reduced_data_rect.center(), // This point constantly moves - }, - .source = fizzy.image.fromPixelsPMA( - @ptrCast(file.editor.transform_layer.pixelsFromRect(fizzy.app.allocator, reduced_data_rect)), - @intFromFloat(reduced_data_rect.w), - @intFromFloat(reduced_data_rect.h), - .ptr, - ) catch return error.MemoryAllocationFailed, - }; - - for (file.editor.transform.?.data_points[0..4]) |*point| { - const d = point.diff(file.editor.transform.?.point(.pivot).*); - if (d.length() > file.editor.transform.?.radius) { - file.editor.transform.?.radius = d.length() + 4; - } - } - } - } + try editor.runActiveDocCommand("transform"); } /// Performs a save operation on the currently open file. /// Paths without a recognized on-disk extension (e.g. in-memory `untitled-n`) open Save As instead. pub fn save(editor: *Editor) !void { - const file = editor.activeFile() orelse return; - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) { + const doc = editor.activeDoc() orelse return; + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) { editor.requestSaveAs(); return; } - if (file.shouldConfirmFlatRasterSave()) { - Dialogs.FlatRasterSaveWarning.request(file.id, .editor_save); + if (doc.owner.saveNeedsConfirmation(doc)) { + doc.owner.requestSaveConfirmation(doc, .editor_save, false); return; } if (comptime builtin.target.cpu.arch == .wasm32) { editor.requestWebSaveDialog(.save); return; } - try file.saveAsync(); + try doc.owner.saveDocument(doc); } /// Browser: pick download filename/extension before encoding (`processPendingSaveAs`). pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void { if (comptime builtin.target.cpu.arch != .wasm32) return; - const file = editor.activeFile() orelse return; - Dialogs.WebSaveAs.request(std.fs.path.basename(file.path), kind); + const doc = editor.activeDoc() orelse return; + Dialogs.WebSaveAs.request(std.fs.path.basename(editor.docPath(doc)), kind); } /// Kick off an async save for every dirty file with a recognized extension. @@ -3046,12 +2755,12 @@ pub fn requestWebSaveDialog(editor: *Editor, kind: Dialogs.WebSaveAs.Kind) void /// or flat-raster confirmation are skipped — the user can save those individually. /// Files that are already saving are also skipped (their `saveAsync` no-ops). pub fn saveAll(editor: *Editor) !void { - for (editor.open_files.values()) |*file| { - if (!file.dirty()) continue; - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) continue; - if (file.shouldConfirmFlatRasterSave()) continue; - file.saveAsync() catch |err| { - dvui.log.err("Save All: file {s} failed: {s}", .{ file.path, @errorName(err) }); + for (editor.open_files.values()) |doc| { + if (!doc.owner.isDirty(doc)) continue; + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) continue; + if (doc.owner.saveNeedsConfirmation(doc)) continue; + doc.owner.saveDocument(doc) catch |err| { + dvui.log.err("Save All: file {s} failed: {s}", .{ editor.docPath(doc), @errorName(err) }); }; } } @@ -3064,13 +2773,13 @@ const save_as_dialog_filters: [3]fizzy.backend.DialogFileFilter = .{ /// Opens a Save As dialog: `.fiz` (all layers; `.pixi` also accepted for legacy) or flat `.png` / `.jpg` / `.jpeg` (visible layers composited). pub fn requestSaveAs(_: *Editor) void { - const active = fizzy.editor.activeFile() orelse return; - const def = fizzy.Internal.File.defaultSaveAsFilename(fizzy.app.allocator, active.path) catch { + const doc = fizzy.editor.activeDoc() orelse return; + const def = doc.owner.documentDefaultSaveAsFilename(doc, fizzy.app.allocator) catch { std.log.err("Failed to build default save-as name", .{}); return; }; defer fizzy.app.allocator.free(def); - const current_file_dir: ?[]const u8 = std.fs.path.dirname(active.path); + const current_file_dir: ?[]const u8 = std.fs.path.dirname(fizzy.editor.docPath(doc)); fizzy.backend.showSaveFileDialog(saveAsDialogCallback, &save_as_dialog_filters, def, current_file_dir); } @@ -3088,16 +2797,16 @@ pub fn cancelPendingSaveDialog(editor: *Editor) void { } } - const file_id = editor.pending_close_file_id orelse if (editor.activeFile()) |f| f.id else null; + const file_id = editor.pending_close_file_id orelse if (editor.activeDoc()) |doc| doc.id else null; editor.pending_close_file_id = null; if (file_id) |id| { _ = editor.pending_close_after_save.swapRemove(id); - if (editor.open_files.getPtr(id)) |f| { - f.resetSaveUIState(); + if (editor.docById(id)) |doc| { + doc.owner.resetDocumentSaveUIState(doc); } - } else if (editor.activeFile()) |f| { - f.resetSaveUIState(); + } else if (editor.activeDoc()) |doc| { + doc.owner.resetDocumentSaveUIState(doc); } if (editor.quit_save_all_ids.items.len > 0 or editor.quit_in_progress) { @@ -3125,85 +2834,40 @@ pub fn saveAsDialogCallback(paths: ?[][:0]const u8) void { } fn processPendingSaveAs(editor: *Editor) void { - if (comptime builtin.target.cpu.arch == .wasm32) { - const path = blk: { - if (editor.pending_save_as_path) |p| break :blk p; + const path = blk: { + if (editor.pending_save_as_path) |p| break :blk p; + if (comptime builtin.target.cpu.arch == .wasm32) { const WebFileIo = @import("WebFileIo.zig"); if (WebFileIo.pending_save_filename) |p| break :blk p; - return; - }; - const owned_by_editor = editor.pending_save_as_path != null; - editor.pending_save_as_path = null; + } + return; + }; + const owned_by_editor = editor.pending_save_as_path != null; + editor.pending_save_as_path = null; + if (comptime builtin.target.cpu.arch == .wasm32) { if (!owned_by_editor) { const WebFileIo = @import("WebFileIo.zig"); WebFileIo.pending_save_filename = null; } - defer fizzy.app.allocator.free(path); - - const file = editor.activeFile() orelse return; - const ext = std.fs.path.extension(path); - const saved: bool = blk: { - if (fizzy.Internal.File.isFizzyExtension(ext)) { - file.saveAsFizzy(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; - } else if (std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - file.saveAsFlattened(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; - } else { - dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{ext}); - break :blk false; - } - break :blk true; - }; - if (!saved) return; - if (editor.pending_close_file_id) |cid| { - if (file.id == cid) { - editor.pending_close_file_id = null; - editor.rawCloseFileID(cid) catch |err| { - dvui.log.err("Failed to close file after Save As: {s}", .{@errorName(err)}); - }; - } - } - return; } - const path = editor.pending_save_as_path orelse return; - editor.pending_save_as_path = null; defer fizzy.app.allocator.free(path); - const ext = std.fs.path.extension(path); - const file = editor.activeFile() orelse { + const doc = editor.activeDoc() orelse { editor.pending_close_file_id = null; return; }; - const saved: bool = blk: { - if (fizzy.Internal.File.isFizzyExtension(ext)) { - file.saveAsFizzy(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; - } else if (std.mem.eql(u8, ext, ".png") or - std.mem.eql(u8, ext, ".jpg") or - std.mem.eql(u8, ext, ".jpeg")) - { - file.saveAsFlattened(path, dvui.currentWindow()) catch |err| { - dvui.log.err("Save As: {any}", .{err}); - break :blk false; - }; + doc.owner.saveDocumentAs(doc, path, dvui.currentWindow()) catch |err| { + if (err == error.UnsupportedSaveExtension) { + dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{std.fs.path.extension(path)}); } else { - dvui.log.err("Save As: choose extension .fiz, .png, .jpg, or .jpeg (got {s})", .{ext}); - break :blk false; + dvui.log.err("Save As: {any}", .{err}); } - break :blk true; + return; }; - if (!saved) return; if (editor.pending_close_file_id) |cid| { - if (file.id == cid) { + if (doc.id == cid) { editor.pending_close_file_id = null; editor.rawCloseFileID(cid) catch |err| { dvui.log.err("Failed to close file after Save As: {s}", .{@errorName(err)}); @@ -3219,15 +2883,13 @@ fn processPendingSaveAs(editor: *Editor) void { } pub fn undo(editor: *Editor) !void { - if (editor.activeFile()) |file| { - try file.history.undoRedo(file, .undo); - } + const doc = editor.activeDoc() orelse return; + try doc.owner.undo(doc); } pub fn redo(editor: *Editor) !void { - if (editor.activeFile()) |file| { - try file.history.undoRedo(file, .redo); - } + const doc = editor.activeDoc() orelse return; + try doc.owner.redo(doc); } pub fn openInFileBrowser(_: *Editor, path: []const u8) !void { @@ -3239,8 +2901,8 @@ pub fn openInFileBrowser(_: *Editor, path: []const u8) !void { } pub fn closeFileID(editor: *Editor, id: u64) !void { - if (editor.open_files.get(id)) |file| { - if (file.dirty()) { + if (editor.open_files.get(id)) |doc| { + if (doc.owner.isDirty(doc)) { Dialogs.UnsavedClose.request(id); return; } @@ -3249,58 +2911,57 @@ pub fn closeFileID(editor: *Editor, id: u64) !void { } pub fn closeFile(editor: *Editor, index: usize) !void { - const file = editor.open_files.values()[index]; - try editor.closeFileID(file.id); + const doc = editor.docAt(index) orelse return; + try editor.closeFileID(doc.id); +} + +/// Tear down a document via its owning plugin, falling back to a direct `deinit`. +/// Removes the entry from the plugin's document registry; the shell still removes +/// the matching `DocHandle` from `open_files`. +fn closeDocumentResources(_: *Editor, doc: sdk.DocHandle) void { + _ = doc.owner.closeDocument(doc); + doc.owner.unregisterDocument(doc.id); } pub fn rawCloseFile(editor: *Editor, index: usize) !void { - //editor.open_file_index = 0; - var file = editor.open_files.values()[index]; - - if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { - if (workspace.open_file_index == fizzy.editor.open_files.getIndex(file.id)) { - for (fizzy.editor.open_files.values(), 0..) |f, i| { - if (f.grouping == workspace.grouping and f.id != file.id) { - workspace.open_file_index = i; - break; - } - } + const doc = editor.docAt(index) orelse return; + const grouping = editor.docGrouping(doc); + + const replacement_index: ?usize = blk: { + for (editor.open_files.values(), 0..) |d, i| { + if (i == index) continue; + if (editor.docGrouping(d) == grouping) break :blk i; } - } + break :blk null; + }; + editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index); - file.deinit(); + editor.closeDocumentResources(doc); editor.open_files.orderedRemoveAt(index); } pub fn rawCloseFileID(editor: *Editor, id: u64) !void { - if (editor.open_files.getPtr(id)) |file| { - - //editor.open_file_index = 0; - if (editor.workspaces.getPtr(file.editor.grouping)) |workspace| { - if (workspace.open_file_index == fizzy.editor.open_files.getIndex(file.id)) { - for (fizzy.editor.open_files.values(), 0..) |f, i| { - if (f.editor.grouping == workspace.grouping and f.id != file.id) { - workspace.open_file_index = i; - break; - } - } - } + const doc = editor.open_files.get(id) orelse return; + const index = editor.open_files.getIndex(id) orelse return; + const grouping = editor.docGrouping(doc); + + const replacement_index: ?usize = blk: { + for (editor.open_files.values(), 0..) |d, i| { + if (i == index) continue; + if (editor.docGrouping(d) == grouping) break :blk i; } - file.deinit(); - _ = editor.open_files.orderedRemove(id); - } -} + break :blk null; + }; + editor.workbench.adjustOpenFileIndexAfterClose(grouping, index, replacement_index); -pub fn closeReference(editor: *Editor, index: usize) !void { - editor.open_reference_index = 0; - var reference: fizzy.Internal.Reference = editor.open_references.orderedRemove(index); - reference.deinit(); + editor.closeDocumentResources(doc); + _ = editor.open_files.orderedRemove(id); } pub fn deinit(editor: *Editor) !void { // Drain & join the save-queue worker before tearing anything else down. Any // queued jobs need to finish writing or be dropped before File data is freed. - fizzy.Internal.File.deinitSaveQueue(); + for (editor.host.plugins.items) |plugin| plugin.deinit(); // Signal cancel to any in-flight load workers. They check the flag after `fromPath` returns // and discard the result; we can't synchronously join them without blocking quit, so we // accept a brief window where a worker may still be running with a discardable result. @@ -3319,17 +2980,7 @@ pub fn deinit(editor: *Editor) !void { editor.loading_jobs.deinit(fizzy.app.allocator); } - for (editor.pack_jobs.items) |job| { - // Detached workers still reference each job. Signal cancellation and leak the structs - // on hard quit — better than a use-after-free if a worker hasn't yet observed it. - job.cancelled.store(true, .monotonic); - } - editor.pack_jobs.deinit(fizzy.app.allocator); - - if (editor.tab_drag_from_tree_path) |p| { - fizzy.app.allocator.free(p); - editor.tab_drag_from_tree_path = null; - } + editor.workbench.clearFileTreeTabDragDropState(); if (editor.pending_save_as_path) |p| { fizzy.app.allocator.free(p); @@ -3340,9 +2991,6 @@ pub fn deinit(editor: *Editor) !void { editor.quit_saves_in_flight.deinit(fizzy.app.allocator); editor.pending_close_after_save.deinit(fizzy.app.allocator); - if (editor.colors.palette) |*palette| palette.deinit(); - if (editor.colors.file_tree_palette) |*palette| palette.deinit(); - // Recents persist via Io.Dir.cwd writes — no FS on wasm; skip persist. if (comptime builtin.target.cpu.arch != .wasm32) { editor.recents.save(fizzy.app.allocator, try std.fs.path.join(fizzy.app.allocator, &.{ editor.config_folder, "recents.json" })) catch { @@ -3358,24 +3006,22 @@ pub fn deinit(editor: *Editor) !void { } editor.settings.deinit(fizzy.app.allocator); - if (editor.project) |*project| { - // Wasm: skip project.save() — it walks std.Io.Dir.cwd() which pulls in - // posix.AT (unavailable on freestanding). Browser tabs have no - // persistent on-disk project anyway. - if (comptime builtin.target.cpu.arch != .wasm32) { - project.save() catch { - dvui.log.err("Failed to save project file", .{}); - }; - } - project.deinit(fizzy.app.allocator); - } - editor.explorer.deinit(); + editor.panel.deinit(fizzy.app.allocator); + fizzy.app.allocator.destroy(editor.panel); + + editor.workbench.deinitWorkspaces(); + editor.unloadPluginLibs(); + editor.host.deinit(); + editor.workbench.deinit(); - editor.tools.deinit(fizzy.app.allocator); + // Pixel-art state (tools/colors/project/pack jobs) is torn down by + // `State.deinit` in `App.AppDeinit`, after this returns. editor.ignore.deinit(fizzy.app.allocator); + editor.atlas.deinit(fizzy.app.allocator); + if (editor.folder) |folder| fizzy.app.allocator.free(folder); editor.arena.deinit(); } diff --git a/src/editor/FileLoadJob.zig b/src/editor/FileLoadJob.zig deleted file mode 100644 index 22b06b50..00000000 --- a/src/editor/FileLoadJob.zig +++ /dev/null @@ -1,163 +0,0 @@ -//! Background file-load job. Owns a worker thread that runs `Internal.File.fromPath` off the -//! main thread so large files don't stall the editor. The main thread polls `done` each frame -//! via `Editor.processLoadingJobs`; once true, the result is moved into `editor.open_files`. -//! -//! Cancellation is best-effort: `Internal.File.fromPath` is monolithic, so we can only -//! observe cancellation AFTER it returns. The worker checks the flag, frees the loaded file -//! if cancelled, and exits. -//! -//! Ownership / threading model: -//! - `path` is owned by the job, freed in `destroy()`. -//! - `result` is written by the worker, read by the main thread only after `done.load(.acquire)`. -//! - `phase` / `cancelled` are written by either side, read by either side. -//! - The job pointer itself is owned by `Editor.loading_jobs`. Worker holds a borrowed pointer -//! but only writes through atomic fields + the worker-only `result`/`err`/`canvas_target_grouping` fields. - -const std = @import("std"); -const fizzy = @import("../fizzy.zig"); -const dvui = @import("dvui"); -const perf = @import("../gfx/perf.zig"); - -const FileLoadJob = @This(); - -pub const Phase = enum(u8) { - queued = 0, - reading = 1, - ready = 2, - failed = 3, - cancelled = 4, -}; - -allocator: std.mem.Allocator, - -/// Absolute path. Owned by this job. -path: []u8, - -/// Workspace grouping the file should land in once loaded. -target_grouping: u64, - -/// Captured at create time on the GUI thread. The worker uses this to wake the main loop -/// (`dvui.refresh(window, ...)`) the instant the load finishes, so small files don't sit -/// completed-but-unconsumed waiting for an unrelated input event to tick the editor. -window: *dvui.Window, - -/// Monotonic timestamp (boot clock, nanos) captured on the main thread at job creation. -/// Compared against the main thread's current `perf.nanoTimestamp` to gate the 150ms toast -/// threshold. Only read on the main thread. -started_at_ns: i128, - -/// Atomic phase, written by worker, read by main. Cast through `Phase`. -phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)), - -/// Optional progress hint, written by worker. `den == 0` means indeterminate. -progress_num: std.atomic.Value(u32) = .init(0), -progress_den: std.atomic.Value(u32) = .init(0), - -/// Main thread sets true on close-while-loading / quit. Worker checks after `fromPath` returns -/// and discards the result instead of publishing. -cancelled: std.atomic.Value(bool) = .init(false), - -/// Worker → main publish flag. `release` on write, `acquire` on read. -done: std.atomic.Value(bool) = .init(false), - -/// Filled by worker iff load succeeds AND wasn't cancelled. Safe to read after `done.load(.acquire)`. -result: ?fizzy.Internal.File = null, - -/// Filled by worker iff load failed. Safe to read after `done.load(.acquire)`. -err: ?anyerror = null, - -pub fn create(allocator: std.mem.Allocator, path: []const u8, target_grouping: u64) !*FileLoadJob { - const path_copy = try allocator.dupe(u8, path); - errdefer allocator.free(path_copy); - - const job = try allocator.create(FileLoadJob); - job.* = .{ - .allocator = allocator, - .path = path_copy, - .target_grouping = target_grouping, - .window = dvui.currentWindow(), - .started_at_ns = perf.nanoTimestamp(), - }; - return job; -} - -pub fn destroy(job: *FileLoadJob) void { - const a = job.allocator; - a.free(job.path); - a.destroy(job); -} - -/// Worker entry point. Spawn with `std.Thread.spawn(.{}, FileLoadJob.workerMain, .{job})`. -pub fn workerMain(job: *FileLoadJob) void { - defer { - // Publish before waking the GUI thread so `done.load(.acquire)` on the consumer side - // sees `result` / `err` / `phase` already in place. - job.done.store(true, .release); - // Wake the GUI thread from this thread. `dvui.refresh` with a non-null Window pointer - // is the documented thread-safe entry — it goes through the backend to interrupt the - // event-driven idle loop, so the editor processes our completion immediately instead - // of waiting for the next unrelated input event. - dvui.refresh(job.window, @src(), null); - } - - if (job.cancelled.load(.monotonic)) { - job.phase.store(@intFromEnum(Phase.cancelled), .release); - return; - } - - job.phase.store(@intFromEnum(Phase.reading), .release); - - const maybe_file = fizzy.Internal.File.fromPath(job.path) catch |e| { - job.err = e; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - - const file = maybe_file orelse { - job.err = error.InvalidFile; - job.phase.store(@intFromEnum(Phase.failed), .release); - return; - }; - - // Cancellation check post-load: if the user closed the tab / quit while we were loading, - // discard the file rather than publishing it. - if (job.cancelled.load(.monotonic)) { - var f = file; - f.deinit(); - job.phase.store(@intFromEnum(Phase.cancelled), .release); - return; - } - - job.result = file; - job.phase.store(@intFromEnum(Phase.ready), .release); -} - -/// True iff at least `threshold_ms` of wall-clock time has elapsed since job creation. Used -/// to delay the toast appearance so sub-threshold loads don't flash a UI element. Must be -/// called from the main thread (uses `dvui.io` via `perf.nanoTimestamp`). -pub fn elapsedExceeds(job: *const FileLoadJob, threshold_ms: i64) bool { - const elapsed_ns = perf.nanoTimestamp() - job.started_at_ns; - return @divTrunc(elapsed_ns, std.time.ns_per_ms) >= threshold_ms; -} - -pub fn currentPhase(job: *const FileLoadJob) Phase { - const raw = job.phase.load(.acquire); - return switch (raw) { - 0 => .queued, - 1 => .reading, - 2 => .ready, - 3 => .failed, - 4 => .cancelled, - else => .queued, - }; -} - -pub fn phaseLabel(phase: Phase) []const u8 { - return switch (phase) { - .queued => "Queued", - .reading => "Reading", - .ready => "Done", - .failed => "Failed", - .cancelled => "Cancelled", - }; -} diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index 9110c24e..9e728177 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -2,7 +2,7 @@ const std = @import("std"); const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); -const update_notify = @import("../update_notify.zig"); +const update_notify = @import("../backend/update_notify.zig"); const Dialogs = fizzy.Editor.Dialogs; pub const Infobar = @This(); @@ -23,7 +23,6 @@ pub fn deinit() void { pub fn draw(_: Infobar) !void { const font = dvui.Font.theme(.body).larger(-1.0); - const font_mono = dvui.Font.theme(.mono).larger(-3.0); var scrollarea = dvui.scrollArea(@src(), .{}, .{ .expand = .horizontal, @@ -106,60 +105,9 @@ pub fn draw(_: Infobar) !void { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - if (fizzy.editor.activeFile()) |file| { - dvui.icon( - @src(), - "file_icon", - icons.tvg.lucide.file, - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ .font = font, .gravity_y = 0.5 }); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - - dvui.icon( - @src(), - "width_icon", - icons.tvg.lucide.@"ruler-dimension-line", - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - - fizzy.Editor.Dialogs.drawDimensionsLabel(@src(), file.width(), file.height(), font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - - dvui.icon( - @src(), - "sprite_icon", - dvui.entypo.grid, - .{ .fill_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - - fizzy.Editor.Dialogs.drawDimensionsLabel(@src(), file.column_width, file.row_height, font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); - - //dvui.label(@src(), "{d}x{d} - {d}x{d}", .{ file.width(), file.height(), file.column_width, file.row_height }, .{ .font = font, .gravity_y = 0.5 }); - - const mouse_pt = dvui.currentWindow().mouse_pt; - const data_pt = file.editor.canvas.dataFromScreenPoint(mouse_pt); - - const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); - - if (file_rect.contains(data_pt)) { - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); - - dvui.icon( - @src(), - "mouse_icon", - icons.tvg.lucide.@"mouse-pointer", - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ .gravity_y = 0.5 }, - ); - - const sprite_pt = file.spritePoint(data_pt); - dvui.label(@src(), "{d:0.0},{d:0.0} - {d:0.0},{d:0.0}", .{ @floor(data_pt.x), @floor(data_pt.y), @floor(sprite_pt.x / @as(f32, @floatFromInt(file.column_width))), @floor(sprite_pt.y / @as(f32, @floatFromInt(file.row_height))) }, .{ .gravity_y = 0.5, .font = font_mono }); - } + if (fizzy.editor.activeDoc()) |doc| { + doc.owner.drawDocumentInfobar(doc) catch { + dvui.log.err("Failed to draw document infobar", .{}); + }; } } diff --git a/src/editor/InstalledPlugins.zig b/src/editor/InstalledPlugins.zig new file mode 100644 index 00000000..477faaea --- /dev/null +++ b/src/editor/InstalledPlugins.zig @@ -0,0 +1,89 @@ +//! Settings → Plugins: local plugin inventory (no network store yet). +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); +const fizzy = @import("../fizzy.zig"); + +const version = sdk.version; +const dylib = sdk.dylib; + +pub fn register(host: *sdk.Host) !void { + try host.registerSettingsSection(.{ + .id = "shell.settings.plugins", + .title = "Plugins", + .draw = drawPlugins, + }); +} + +fn isBundled(id: []const u8) bool { + return std.mem.eql(u8, id, "pixi") or + std.mem.eql(u8, id, "workbench") or + std.mem.eql(u8, id, "code"); +} + +fn drawPlugins(_: ?*anyopaque) anyerror!void { + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + var host_sdk_buf: [96]u8 = undefined; + const host_sdk = std.fmt.bufPrint(&host_sdk_buf, "Host SDK {d}.{d}.{d} · ABI 0x{x}", .{ + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + }) catch "Host SDK ?"; + dvui.labelNoFmt(@src(), host_sdk, .{}, .{ .margin = .{ .h = 4 } }); + + dvui.labelNoFmt(@src(), "Registered plugins", .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .y = 8 }, + }); + + for (fizzy.editor.host.plugins.items, 0..) |plugin, i| { + const tag: []const u8 = if (isBundled(plugin.id)) " (bundled)" else ""; + dvui.label(@src(), "• {s} — {s}{s}", .{ plugin.display_name, plugin.id, tag }, .{ .id_extra = i }); + } + + if (fizzy.editor.loaded_plugin_libs.items.len > 0) { + dvui.labelNoFmt(@src(), "User dylibs", .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .y = 8 }, + }); + for (fizzy.editor.loaded_plugin_libs.items, 0..) |loaded, i| { + const vi = loaded.version_info; + var ver_buf: [32]u8 = undefined; + const ver = std.fmt.bufPrint(&ver_buf, "{d}.{d}.{d}", .{ + vi.plugin_version.major, + vi.plugin_version.minor, + vi.plugin_version.patch, + }) catch "?"; + dvui.label( + @src(), + "• {s} — v{s} (SDK {d}.{d}.{d})", + .{ + loaded.plugin_id, + ver, + vi.built_with_sdk_version.major, + vi.built_with_sdk_version.minor, + vi.built_with_sdk_version.patch, + }, + .{ .id_extra = i }, + ); + } + } + + if (fizzy.editor.failed_user_plugins.items.len > 0) { + dvui.labelNoFmt(@src(), "Load failures", .{}, .{ + .font = dvui.Font.theme(.heading), + .margin = .{ .y = 8 }, + .color_text = dvui.themeGet().color(.err, .text), + }); + for (fizzy.editor.failed_user_plugins.items, 0..) |f, i| { + if (f.detail) |detail| { + dvui.label(@src(), "• {s} — {s} ({s})", .{ f.id, f.reason, detail }, .{ .id_extra = i }); + } else { + dvui.label(@src(), "• {s} — {s}", .{ f.id, f.reason }, .{ .id_extra = i }); + } + } + } +} diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index cc66b279..39a8bee6 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -6,60 +6,29 @@ const dvui = @import("dvui"); pub const Keybinds = @This(); +/// Register the shell's own global / navigation / region binds. File-management +/// binds and pixel-art editing binds are contributed by the workbench and +/// pixel-art plugins (their `contributeKeybinds`), which `Editor.postInit` invokes +/// after the plugins register. This runs during `Editor.init`, before postInit, so +/// the shell binds land first; the split is disjoint, so no `putNoClobber` clashes. +/// +/// Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for +/// wasm32-freestanding, so macOS web users would otherwise get the Windows (Ctrl) +/// bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`-derived +/// choice on web and uses `os.tag` on native. pub fn register() !void { const window = dvui.currentWindow(); - // Runtime mac detection — `builtin.os.tag.isDarwin()` is `false` for - // wasm32-freestanding, so macOS web users would otherwise get the Windows - // (Ctrl) bindings. `fizzy.platform.isMacOS()` reads DVUI's `navigator.platform`- - // derived choice on web and uses `os.tag` on native. + // Region toggles (explorer / workspace) are platform-dependent. if (fizzy.platform.isMacOS()) { - try window.keybinds.putNoClobber(window.gpa, "open_folder", .{ .key = .f, .command = true }); - try window.keybinds.putNoClobber(window.gpa, "new_file", .{ .key = .n, .command = true }); - try window.keybinds.putNoClobber(window.gpa, "open_files", .{ .key = .o, .command = true }); - try window.keybinds.putNoClobber(window.gpa, "undo", .{ .key = .z, .command = true, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "redo", .{ .key = .z, .command = true, .shift = true }); - try window.keybinds.putNoClobber(window.gpa, "zoom", .{ .command = true }); - try window.keybinds.putNoClobber(window.gpa, "save", .{ .command = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_as", .{ .command = true, .shift = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_all", .{ .command = true, .alt = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "sample", .{ .control = true }); - try window.keybinds.putNoClobber(window.gpa, "transform", .{ .command = true, .key = .t }); - try window.keybinds.putNoClobber(window.gpa, "grid_layout", .{ .command = true, .key = .g }); try window.keybinds.putNoClobber(window.gpa, "explorer", .{ .command = true, .key = .e }); try window.keybinds.putNoClobber(window.gpa, "workspace", .{ .command = true, .key = .w }); - try window.keybinds.putNoClobber(window.gpa, "export", .{ .command = true, .key = .p }); - try window.keybinds.putNoClobber(window.gpa, "delete_selection_contents", .{ .key = .backspace }); } else { - try window.keybinds.putNoClobber(window.gpa, "open_folder", .{ .key = .f, .control = true }); - try window.keybinds.putNoClobber(window.gpa, "new_file", .{ .key = .n, .control = true }); - try window.keybinds.putNoClobber(window.gpa, "open_files", .{ .key = .o, .control = true }); - try window.keybinds.putNoClobber(window.gpa, "undo", .{ .key = .z, .control = true, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "redo", .{ .key = .z, .control = true, .shift = true }); - try window.keybinds.putNoClobber(window.gpa, "zoom", .{ .control = true }); - try window.keybinds.putNoClobber(window.gpa, "save", .{ .control = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_as", .{ .control = true, .shift = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "save_all", .{ .control = true, .alt = true, .key = .s }); - try window.keybinds.putNoClobber(window.gpa, "sample", .{ .alt = true }); - try window.keybinds.putNoClobber(window.gpa, "transform", .{ .control = true, .key = .t }); - try window.keybinds.putNoClobber(window.gpa, "grid_layout", .{ .control = true, .key = .g }); try window.keybinds.putNoClobber(window.gpa, "explorer", .{ .control = true, .key = .e }); try window.keybinds.putNoClobber(window.gpa, "workspace", .{ .control = true, .key = .w }); - try window.keybinds.putNoClobber(window.gpa, "export", .{ .control = true, .key = .p }); - try window.keybinds.putNoClobber(window.gpa, "delete_selection_contents", .{ .key = .delete }); } try window.keybinds.putNoClobber(window.gpa, "shift", .{ .shift = true }); - try window.keybinds.putNoClobber(window.gpa, "increase_stroke_size", .{ .key = .right_bracket }); - try window.keybinds.putNoClobber(window.gpa, "decrease_stroke_size", .{ .key = .left_bracket }); - - try window.keybinds.putNoClobber(window.gpa, "quick_tools", .{ .key = .space }); - - try window.keybinds.putNoClobber(window.gpa, "pencil", .{ .key = .d, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "eraser", .{ .key = .e, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "bucket", .{ .key = .b, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "selection", .{ .key = .s, .command = false, .control = false, .alt = false, .shift = false }); - try window.keybinds.putNoClobber(window.gpa, "pointer", .{ .key = .escape }); try window.keybinds.putNoClobber(window.gpa, "up", .{ .key = .up }); try window.keybinds.putNoClobber(window.gpa, "down", .{ .key = .down }); @@ -94,7 +63,7 @@ pub fn tick() !void { .{ .title = "Open Files...", .filter_description = ".fiz, .pixi, .png, .jpg, .jpeg", .filters = &.{ "*.fiz", "*.pixi", "*.png", "*.jpg", "*.jpeg" } }, )) |files| { for (files) |file| { - _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch { + _ = fizzy.editor.openFilePath(file, fizzy.editor.currentGroupingID()) catch { std.log.err("Failed to open file: {s}", .{file}); }; } @@ -102,34 +71,6 @@ pub fn tick() !void { } } - if (ke.matchBind("quick_tools")) { - const rm = &fizzy.editor.tools.radial_menu; - switch (ke.action) { - .down => { - const mp = dvui.currentWindow().mouse_pt; - rm.mouse_position = mp; - rm.center = mp; - rm.opened_by_press = false; - rm.suppress_next_pointer_release = false; - rm.outside_click_press_p = null; - rm.visible = true; - }, - .repeat => rm.visible = true, - .up => rm.close(), - } - // If we include a refresh here, the underlying gui has a chance to reset the cursor - dvui.refresh(null, @src(), dvui.currentWindow().data().id); - } - - if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) { - if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) - fizzy.editor.tools.stroke_size += 1; - - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); - } - } - if (ke.matchBind("save_as") and ke.action == .down) { fizzy.editor.requestSaveAs(); } @@ -140,32 +81,6 @@ pub fn tick() !void { }; } - if (ke.matchBind("export") and ke.action == .down) { - // Create a generic dialog that contains typical okay and cancel buttons and header - // The displayFn will be called during the drawing of the dialog, prior to ok and cancel buttons - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.Export.dialog, - .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, - .title = "Export...", - .ok_label = "Export", - .cancel_label = "Cancel", - .resizeable = false, - .modal = false, - .header_kind = .info, - .default = .ok, - }); - mutex.mutex.unlock(dvui.io); - } - - if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.current != .selection or fizzy.editor.tools.selection_mode == .pixel) { - if (fizzy.editor.tools.stroke_size > 1) - fizzy.editor.tools.stroke_size -= 1; - - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); - } - } - if (ke.matchBind("delete_selection_contents")) { if (ke.action == .down) { fizzy.editor.deleteSelectedContents(); @@ -236,27 +151,11 @@ pub fn tick() !void { } if (ke.matchBind("grid_layout") and ke.action == .down) { - if (fizzy.editor.activeFile() != null) { + if (fizzy.editor.activeDoc() != null) { fizzy.editor.requestGridLayoutDialog(); } } } - - if (ke.matchBind("pencil") and ke.action == .down) { - fizzy.editor.tools.set(.pencil); - } - if (ke.matchBind("eraser") and ke.action == .down) { - fizzy.editor.tools.set(.eraser); - } - if (ke.matchBind("bucket") and ke.action == .down) { - fizzy.editor.tools.set(.bucket); - } - if (ke.matchBind("pointer") and ke.action == .down) { - fizzy.editor.tools.set(.pointer); - } - if (ke.matchBind("selection") and ke.action == .down) { - fizzy.editor.tools.set(.selection); - } }, else => {}, } diff --git a/src/editor/Menu.zig b/src/editor/Menu.zig index 47a3b99f..abd06f3c 100644 --- a/src/editor/Menu.zig +++ b/src/editor/Menu.zig @@ -3,7 +3,6 @@ const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const Editor = fizzy.Editor; const settings = fizzy.settings; -const zstbi = @import("zstbi"); const builtin = @import("builtin"); pub var mouse_distance: f32 = std.math.floatMax(f32); @@ -24,6 +23,19 @@ pub fn draw() !dvui.App.Result { dvui.themeSet(theme); } + // The shell owns only the menu bar container + theme; the top-level menus are + // plugin (and shell built-in) contributions, drawn in registration order. + for (fizzy.editor.host.menus.items) |*menu| { + menu.draw(menu.ctx) catch |err| { + dvui.log.err("Menu contribution failed: {any}", .{err}); + }; + } + + return .ok; +} + +/// File menu (workbench contribution). +pub fn drawFileMenu(_: ?*anyopaque) anyerror!void { if (menuItem(@src(), "File", .{ .submenu = true }, .{ .expand = .horizontal, //.color_accent = dvui.themeGet().color(.window, .fill), @@ -109,7 +121,7 @@ pub fn draw() !dvui.App.Result { const folder = fizzy.editor.recents.folders.items[i - 1]; if (menuItem(@src(), folder, .{}, .{ .expand = .horizontal, - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = dvui.Font.theme(.mono), .id_extra = i, .margin = dvui.Rect.all(1), .padding = dvui.Rect.all(2), @@ -121,8 +133,8 @@ pub fn draw() !dvui.App.Result { _ = dvui.separator(@src(), .{ .expand = .horizontal }); - if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeFile()) |file| - (file.dirty() or !fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) + if (menuItemWithHotkey(@src(), "Save", dvui.currentWindow().keybinds.get("save") orelse .{}, if (fizzy.editor.activeDoc()) |doc| + (doc.owner.isDirty(doc) or !doc.owner.documentHasRecognizedSaveExtension(doc)) else false, .{}, .{ .expand = .horizontal, @@ -134,7 +146,7 @@ pub fn draw() !dvui.App.Result { fw.close(); } - if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeFile() != null, .{}, .{ + if (menuItemWithHotkey(@src(), "Save As…", dvui.currentWindow().keybinds.get("save_as") orelse .{}, fizzy.editor.activeDoc() != null, .{}, .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.window, .text), }) != null) { @@ -145,8 +157,8 @@ pub fn draw() !dvui.App.Result { // Save All is enabled whenever any open file is dirty with a recognized // extension. Worker queue handles them serially; UI stays responsive. const any_dirty = blk: { - for (fizzy.editor.open_files.values()) |*f| { - if (f.dirty() and fizzy.Internal.File.hasRecognizedSaveExtension(f.path)) break :blk true; + for (fizzy.editor.open_files.values()) |doc| { + if (doc.owner.isDirty(doc) and doc.owner.documentHasRecognizedSaveExtension(doc)) break :blk true; } break :blk false; }; @@ -160,7 +172,10 @@ pub fn draw() !dvui.App.Result { fw.close(); } } +} +/// Edit menu (pixi contribution). +pub fn drawEditMenu(_: ?*anyopaque) anyerror!void { if (menuItem( @src(), "Edit", @@ -168,7 +183,6 @@ pub fn draw() !dvui.App.Result { .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.control, .text), - //.style = .control, }, )) |r| { var animator = dvui.animate(@src(), .{ @@ -186,32 +200,28 @@ pub fn draw() !dvui.App.Result { @src(), "Copy", dvui.currentWindow().keybinds.get("copy") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDocCommandEnabled("copy"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { - fizzy.editor.copy() catch { - std.log.err("Failed to copy", .{}); - }; - fw.close(); - } + fizzy.editor.copy() catch { + std.log.err("Failed to copy", .{}); + }; + fw.close(); } if (menuItemWithHotkey( @src(), "Paste", dvui.currentWindow().keybinds.get("paste") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDocCommandEnabled("paste"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { - fizzy.editor.paste() catch { - std.log.err("Failed to paste", .{}); - }; - fw.close(); - } + fizzy.editor.paste() catch { + std.log.err("Failed to paste", .{}); + }; + fw.close(); } _ = dvui.separator(@src(), .{ .expand = .horizontal }); @@ -220,12 +230,12 @@ pub fn draw() !dvui.App.Result { @src(), "Undo", dvui.currentWindow().keybinds.get("undo") orelse .{}, - if (fizzy.editor.activeFile()) |file| if (file.history.undo_stack.items.len > 0) true else false else false, + if (fizzy.editor.activeDoc()) |doc| doc.owner.canUndo(doc) else false, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile()) |file| { - file.history.undoRedo(file, .undo) catch { + if (fizzy.editor.activeDoc()) |doc| { + doc.owner.undo(doc) catch { std.log.err("Failed to undo", .{}); }; } @@ -235,12 +245,12 @@ pub fn draw() !dvui.App.Result { @src(), "Redo", dvui.currentWindow().keybinds.get("redo") orelse .{}, - if (fizzy.editor.activeFile()) |file| if (file.history.redo_stack.items.len > 0) true else false else false, + if (fizzy.editor.activeDoc()) |doc| doc.owner.canRedo(doc) else false, .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile()) |file| { - file.history.undoRedo(file, .redo) catch { + if (fizzy.editor.activeDoc()) |doc| { + doc.owner.redo(doc) catch { std.log.err("Failed to redo", .{}); }; } @@ -252,16 +262,14 @@ pub fn draw() !dvui.App.Result { @src(), "Transform", dvui.currentWindow().keybinds.get("transform") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDocCommandEnabled("transform"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { - fizzy.editor.transform() catch { - std.log.err("Failed to transform", .{}); - }; - fw.close(); - } + fizzy.editor.transform() catch { + std.log.err("Failed to transform", .{}); + }; + fw.close(); } _ = dvui.separator(@src(), .{ .expand = .horizontal }); @@ -270,17 +278,20 @@ pub fn draw() !dvui.App.Result { @src(), "Grid Layout…", dvui.currentWindow().keybinds.get("grid_layout") orelse .{}, - if (fizzy.editor.activeFile() != null) true else false, + fizzy.editor.activeDocCommandEnabled("gridLayout"), .{}, .{ .expand = .horizontal }, ) != null) { - if (fizzy.editor.activeFile() != null) { - fizzy.editor.requestGridLayoutDialog(); - fw.close(); - } + fizzy.editor.requestGridLayoutDialog(); + fw.close(); } + + try drawMenuSections("pixi.menu.edit"); } +} +/// View menu (shell built-in). +pub fn drawViewMenu(_: ?*anyopaque) anyerror!void { if (menuItem(@src(), "View", .{ .submenu = true }, .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.control, .text), @@ -315,6 +326,8 @@ pub fn draw() !dvui.App.Result { fw.close(); } + try drawMenuSections("shell.menu.view"); + _ = dvui.separator(@src(), .{ .expand = .horizontal }); if (menuItem(@src(), "Show DVUI Demo", .{}, .{ .expand = .horizontal }) != null) { @@ -322,8 +335,11 @@ pub fn draw() !dvui.App.Result { fw.close(); } } +} - // Help — matches the macOS native Help menu so the two menubars stay congruent. +/// Help menu (shell built-in). Matches the macOS native Help menu so the two +/// menubars stay congruent. +pub fn drawHelpMenu(_: ?*anyopaque) anyerror!void { if (menuItem(@src(), "Help", .{ .submenu = true }, .{ .expand = .horizontal, .color_text = dvui.themeGet().color(.control, .text), @@ -354,8 +370,6 @@ pub fn draw() !dvui.App.Result { fw.close(); } } - - return .ok; } pub fn menuItemWithHotkey(src: std.builtin.SourceLocation, label_str: []const u8, hotkey: dvui.enums.Keybind, enabled: bool, init_opts: dvui.MenuItemWidget.InitOptions, opts: dvui.Options) ?dvui.Rect.Natural { @@ -438,3 +452,13 @@ pub fn menuItemWithChevron(src: std.builtin.SourceLocation, label_str: []const u return ret; } + +/// Draw registered menu sections for an open parent menu. +pub fn drawMenuSections(parent_menu_id: []const u8) !void { + for (fizzy.editor.host.menu_sections.items) |*section| { + if (!std.mem.eql(u8, section.parent_menu_id, parent_menu_id)) continue; + section.draw(section.ctx) catch |err| { + dvui.log.err("Menu section '{s}' failed: {any}", .{ section.id, err }); + }; + } +} diff --git a/src/editor/PluginLoader.zig b/src/editor/PluginLoader.zig new file mode 100644 index 00000000..951fd98d --- /dev/null +++ b/src/editor/PluginLoader.zig @@ -0,0 +1,286 @@ +//! Native runtime loader for Fizzy plugin dylibs. +//! +//! Opens a prebuilt plugin library, checks the SDK ABI fingerprint and version, and calls +//! `fizzy_plugin_register`. The returned `std.DynLib` must stay open for the app's lifetime. +//! +//! **Native targets only.** Wasm imports `PluginLoader_stub.zig` instead. +const std = @import("std"); +const builtin = @import("builtin"); + +const sdk = @import("sdk"); +const Host = sdk.Host; +const dylib_api = sdk.dylib; +const dvui_context = sdk.dvui_context; +const version = sdk.version; + +/// Zig 0.16.0's `std.DynLib` dropped Windows support; this thin wrapper restores it for +/// Windows while delegating elsewhere. Shape matches `std.DynLib.{open, close, lookup}`. +pub const DynLib = if (builtin.os.tag == .windows) WindowsDynLib else std.DynLib; + +const WindowsDynLib = struct { + const windows = std.os.windows; + + extern "kernel32" fn LoadLibraryW(lpLibFileName: [*:0]const u16) callconv(.winapi) ?windows.HMODULE; + extern "kernel32" fn GetProcAddress(hModule: windows.HMODULE, lpProcName: [*:0]const u8) callconv(.winapi) ?*anyopaque; + extern "kernel32" fn FreeLibrary(hLibModule: windows.HMODULE) callconv(.winapi) windows.BOOL; + + handle: windows.HMODULE, + + pub const Error = error{ FileNotFound, InvalidUtf8 }; + + pub fn open(path: []const u8) Error!WindowsDynLib { + var buf: [windows.PATH_MAX_WIDE:0]u16 = undefined; + const len = std.unicode.wtf8ToWtf16Le(buf[0..], path) catch return error.InvalidUtf8; + if (len >= buf.len) return error.FileNotFound; + buf[len] = 0; + const wide_path: [*:0]const u16 = buf[0..len :0].ptr; + const handle = LoadLibraryW(wide_path) orelse return error.FileNotFound; + return .{ .handle = handle }; + } + + pub fn close(self: *WindowsDynLib) void { + _ = FreeLibrary(self.handle); + self.* = undefined; + } + + pub fn lookup(self: *WindowsDynLib, comptime T: type, name: [:0]const u8) ?T { + if (GetProcAddress(self.handle, name.ptr)) |sym| { + return @as(T, @ptrCast(@alignCast(sym))); + } + return null; + } +}; + +pub const LoadError = error{ + DylibOpenFailed, + AbiFingerprintSymbolMissing, + RegisterSymbolMissing, + SetGlobalsSymbolMissing, + SetDvuiContextSymbolMissing, + SetRenderBridgeSymbolMissing, + SdkVersionSymbolMissing, + AbiMismatch, + SdkVersionMismatch, + PluginIdMismatch, + RegisterRejected, +}; + +pub const PluginVersionInfo = struct { + plugin_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + built_with_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + min_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + declared_id: ?[]const u8 = null, +}; + +pub const LoadedLib = struct { + lib: DynLib, + path: []const u8, + /// Declared plugin id from the dylib (must match filename basename). + plugin_id: []const u8, + version_info: PluginVersionInfo = .{}, + set_globals: dylib_api.SetGlobalsFn, + set_dvui_context: dvui_context.SetContextFn, + set_render_bridge: sdk.render_bridge.SetRenderBridgeFn, +}; + +/// Host-owned pointers injected into the plugin image immediately before `register`. +pub const PreRegister = struct { + gpa: ?*const std.mem.Allocator = null, + arg_b: ?*anyopaque = null, + arg_c: ?*anyopaque = null, +}; + +/// Platform-specific plugin dylib extension. +pub fn pluginExtension() []const u8 { + return switch (builtin.os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; +} + +/// `{name}.{ext}` — flat layout under `{dir}/plugins/`. +pub fn pluginFilename(name: []const u8, allocator: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(allocator, "{s}.{s}", .{ name, pluginExtension() }); +} + +/// `{exe_dir}/plugins/{name}.{ext}` +pub fn builtinPluginPath( + allocator: std.mem.Allocator, + exe_dir: []const u8, + name: []const u8, +) ![]const u8 { + const file_name = try pluginFilename(name, allocator); + defer allocator.free(file_name); + return std.fs.path.join(allocator, &.{ exe_dir, "plugins", file_name }); +} + +/// Resolve a plugin dylib path: `FIZZY_PLUGIN_PATH` when set, else the built-in layout above. +pub fn resolvePluginPath( + allocator: std.mem.Allocator, + exe_dir: []const u8, + builtin_name: []const u8, +) ![]const u8 { + if (std.process.Environ.getAlloc(nativeEnviron(), allocator, "FIZZY_PLUGIN_PATH")) |override| { + return override; + } else |_| {} + return builtinPluginPath(allocator, exe_dir, builtin_name); +} + +fn nativeEnviron() std.process.Environ { + if (builtin.os.tag == .windows) { + return .{ .block = .global }; + } + var n: usize = 0; + while (std.c.environ[n] != null) : (n += 1) {} + const slice: [:null]const ?[*:0]const u8 = @as([*:null]const ?[*:0]const u8, @ptrCast(std.c.environ))[0..n :null]; + return .{ .block = .{ .slice = slice } }; +} + +fn lookupVersionFn(lib: *DynLib, symbol: [:0]const u8) ?dylib_api.GetSdkVersionFn { + return lib.lookup(dylib_api.GetSdkVersionFn, symbol); +} + +fn lookupPluginIdFn(lib: *DynLib, symbol: [:0]const u8) ?dylib_api.GetPluginIdFn { + return lib.lookup(dylib_api.GetPluginIdFn, symbol); +} + +fn readVersionTriplet(get_fn: ?dylib_api.GetSdkVersionFn) std.SemanticVersion { + if (get_fn) |f| { + return dylib_api.semverFromTriplet(f()); + } + return .{ .major = 0, .minor = 0, .patch = 0 }; +} + +pub fn loadAndRegister( + host: *Host, + path: []const u8, + expected_id: []const u8, + pre: ?PreRegister, +) LoadError!LoadedLib { + var lib = DynLib.open(path) catch return error.DylibOpenFailed; + errdefer lib.close(); + + const abi_fp_fn = lib.lookup( + dylib_api.GetAbiFingerprintFn, + dylib_api.symbol_abi_fingerprint, + ) orelse return error.AbiFingerprintSymbolMissing; + const plugin_fp = abi_fp_fn(); + if (!dylib_api.fingerprintMatches(plugin_fp)) { + if (allowAbiWarn()) { + std.log.warn("plugin '{s}': ABI fingerprint mismatch (host 0x{x}, plugin 0x{x}) — loading anyway (FIZZY_PLUGIN_ABI_WARN)", .{ + expected_id, + dylib_api.abi_fingerprint, + plugin_fp, + }); + } else { + return error.AbiMismatch; + } + } + + const get_sdk_version = lookupVersionFn(&lib, dylib_api.symbol_sdk_version); + const get_min_sdk = lookupVersionFn(&lib, dylib_api.symbol_min_sdk_version); + const get_plugin_version = lookupVersionFn(&lib, dylib_api.symbol_plugin_version); + const get_plugin_id = lookupPluginIdFn(&lib, dylib_api.symbol_plugin_id); + + const built_with = readVersionTriplet(get_sdk_version); + const min_sdk = readVersionTriplet(get_min_sdk); + const plugin_version = readVersionTriplet(get_plugin_version); + + if (get_min_sdk != null and !version.sdkVersionSatisfies(version.sdk_version, min_sdk)) { + return error.SdkVersionMismatch; + } + + if (get_plugin_id) |id_fn| { + const declared = std.mem.span(id_fn()); + if (!std.mem.eql(u8, declared, expected_id)) return error.PluginIdMismatch; + } + + const set_globals = lib.lookup( + dylib_api.SetGlobalsFn, + dylib_api.symbol_set_globals, + ) orelse return error.SetGlobalsSymbolMissing; + + const reg_fn = lib.lookup( + *const fn (?*Host) callconv(.c) u32, + dylib_api.symbol_register, + ) orelse return error.RegisterSymbolMissing; + + const set_ctx = lib.lookup( + dvui_context.SetContextFn, + dylib_api.symbol_set_dvui_context, + ) orelse return error.SetDvuiContextSymbolMissing; + + const set_bridge = lib.lookup( + sdk.render_bridge.SetRenderBridgeFn, + dylib_api.symbol_set_render_bridge, + ) orelse return error.SetRenderBridgeSymbolMissing; + + if (pre) |inject| { + set_globals( + if (inject.gpa) |gpa| @ptrCast(gpa) else null, + inject.arg_b, + inject.arg_c, + ); + } + + const status: dylib_api.RegisterStatus = @enumFromInt(reg_fn(host)); + switch (status) { + .ok => {}, + .err_abi_mismatch => return error.AbiMismatch, + .err_sdk_version => return error.SdkVersionMismatch, + else => return error.RegisterRejected, + } + + return .{ + .lib = lib, + .path = path, + .plugin_id = expected_id, + .version_info = .{ + .plugin_version = plugin_version, + .built_with_sdk_version = built_with, + .min_sdk_version = min_sdk, + .declared_id = if (get_plugin_id) |f| std.mem.span(f()) else null, + }, + .set_globals = set_globals, + .set_dvui_context = set_ctx, + .set_render_bridge = set_bridge, + }; +} + +fn allowAbiWarn() bool { + if (builtin.mode != .Debug) return false; + if (std.c.getenv("FIZZY_PLUGIN_ABI_WARN")) |v| { + return std.mem.eql(u8, std.mem.span(v), "1"); + } + return false; +} + +/// Best-effort read of version exports from a dylib (for failure diagnostics). +pub fn probeVersionInfo(path: []const u8) ?PluginVersionInfo { + var lib = DynLib.open(path) catch return null; + defer lib.close(); + const get_sdk_version = lookupVersionFn(&lib, dylib_api.symbol_sdk_version); + const get_min_sdk = lookupVersionFn(&lib, dylib_api.symbol_min_sdk_version); + const get_plugin_version = lookupVersionFn(&lib, dylib_api.symbol_plugin_version); + return .{ + .plugin_version = readVersionTriplet(get_plugin_version), + .built_with_sdk_version = readVersionTriplet(get_sdk_version), + .min_sdk_version = readVersionTriplet(get_min_sdk), + }; +} + +test "builtin plugin path joins exe_dir/plugins" { + const path = try builtinPluginPath(std.testing.allocator, "/app", "pixi"); + defer std.testing.allocator.free(path); + switch (builtin.os.tag) { + .windows => try std.testing.expectEqualStrings("/app/plugins/pixi.dll", path), + .macos => try std.testing.expectEqualStrings("/app/plugins/pixi.dylib", path), + else => try std.testing.expectEqualStrings("/app/plugins/pixi.so", path), + } +} + +test "sdk version satisfy" { + try std.testing.expect(version.sdkVersionSatisfies(.{ .major = 0, .minor = 2, .patch = 0 }, .{ .major = 0, .minor = 1, .patch = 5 })); + try std.testing.expect(!version.sdkVersionSatisfies(.{ .major = 0, .minor = 0, .patch = 9 }, .{ .major = 0, .minor = 1, .patch = 0 })); +} diff --git a/src/editor/PluginLoader_stub.zig b/src/editor/PluginLoader_stub.zig new file mode 100644 index 00000000..6ef28fc1 --- /dev/null +++ b/src/editor/PluginLoader_stub.zig @@ -0,0 +1,29 @@ +//! Wasm stub — dynamic plugin loading is native-only (no `dlopen` in the browser; web plugins +//! are statically linked). The shell still references these types in cross-platform code +//! (e.g. the Settings → Plugins list), so `LoadedLib` mirrors the read-shape of the real +//! `PluginLoader.LoadedLib`. On wasm `loaded_plugin_libs` is always empty, so the values are +//! never produced — only the type has to satisfy those field accesses. +const std = @import("std"); + +pub const LoadError = error{Unsupported}; + +pub const PluginVersionInfo = struct { + plugin_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + built_with_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + min_sdk_version: std.SemanticVersion = .{ .major = 0, .minor = 0, .patch = 0 }, + declared_id: ?[]const u8 = null, +}; + +pub const LoadedLib = struct { + path: []const u8, + plugin_id: []const u8 = "", + version_info: PluginVersionInfo = .{}, +}; + +pub fn resolvePluginPath(_: std.mem.Allocator, _: []const u8, _: []const u8) ![]const u8 { + return error.Unsupported; +} + +pub fn loadAndRegister(_: anytype, _: []const u8) LoadError!void { + return error.Unsupported; +} diff --git a/src/editor/Settings.zig b/src/editor/Settings.zig index 83a012df..b506a23d 100644 --- a/src/editor/Settings.zig +++ b/src/editor/Settings.zig @@ -12,26 +12,9 @@ pub const autosave_timeout_ns: i128 = 500 * 1_000_000; pub var parsed: ?std.json.Parsed(Settings) = null; -pub const InputScheme = enum { auto, mouse, trackpad }; - -/// Resolved zoom/pan control style after applying `auto` (`dvui.getMouseTypeHint`). -pub const ResolvedPanZoomScheme = enum { - mouse, - trackpad, -}; pub const FlipbookView = enum { sequential, grid }; pub const Compatibility = enum { none, ldtk }; -/// How sprite-cell transparency (checkerboard) is tinted behind the canvas. -pub const TransparencyEffect = enum { - /// Uniform default tone only (no hue gradient). - none, - /// Mouse-smoothed corner gradient (current default). - rainbow, - /// Per-cell tone shifted toward the animation’s palette color (when the sprite belongs to an animation). - animation, -}; - /// The ratio of the explorer to the artboard. explorer_ratio: f32 = 0.35, @@ -42,38 +25,15 @@ min_window_size: [2]f32 = .{ 640, 480 }, initial_window_size: [2]f32 = .{ 1280, 720 }, -/// Zoom/pan control scheme (`auto` picks mouse vs trackpad gestures from `dvui.getMouseTypeHint` after scroll events). -input_scheme: InputScheme = .auto, - /// Touch or long-press duration (ms) before a context menu opens instead of a normal click. hold_menu_duration_ms: u32 = 500, -/// Whether or not to show rulers on each canvas. -show_rulers: bool = true, - -/// Sprites panel: when true, show side cards in the cover-flow strip; when false, -/// fly them away for single-card focus (snap scroll) -scrolling_cards: bool = true, - /// When true, print frame/draw perf stats to the console (Debug / ReleaseSafe only for tick stats). perf_logging: bool = false, /// Pretend an app update is available (badge + launch toast). Restart after toggling. debug_simulate_update_available: bool = false, -/// Padding to include in the size of the ruler outside of the font height. -ruler_padding: f32 = 4.0, - -/// Setting to control overall zoom sensitivity -/// 0 - 1 -zoom_sensitivity: f32 = 1.0, - -/// Predetermined zoom steps, each is pixel perfect. -zoom_steps: [23]f32 = [_]f32{ 0.125, 0.167, 0.2, 0.25, 0.333, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 18, 28, 38, 50, 70, 90, 128, 256, 512 }, - -/// Maximum file size -max_file_size: [2]i32 = .{ 4096, 4096 }, - /// Maximum number of recents before removing oldest max_recents: usize = 10, @@ -84,41 +44,21 @@ theme: []const u8 = default_theme, font_body_size: f32 = 9, font_title_size: f32 = 9, font_heading_size: f32 = 8, -font_mono_size: f32 = 10, - -/// Color for the even squares of the checkerboard pattern -checker_color_even: [4]u8 = .{ 255, 255, 255, 255 }, -/// Color for the odd squares of the checkerboard pattern -checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 }, +font_mono_size: f32 = 9, /// Opacity of the background window /// CURRENTLY ONLY SUPPORTED ON MACOS and Windows window_opacity_dark: f32 = 0.7, window_opacity_light: f32 = 0.3, -content_opacity: f32 = 0.7, -/// Checkerboard / transparency tint behind sprites (grid cells). -transparency_effect: TransparencyEffect = .none, +/// Opacity of the content area (also drives plugin panes that match the shell chrome). +content_opacity: f32 = 0.7, titlebar_height: f32 = 26.0, // This is the height of the titlebar in pixels /// Empty strip below the top window edge (non-macOS), above the main title row (in-window menu, etc.). titlebar_top_buffer: f32 = 10.0, -pub fn resolvedPanZoomScheme(settings: *const Settings) ResolvedPanZoomScheme { - return switch (settings.input_scheme) { - .auto => switch (dvui.mouseType()) { - // Use runtime platform detection so macOS web users get the trackpad - // default. `builtin.os.tag == .macos` is false on wasm32-freestanding. - .unknown => if (fizzy.platform.isMacOS()) .trackpad else .mouse, - .mouse => .mouse, - .trackpad => .trackpad, - }, - .mouse => .mouse, - .trackpad => .trackpad, - }; -} - fn default(allocator: std.mem.Allocator) !Settings { return .{ .theme = try allocator.dupe(u8, default_theme), @@ -133,6 +73,7 @@ pub fn setThemeName(settings: *Settings, allocator: std.mem.Allocator, name: []c } /// Loads settings (`theme` is always heap-owned after successful return — see `setThemeName` / `deinit`). +/// Unknown keys (e.g. the "plugins" object, parsed separately by `loadPluginStore`) are ignored. pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings { // Wasm: no on-disk config; `fizzy.fs.read` uses `Io.Dir.cwd()` (posix.AT). if (comptime builtin.target.cpu.arch == .wasm32) return default(allocator); @@ -157,13 +98,105 @@ pub fn load(allocator: std.mem.Allocator, path: []const u8) !Settings { return result; } -pub fn save(settings: *Settings, allocator: std.mem.Allocator, path: []const u8) !void { - const str = try std.json.Stringify.valueAlloc(allocator, settings, .{}); +/// Serialize the shell settings plus the opaque per-plugin store into a single +/// settings.json document: `{ , "plugins": { : , … } }`. The +/// plugin blobs are already-serialized JSON objects, spliced in verbatim — the shell +/// never interprets them. +pub fn serialize( + settings: *const Settings, + plugin_settings: *const std.StringArrayHashMapUnmanaged([]const u8), + allocator: std.mem.Allocator, +) ![]u8 { + const fields = try std.json.Stringify.valueAlloc(allocator, settings, .{}); + defer allocator.free(fields); + // `fields` is a `{…}` object with at least one member, so dropping the trailing + // brace and appending `,"plugins":{…}}` always yields valid JSON. + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(allocator); + try out.appendSlice(allocator, fields[0 .. fields.len - 1]); + try out.appendSlice(allocator, ",\"plugins\":{"); + var first = true; + var it = plugin_settings.iterator(); + while (it.next()) |e| { + if (!first) try out.append(allocator, ','); + first = false; + const key = try std.json.Stringify.valueAlloc(allocator, e.key_ptr.*, .{}); + defer allocator.free(key); + try out.appendSlice(allocator, key); + try out.append(allocator, ':'); + try out.appendSlice(allocator, e.value_ptr.*); + } + try out.appendSlice(allocator, "}}"); + return out.toOwnedSlice(allocator); +} + +pub fn save( + settings: *Settings, + plugin_settings: *const std.StringArrayHashMapUnmanaged([]const u8), + allocator: std.mem.Allocator, + path: []const u8, +) !void { + const str = try serialize(settings, plugin_settings, allocator); defer allocator.free(str); try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = str }); } +/// Populate `store` (id -> owned JSON blob) from the "plugins" object in settings.json. +/// One-time migration: a legacy flat settings.json (no "plugins" object) seeds the +/// pixel-art blob from the whole root so its moved fields (show_rulers, input_scheme, …) +/// survive the format change — pixel art ignores unknown keys, and the next save rewrites +/// the blob cleanly. +pub fn loadPluginStore( + allocator: std.mem.Allocator, + path: []const u8, + store: *std.StringArrayHashMapUnmanaged([]const u8), +) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + const data = fizzy.fs.read(allocator, dvui.io, path) catch return; + defer allocator.free(data); + + var parsed_v = std.json.parseFromSlice(std.json.Value, allocator, data, .{}) catch return; + defer parsed_v.deinit(); + + const root = switch (parsed_v.value) { + .object => |o| o, + else => return, + }; + + if (root.get("plugins")) |plugins_val| { + switch (plugins_val) { + .object => |plugins| { + var it = plugins.iterator(); + while (it.next()) |e| { + const blob = std.json.Stringify.valueAlloc(allocator, e.value_ptr.*, .{}) catch continue; + const key = allocator.dupe(u8, e.key_ptr.*) catch { + allocator.free(blob); + continue; + }; + store.put(allocator, key, blob) catch { + allocator.free(key); + allocator.free(blob); + }; + } + return; + }, + else => {}, + } + } + + // Legacy flat settings.json: seed the pixel-art blob from the whole root. + const legacy_blob = std.json.Stringify.valueAlloc(allocator, parsed_v.value, .{}) catch return; + const key = allocator.dupe(u8, "pixi") catch { + allocator.free(legacy_blob); + return; + }; + store.put(allocator, key, legacy_blob) catch { + allocator.free(key); + allocator.free(legacy_blob); + }; +} + pub fn deinit(settings: *Settings, allocator: std.mem.Allocator) void { allocator.free(settings.theme); defer parsed = null; diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index d2cebba4..521995dc 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -5,7 +5,7 @@ const dvui = @import("dvui"); const App = fizzy.App; const Editor = fizzy.Editor; -const Pane = @import("explorer/Explorer.zig").Pane; +const SidebarView = fizzy.sdk.SidebarView; pub const Sidebar = @This(); @@ -32,28 +32,21 @@ pub fn draw(_: Sidebar) !Action { }); defer vbox.deinit(); - const options = [_]struct { pane: Pane, icon: []const u8 }{ - .{ .pane = .files, .icon = dvui.entypo.folder }, - .{ .pane = .tools, .icon = dvui.entypo.pencil }, - .{ .pane = .sprites, .icon = dvui.entypo.grid }, - //.{ .pane = .animations, .icon = dvui.entypo.controller_play }, - //.{ .pane = .keyframe_animations, .icon = dvui.entypo.key }, - .{ .pane = .project, .icon = dvui.entypo.box }, - .{ .pane = .settings, .icon = dvui.entypo.cog }, - }; - var ret: Action = .none; - for (options) |option| { - const a = try drawOption(option.pane, option.icon, 20); + // One icon per registered sidebar view (plugins contribute these; the shell + // owns none of them itself). Registration order is the display order. + for (fizzy.editor.host.sidebar_views.items, 0..) |*view, i| { + if (view.hidden) continue; + const a = try drawOption(view, i, 20); if (a != .none) ret = a; } return ret; } -fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { - const selected = option == fizzy.editor.explorer.pane; +fn drawOption(view: *const SidebarView, index: usize, size: f32) !Action { + const selected = fizzy.editor.host.isActiveSidebarView(view.id); var ret: Action = .none; const theme = dvui.themeGet(); @@ -61,7 +54,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { var bw: dvui.ButtonWidget = undefined; bw.init(@src(), .{}, .{ - .id_extra = @intFromEnum(option), + .id_extra = index, .min_size_content = .{ .h = size }, }); defer bw.deinit(); @@ -80,16 +73,17 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { dvui.icon( @src(), - @tagName(option), - icon, + view.id, + view.icon, .{ .fill_color = color }, .{ + .id_extra = index, .min_size_content = .{ .h = size }, }, ); if (bw.clicked()) { - // Tapping the icon for the pane that's already showing toggles the explorer + // Tapping the icon for the view that's already showing toggles the explorer // closed (same effect as the floating collapse button). We *report* the intent // here; Editor.zig invokes `peekClose` / `open` after `editor.explorer.paned` has // been recreated for this frame. Doing the call directly here would dereference @@ -98,7 +92,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { if (selected and explorer_visible) { ret = .close; } else { - fizzy.editor.explorer.pane = option; + fizzy.editor.host.setActiveSidebarView(view.id); ret = .open; } dvui.refresh(null, @src(), null); @@ -110,7 +104,7 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { .active_rect = bw.data().rectScale().r, .delay = 350_000, }, .{ - .id_extra = @intFromEnum(option), + .id_extra = index, .color_fill = dvui.themeGet().color(.window, .fill), .border = dvui.Rect.all(0), .box_shadow = .{ @@ -144,7 +138,8 @@ fn drawOption(option: Pane, icon: []const u8, size: f32) !Action { .background = false, .padding = dvui.Rect.all(4), }); - tl2.format("{s}", .{fizzy.Editor.Explorer.title(option, true)}, .{ + const tip = std.ascii.allocUpperString(dvui.currentWindow().arena(), view.title) catch view.title; + tl2.format("{s}", .{tip}, .{ .font = dvui.Font.theme(.heading), }); tl2.deinit(); diff --git a/src/editor/WebFileIo.zig b/src/editor/WebFileIo.zig index 2582e00d..29c21f0b 100644 --- a/src/editor/WebFileIo.zig +++ b/src/editor/WebFileIo.zig @@ -46,7 +46,7 @@ pub fn showOpenFileDialog( ) void { if (comptime builtin.target.cpu.arch != .wasm32) return; open_callback = cb; - open_grouping = fizzy.editor.open_workspace_grouping; + open_grouping = fizzy.editor.currentGroupingID(); open_picker_id = dvui.Id.extendId(null, @src(), 0); dvui.dialogWasmFileOpenMultiple(open_picker_id.?, .{ .accept = open_accept }); } @@ -79,19 +79,12 @@ pub fn pollOpenPicker(editor: *fizzy.Editor) void { defer fizzy.app.allocator.free(bytes); const path_owned = fizzy.app.allocator.dupe(u8, wasm_file.name) catch continue; - if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |file| { - editor.open_files.put(fizzy.app.allocator, file.id, file) catch { - var f = file; - f.deinit(); - fizzy.app.allocator.free(path_owned); - }; - if (editor.open_files.getIndex(file.id)) |idx| { + if (editor.openFileFromBytes(path_owned, bytes, open_grouping)) |doc_id| { + if (editor.open_files.getIndex(doc_id)) |idx| { editor.setActiveFile(idx); editor.pending_composite_warmup = true; } - } else |_| { - fizzy.app.allocator.free(path_owned); - } + } else |_| {} } open_callback = null; diff --git a/src/editor/Workspace.zig b/src/editor/Workspace.zig deleted file mode 100644 index a2b0e5de..00000000 --- a/src/editor/Workspace.zig +++ /dev/null @@ -1,2433 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); - -const dvui = @import("dvui"); -const fizzy = @import("../fizzy.zig"); -const icons = @import("icons"); - -const App = fizzy.App; -const Editor = fizzy.Editor; - -/// Workspaces are drawn recursively inside of the explorer paned widget -/// second pane, and contains drag/drop enabled tabs. Tabs can freely be dragged to -/// panes or other tab bars. -/// Workspaces can potentially draw open files, the project logo, or the project pane -/// containing the packed atlas. -pub const Workspace = @This(); - -open_file_index: usize = 0, -grouping: u64 = 0, -center: bool = false, - -tabs_drag_index: ?usize = null, -tabs_removed_index: ?usize = null, -tabs_insert_before_index: ?usize = null, - -columns_drag_name: []const u8 = undefined, -columns_drag_index: ?usize = null, -columns_target_id: ?dvui.Id = null, -columns_target_index: ?usize = null, -columns_removed_index: ?usize = null, -columns_insert_before_index: ?usize = null, - -rows_drag_name: []const u8 = undefined, -rows_drag_index: ?usize = null, -rows_target_id: ?dvui.Id = null, -rows_target_index: ?usize = null, -rows_removed_index: ?usize = null, -rows_insert_before_index: ?usize = null, - -horizontal_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, -vertical_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, - -horizontal_ruler_height: f32 = 0.0, -vertical_ruler_width: f32 = 0.0, - -/// Floating Edit-pill quick-access bar collapse state. Starts collapsed (single -/// hamburger button); the user toggles to expand the full action row. -edit_pill_expanded: bool = false, - -/// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during -/// `drawCanvas` / `drawProject`. `null` until the workspace has rendered at least once. Used -/// by the editor-level load/save toast overlays to center cards over the area the user is -/// actually looking at (rather than the OS window rect). -canvas_rect_physical: ?dvui.Rect.Physical = null, - -pub fn init(grouping: u64) Workspace { - return .{ - .grouping = grouping, - .columns_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "column_drag_{d}", .{grouping}) catch "column_drag", - .rows_drag_name = std.fmt.allocPrint(fizzy.app.allocator, "row_drag_{d}", .{grouping}) catch "row_drag", - }; -} - -const handle_size = 10; -const handle_dist = 60; - -const opacity = 60; - -const color_0 = fizzy.math.Color.initBytes(0, 0, 0, 0); -const color_1 = fizzy.math.Color.initBytes(230, 175, 137, opacity); -const color_2 = fizzy.math.Color.initBytes(216, 145, 115, opacity); -const color_3 = fizzy.math.Color.initBytes(41, 23, 41, opacity); -const color_4 = fizzy.math.Color.initBytes(194, 109, 92, opacity); -const color_5 = fizzy.math.Color.initBytes(180, 89, 76, opacity); - -const logo_colors: [12]fizzy.math.Color = [_]fizzy.math.Color{ - color_1, color_1, color_1, - color_2, color_2, color_3, - color_4, color_3, color_0, - color_3, color_0, color_0, -}; - -var dragging: bool = false; - -pub fn draw(self: *Workspace) !dvui.App.Result { - defer self.columns_drag_index = null; - defer self.rows_drag_index = null; - - // Process the column reorder, when both fields are set and we can take action - defer self.processColumnReorder(); - defer self.processRowReorder(); - - // Canvas Area - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .gravity_y = 0.0, - .id_extra = @intCast(self.grouping), - }); - defer vbox.deinit(); - - // Set the active workspace grouping when the user clicks on the workspace rect - for (dvui.events()) |*e| { - if (!vbox.matchEvent(e)) { - continue; - } - - if (e.evt == .mouse) { - if (e.evt.mouse.action == .press or (e.evt.mouse.action == .position and e.evt.mouse.mod.matchBind("ctrl/cmd"))) { - fizzy.editor.open_workspace_grouping = self.grouping; - } - } - } - - if (fizzy.editor.explorer.pane == .project) { - self.drawProject(); - } else { - self.drawTabs(); - try self.drawCanvas(); - } - - return .ok; -} - -/// Same `@src()` for every call so DVUI sees one stable id when switching between `drawCanvas` and -/// `drawProject` (avoids first-frame min-size / layout flash). Use `grouping` so multi-workspace panes stay distinct. -fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { - return dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = background, - .color_fill = content_color, - .id_extra = @intCast(grouping), - }); -} - -/// Rounded “card” behind the project empty state and the homepage. Shared id base + `grouping` so -/// switching project tab ↔ file pane (no open files) does not create a new widget each time. -fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { - return dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = content_color, - .corner_radius = dvui.Rect.all(16), - .margin = .{ .y = 10 }, - .id_extra = @intCast(grouping), - }); -} - -fn drawProject(self: *Workspace) void { - var content_color = dvui.themeGet().color(.window, .fill); - - switch (builtin.os.tag) { - .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - else => {}, - } - - const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) - fizzy.packer.atlas != null - else - fizzy.editor.folder != null and fizzy.packer.atlas != null; - - // Match `drawCanvas`: no outer fill when showing centered card (transparency shows through like homepage). - var canvas_vbox = workspaceMainCanvasVbox(content_color, show_packed_atlas, self.grouping); - defer { - self.canvas_rect_physical = canvas_vbox.data().contentRectScale().r; - dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); - canvas_vbox.deinit(); - } - - if (show_packed_atlas) { - const atlas = &fizzy.packer.atlas.?; - var image_widget = fizzy.dvui.ImageWidget.init(@src(), .{ - .source = atlas.source, - .canvas = &atlas.canvas, - .grouping = self.grouping, - }, .{ - .id_extra = @intCast(self.grouping), - .expand = .both, - .background = false, - .color_fill = .transparent, - }); - defer image_widget.deinit(); - - image_widget.processEvents(); - - if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { - if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { - fizzy.dvui.ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); - } - } - } else { - var box = workspaceEmptyStateCard(content_color, self.grouping); - defer box.deinit(); - - const alpha = dvui.alpha(1.0); - dvui.alphaSet(1.0); - defer dvui.alphaSet(alpha); - - const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) - "Pack open files to see the preview." - else if (fizzy.editor.folder == null) - "Open a project folder, then pack to see the preview." - else - "Pack the project to see the preview."; - - dvui.labelNoFmt( - @src(), - hint, - .{ .align_x = 0.5 }, - .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = dvui.themeGet().color(.control, .text), - .font = dvui.Font.theme(.body), - }, - ); - } -} - -fn drawTabs(self: *Workspace) void { - if (fizzy.editor.open_files.values().len == 0) return; - - // Handle dragging of tabs between workspace reorderables (tab bars) - defer self.processTabsDrag(); - - { - var tabs_anim = dvui.animate(@src(), .{ .duration = 500_000, .kind = .vertical, .easing = dvui.easing.outBack }, .{}); - defer tabs_anim.deinit(); - - var tabs_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .id_extra = @intCast(self.grouping), - }); - defer tabs_box.deinit(); - - var scroll_area = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .hide, .vertical_bar = .hide }, .{ - .expand = .none, - .background = false, - .corner_radius = dvui.Rect.all(0), - .id_extra = @intCast(self.grouping), - }); - defer scroll_area.deinit(); - - { - var tabs = dvui.reorder(@src(), .{ .drag_name = "tab_drag" }, .{ - .expand = .none, - .background = false, - }); - defer tabs.deinit(); - - var tabs_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .id_extra = @intCast(self.grouping), - }); - defer tabs_hbox.deinit(); - - const files = fizzy.editor.open_files.values(); - const files_len = files.len; - - // Find the neighbouring tabs (within this workspace grouping) of the active tab. - var prev_same_group_index: ?usize = null; - var next_same_group_index: ?usize = null; - - const active_in_this_group = blk: { - if (fizzy.editor.open_workspace_grouping != self.grouping) break :blk false; - if (self.open_file_index >= files_len) break :blk false; - if (files[self.open_file_index].editor.grouping != self.grouping) break :blk false; - break :blk true; - }; - - if (active_in_this_group) { - const active_index = self.open_file_index; - - // Scan left from the active tab to find the previous tab in this grouping. - var j: usize = active_index; - while (j > 0) { - j -= 1; - if (files[j].editor.grouping == self.grouping) { - prev_same_group_index = j; - break; - } - } - - // Scan right from the active tab to find the next tab in this grouping. - j = active_index + 1; - while (j < files_len) : (j += 1) { - if (files[j].editor.grouping == self.grouping) { - next_same_group_index = j; - break; - } - } - } - - for (files, 0..) |file, i| { - const is_fizzy_file = fizzy.Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); - - if (file.editor.grouping != self.grouping) continue; - - var reorderable = tabs.reorderable(@src(), .{}, .{ - .expand = .vertical, - .id_extra = i, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - }); - defer reorderable.deinit(); - - const selected = self.open_file_index == i and fizzy.editor.open_workspace_grouping == self.grouping; - - var anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{}); - defer anim.deinit(); - - var hbox: dvui.BoxWidget = undefined; - hbox.init(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .border = .all(0), - .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(fizzy.editor.settings.content_opacity), - .background = true, - .id_extra = i, - .padding = dvui.Rect.all(2), - .margin = dvui.Rect.all(0), - }); - - defer hbox.deinit(); - - const tab_hovered = fizzy.dvui.hovered(hbox.data()); - - if (selected) { - if (!reorderable.floating()) { - dvui.Path.stroke(.{ - .points = &.{ - hbox.data().rectScale().r.bottomLeft(), - hbox.data().rectScale().r.bottomRight(), - }, - }, .{ - .color = dvui.themeGet().color(.window, .text), - .thickness = 1, - }); - } - } - - if (reorderable.floating()) { - self.tabs_drag_index = i; - hbox.data().options.color_fill = dvui.themeGet().color(.control, .fill); - } - hbox.drawBackground(); - - if (!selected and active_in_this_group and tabs.drag_point == null) { - // Draw edge shadow between the active tab and its neighbours within this grouping. - if (prev_same_group_index) |prev_index| { - if (i == prev_index) { - // This tab is directly to the left of the active tab. - fizzy.dvui.drawEdgeShadow(hbox.data().rectScale(), .right, .{}); - } - } - - if (next_same_group_index) |next_index| { - if (i == next_index) { - // This tab is directly to the right of the active tab. - fizzy.dvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{}); - } - } - } - - if (reorderable.removed()) { - self.tabs_removed_index = i; - } else if (reorderable.insertBefore()) { - self.tabs_insert_before_index = i; - } - - if (is_fizzy_file) { - _ = fizzy.dvui.sprite(@src(), .{ - .source = fizzy.editor.atlas.source, - .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], - .scale = 2.0, - }, .{ - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - }); - } else { - dvui.icon(@src(), "file_icon", icons.tvg.lucide.file, .{ - .stroke_color = if (is_fizzy_file) .transparent else dvui.themeGet().color(.control, .text), - }, .{ - .gravity_y = 0.5, - .padding = dvui.Rect.all(4), - }); - } - - dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ - .color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), - .padding = dvui.Rect.all(4), - .gravity_y = 0.5, - }); - - const close_inner = fizzy.dvui.windowHeaderCloseInnerSide(); - const close_pad = fizzy.dvui.window_header_close_margin; - const tab_status_slot = close_inner + close_pad.x + close_pad.w; - - const status_close_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .gravity_y = 0.5, - .min_size_content = .{ .w = tab_status_slot, .h = tab_status_slot }, - }); - defer status_close_box.deinit(); - - // Saving has priority over hover/close/dirty indicators: the user wants visible - // confirmation that the save is in flight, and the slot's size matches the close - // button so the layout doesn't shift when saving starts/ends. `editor.saving` - // can be written by a background save worker (`saveZip`), so we read it with an - // atomic load — the write side uses an atomic store in matching `save*` paths. - const save_flash_elapsed = file.timeSinceSaveComplete(); - const save_in_check_phase = if (save_flash_elapsed) |elapsed| - fizzy.dvui.bubbleSpinnerSaveInCheckPhase(elapsed) - else - false; - const save_blocks_tab_close = file.isSaving() or - (file.showsSaveStatusIndicator() and !save_in_check_phase); - - if (save_blocks_tab_close) { - fizzy.dvui.bubbleSpinner(@src(), .{ - .id_extra = i *% 16 + 5, - .expand = .none, - .min_size_content = .{ .w = close_inner, .h = close_inner }, - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = dvui.themeGet().color(.window, .text), - }, .{ - .complete_elapsed_ns = save_flash_elapsed, - }); - } else if (save_in_check_phase and !tab_hovered) { - fizzy.dvui.bubbleSpinner(@src(), .{ - .id_extra = i *% 16 + 5, - .expand = .none, - .min_size_content = .{ .w = close_inner, .h = close_inner }, - .gravity_x = 0.5, - .gravity_y = 0.5, - .color_text = dvui.themeGet().color(.window, .text), - }, .{ - .complete_elapsed_ns = save_flash_elapsed, - }); - } else if (tab_hovered) { - var tab_close_button: dvui.ButtonWidget = undefined; - tab_close_button.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{ - .expand = .none, - .min_size_content = .{ .w = close_inner, .h = close_inner }, - .id_extra = i *% 16 + 1, - })); - defer tab_close_button.deinit(); - - tab_close_button.processEvents(); - tab_close_button.drawBackground(); - tab_close_button.drawFocus(); - - if (tab_close_button.hovered()) { - dvui.icon(@src(), "close", icons.tvg.lucide.x, .{ - .stroke_color = dvui.themeGet().color(.err, .fill).lighten(if (dvui.themeGet().dark) -10 else 10), - .fill_color = dvui.themeGet().color(.err, .fill).lighten(if (dvui.themeGet().dark) -10 else 10), - }, .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .id_extra = i *% 16 + 2, - }); - } - - if (tab_close_button.clicked()) { - fizzy.editor.closeFileID(file.id) catch |err| { - dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); - }; - break; - } - } else if (selected and !file.dirty()) { - const tab_text = dvui.themeGet().color(.window, .text); - var ghost_close: dvui.ButtonWidget = undefined; - ghost_close.init(@src(), .{ .draw_focus = false }, fizzy.dvui.windowHeaderCloseButtonOptions(.{ - .expand = .none, - .min_size_content = .{ .w = close_inner, .h = close_inner }, - .id_extra = i *% 16 + 3, - .style = .window, - .background = false, - .box_shadow = null, - .border = .all(0), - .color_fill = .transparent, - .color_fill_hover = .transparent, - .color_fill_press = .transparent, - .ninepatch_fill = &dvui.Ninepatch.none, - .ninepatch_hover = &dvui.Ninepatch.none, - .ninepatch_press = &dvui.Ninepatch.none, - })); - defer ghost_close.deinit(); - - ghost_close.processEvents(); - // Invisible hit target only — `drawBackground` would run theme ninepatch. - - dvui.icon(@src(), "close", icons.tvg.lucide.x, .{ - .stroke_color = tab_text, - .fill_color = tab_text, - }, .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .id_extra = i *% 16 + 4, - .background = false, - .border = .all(0), - .box_shadow = null, - .ninepatch_fill = &dvui.Ninepatch.none, - .ninepatch_hover = &dvui.Ninepatch.none, - .ninepatch_press = &dvui.Ninepatch.none, - }); - - if (ghost_close.clicked()) { - fizzy.editor.closeFileID(file.id) catch |err| { - dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); - }; - break; - } - } else if (file.dirty()) { - dvui.icon(@src(), "dirty_icon", icons.tvg.lucide.@"circle-small", .{ - .stroke_color = dvui.themeGet().color(.window, .text), - }, .{ - .gravity_x = 0.5, - .gravity_y = 0.5, - .padding = dvui.Rect.all(2), - .id_extra = i *% 16 + 0, - }); - } - - loop: for (dvui.events()) |*e| { - if (!hbox.matchEvent(e)) { - continue; - } - - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - fizzy.editor.setActiveFile(i); - dvui.refresh(null, @src(), hbox.data().id); - - e.handle(@src(), hbox.data()); - dvui.captureMouse(hbox.data(), e.num); - dvui.dragPreStart(me.p, .{ .size = reorderable.data().rectScale().r.size(), .offset = reorderable.data().rectScale().r.topLeft().diff(me.p) }); - } else if (me.action == .release and me.button.pointer()) { - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - } else if (me.action == .motion) { - if (dvui.captured(hbox.data().id)) { - e.handle(@src(), hbox.data()); - if (dvui.dragging(me.p, null)) |_| { - reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); // reorder grabs capture - break :loop; - } - } - } - }, - - else => {}, - } - } - } - if (tabs.finalSlot()) { - self.tabs_insert_before_index = fizzy.editor.open_files.values().len; - } - } - } -} - -pub fn processTabsDrag(self: *Workspace) void { - if (self.tabs_insert_before_index) |insert_before| { - if (self.tabs_removed_index) |removed| { // Dragging from this workspace - - if (removed > fizzy.editor.open_files.count()) return; - if (removed > insert_before) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.setActiveFile(insert_before); - } else { - if (insert_before > 0) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); - fizzy.editor.setActiveFile(insert_before - 1); - } else { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.setActiveFile(insert_before); - } - } - - self.tabs_removed_index = null; - self.tabs_insert_before_index = null; - } else { // Dragging from another workspace - for (fizzy.editor.workspaces.values()) |*workspace| { - if (workspace.tabs_removed_index) |removed| { - if (removed > insert_before) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - - fizzy.editor.open_files.values()[insert_before].editor.grouping = self.grouping; - fizzy.editor.setActiveFile(insert_before); - } else { - if (insert_before > 0) { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before - 1]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before - 1]); - fizzy.editor.open_files.values()[insert_before - 1].editor.grouping = self.grouping; - fizzy.editor.setActiveFile(insert_before - 1); - } else { - std.mem.swap(fizzy.Internal.File, &fizzy.editor.open_files.values()[removed], &fizzy.editor.open_files.values()[insert_before]); - std.mem.swap(u64, &fizzy.editor.open_files.keys()[removed], &fizzy.editor.open_files.keys()[insert_before]); - fizzy.editor.open_files.values()[insert_before].editor.grouping = self.grouping; - fizzy.editor.setActiveFile(insert_before); - } - } - - self.tabs_removed_index = null; - self.tabs_insert_before_index = null; - - workspace.tabs_removed_index = null; - workspace.tabs_insert_before_index = null; - } - } - } - } -} - -/// Repoint `open_file_index` on workspaces that were showing the dragged tab as active. -fn repointWorkspacesAfterTabDrag(editor: *Editor, tab_bar_workspace: ?*Workspace, drag_index: usize) void { - const dragged_file = &editor.open_files.values()[drag_index]; - if (tab_bar_workspace) |workspace| { - if (workspace.open_file_index == editor.open_files.getIndex(dragged_file.id)) { - for (editor.open_files.values()) |f| { - if (f.editor.grouping == workspace.grouping and f.id != dragged_file.id) { - workspace.open_file_index = editor.open_files.getIndex(f.id) orelse 0; - break; - } - } - } - } else { - for (editor.workspaces.values()) |*w| { - if (w.open_file_index == drag_index) { - for (editor.open_files.values()) |f| { - if (f.editor.grouping == w.grouping and f.id != dragged_file.id) { - w.open_file_index = editor.open_files.getIndex(f.id) orelse 0; - break; - } - } - } - } - } -} - -const WorkspaceTabDragSrc = union(enum) { - tab_bar: struct { ws: *Workspace, index: usize }, - tree_open: usize, - tree_closed: []const u8, - none, - - fn resolve(editor: *Editor) WorkspaceTabDragSrc { - for (editor.workspaces.values()) |*w| { - if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } }; - } - if (editor.tab_drag_from_tree_path) |p| { - if (editor.getFileFromPath(p)) |f| { - const idx = editor.open_files.getIndex(f.id) orelse return .none; - return .{ .tree_open = idx }; - } - return .{ .tree_closed = p }; - } - return .none; - } -}; - -/// Responsible for handling the cross-widget drag of tabs between multiple workspaces or between tabs and workspaces. -/// Also handles the same `tab_drag` from the Files tree (see `files.zig` + DVUI reorder_tree cross-widget pattern). -pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { - if (!dvui.dragName("tab_drag")) { - fizzy.editor.clearFileTreeTabDragDropState(); - return; - } - - const drag_src = WorkspaceTabDragSrc.resolve(fizzy.editor); - switch (drag_src) { - .none => return, - else => {}, - } - - events_loop: for (dvui.events()) |*e| { - if (!dvui.eventMatch(e, .{ .id = data.id, .r = data.rectScale().r, .drag_name = "tab_drag" })) continue; - - switch (drag_src) { - .none => unreachable, - .tab_bar => |tb| { - const workspace = tb.ws; - const drag_index = tb.index; - - var right_side = data.rectScale().r; - right_side.w /= 2; - right_side.x += right_side.w; - - if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) { - if (e.evt == .mouse and e.evt.mouse.action == .position) { - right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ - .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), - }); - } - - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - defer workspace.tabs_drag_index = null; - e.handle(@src(), data); - dvui.dragEnd(); - dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); - - repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; - dragged_file.editor.grouping = fizzy.editor.newGroupingID(); - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; - } - } else if (data.rectScale().r.contains(e.evt.mouse.p)) { - if (e.evt == .mouse and e.evt.mouse.action == .position) { - data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ - .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), - }); - } - - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - defer workspace.tabs_drag_index = null; - e.handle(@src(), data); - dvui.dragEnd(); - dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); - - repointWorkspacesAfterTabDrag(fizzy.editor, workspace, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; - dragged_file.editor.grouping = self.grouping; - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; - self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0; - } - } - }, - .tree_open => |drag_index| { - var right_side = data.rectScale().r; - right_side.w /= 2; - right_side.x += right_side.w; - - if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) { - if (e.evt == .mouse and e.evt.mouse.action == .position) { - right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ - .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), - }); - } - - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - e.handle(@src(), data); - dvui.dragEnd(); - dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); - - repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; - dragged_file.editor.grouping = fizzy.editor.newGroupingID(); - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; - } - } else if (data.rectScale().r.contains(e.evt.mouse.p)) { - if (e.evt == .mouse and e.evt.mouse.action == .position) { - data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ - .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), - }); - } - - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - e.handle(@src(), data); - dvui.dragEnd(); - dvui.refresh(null, @src(), data.id); - fizzy.editor.clearFileTreeTabDragDropState(); - - repointWorkspacesAfterTabDrag(fizzy.editor, null, drag_index); - var dragged_file = &fizzy.editor.open_files.values()[drag_index]; - dragged_file.editor.grouping = self.grouping; - fizzy.editor.open_workspace_grouping = dragged_file.editor.grouping; - self.open_file_index = fizzy.editor.open_files.getIndex(dragged_file.id) orelse 0; - } - } - }, - .tree_closed => |path| { - var right_side = data.rectScale().r; - right_side.w /= 2; - right_side.x += right_side.w; - - if (right_side.contains(e.evt.mouse.p) and fizzy.editor.workspaces.keys()[fizzy.editor.workspaces.keys().len - 1] == self.grouping) { - if (e.evt == .mouse and e.evt.mouse.action == .position) { - right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ - .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), - }); - } - - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - e.handle(@src(), data); - dvui.dragEnd(); - dvui.refresh(null, @src(), data.id); - const new_g = fizzy.editor.newGroupingID(); - const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, new_g) catch { - fizzy.editor.clearFileTreeTabDragDropState(); - continue :events_loop; - }; - if (maybe_idx) |idx| { - // File was already open and moved between groupings — repoint the - // workspaces that were showing it, and focus the new pane now. - repointWorkspacesAfterTabDrag(fizzy.editor, null, idx); - fizzy.editor.open_workspace_grouping = new_g; - } - // Else: async load — leave `open_workspace_grouping` alone. Switching - // to the not-yet-extant workspace would make `activeFile()` null and - // collapse the bottom panel mid-load; `processLoadingJobs` will focus - // the new pane once the worker lands the file, matching the - // "Open to the side" menu action. - fizzy.editor.clearFileTreeTabDragDropState(); - } - } else if (data.rectScale().r.contains(e.evt.mouse.p)) { - if (e.evt == .mouse and e.evt.mouse.action == .position) { - data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ - .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), - }); - } - - if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { - e.handle(@src(), data); - dvui.dragEnd(); - dvui.refresh(null, @src(), data.id); - const maybe_idx = fizzy.editor.openOrFocusFileAtGrouping(path, self.grouping) catch { - fizzy.editor.clearFileTreeTabDragDropState(); - continue :events_loop; - }; - if (maybe_idx) |idx| { - repointWorkspacesAfterTabDrag(fizzy.editor, null, idx); - self.open_file_index = idx; - } - // Else: async load into this workspace's existing grouping. The - // worker's `processLoadingJobs` focus handler will set the active - // file once it lands. - fizzy.editor.clearFileTreeTabDragDropState(); - } - } - }, - } - } -} - -pub fn drawCanvas(self: *Workspace) !void { - var content_color = dvui.themeGet().color(.window, .fill); - - switch (builtin.os.tag) { - .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - else => {}, - } - - const has_files = fizzy.editor.open_files.values().len > 0; - - var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, self.grouping); - defer { - self.canvas_rect_physical = canvas_vbox.data().contentRectScale().r; - dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); - canvas_vbox.deinit(); - } - defer self.processTabDrag(canvas_vbox.data()); - - if (has_files) { - if (self.open_file_index >= fizzy.editor.open_files.values().len) { - self.open_file_index = fizzy.editor.open_files.values().len - 1; - } - - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - file.editor.canvas.id = canvas_vbox.data().id; - file.editor.workspace = self; - - if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) { - defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .top, .{}); - self.drawRuler(.horizontal); - } - - var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); - defer canvas_hbox.deinit(); - - if (fizzy.editor.settings.show_rulers and !dvui.firstFrame(canvas_vbox.data().id)) { - defer fizzy.dvui.drawEdgeShadow(canvas_vbox.data().rectScale(), .left, .{}); - self.drawRuler(.vertical); - } - - self.drawTransformDialog(canvas_vbox); - self.drawEditPill(canvas_vbox); - // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). - self.drawSampleButton(canvas_vbox); - - if (self.grouping != file.editor.grouping) return; - - fizzy.perf.canvasPaneDrawn(); - - var file_widget = fizzy.dvui.FileWidget.init(@src(), .{ - .file = file, - .center = self.center, - }, .{ - .expand = .both, - .background = false, - .color_fill = .transparent, - }); - - defer file_widget.deinit(); - file_widget.processEvents(); - - if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { - if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { - fizzy.dvui.FileWidget.drawSampleMagnifier(file, data_pt); - } - } - } else { - var box = workspaceEmptyStateCard(content_color, self.grouping); - defer box.deinit(); - - // Make sure alpha is 1 before we draw the homepage, as the logo hover animation breaks if alpha is not 1 - const alpha = dvui.alpha(1.0); - dvui.alphaSet(1.0); - defer dvui.alphaSet(alpha); - - try self.drawHomePage(canvas_vbox); - } -} - -pub const RulerOrientation = enum { - horizontal, - vertical, -}; - -pub fn drawRuler(self: *Workspace, orientation: RulerOrientation) void { - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - const font = dvui.Font.theme(.body).larger(-1); - - const largest_label = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{file.rows - 1}) catch { - dvui.log.err("Failed to allocate largest label", .{}); - return; - }; - const largest_label_size = font.textSize(largest_label); - const natural_scale = dvui.currentWindow().natural_scale; - const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical); - const base_ruler_size = largest_label_size.w + fizzy.editor.settings.ruler_padding; - - const ruler_thickness: f32 = switch (orientation) { - .horizontal => blk: { - self.horizontal_ruler_height = font.textSize("M").h + fizzy.editor.settings.ruler_padding; - break :blk self.horizontal_ruler_height; - }, - .vertical => blk: { - self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + fizzy.editor.settings.ruler_padding); - break :blk self.vertical_ruler_width; - }, - }; - - switch (orientation) { - .horizontal => { - var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - }); - defer canvas_hbox.deinit(); - - var corner_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .min_size_content = .{ .h = self.vertical_ruler_width, .w = self.vertical_ruler_width }, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - corner_box.deinit(); - - var top_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .min_size_content = .{ .h = ruler_thickness, .w = ruler_thickness }, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - defer top_box.deinit(); - - self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, null); - }, - .vertical => { - var ruler_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .vertical, - .min_size_content = .{ .w = ruler_thickness, .h = 1.0 }, - .background = true, - .color_fill = dvui.themeGet().color(.window, .fill), - }); - defer ruler_box.deinit(); - - self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, largest_label_phys); - }, - } -} - -/// `largest_row_index_*` come from `drawRuler` (widest row index string and its measured size in physical pixels). -fn drawRulerContent( - self: *Workspace, - file: *fizzy.Internal.File, - font: dvui.Font, - orientation: RulerOrientation, - ruler_size: f32, - largest_row_index_label: []const u8, - largest_row_index_size_phys: ?dvui.Size.Physical, -) void { - const scale = file.editor.canvas.scale; - const canvas = file.editor.canvas; - - switch (orientation) { - .horizontal => { - self.horizontal_scroll_info.virtual_size.w = canvas.scroll_info.virtual_size.w; - self.horizontal_scroll_info.virtual_size.h = ruler_size; - self.horizontal_scroll_info.viewport.w = canvas.scroll_info.viewport.w; - self.horizontal_scroll_info.viewport.x = canvas.scroll_info.viewport.x; - }, - .vertical => { - self.vertical_scroll_info.virtual_size.h = canvas.scroll_info.virtual_size.h; - self.vertical_scroll_info.virtual_size.w = ruler_size; - self.vertical_scroll_info.viewport.h = canvas.scroll_info.viewport.h; - self.vertical_scroll_info.viewport.y = canvas.scroll_info.viewport.y; - }, - } - - const scroll_info = switch (orientation) { - .horizontal => &self.horizontal_scroll_info, - .vertical => &self.vertical_scroll_info, - }; - - var scroll_area = dvui.scrollArea(@src(), .{ - .scroll_info = scroll_info, - .container = true, - .process_events_after = true, - .horizontal_bar = .hide, - .vertical_bar = .hide, - }, .{ .expand = .both }); - defer scroll_area.deinit(); - - const scale_rect = switch (orientation) { - .horizontal => dvui.Rect{ .x = -canvas.origin.x, .y = 0, .w = 0, .h = 0 }, - .vertical => dvui.Rect{ .x = 0, .y = -canvas.origin.y, .w = 0, .h = 0 }, - }; - var scaler = dvui.scale(@src(), .{ .scale = &file.editor.canvas.scale }, .{ .rect = scale_rect }); - defer scaler.deinit(); - - const outer_rect: dvui.Rect = switch (orientation) { - .horizontal => .{ - .x = 0, - .y = 0, - .w = @as(f32, @floatFromInt(file.width())), - .h = ruler_size / scale, - }, - .vertical => .{ - .x = 0, - .y = 0, - .w = ruler_size / scale, - .h = @as(f32, @floatFromInt(file.height())), - }, - }; - var outer_box = dvui.box(@src(), .{ .dir = switch (orientation) { - .horizontal => .horizontal, - .vertical => .horizontal, - } }, .{ - .expand = .none, - .rect = outer_rect, - }); - defer outer_box.deinit(); - - const drag_name = switch (orientation) { - .horizontal => self.columns_drag_name, - .vertical => self.rows_drag_name, - }; - - var reorder = fizzy.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ - .expand = .both, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - .background = false, - .corner_radius = dvui.Rect.all(0), - }); - defer reorder.deinit(); - - const reorder_box_dir: dvui.enums.Direction = switch (orientation) { - .horizontal => .horizontal, - .vertical => .vertical, - }; - var reorder_box = dvui.box(@src(), .{ .dir = reorder_box_dir }, .{ - .expand = .both, - .background = false, - .corner_radius = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - defer reorder_box.deinit(); - - const ruler_stroke_color = dvui.themeGet().color(.control, .fill_hover).lighten(switch (orientation) { - .horizontal => 2.0, - .vertical => 0.0, - }); - - const edge_stroke_points = switch (orientation) { - .horizontal => .{ - reorder_box.data().rectScale().r.topRight(), - reorder_box.data().rectScale().r.bottomRight(), - }, - .vertical => .{ - reorder_box.data().rectScale().r.bottomRight(), - reorder_box.data().rectScale().r.bottomLeft(), - }, - }; - defer dvui.Path.stroke(.{ .points = &edge_stroke_points }, .{ - .color = ruler_stroke_color, - .thickness = 1.0, - }); - - const count = switch (orientation) { - .horizontal => file.columns, - .vertical => file.rows, - }; - const cell_min_size: dvui.Size = switch (orientation) { - .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 }, - .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) }, - }; - const reorder_mode: fizzy.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { - .horizontal => .any_y, - .vertical => .any_x, - }; - const reorder_expand: dvui.Options.Expand = switch (orientation) { - .horizontal => .vertical, - .vertical => .horizontal, - }; - - // Shared layout width for every row tick (widest index string); actual glyph size may differ per cell. - const vertical_row_layout_size_phys: ?dvui.Size.Physical = switch (orientation) { - .vertical => largest_row_index_size_phys, - .horizontal => null, - }; - - // Captured during iteration: the highlighted target slot (drop location) screen rect. - var target_rs_screen: ?dvui.RectScale = null; - - var index: usize = 0; - while (index < count) : (index += 1) { - var reorderable = reorder.reorderable(@src(), .{ - .mode = reorder_mode, - .clamp_to_edges = true, - }, .{ - .expand = reorder_expand, - .id_extra = index, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .min_size_content = cell_min_size, - }); - defer reorderable.deinit(); - - if (reorderable.targetRectScale()) |trs| { - target_rs_screen = trs; - } - - var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill); - - if (fizzy.dvui.hovered(reorderable.data())) { - button_color = dvui.themeGet().color(.control, .fill_hover); - dvui.cursorSet(.hand); - } - - var cell_box: dvui.BoxWidget = undefined; - cell_box.init(@src(), .{ .dir = .horizontal }, .{ - .expand = .both, - .background = true, - .color_fill = button_color, - .id_extra = index, - }); - - switch (orientation) { - .horizontal => { - if (reorderable.floating()) { - self.columns_drag_index = index; - reorder.reorderable_size.h = 0.0; - dvui.cursorSet(.hand); - } - if (reorderable.removed()) self.columns_removed_index = index; - if (reorderable.insertBefore()) self.columns_insert_before_index = index; - if (reorderable.targetID()) |target_id| self.columns_target_id = target_id; - if (self.columns_drag_index) |_| { - var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); - mouse_pt.y = 0.0; - mouse_pt.x = std.math.clamp(mouse_pt.x, 0.0, @as(f32, @floatFromInt(file.width() - 1))); - self.columns_target_index = file.columnIndex(mouse_pt); - } - }, - .vertical => { - if (reorderable.floating()) { - self.rows_drag_index = index; - reorder.reorderable_size.w = 0.0; - dvui.cursorSet(.hand); - } - if (reorderable.removed()) self.rows_removed_index = index; - if (reorderable.insertBefore()) self.rows_insert_before_index = index; - if (reorderable.targetID()) |target_id| self.rows_target_id = target_id; - if (self.rows_drag_index) |_| { - var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); - mouse_pt.x = 0.0; - mouse_pt.y = std.math.clamp(mouse_pt.y, 0.0, @as(f32, @floatFromInt(file.height() - 1))); - self.rows_target_index = file.rowIndex(mouse_pt); - } - }, - } - - { - defer cell_box.deinit(); - - // The dragged item's cell_box is parented to the reorderable's floating widget - // (rendered at the mouse position). We collapse that floating widget to h/w = 0 - // above, but `dvui.renderText` is not clipped by that, so the label would still - // appear at the cursor. Skip the visible cell rendering entirely while floating; - // the dragged label is drawn over the highlighted target slot below instead. - if (!reorderable.floating()) { - cell_box.drawBackground(); - - const label = switch (orientation) { - .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(index)) catch { - dvui.log.err("Failed to allocate label", .{}); - return; - }, - .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{index}) catch { - dvui.log.err("Failed to allocate label", .{}); - return; - }, - }; - - self.drawRulerLabel(.{ - .font = font, - .label = label, - .rect = cell_box.data().rectScale().r, - .color = dvui.themeGet().color(.control, .text).opacity(0.5), - .mode = switch (orientation) { - .horizontal => .horizontal, - .vertical => .vertical, - }, - .largest_label = if (orientation == .vertical) largest_row_index_label else null, - .ref_size_physical = vertical_row_layout_size_phys, - }); - - const cell_rect = cell_box.data().rectScale().r; - const cell_stroke_points = switch (orientation) { - .horizontal => .{ cell_rect.topLeft(), cell_rect.bottomLeft() }, - .vertical => .{ cell_rect.topLeft(), cell_rect.topRight() }, - }; - dvui.Path.stroke(.{ .points = &cell_stroke_points }, .{ .color = ruler_stroke_color, .thickness = 2.0 }); - } - - loop: for (dvui.events()) |*e| { - if (!cell_box.matchEvent(e)) continue; - - switch (e.evt) { - .mouse => |me| { - if (me.action == .press and me.button.pointer()) { - e.handle(@src(), cell_box.data()); - dvui.captureMouse(cell_box.data(), e.num); - dvui.dragPreStart(me.p, .{ - .size = reorderable.data().rectScale().r.size(), - .offset = reorderable.data().rectScale().r.topLeft().diff(me.p), - }); - } else if (me.action == .release and me.button.pointer()) { - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - switch (orientation) { - .horizontal => self.columns_drag_index = null, - .vertical => self.rows_drag_index = null, - } - } else if (me.action == .motion) { - if (dvui.captured(cell_box.data().id)) { - e.handle(@src(), cell_box.data()); - if (dvui.dragging(me.p, null)) |_| { - reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); - break :loop; - } - } - } - }, - else => {}, - } - } - } - } - - const final_slot_id = switch (orientation) { - .horizontal => file.columns, - .vertical => file.rows, - }; - if (reorder.needFinalSlot()) { - var reorderable = reorder.reorderable(@src(), .{ - .mode = reorder_mode, - .last_slot = true, - .clamp_to_edges = true, - }, .{ - .expand = reorder_expand, - .id_extra = final_slot_id, - .padding = dvui.Rect.all(0), - .margin = dvui.Rect.all(0), - .min_size_content = cell_min_size, - }); - defer reorderable.deinit(); - - if (reorderable.targetRectScale()) |trs| { - target_rs_screen = trs; - } - - if (reorderable.insertBefore()) { - switch (orientation) { - .horizontal => self.columns_insert_before_index = final_slot_id, - .vertical => self.rows_insert_before_index = final_slot_id, - } - } - } - - // Drag overlay: draw the dragged column/row label on the highlighted target slot in - // highlight-text color (no extra fill, the reorderable's own focus fill is the - // background) and a thick err-colored marker line at the dragged-from position in the - // ruler that lines up with the equivalent indicator in the file canvas. - const drag_idx_for_overlay = switch (orientation) { - .horizontal => self.columns_drag_index, - .vertical => self.rows_drag_index, - }; - if (drag_idx_for_overlay) |di| { - const target_idx_opt = switch (orientation) { - .horizontal => self.columns_target_index, - .vertical => self.rows_target_index, - }; - const same_slot = target_idx_opt == di; - - if (target_rs_screen) |trs| { - const drag_label_opt: ?[]const u8 = switch (orientation) { - .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(di)) catch null, - .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{di}) catch null, - }; - if (drag_label_opt) |drag_label| { - if (same_slot) { - // Reorderable still draws theme focus fill for the drop target; paint control - // hover on top so "no move" matches ruler button hover styling. - trs.r.fill(.all(0), .{ .color = dvui.themeGet().color(.control, .fill_hover), .fade = 1.0 }); - } - self.drawRulerLabel(.{ - .font = font, - .label = drag_label, - .rect = trs.r, - .color = if (same_slot) - dvui.themeGet().color(.control, .text).opacity(0.5) - else - dvui.themeGet().color(.highlight, .text), - .mode = switch (orientation) { - .horizontal => .horizontal, - .vertical => .vertical, - }, - .largest_label = if (orientation == .vertical) largest_row_index_label else null, - .ref_size_physical = vertical_row_layout_size_phys, - }); - } - } - - // Use the canvas data->screen mapping for the cross-axis position so the marker - // line aligns exactly with the err indicator drawn over the file canvas grid. - // The other axis uses the ruler's own screen extents so the line fills the ruler. - const target_idx_for_line = switch (orientation) { - .horizontal => self.columns_target_index, - .vertical => self.rows_target_index, - }; - if (target_idx_for_line) |ti| { - if (di != ti) { - const removed_data_rect = switch (orientation) { - .horizontal => file.columnRect(di), - .vertical => file.rowRect(di), - }; - const removed_canvas_screen = file.editor.canvas.screenFromDataRect(removed_data_rect); - const ruler_screen = outer_box.data().contentRectScale().r; - const err_color = dvui.themeGet().color(.err, .fill); - const thickness = 3.0 * dvui.currentWindow().natural_scale; - switch (orientation) { - .horizontal => { - const edge_x = if (di < ti) - removed_canvas_screen.x - else - removed_canvas_screen.x + removed_canvas_screen.w; - dvui.Path.stroke(.{ .points = &.{ - .{ .x = edge_x, .y = ruler_screen.y }, - .{ .x = edge_x, .y = ruler_screen.y + ruler_screen.h }, - } }, .{ .thickness = thickness, .color = err_color }); - }, - .vertical => { - const edge_y = if (di < ti) - removed_canvas_screen.y - else - removed_canvas_screen.y + removed_canvas_screen.h; - dvui.Path.stroke(.{ .points = &.{ - .{ .x = ruler_screen.x, .y = edge_y }, - .{ .x = ruler_screen.x + ruler_screen.w, .y = edge_y }, - } }, .{ .thickness = thickness, .color = err_color }); - }, - } - } - } - } -} - -pub const TextLabelOptions = struct { - pub const Mode = enum { - horizontal, - vertical, - }; - - font: dvui.Font, - label: []const u8, - rect: dvui.Rect.Physical, - color: dvui.Color, - mode: Mode = .horizontal, - /// Widest row index string (e.g. `"99"`); layout cell size uses this, text may be a shorter index. - largest_label: ?[]const u8 = null, - /// When set, layout size for that widest string (already × `natural_scale`); skips `textSize(largest_label)` per cell. - ref_size_physical: ?dvui.Size.Physical = null, -}; - -pub fn drawRulerLabel(_: *Workspace, options: TextLabelOptions) void { - const font = options.font; - const label = options.label; - const rect = options.rect; - const color = options.color; - const natural = dvui.currentWindow().natural_scale; - - const ref_for_layout = options.largest_label orelse label; - const label_size = options.ref_size_physical orelse font.textSize(ref_for_layout).scale(natural, dvui.Size.Physical); - const actual_label_size = if (std.mem.eql(u8, ref_for_layout, label)) - label_size - else - font.textSize(label).scale(natural, dvui.Size.Physical); - - const padding = fizzy.editor.settings.ruler_padding * natural; - - var label_rect = rect; - - if (label_size.w + padding <= label_rect.w and options.mode == .horizontal) { - label_rect.h = label_size.h + padding; - label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; - label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; - - dvui.renderText(.{ - .text = label, - .font = font, - .color = color, - .rs = .{ - .r = label_rect, - .s = natural, - }, - }) catch { - dvui.log.err("Failed to render text", .{}); - }; - } else if (label_size.h + padding <= label_rect.h and options.mode == .vertical) { - label_rect.w = label_size.h + padding; - label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; - label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; - - dvui.renderText(.{ - .text = label, - .font = font, - .color = color, - .rs = .{ - .r = label_rect, - .s = natural, - }, - }) catch { - dvui.log.err("Failed to render text", .{}); - }; - } -} - -pub fn processColumnReorder(self: *Workspace) void { - if (self.columns_removed_index) |columns_removed_index| { - if (self.columns_insert_before_index) |columns_insert_before_index| { - defer self.columns_removed_index = null; - defer self.columns_insert_before_index = null; - - if (columns_removed_index == columns_insert_before_index or columns_removed_index + 1 == columns_insert_before_index) return; - - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - - file.reorderColumns(columns_removed_index, columns_insert_before_index) catch { - dvui.log.err("Failed to reorder columns", .{}); - return; - }; - - // We'll store the previous indices for clarity. - const prev_removed_index = columns_removed_index; - const prev_insert_before_index = columns_insert_before_index; - - if (prev_removed_index < prev_insert_before_index) { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .columns, - .removed_index = prev_insert_before_index - 1, - .insert_before_index = prev_removed_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } else { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .columns, - .removed_index = prev_insert_before_index, - .insert_before_index = prev_removed_index + 1, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - } -} - -pub fn processRowReorder(self: *Workspace) void { - if (self.rows_removed_index) |rows_removed_index| { - if (self.rows_insert_before_index) |rows_insert_before_index| { - defer self.rows_removed_index = null; - defer self.rows_insert_before_index = null; - if (rows_removed_index == rows_insert_before_index or rows_removed_index + 1 == rows_insert_before_index) return; - - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - - file.reorderRows(rows_removed_index, rows_insert_before_index) catch { - dvui.log.err("Failed to reorder rows", .{}); - return; - }; - - // We'll store the previous indices for clarity. - const prev_removed_index = rows_removed_index; - const prev_insert_before_index = rows_insert_before_index; - - if (prev_removed_index < prev_insert_before_index) { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .rows, - .removed_index = prev_insert_before_index - 1, - .insert_before_index = prev_removed_index, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } else { - file.history.append(.{ - .reorder_col_row = .{ - .mode = .rows, - .removed_index = prev_insert_before_index, - .insert_before_index = prev_removed_index + 1, - }, - }) catch { - dvui.log.err("Failed to append history", .{}); - }; - } - } - } -} - -pub fn drawTransformDialog(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { - const file = &fizzy.editor.open_files.values()[self.open_file_index]; - if (file.editor.transform) |*transform| { - var rect = canvas_vbox.data().rect; - rect.w = 0; - rect.h = 0; - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ .x = canvas_vbox.data().rectScale().r.toNatural().x + 10, .y = canvas_vbox.data().rectScale().r.toNatural().y + 10, .w = 0, .h = 0 }, - .expand = .none, - .background = true, - .color_fill = dvui.themeGet().color(.control, .fill), - .corner_radius = dvui.Rect.all(8), - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 8, - .corner_radius = dvui.Rect.all(8), - }, - }); - defer fw.deinit(); - - var anim = dvui.animate(@src(), .{ .kind = .vertical, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); - defer anim.deinit(); - - var anim_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - }); - defer anim_box.deinit(); - - dvui.labelNoFmt(@src(), "TRANSFORM", .{ .align_x = 0.5 }, .{ - .padding = dvui.Rect.all(4), - .expand = .horizontal, - .font = dvui.Font.theme(.heading).withWeight(.bold), - }); - _ = dvui.separator(@src(), .{ .expand = .horizontal }); - - _ = dvui.spacer(@src(), .{ .expand = .horizontal }); - - var degrees: f32 = std.math.radiansToDegrees(transform.rotation); - - var slider_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .background = false, - }); - - if (dvui.sliderEntry(@src(), "{d:0.0}°", .{ - .value = °rees, - .min = 0, - .max = 360, - .interval = 1, - }, .{ .expand = .horizontal, .color_fill = dvui.themeGet().color(.window, .fill) })) { - transform.rotation = std.math.degreesToRadians(degrees); - } - slider_box.deinit(); - - if (transform.ortho) { - var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ - .expand = .horizontal, - .background = false, - }); - defer box.deinit(); - dvui.label(@src(), "Width: {d:0.0}", .{transform.point(.bottom_left).diff(transform.point(.bottom_right).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); - dvui.label(@src(), "Height: {d:0.0}", .{transform.point(.top_left).diff(transform.point(.bottom_left).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); - } - - { - var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ - .expand = .horizontal, - .background = false, - }); - defer box.deinit(); - if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) { - fizzy.editor.cancel() catch { - dvui.log.err("Failed to cancel transform", .{}); - }; - } - if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) { - fizzy.editor.accept() catch { - dvui.log.err("Failed to accept transform", .{}); - }; - } - } - } -} - -/// Floating rounded-pill quick-access bar anchored to the top-right of the workspace -/// canvas. Mirrors the Edit menu (Undo / Redo / Copy / Paste / Transform / Grid Layout) -/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a -/// single hamburger circle; tapping toggles the row of action buttons in/out with a -/// width animation. -pub fn drawEditPill(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { - const file = fizzy.editor.activeFile() orelse return; - - const button_size: f32 = 36; - const button_gap: f32 = 6; - const pill_padding: f32 = 6; - const margin: f32 = 10; - // Canvas scroll area uses a non-overlay vertical bar on the right edge; keep the - // pill clear of it (see `CanvasWidget.install` + dvui `ScrollBarWidget` width). - const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; - // Icons render at ~60% of their previous size — previous padding was 0.22 (icon - // ≈ 56% of button); new padding is 0.33 so the icon ends up ≈ 34% of the button, - // which is roughly 60% of the prior icon footprint. - const icon_padding: f32 = button_size * 0.33; - - const Action = enum { save, exportd, undo, redo, copy, paste, transform, grid_layout }; - const Entry = struct { - action: Action, - tvg: []const u8, - tooltip: []const u8, - }; - - const entries = [_]Entry{ - .{ .action = .save, .tvg = icons.tvg.lucide.save, .tooltip = "Save" }, - .{ .action = .exportd, .tvg = icons.tvg.lucide.@"file-output", .tooltip = "Export" }, - .{ .action = .undo, .tvg = icons.tvg.lucide.undo, .tooltip = "Undo" }, - .{ .action = .redo, .tvg = icons.tvg.lucide.redo, .tooltip = "Redo" }, - .{ .action = .copy, .tvg = icons.tvg.lucide.copy, .tooltip = "Copy" }, - .{ .action = .paste, .tvg = icons.tvg.lucide.@"clipboard-paste", .tooltip = "Paste" }, - .{ .action = .transform, .tvg = icons.tvg.lucide.scaling, .tooltip = "Transform" }, - .{ .action = .grid_layout, .tvg = icons.tvg.lucide.@"layout-grid", .tooltip = "Grid Layout" }, - }; - - // Vertical pill: width is fixed (one button + padding), height animates between a - // single-button "collapsed" state and the full-stack "expanded" state. Most screens - // have more vertical real estate than horizontal, so growing the pill downward keeps - // it from eating into the canvas's working width. - const pill_w: f32 = button_size + 2 * pill_padding; - const collapsed_h: f32 = button_size + 2 * pill_padding; - const expanded_h: f32 = @as(f32, @floatFromInt(entries.len + 1)) * button_size + - @as(f32, @floatFromInt(entries.len)) * button_gap + 2 * pill_padding; - const pill_radius: f32 = pill_w / 2; - const btn_radius: f32 = button_size / 2; - - // Drive the expand/collapse with a dvui animation. Look up the current value, and on - // a toggle click kick off a new animation between the current value and the target. - const anim_id = dvui.Id.update(canvas_vbox.data().id, "edit_pill_expand"); - var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; - if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0); - - const pill_h: f32 = collapsed_h + (expanded_h - collapsed_h) * anim_value; - - // Compute the scroll-area rect — the canvas region inside the rulers. We pull this - // off the live `canvas_vbox` (so the values are this frame's, not a stale latch) and - // subtract the ruler thickness from the top/left. Anchoring against this rect means - // the pill follows the workspace exactly: as a split is dragged shut the canvas area - // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — - // so closing splits cleanly hides the menu. - const wb = canvas_vbox.data().rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; - const canvas_nat = dvui.Rect{ - .x = wb.x + ruler_left, - .y = wb.y + ruler_top, - .w = wb.w - ruler_left, - .h = wb.h - ruler_top, - }; - - if (canvas_nat.w < pill_w + margin + right_margin or canvas_nat.h < collapsed_h + 2 * margin) return; - - const pill_x: f32 = canvas_nat.x + canvas_nat.w - right_margin - pill_w; - const pill_y: f32 = canvas_nat.y + margin; - - // Clamp the bottom edge so the expanded pill never spills past the canvas area — - // FloatingWidget bypasses parent clipping, so we cap the height explicitly. - const max_pill_h: f32 = canvas_nat.h - 2 * margin; - const effective_pill_h: f32 = @min(pill_h, max_pill_h); - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ - .x = pill_x, - .y = pill_y, - .w = pill_w, - .h = effective_pill_h, - }, - .expand = .none, - .background = self.edit_pill_expanded, - .color_fill = dvui.themeGet().color(.window, .fill), - .corner_radius = dvui.Rect.all(pill_radius), - .box_shadow = if (self.edit_pill_expanded) .{ - .color = .black, - .alpha = 0.25, - .fade = 10, - .offset = .{ .x = 0, .y = 3 }, - .corner_radius = dvui.Rect.all(pill_radius), - } else null, - }); - defer fw.deinit(); - - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .both, - .background = false, - .padding = dvui.Rect.all(pill_padding), - }); - defer vbox.deinit(); - - // Hamburger toggle is always present at the top of the pill; the stack of action - // buttons grows downward beneath it as the pill expands. - { - var btn: dvui.ButtonWidget = undefined; - btn.init(@src(), .{}, .{ - .id_extra = entries.len, // distinct from action button ids below - .min_size_content = .{ .w = button_size, .h = button_size }, - .expand = .none, - .gravity_x = 0.5, - .gravity_y = 0.0, - .background = true, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = dvui.themeGet().color(.content, .fill), - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - .padding = .all(0), - .margin = .{}, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corner_radius = dvui.Rect.all(btn_radius), - }, - }); - defer btn.deinit(); - btn.processEvents(); - btn.drawBackground(); - - const icon_color = dvui.themeGet().color(.content, .text); - dvui.icon( - @src(), - "edit_pill_toggle", - icons.tvg.lucide.menu, - .{ .stroke_color = icon_color, .fill_color = icon_color }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = dvui.Rect.all(icon_padding), - }, - ); - - if (btn.clicked()) { - self.edit_pill_expanded = !self.edit_pill_expanded; - const target: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; - dvui.animation(anim_id, "_t", .{ - .start_val = anim_value, - .end_val = target, - .end_time = 250_000, - .easing = dvui.easing.outBack, - }); - } - } - - // Action buttons live inside a scroll area so the pill stays the right width and - // never visually "squishes" when there isn't enough vertical room — instead the - // overflow buttons become reachable via vertical scroll inside the pill. Bars are - // hidden to preserve the rounded-pill look; touch / wheel still drives the scroll. - var actions_scroll = dvui.scrollArea(@src(), .{ - .vertical_bar = .hide, - .horizontal_bar = .hide, - }, .{ - .expand = .both, - .background = false, - .padding = .{}, - .margin = .{}, - .border = dvui.Rect.all(0), - .color_fill = .transparent, - }); - defer actions_scroll.deinit(); - - // Action buttons stacked below the hamburger. We draw them all and let the - // scrollArea handle any overflow when the pill is clamped to the canvas height. - for (entries, 0..) |entry, i| { - const enabled: bool = switch (entry.action) { - .save => file.dirty(), - .undo => file.history.undo_stack.items.len > 0, - .redo => file.history.redo_stack.items.len > 0, - else => true, - }; - - var btn: dvui.ButtonWidget = undefined; - btn.init(@src(), .{}, .{ - .id_extra = i, - .min_size_content = .{ .w = button_size, .h = button_size }, - .expand = .none, - .gravity_x = 0.5, - .background = true, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = dvui.themeGet().color(.content, .fill), - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - .padding = .all(0), - .margin = .{ .y = button_gap }, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corner_radius = dvui.Rect.all(btn_radius), - }, - }); - defer btn.deinit(); - btn.processEvents(); - btn.drawBackground(); - - const icon_color = if (enabled) dvui.themeGet().color(.content, .text) else dvui.themeGet().color(.content, .text).opacity(0.35); - - dvui.icon( - @src(), - entry.tooltip, - entry.tvg, - .{ .stroke_color = icon_color, .fill_color = icon_color }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = dvui.Rect.all(icon_padding), - }, - ); - - // Suppress activation while collapsed (or mid-animation) so a stray tap on a - // partially-visible button doesn't fire an Edit action behind the hamburger. - const fully_expanded = anim_value >= 0.999; - if (btn.clicked() and enabled and fully_expanded) { - switch (entry.action) { - .save => fizzy.editor.save() catch { - dvui.log.err("Failed to save", .{}); - }, - .exportd => { - // Open the Export dialog (same configuration the `export` keybind uses). - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.Export.dialog, - .callafterFn = fizzy.Editor.Dialogs.Export.callAfter, - .title = "Export...", - .ok_label = "Export", - .cancel_label = "Cancel", - .resizeable = false, - .modal = false, - .header_kind = .info, - .default = .ok, - }); - mutex.mutex.unlock(dvui.io); - }, - .undo => file.history.undoRedo(file, .undo) catch { - dvui.log.err("Failed to undo", .{}); - }, - .redo => file.history.undoRedo(file, .redo) catch { - dvui.log.err("Failed to redo", .{}); - }, - .copy => fizzy.editor.copy() catch { - dvui.log.err("Failed to copy", .{}); - }, - .paste => fizzy.editor.paste() catch { - dvui.log.err("Failed to paste", .{}); - }, - .transform => fizzy.editor.transform() catch { - dvui.log.err("Failed to start transform", .{}); - }, - .grid_layout => fizzy.editor.requestGridLayoutDialog(), - } - } - } -} - -/// Floating round button anchored just to the left of the Edit pill at the top-right of -/// the canvas. Tapping it shows a tooltip explaining the gesture; the primary action is -/// to drag from the button toward whatever pixel you want to sample. The button itself -/// stays put — instead, while the drag is in progress, we route the touch position -/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders -/// the existing color-dropper magnifier at the touch location. On release we read the -/// color underneath the sample point and apply it to the primary color slot. -pub fn drawSampleButton(self: *Workspace, canvas_vbox: *dvui.BoxWidget) void { - const file = fizzy.editor.activeFile() orelse return; - - const pill_button_size: f32 = 36; - const pill_padding: f32 = 6; - const pill_outer_w: f32 = pill_button_size + 2 * pill_padding; - const button_size: f32 = 36; - const btn_radius: f32 = button_size / 2; - const icon_padding: f32 = button_size * 0.33; - const margin: f32 = 10; - const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; - const gap: f32 = 6; - - // Anchor against the same canvas-scroll-area rect the pill uses. - const wb = canvas_vbox.data().rectScale().r.toNatural(); - const ruler_top: f32 = if (fizzy.editor.settings.show_rulers) self.horizontal_ruler_height else 0; - const ruler_left: f32 = if (fizzy.editor.settings.show_rulers) self.vertical_ruler_width else 0; - const canvas_nat = dvui.Rect{ - .x = wb.x + ruler_left, - .y = wb.y + ruler_top, - .w = wb.w - ruler_left, - .h = wb.h - ruler_top, - }; - - // Only draw when the canvas area can fit pill + gap + sample button + margins. - if (canvas_nat.w < pill_outer_w + gap + button_size + margin + right_margin) return; - if (canvas_nat.h < button_size + 2 * margin) return; - - const btn_x = canvas_nat.x + canvas_nat.w - right_margin - pill_outer_w - gap - button_size; - // Match the hamburger row inside the pill (pill top + inner vbox padding). - const btn_y = canvas_nat.y + margin + pill_padding; - - var fw: dvui.FloatingWidget = undefined; - fw.init(@src(), .{}, .{ - .rect = .{ .x = btn_x, .y = btn_y, .w = button_size, .h = button_size }, - .expand = .none, - .background = false, - }); - defer fw.deinit(); - - var btn: dvui.ButtonWidget = undefined; - // `touch_drag = true` keeps `ButtonWidget`'s own capture alive while the touch is - // dragging away from the button — without it, dvui's default `clickedEx` releases - // capture as soon as the drag crosses the threshold (treating the gesture as a - // canceled scroll), which would also cancel our custom drag-to-sample handler. - btn.init(@src(), .{ .touch_drag = true }, .{ - .expand = .both, - .background = true, - .min_size_content = .{ .w = button_size, .h = button_size }, - .corner_radius = dvui.Rect.all(btn_radius), - .color_fill = dvui.themeGet().color(.content, .fill), - .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), - .color_border = .transparent, - .padding = .all(0), - .margin = .{}, - .box_shadow = .{ - .color = .black, - .alpha = 0.2, - .fade = 4, - .offset = .{ .x = 0, .y = 2 }, - .corner_radius = dvui.Rect.all(btn_radius), - }, - }); - defer btn.deinit(); - - // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag - // threshold). Stored via dataSet because the button widget is recreated each frame. - const drag_state_id = dvui.Id.update(canvas_vbox.data().id, "sample_button_drag"); - var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false; - var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false; - - // The button's screen rect is the "press home base"; events that happen here belong - // to us regardless of whether motion has carried the pointer away. - const btn_rs = btn.data().rectScale(); - - // Custom event handling runs *before* `btn.processEvents()` so we can claim the - // press / motion / release events first. `ButtonWidget.clickedEx` ALWAYS releases - // mouse capture and ends the drag on a release event (regardless of touch_drag) — - // if we ran after it, our release branch would see `dvui.captured(...)` already - // false and the magnifier would stay stuck on screen. Calling `e.handle(...)` here - // makes `clickedEx`'s match-event check skip these events entirely, so the button - // leaves our gesture alone. - for (dvui.events()) |*e| { - if (e.evt != .mouse) continue; - const me = e.evt.mouse; - - switch (me.action) { - .press => { - if (!me.button.pointer()) continue; - if (!btn_rs.r.contains(me.p)) continue; - e.handle(@src(), btn.data()); - dvui.captureMouse(btn.data(), e.num); - dvui.dragPreStart(me.p, .{ .name = "sample_button_drag" }); - is_drag_sampling = false; - did_sample = false; - }, - .motion => { - if (!dvui.captured(btn.data().id)) continue; - if (dvui.dragging(me.p, "sample_button_drag")) |_| { - is_drag_sampling = true; - if (file.editor.canvas.samplePointerInViewport(me.p)) { - const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); - dvui.dataSet(null, file.editor.canvas.id, "sample_data_point", data_pt); - did_sample = true; - } else { - dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); - } - dvui.refresh(null, @src(), file.editor.canvas.id); - e.handle(@src(), btn.data()); - } - }, - .release => { - if (!me.button.pointer()) continue; - if (!dvui.captured(btn.data().id)) continue; - e.handle(@src(), btn.data()); - dvui.captureMouse(null, e.num); - dvui.dragEnd(); - - if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) { - const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); - fizzy.dvui.FileWidget.sampleColorAtPoint(file, data_pt, false, true, true); - } - - // Clear sample state so the magnifier disappears on the next frame. - dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); - is_drag_sampling = false; - did_sample = false; - dvui.refresh(null, @src(), file.editor.canvas.id); - }, - else => {}, - } - } - - // Persist the drag state for the next frame's widget recreate. - dvui.dataSet(null, drag_state_id, "active", is_drag_sampling); - dvui.dataSet(null, drag_state_id, "did_sample", did_sample); - - // Now let the button run its own pass to handle hover styling against any remaining - // (non-claimed) events — i.e. plain mouse hover when we're not in a drag. - btn.processEvents(); - btn.drawBackground(); - - const icon_color = dvui.themeGet().color(.content, .text); - dvui.icon( - @src(), - "sample_dropper", - icons.tvg.lucide.pipette, - .{ .stroke_color = icon_color, .fill_color = icon_color }, - .{ - .expand = .ratio, - .gravity_x = 0.5, - .gravity_y = 0.5, - .min_size_content = .{ .w = 1.0, .h = 1.0 }, - .padding = dvui.Rect.all(icon_padding), - }, - ); - - // While the drag is in progress, hide the OS cursor entirely so only the canvas - // magnifier (drawn at the touch point via `FileWidget.drawSample`) communicates - // where the sample is happening. Set after `btn.processEvents()` so it overrides - // the `.hand` hover cursor `clickedEx` would otherwise leave in place. - if (is_drag_sampling) { - dvui.cursorSet(.hidden); - } - - // Tooltip prompting the gesture. We hide it during an active sample drag so it - // doesn't compete with the magnifier on screen. - if (!is_drag_sampling) { - var tooltip: dvui.FloatingTooltipWidget = undefined; - tooltip.init(@src(), .{ - .active_rect = btn.data().rectScale().r, - .delay = 350_000, - }, .{ - .color_fill = dvui.themeGet().color(.window, .fill), - .border = dvui.Rect.all(0), - .box_shadow = .{ - .color = .black, - .shrink = 0, - .corner_radius = dvui.Rect.all(8), - .offset = .{ .x = 0, .y = 2 }, - .fade = 4, - .alpha = 0.2, - }, - }); - defer tooltip.deinit(); - - if (tooltip.shown()) { - var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both }); - defer anim.deinit(); - - var tl = dvui.textLayout(@src(), .{}, .{ - .background = false, - .padding = dvui.Rect.all(6), - }); - tl.format("Drag to sample color", .{}, .{ .font = dvui.Font.theme(.body) }); - tl.deinit(); - } - } -} - -pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { - const logo_pixel_size = 32; - const logo_width = 3; - const logo_height = 5; - - const logo_vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .gravity_x = 0.5, - .gravity_y = 0.5, - .background = false, - .padding = dvui.Rect.all(10), - }); - defer logo_vbox.deinit(); - - { // Logo - - const vbox2 = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .gravity_x = 0.5, - .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size * logo_height }, - .padding = dvui.Rect.all(20), - }); - defer vbox2.deinit(); - - for (0..4) |i| { - const hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size }, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - .id_extra = i, - }); - defer hbox.deinit(); - - for (0..3) |j| { - const index = i * logo_width + j; - var fizzy_color = logo_colors[index]; - - if (fizzy_color.value[3] < 1.0 and fizzy_color.value[3] > 0.0) { - const theme_bg = dvui.themeGet().color(.window, .fill); - fizzy_color = fizzy_color.lerp(fizzy.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]); - fizzy_color.value[3] = 1.0; - } - - const color = fizzy_color.bytes(); - - const pixel = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .none, - .min_size_content = .{ .w = logo_pixel_size, .h = logo_pixel_size }, - .id_extra = index, - .background = false, - .color_fill = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, - .margin = dvui.Rect.all(0), - .padding = dvui.Rect.all(0), - }); - - const rect = pixel.data().rect.outset(.{ .x = 0, .y = 0 }); - const rs = pixel.data().rectScale(); - pixel.deinit(); - - if (fizzy_color.value[3] <= 0.0) continue; - - try drawBubble(rect, rs, color, index); - } - } - } - - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .gravity_x = 0.5, - }); - - { - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{ .draw_focus = true }, .{ - .gravity_x = 0.5, - .expand = .horizontal, - .padding = dvui.Rect.all(2), - .color_fill = .transparent, - .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), - .color_fill_press = dvui.themeGet().color(.window, .fill_press), - }); - defer button.deinit(); - - button.processEvents(); - button.drawBackground(); - - fizzy.dvui.labelWithKeybind( - "New File", - dvui.currentWindow().keybinds.get("new_file") orelse .{}, - true, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - ); - - if (button.clicked()) { - fizzy.editor.requestNewFileDialog(); - } - } - { - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{ .draw_focus = true }, .{ - .gravity_x = 0.5, - .expand = .horizontal, - .padding = dvui.Rect.all(2), - .color_fill = .transparent, - .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), - .color_fill_press = dvui.themeGet().color(.window, .fill_press), - }); - defer button.deinit(); - - button.processEvents(); - button.drawBackground(); - - fizzy.dvui.labelWithKeybind( - "Open Folder", - dvui.currentWindow().keybinds.get("open_folder") orelse .{}, - true, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - ); - - if (button.clicked()) { - fizzy.backend.showOpenFolderDialog(setProjectFolderCallback, null); - } - } - - { - var button: dvui.ButtonWidget = undefined; - button.init(@src(), .{ .draw_focus = true }, .{ - .gravity_x = 0.5, - .expand = .horizontal, - .padding = dvui.Rect.all(2), - .color_fill = .transparent, - .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), - .color_fill_press = dvui.themeGet().color(.window, .fill_press), - }); - defer button.deinit(); - - button.processEvents(); - button.drawBackground(); - - fizzy.dvui.labelWithKeybind( - "Open Files", - dvui.currentWindow().keybinds.get("open_files") orelse .{}, - true, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, - .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0, .font = dvui.Font.theme(.heading) }, - ); - - if (button.clicked()) { - // if (try dvui.dialogNativeFileOpenMultiple(dvui.currentWindow().arena(), .{ - // .title = "Open Files...", - // .filter_description = ".pixi, .png", - // .filters = &.{ "*.pixi", "*.png" }, - // })) |files| { - // for (files) |file| { - // _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch { - // std.log.err("Failed to open file: {s}", .{file}); - // }; - // } - // } - - fizzy.backend.showOpenFileDialog(openFilesCallback, &.{ - .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" }, - }, "", null); - } - } - vbox.deinit(); - - const spacer = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .h = 30 } }); - - { - var recents_box = dvui.box(@src(), .{ .dir = .vertical }, .{ - .expand = .none, - .gravity_x = 0.5, - .max_size_content = .{ .h = (canvas_vbox.data().rect.h - spacer.rect.y) / 3.0, .w = canvas_vbox.data().rect.w / 2.0 }, - }); - defer recents_box.deinit(); - - var scroll_area = dvui.scrollArea(@src(), .{}, .{ - .expand = .both, - .color_border = dvui.themeGet().color(.control, .fill), - .corner_radius = dvui.Rect.all(8), - .color_fill = .transparent, - }); - defer scroll_area.deinit(); - - var i: usize = fizzy.editor.recents.folders.items.len; - while (i > 0) : (i -= 1) { - var anim = dvui.animate(@src(), .{ - .kind = .horizontal, - .duration = 150_000 + 150_000 * @as(i32, @intCast(i)), - .easing = dvui.easing.outBack, - }, .{ - .id_extra = i, - .expand = .horizontal, - }); - defer anim.deinit(); - - const folder = fizzy.editor.recents.folders.items[i - 1]; - if (dvui.button(@src(), folder, .{ - .draw_focus = false, - }, .{ - .expand = .horizontal, - .font = dvui.Font.theme(.mono).larger(-2.0), - .id_extra = i, - .margin = dvui.Rect.all(1), - .padding = dvui.Rect.all(2), - .color_fill = .transparent, - .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), - .color_fill_press = dvui.themeGet().color(.window, .fill_press), - .color_text = dvui.themeGet().color(.control, .text).opacity(0.5), - })) { - try fizzy.editor.setProjectFolder(folder); - } - } - } -} - -pub fn drawBubble(rect: dvui.Rect, rs: dvui.RectScale, color: [4]u8, _: usize) !void { - var bubble_h: f32 = rect.h; - for (dvui.events()) |evt| { - switch (evt.evt) { - .mouse => |me| { - const dx = @abs(me.p.x - (rs.r.x + rs.r.w * 0.5)) / rs.s; - const dy = @abs(me.p.y - (rs.r.y - rs.r.h * 0.5)) / rs.s; - const distance = @sqrt(dx * dx + dy * dy); - const max_distance: f32 = rect.h * 2.0; - - var t = distance / max_distance; - if (t > 1.0) t = 1.0; - if (t < 0.0) t = 0.0; - bubble_h = @ceil(rect.h - rect.h * t); - }, - else => {}, - } - } - - // Derive the pill's physical rect directly from the base's physical rect - // (no dvui.box layout round-trip). This guarantees identical left/right - // edges between base and pill at any scale or splitter ratio. - const base_phys = rs.r.outsetAll(1); - const bubble_h_phys = @ceil(bubble_h * rs.s); - const bubble_phys = dvui.Rect.Physical{ - .x = base_phys.x, - .y = rs.r.y - bubble_h_phys, - .w = base_phys.w, - .h = bubble_h_phys, - }; - - var path = dvui.Path.Builder.init(dvui.currentWindow().lifo()); - defer path.deinit(); - - path.addRect(base_phys, dvui.Rect.Physical.all(0)); - - if (bubble_phys.h > 0) { - const rad_x = rs.r.w / 2.0; - const rad_y = rs.r.h / 2.0; - const r = bubble_phys; - const tl = dvui.Point.Physical{ .x = r.x + rad_x, .y = r.y + rad_x }; - const bl = dvui.Point.Physical{ .x = r.x, .y = r.y + r.h }; - const br = dvui.Point.Physical{ .x = r.x + r.w, .y = r.y + r.h }; - const tr = dvui.Point.Physical{ .x = r.x + r.w - rad_y, .y = r.y + rad_y }; - path.addArc(tl, rad_x, dvui.math.pi * 1.5, dvui.math.pi, true); - path.addArc(bl, 0, dvui.math.pi, dvui.math.pi * 0.5, true); - path.addArc(br, 0, dvui.math.pi * 0.5, 0, true); - path.addArc(tr, rad_y, dvui.math.pi * 2.0, dvui.math.pi * 1.5, false); - } - - path.build().fillConvex(.{ .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, .fade = 1.0 }); -} - -// This should never be able to return more than one folder -pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void { - if (folder) |f| { - fizzy.editor.setProjectFolder(f[0]) catch { - dvui.log.err("Failed to set project folder: {s}", .{f[0]}); - }; - } -} - -pub fn openFilesCallback(files: ?[][:0]const u8) void { - if (files) |f| { - for (f) |file| { - _ = fizzy.editor.openFilePath(file, fizzy.editor.open_workspace_grouping) catch { - dvui.log.err("Failed to open file: {s}", .{file}); - }; - } - } -} diff --git a/src/editor/dialogs/AboutFizzy.zig b/src/editor/dialogs/AboutFizzy.zig index eb0b9313..8b15a4a2 100644 --- a/src/editor/dialogs/AboutFizzy.zig +++ b/src/editor/dialogs/AboutFizzy.zig @@ -3,8 +3,8 @@ const builtin = @import("builtin"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const build_opts = @import("build_opts"); -const auto_update = @import("../../auto_update.zig"); -const update_notify = @import("../../update_notify.zig"); +const auto_update = @import("../../backend/auto_update.zig"); +const update_notify = @import("../../backend/update_notify.zig"); const assets = @import("assets"); fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool { diff --git a/src/editor/dialogs/AppQuitUnsaved.zig b/src/editor/dialogs/AppQuitUnsaved.zig index 4246abff..48aafd04 100644 --- a/src/editor/dialogs/AppQuitUnsaved.zig +++ b/src/editor/dialogs/AppQuitUnsaved.zig @@ -31,8 +31,8 @@ pub fn request() void { fn dirtyCount() usize { var n: usize = 0; - for (fizzy.editor.open_files.values()) |f| { - if (f.dirty()) n += 1; + for (fizzy.editor.open_files.values()) |doc| { + if (doc.owner.isDirty(doc)) n += 1; } return n; } @@ -112,8 +112,8 @@ fn onSaveAllAndQuit() !void { fizzy.dvui.closeFloatingDialogAnchored(); fizzy.editor.quit_save_all_ids.clearRetainingCapacity(); - for (fizzy.editor.open_files.values()) |f| { - if (f.dirty()) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, f.id); + for (fizzy.editor.open_files.values()) |doc| { + if (doc.owner.isDirty(doc)) try fizzy.editor.quit_save_all_ids.append(fizzy.app.allocator, doc.id); } if (fizzy.editor.quit_save_all_ids.items.len == 0) { fizzy.editor.pending_app_close = true; diff --git a/src/editor/dialogs/Dialogs.zig b/src/editor/dialogs/Dialogs.zig index cdcf0ab2..13920b3d 100644 --- a/src/editor/dialogs/Dialogs.zig +++ b/src/editor/dialogs/Dialogs.zig @@ -1,16 +1,15 @@ -const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); const Dialogs = @This(); -pub const NewFile = @import("NewFile.zig"); -pub const Export = @import("Export.zig"); +// Plugin-owned dialogs (New File, Grid Layout, Export, Flat-raster save warning) are no longer +// re-exported here. The shell triggers them through plugin vtable hooks / `Host.requestNewDocument` +// so it never names a plugin's dialog implementation. This hub owns only shell-level dialogs. pub const UnsavedClose = @import("UnsavedClose.zig"); pub const AppQuitUnsaved = @import("AppQuitUnsaved.zig"); -pub const GridLayout = @import("GridLayout.zig"); -pub const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig"); pub const AboutFizzy = @import("AboutFizzy.zig"); +pub const PluginLoadFailures = @import("PluginLoadFailures.zig"); pub const WebFolderUnavailable = if (builtin.target.cpu.arch == .wasm32) @import("WebFolderUnavailable.zig") else @@ -30,75 +29,3 @@ else return false; } }; - -pub fn drawDimensionsLabel(src: std.builtin.SourceLocation, width: u32, height: u32, font: dvui.Font, unit: []const u8, opts: dvui.Options) void { - { - var hbox = dvui.box(src, .{ .dir = .horizontal }, opts); - defer hbox.deinit(); - - dvui.label( - src, - "{d}", - .{width}, - .{ - .font = font, - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 1.0, - .id_extra = 1, - }, - ); - - dvui.label( - src, - "{s}", - .{unit}, - .{ - .font = dvui.Font.theme(.body).withSize(font.size - 1.0), - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 2, - }, - ); - - dvui.label( - src, - "x", - .{}, - .{ - .font = dvui.Font.theme(.body).withSize(font.size - 1.0), - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 3, - }, - ); - - dvui.label( - src, - "{d}", - .{height}, - .{ - .font = font, - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 4, - }, - ); - - dvui.label( - src, - "{s}", - .{unit}, - .{ - .font = dvui.Font.theme(.body).withSize(font.size - 1.0), - .margin = .{ .x = 1, .w = 1 }, - .padding = .all(0), - .gravity_y = 0.5, - .id_extra = 5, - }, - ); - } -} diff --git a/src/editor/dialogs/PluginLoadFailures.zig b/src/editor/dialogs/PluginLoadFailures.zig new file mode 100644 index 00000000..8bb1f350 --- /dev/null +++ b/src/editor/dialogs/PluginLoadFailures.zig @@ -0,0 +1,111 @@ +//! Shown once at startup when one or more user plugins failed to load, so an author isn't +//! left guessing why their plugin didn't appear. Reads the recorded failures off the live +//! `fizzy.editor` (populated by `Editor.loadUserPlugins`); the shell calls `request()` after +//! user-plugin loading when `editor.failed_user_plugins` is non-empty. + +const std = @import("std"); +const dvui = @import("dvui"); +const sdk = @import("sdk"); +const fizzy = @import("../../fizzy.zig"); + +const version = sdk.version; +const dylib = sdk.dylib; + +pub fn request() void { + if (active(dvui.currentWindow())) return; + var mutex = fizzy.dvui.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .title = "Plugin Load Failures", + .ok_label = "", + .cancel_label = "", + .resizeable = false, + .default = .cancel, + .hide_footer = true, + .header_kind = .err, + }); + mutex.mutex.unlock(dvui.io); +} + +pub fn active(win: *dvui.Window) bool { + var it = win.dialogs.iterator(null); + while (it.next()) |d| { + const df = dvui.dataGet(null, d.id, "_displayFn", fizzy.dvui.DisplayFn) orelse continue; + if (df == dialog) return true; + } + return false; +} + +fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8) bool { + const opts: dvui.Options = .{ + .tab_index = 1, + .style = .control, + .box_shadow = .{ + .color = .black, + .alpha = 0.25, + .offset = .{ .x = -4, .y = 4 }, + .fade = 8, + }, + }; + var button: dvui.ButtonWidget = undefined; + button.init(src, .{}, opts); + defer button.deinit(); + button.processEvents(); + button.drawFocus(); + button.drawBackground(); + dvui.labelNoFmt(src, label_text, .{}, opts.strip().override(button.style()).override(.{ .gravity_x = 0.5, .gravity_y = 0.5 })); + return button.clicked(); +} + +pub fn dialog(_: dvui.Id) anyerror!bool { + var outer = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .padding = .all(12) }); + defer outer.deinit(); + + var host_line_buf: [96]u8 = undefined; + const host_line = std.fmt.bufPrint(&host_line_buf, "Host SDK {d}.{d}.{d} · ABI 0x{x}", .{ + version.sdk_version.major, + version.sdk_version.minor, + version.sdk_version.patch, + dylib.abi_fingerprint, + }) catch "Host SDK ?"; + + dvui.labelNoFmt( + @src(), + "Some installed plugins could not be loaded:", + .{}, + .{ .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 8 } }, + ); + dvui.labelNoFmt(@src(), host_line, .{}, .{ + .color_text = dvui.themeGet().color(.window, .text), + .margin = .{ .h = 4 }, + }); + + for (fizzy.editor.failed_user_plugins.items, 0..) |f, i| { + if (f.detail) |detail| { + dvui.label( + @src(), + "• {s} — {s} ({s})", + .{ f.id, f.reason, detail }, + .{ .id_extra = i, .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 4 } }, + ); + } else { + dvui.label( + @src(), + "• {s} — {s}", + .{ f.id, f.reason }, + .{ .id_extra = i, .color_text = dvui.themeGet().color(.window, .text), .margin = .{ .h = 4 } }, + ); + } + } + + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 0.5 }); + defer row.deinit(); + + if (dialogButton(@src(), "OK")) { + fizzy.dvui.closeFloatingDialogAnchored(); + } + + return true; +} + +pub fn callAfter(_: dvui.Id, _: dvui.enums.DialogResponse) anyerror!void {} diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index b8aa3466..bcf0dc1e 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -1,7 +1,6 @@ const std = @import("std"); const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const FlatRasterSaveWarning = @import("FlatRasterSaveWarning.zig"); pub fn request(file_id: u64) void { var mutex = fizzy.dvui.dialog(@src(), .{ @@ -21,8 +20,8 @@ pub fn request(file_id: u64) void { } fn fileBasename(file_id: u64) []const u8 { - const file = fizzy.editor.open_files.get(file_id) orelse return "?"; - return std.fs.path.basename(file.path); + const doc = fizzy.editor.docById(file_id) orelse return "?"; + return std.fs.path.basename(fizzy.editor.docPath(doc)); } fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool { @@ -93,12 +92,8 @@ fn onCancel() void { fizzy.dvui.closeFloatingDialogAnchored(); } -/// Start an async save for the file (`.fizzy` runs on a worker, PNG/JPG runs sync -/// on the GUI thread) and queue the close for once `File.isSaving()` clears. -/// `Editor.tickPendingSaveCloses` does the actual close on the next frame after -/// the worker settles, so the GUI thread never blocks on the save. -fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void { - if (file.isSaving()) return; +fn beginSaveAndClose(doc: fizzy.sdk.DocHandle, file_id: u64) !void { + if (doc.owner.isDocumentSaving(doc)) return; if (comptime @import("builtin").target.cpu.arch == .wasm32) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); @@ -106,13 +101,13 @@ fn beginSaveAndClose(file: *fizzy.Internal.File, file_id: u64) !void { fizzy.editor.requestWebSaveDialog(.save); return; } - try file.saveAsync(); + try doc.owner.saveDocumentAsync(doc); try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {}); } fn onSaveAndClose(file_id: u64) !void { - const file = fizzy.editor.open_files.getPtr(file_id) orelse return; - if (!fizzy.Internal.File.hasRecognizedSaveExtension(file.path)) { + const doc = fizzy.editor.docById(file_id) orelse return; + if (!doc.owner.documentHasRecognizedSaveExtension(doc)) { const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; fizzy.editor.setActiveFile(idx); fizzy.editor.pending_close_file_id = file_id; @@ -120,16 +115,12 @@ fn onSaveAndClose(file_id: u64) !void { fizzy.editor.requestSaveAs(); return; } - if (file.shouldConfirmFlatRasterSave()) { - FlatRasterSaveWarning.pending_from_save_all_quit = false; + if (doc.owner.saveNeedsConfirmation(doc)) { fizzy.dvui.closeFloatingDialogAnchored(); - FlatRasterSaveWarning.request(file_id, .save_and_close); + doc.owner.requestSaveConfirmation(doc, .save_and_close, false); return; } - beginSaveAndClose(file, file_id) catch |err| { - dvui.log.err("Save and Close failed: {s}", .{@errorName(err)}); - return; - }; + try beginSaveAndClose(doc, file_id); fizzy.dvui.closeFloatingDialogAnchored(); } diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 56bb771c..20bb8ec6 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -2,28 +2,24 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("../../fizzy.zig"); +const workbench = @import("workbench"); const icons = @import("icons"); const Core = @import("mach").Core; const App = fizzy.App; const Editor = fizzy.Editor; -const Packer = fizzy.Packer; const nfd = @import("nfd"); pub const Explorer = @This(); -pub const files = @import("files.zig"); -pub const Tools = @import("tools.zig"); -pub const Sprites = @import("sprites.zig"); +pub const files = workbench.files; // pub const animations = @import("animations.zig"); // pub const keyframe_animations = @import("keyframe_animations.zig"); -pub const project = @import("project.zig"); +// The pixel-art project view is contributed by the plugin via `Host.registerSidebarView`, +// not re-exported here. pub const settings = @import("settings.zig"); -sprites: Sprites = .{}, -tools: Tools = .{}, -pane: Pane = .files, paned: *fizzy.dvui.PanedWidget = undefined, scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, @@ -31,8 +27,6 @@ scroll_info: dvui.ScrollInfo = .{ rect: dvui.Rect = .{}, rect_screen: dvui.Rect.Physical = .{}, open_branches: std.AutoHashMap(dvui.Id, void) = undefined, -pinned_palettes: bool = false, -layers_ratio: f32 = 0.5, animations_ratio: f32 = 0.5, closed: bool = false, @@ -43,16 +37,6 @@ closed: bool = false, peek_open: bool = false, collapse_btn_anim_started: bool = false, -pub const Pane = enum(u32) { - files, - tools, - sprites, - animations, - keyframe_animations, - project, - settings, -}; - pub fn init() Explorer { return .{ .open_branches = .init(fizzy.app.allocator), @@ -64,18 +48,6 @@ pub fn deinit(self: *Explorer) void { self.open_branches.deinit(); } -pub fn title(pane: Pane, all_caps: bool) []const u8 { - return switch (pane) { - .files => if (all_caps) "FILES" else "Files", - .tools => if (all_caps) "TOOLS" else "Tools", - .sprites => if (all_caps) "SPRITES" else "Sprites", - .animations => if (all_caps) "ANIMATIONS" else "Animations", - .keyframe_animations => if (all_caps) "KEYFRAME ANIMATIONS" else "Keyframe Animations", - .project => if (all_caps) "PROJECT" else "Project", - .settings => if (all_caps) "SETTINGS" else "Settings", - }; -} - pub fn close(explorer: *Explorer) void { explorer.paned.animateSplit(0.0, dvui.easing.outQuint); explorer.closed = true; @@ -136,21 +108,14 @@ pub fn draw(explorer: *Explorer) !dvui.App.Result { .background = false, }); - if (explorer.pane != .files) { - fizzy.editor.file_tree_data_id = null; - if (fizzy.editor.tab_drag_from_tree_path) |p| { - fizzy.app.allocator.free(p); - fizzy.editor.tab_drag_from_tree_path = null; + if (comptime workbench.plugin.has_file_tree) { + if (!fizzy.editor.host.isActiveSidebarView(fizzy.Editor.workbench_files_view)) { + fizzy.editor.resetFileTreeWhenFilesHidden(); } } - switch (explorer.pane) { - .files => try files.draw(), - .settings => try settings.draw(), - .project => try project.draw(), - .tools => try explorer.tools.draw(), - .sprites => try explorer.sprites.draw(), - else => {}, + if (fizzy.editor.host.activeSidebarView()) |view| { + try view.draw(view.ctx); } const vertical_scroll = scroll.si.offset(.vertical); @@ -269,8 +234,9 @@ pub fn hovered(explorer: *Explorer) bool { return fizzy.dvui.hovered(explorer.paned.data()); } -pub fn drawHeader(explorer: *Explorer) !void { - const header_title = title(explorer.pane, true); +pub fn drawHeader(_: *Explorer) !void { + const view = fizzy.editor.host.activeSidebarView() orelse return; + const header_title = std.ascii.allocUpperString(dvui.currentWindow().arena(), view.title) catch view.title; dvui.labelNoFmt(@src(), header_title, .{}, .{ .font = dvui.Font.theme(.heading) }); } diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 8b7aba09..5141acf5 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -148,68 +148,6 @@ pub fn draw() !void { dvui.refresh(null, @src(), vbox.data().id); } - { - var dropdown: dvui.DropdownWidget = undefined; - dropdown.init(@src(), .{ .label = "Transparency effect" }, .{ - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - }); - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - const label_text = switch (fizzy.editor.settings.transparency_effect) { - .none => "None", - .rainbow => "Rainbow", - .animation => "Animation", - }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); - - dvui.icon( - @src(), - "dropdown_triangle", - dvui.entypo.triangle_down, - .{}, - .{ .gravity_y = 0.5 }, - ); - - hbox.deinit(); - - if (dropdown.dropped()) { - if (dropdown.addChoiceLabel("None")) { - fizzy.editor.settings.transparency_effect = .none; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Rainbow")) { - fizzy.editor.settings.transparency_effect = .rainbow; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Animation")) { - fizzy.editor.settings.transparency_effect = .animation; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - } - - if (dvui.checkbox(@src(), &fizzy.editor.settings.show_rulers, "Show Rulers", .{ - .expand = .none, - })) { - fizzy.editor.markSettingsDirty(); - } - - if (dvui.checkbox(@src(), &fizzy.editor.settings.scrolling_cards, "Show sprite cover-flow cards", .{ - .expand = .none, - })) { - fizzy.editor.markSettingsDirty(); - } } { @@ -218,62 +156,6 @@ pub fn draw() !void { }); defer box.deinit(); - { - var dropdown: dvui.DropdownWidget = undefined; - dropdown.init(@src(), .{ .label = "Control scheme" }, .{ - .expand = .horizontal, - .corner_radius = dvui.Rect.all(1000), - }); - defer dropdown.deinit(); - - var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .vertical, - .gravity_x = 1.0, - }); - - const label_text: []const u8 = switch (fizzy.editor.settings.input_scheme) { - .auto => switch (dvui.mouseType()) { - // Pre-classification (no scroll events seen yet) — drop the parenthetical - // entirely rather than showing "Auto (unknown)". - .unknown => "Auto", - .mouse, .trackpad => |hint| try std.fmt.allocPrint(dvui.currentWindow().lifo(), "Auto ({s})", .{@tagName(hint)}), - }, - .mouse => "Mouse", - .trackpad => "Trackpad", - }; - dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); - - dvui.icon( - @src(), - "dropdown_triangle", - dvui.entypo.triangle_down, - .{}, - .{ .gravity_y = 0.5 }, - ); - - hbox.deinit(); - - if (dropdown.dropped()) { - if (dropdown.addChoiceLabel("Auto")) { - fizzy.editor.settings.input_scheme = .auto; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Mouse")) { - fizzy.editor.settings.input_scheme = .mouse; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - if (dropdown.addChoiceLabel("Trackpad")) { - fizzy.editor.settings.input_scheme = .trackpad; - fizzy.editor.markSettingsDirty(); - dvui.refresh(null, @src(), vbox.data().id); - } - } - - _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); - } - var hold_menu_ms: f32 = @floatFromInt(fizzy.editor.settings.hold_menu_duration_ms); if (dvui.sliderEntry(@src(), "Context menu hold: {d:0.0} ms", .{ .value = &hold_menu_ms, diff --git a/src/editor/panel/Panel.zig b/src/editor/panel/Panel.zig index bb82654d..b5cedec9 100644 --- a/src/editor/panel/Panel.zig +++ b/src/editor/panel/Panel.zig @@ -1,63 +1,105 @@ const std = @import("std"); -const builtin = @import("builtin"); const dvui = @import("dvui"); const fizzy = @import("../../fizzy.zig"); -const Core = @import("mach").Core; -const App = fizzy.App; -const Editor = fizzy.Editor; -const Packer = fizzy.Packer; +const panel_layout = @import("panel_layout.zig"); +const PanelWorkspace = @import("PanelWorkspace.zig"); pub const Panel = @This(); -pub const Sprites = @import("sprites.zig"); - -sprites: Sprites = .{}, -pane: Pane = .sprites, paned: *fizzy.dvui.PanedWidget = undefined, scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto, }, -pub const Pane = enum(u32) { - sprites, -}; +/// Bottom-panel splits keyed by tab-grouping id (mirrors workbench workspaces). +workspaces: std.AutoArrayHashMapUnmanaged(u64, PanelWorkspace) = .empty, +open_workspace_grouping: u64 = 0, +grouping_id_counter: u64 = 0, +/// Which split each registered bottom view belongs to (`view.id` -> grouping). +view_groupings: std.StringArrayHashMapUnmanaged(u64) = .empty, pub fn init() Panel { return .{}; } -pub fn deinit(_: *Panel) void {} +pub fn deinit(self: *Panel, allocator: std.mem.Allocator) void { + self.workspaces.deinit(allocator); + self.view_groupings.deinit(allocator); +} pub fn draw(panel: *Panel) !dvui.App.Result { - // var scroll_area = dvui.scrollArea(@src(), .{ .scroll_info = &panel.scroll_info }, .{ - // .expand = .both, - // }); - // defer scroll_area.deinit(); - - var content_color = dvui.themeGet().color(.window, .fill); - - switch (builtin.os.tag) { - .macos => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) content_color.opacity(fizzy.editor.settings.content_opacity) else content_color; - }, - else => {}, - } - var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, - .background = true, - .color_fill = content_color, + .background = false, }); defer vbox.deinit(); - switch (panel.pane) { - .sprites => try panel.sprites.draw(), + const host = &fizzy.editor.host; + if (host.bottom_views.items.len == 0) return .ok; + + panel.ensureViewGroupings(host); + try panel_layout.rebuildWorkspaces(panel, host); + + if (panel.workspaces.count() == 0) { + try panel.workspaces.put(fizzy.app.allocator, 0, PanelWorkspace.init(0)); } - return .ok; + return try panel_layout.drawWorkspaces(panel, host, 0); +} + +pub fn ensureViewGroupings(self: *Panel, host: *fizzy.Editor.Host) void { + for (host.bottom_views.items) |view| { + if (self.view_groupings.get(view.id) == null) { + self.view_groupings.put(fizzy.app.allocator, view.id, 0) catch {}; + } + } +} + +pub fn viewGrouping(self: *Panel, view_id: []const u8) u64 { + return self.view_groupings.get(view_id) orelse 0; +} + +pub fn setViewGrouping(self: *Panel, view_id: []const u8, grouping: u64) void { + if (self.view_groupings.getPtr(view_id)) |g| { + g.* = grouping; + } else { + self.view_groupings.put(fizzy.app.allocator, view_id, grouping) catch {}; + } +} + +pub fn newGroupingID(self: *Panel) u64 { + self.grouping_id_counter += 1; + return self.grouping_id_counter; +} + +pub fn viewIndex(self: *Panel, host: *fizzy.Editor.Host, view_id: []const u8) ?usize { + _ = self; + for (host.bottom_views.items, 0..) |view, i| { + if (std.mem.eql(u8, view.id, view_id)) return i; + } + return null; +} + +pub fn activeViewInGrouping(self: *Panel, host: *fizzy.Editor.Host, grouping: u64) ?*fizzy.Editor.Host.BottomView { + const workspace = self.workspaces.get(grouping) orelse return null; + if (workspace.active_view_id) |active_id| { + for (host.bottom_views.items) |*view| { + if (std.mem.eql(u8, view.id, active_id) and self.viewGrouping(view.id) == grouping) { + return view; + } + } + } + for (host.bottom_views.items) |*view| { + if (self.viewGrouping(view.id) == grouping) return view; + } + return null; +} + +pub fn swapBottomViews(_: *Panel, host: *fizzy.Editor.Host, a: usize, b: usize) void { + if (a >= host.bottom_views.items.len or b >= host.bottom_views.items.len or a == b) return; + const tmp = host.bottom_views.items[a]; + host.bottom_views.items[a] = host.bottom_views.items[b]; + host.bottom_views.items[b] = tmp; } diff --git a/src/editor/panel/PanelWorkspace.zig b/src/editor/panel/PanelWorkspace.zig new file mode 100644 index 00000000..6b59ca38 --- /dev/null +++ b/src/editor/panel/PanelWorkspace.zig @@ -0,0 +1,343 @@ +//! One bottom-panel split: workspace-style tab strip + active registered view. +const std = @import("std"); +const builtin = @import("builtin"); + +const dvui = @import("dvui"); +const fizzy = @import("../../fizzy.zig"); + +const Panel = @import("Panel.zig"); + +const panel_corner_radius: f32 = 12; + +pub const drag_name = "panel_tab_drag"; + +pub const PanelWorkspace = @This(); + +grouping: u64, +active_view_id: ?[]const u8 = null, + +tabs_drag_index: ?usize = null, +tabs_removed_index: ?usize = null, +tabs_insert_before_index: ?usize = null, + +pub fn init(grouping: u64) PanelWorkspace { + return .{ .grouping = grouping }; +} + +pub fn draw(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) !dvui.App.Result { + var card = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = true, + .color_fill = panelContentColor(), + .corner_radius = dvui.Rect.all(panel_corner_radius), + .padding = .{ .x = 6, .y = 6, .w = 6, .h = 6 }, + .gravity_y = 0.0, + .id_extra = @intCast(self.grouping), + }); + defer card.deinit(); + + for (dvui.events()) |*e| { + if (!card.matchEvent(e)) continue; + if (e.evt == .mouse) { + if (e.evt.mouse.action == .press or (e.evt.mouse.action == .position and e.evt.mouse.mod.matchBind("ctrl/cmd"))) { + panel.open_workspace_grouping = self.grouping; + } + } + } + + if (host.bottom_views.items.len >= 1) self.drawTabs(panel, host); + try self.drawContent(panel, host); + + return .ok; +} + +fn panelContentColor() dvui.Color { + var content_color = dvui.themeGet().color(.window, .fill); + switch (builtin.os.tag) { + .macos, .windows => { + content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) + content_color.opacity(fizzy.editor.settings.content_opacity) + else + content_color; + }, + else => {}, + } + return content_color; +} + +fn drawTabs(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) void { + defer self.processTabsDrag(panel, host); + + var tabs_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer tabs_box.deinit(); + + var scroll_area = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .hide, .vertical_bar = .hide }, .{ + .expand = .none, + .background = false, + .style = .content, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .border = dvui.Rect.all(0), + .corner_radius = dvui.Rect.all(0), + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + .id_extra = @intCast(self.grouping), + }); + defer scroll_area.deinit(); + + var tabs = dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ + .expand = .none, + .background = false, + }); + defer tabs.deinit(); + + var tabs_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer tabs_hbox.deinit(); + + const active_in_this_group = blk: { + if (panel.open_workspace_grouping != self.grouping) break :blk false; + const active_id = self.active_view_id orelse break :blk false; + if (panel.viewGrouping(active_id) != self.grouping) break :blk false; + break :blk true; + }; + + const active_index = if (active_in_this_group) + panel.viewIndex(host, self.active_view_id.?) orelse null + else + null; + + for (host.bottom_views.items, 0..) |view, i| { + if (panel.viewGrouping(view.id) != self.grouping) continue; + + var reorderable = tabs.reorderable(@src(), .{}, .{ + .expand = .vertical, + .id_extra = i, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .border = .all(0), + }); + defer reorderable.deinit(); + + const selected = active_in_this_group and active_index == i; + + // Tabs carry no background in their resting state — selection is shown purely via the + // label color (see `color_text` below). A fill is drawn only while a tab is being + // dragged, as reorder feedback. + const show_tab_fill = reorderable.floating(); + + var hbox: dvui.BoxWidget = undefined; + hbox.init(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .border = dvui.Rect.all(0), + .background = show_tab_fill, + .color_fill = if (show_tab_fill) dvui.themeGet().color(.control, .fill) else .transparent, + .id_extra = i, + .padding = .{ .x = 2, .y = 2, .w = 2, .h = 2 }, + .margin = dvui.Rect.all(0), + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + }); + defer hbox.deinit(); + + if (reorderable.floating()) { + self.tabs_drag_index = i; + } + if (show_tab_fill) hbox.drawBackground(); + + if (reorderable.removed()) { + self.tabs_removed_index = i; + } else if (reorderable.insertBefore()) { + self.tabs_insert_before_index = i; + } + + var title_buf: [64]u8 = undefined; + const title_upper = if (view.title.len <= title_buf.len) + std.ascii.upperString(&title_buf, view.title) + else + view.title; + + dvui.label(@src(), "{s}", .{title_upper}, .{ + .color_text = if (selected) dvui.themeGet().color(.highlight, .fill) else dvui.themeGet().color(.control, .text), + .font = dvui.Font.theme(.heading), + .padding = dvui.Rect.all(4), + .gravity_y = 0.5, + }); + + loop: for (dvui.events()) |*e| { + if (!hbox.matchEvent(e)) continue; + + switch (e.evt) { + .mouse => |me| { + if (me.action == .press and me.button.pointer()) { + self.active_view_id = view.id; + panel.open_workspace_grouping = self.grouping; + host.setActiveBottomView(view.id); + dvui.refresh(null, @src(), hbox.data().id); + + e.handle(@src(), hbox.data()); + dvui.captureMouse(hbox.data(), e.num); + dvui.dragPreStart(me.p, .{ .size = reorderable.data().rectScale().r.size(), .offset = reorderable.data().rectScale().r.topLeft().diff(me.p) }); + } else if (me.action == .release and me.button.pointer()) { + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + } else if (me.action == .motion) { + if (dvui.captured(hbox.data().id)) { + e.handle(@src(), hbox.data()); + if (dvui.dragging(me.p, null)) |_| { + reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); + break :loop; + } + } + } + }, + else => {}, + } + } + } + + if (tabs.finalSlot()) { + self.tabs_insert_before_index = host.bottom_views.items.len; + } +} + +fn drawContent(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) !void { + var content_vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + .id_extra = @intCast(self.grouping), + }); + defer { + self.processTabDrag(content_vbox.data(), panel, host); + content_vbox.deinit(); + } + + const view = panel.activeViewInGrouping(host, self.grouping) orelse return; + try view.draw(view.ctx); +} + +fn processTabsDrag(self: *PanelWorkspace, panel: *Panel, host: *fizzy.Editor.Host) void { + if (self.tabs_insert_before_index) |insert_before| { + if (self.tabs_removed_index) |removed| { + if (removed >= host.bottom_views.items.len) return; + if (removed > insert_before) { + panel.swapBottomViews(host, removed, insert_before); + self.active_view_id = host.bottom_views.items[insert_before].id; + } else if (insert_before > 0) { + panel.swapBottomViews(host, removed, insert_before - 1); + self.active_view_id = host.bottom_views.items[insert_before - 1].id; + } else { + panel.swapBottomViews(host, removed, insert_before); + self.active_view_id = host.bottom_views.items[insert_before].id; + } + self.tabs_removed_index = null; + self.tabs_insert_before_index = null; + } else { + for (panel.workspaces.values()) |*workspace| { + if (workspace.tabs_removed_index) |removed| { + if (removed >= host.bottom_views.items.len) return; + const view = host.bottom_views.items[removed]; + if (removed > insert_before) { + panel.swapBottomViews(host, removed, insert_before); + panel.setViewGrouping(view.id, self.grouping); + self.active_view_id = view.id; + } else if (insert_before > 0) { + panel.swapBottomViews(host, removed, insert_before - 1); + panel.setViewGrouping(view.id, self.grouping); + self.active_view_id = view.id; + } else { + panel.swapBottomViews(host, removed, insert_before); + panel.setViewGrouping(view.id, self.grouping); + self.active_view_id = view.id; + } + + self.tabs_removed_index = null; + self.tabs_insert_before_index = null; + workspace.tabs_removed_index = null; + workspace.tabs_insert_before_index = null; + panel.open_workspace_grouping = self.grouping; + host.setActiveBottomView(view.id); + break; + } + } + } + } +} + +fn processTabDrag(self: *PanelWorkspace, data: *dvui.WidgetData, panel: *Panel, host: *fizzy.Editor.Host) void { + if (!dvui.dragName(drag_name)) return; + + const drag_src = blk: { + for (panel.workspaces.values()) |*w| { + if (w.tabs_drag_index) |i| break :blk .{ .ws = w, .index = i }; + } + break :blk null; + }; + if (drag_src == null) return; + const workspace = drag_src.?.ws; + const drag_index = drag_src.?.index; + if (drag_index >= host.bottom_views.items.len) return; + const dragged_view = host.bottom_views.items[drag_index]; + + for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = data.id, .r = data.rectScale().r, .drag_name = drag_name })) continue; + if (e.evt != .mouse) continue; + + var right_side = data.rectScale().r; + right_side.w /= 2; + right_side.x += right_side.w; + + const last_grouping = panel.workspaces.keys()[panel.workspaces.keys().len - 1]; + if (right_side.contains(e.evt.mouse.p) and last_grouping == self.grouping) { + if (e.evt.mouse.action == .position) { + right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + defer workspace.tabs_drag_index = null; + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + + const new_g = panel.newGroupingID(); + panel.setViewGrouping(dragged_view.id, new_g); + var new_ws = PanelWorkspace.init(new_g); + new_ws.active_view_id = dragged_view.id; + panel.workspaces.put(fizzy.app.allocator, new_g, new_ws) catch {}; + panel.open_workspace_grouping = new_g; + host.setActiveBottomView(dragged_view.id); + } + } else if (data.rectScale().r.contains(e.evt.mouse.p)) { + if (e.evt.mouse.action == .position) { + data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + defer workspace.tabs_drag_index = null; + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + + panel.setViewGrouping(dragged_view.id, self.grouping); + self.active_view_id = dragged_view.id; + panel.open_workspace_grouping = self.grouping; + host.setActiveBottomView(dragged_view.id); + } + } + } +} diff --git a/src/editor/panel/panel_layout.zig b/src/editor/panel/panel_layout.zig new file mode 100644 index 00000000..45adf3d8 --- /dev/null +++ b/src/editor/panel/panel_layout.zig @@ -0,0 +1,94 @@ +//! Bottom-panel workspace map maintenance + recursive split drawing. +const std = @import("std"); +const dvui = @import("dvui"); +const fizzy = @import("../../fizzy.zig"); + +const Panel = @import("Panel.zig"); +const PanelWorkspace = @import("PanelWorkspace.zig"); + +const handle_size = 10; +const handle_dist = 60; + +pub fn rebuildWorkspaces(panel: *Panel, host: *fizzy.Editor.Host) !void { + panel.ensureViewGroupings(host); + + var i: usize = 0; + while (i < host.bottom_views.items.len) : (i += 1) { + const view = host.bottom_views.items[i]; + const grouping = panel.viewGrouping(view.id); + if (!panel.workspaces.contains(grouping)) { + var workspace = PanelWorkspace.init(grouping); + workspace.active_view_id = view.id; + try panel.workspaces.put(fizzy.app.allocator, grouping, workspace); + } + } + + for (panel.workspaces.values()) |*workspace| { + if (panel.workspaces.count() == 1) break; + + var contains = false; + for (host.bottom_views.items) |v| { + if (panel.viewGrouping(v.id) == workspace.grouping) { + contains = true; + break; + } + } + + if (!contains) { + if (panel.open_workspace_grouping == workspace.grouping) { + for (panel.workspaces.values()) |*w| { + if (w.grouping != workspace.grouping) { + panel.open_workspace_grouping = w.grouping; + break; + } + } + } + _ = panel.workspaces.orderedRemove(workspace.grouping); + break; + } + } + + for (panel.workspaces.values()) |*workspace| { + if (panel.activeViewInGrouping(host, workspace.grouping)) |active| { + if (panel.viewGrouping(active.id) == workspace.grouping) continue; + } + for (host.bottom_views.items) |v| { + if (panel.viewGrouping(v.id) == workspace.grouping) { + workspace.active_view_id = v.id; + break; + } + } + } +} + +pub fn drawWorkspaces( + panel: *Panel, + host: *fizzy.Editor.Host, + index: usize, +) !dvui.App.Result { + if (index >= panel.workspaces.count()) return .ok; + + var s = fizzy.dvui.paned(@src(), .{ + .direction = .horizontal, + .collapsed_size = if (index == panel.workspaces.count() - 1) std.math.floatMax(f32) else 0, + .handle_size = handle_size, + .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist }, + }, .{ + .expand = .both, + .background = false, + .id_extra = @intCast(panel.workspaces.keys()[index]), + }); + defer s.deinit(); + + if (s.showFirst()) { + const result = try panel.workspaces.values()[index].draw(panel, host); + if (result != .ok) return result; + } + + if (s.showSecond()) { + const result = try drawWorkspaces(panel, host, index + 1); + if (result != .ok) return result; + } + + return .ok; +} diff --git a/src/editor/widgets/Widgets.zig b/src/editor/widgets/Widgets.zig deleted file mode 100644 index 5ef58bf5..00000000 --- a/src/editor/widgets/Widgets.zig +++ /dev/null @@ -1,15 +0,0 @@ -const std = @import("std"); - -const fizzy = @import("../../fizzy.zig"); -const dvui = @import("dvui"); - -pub const Widgets = @This(); - -pub const FileWidget = @import("FileWidget.zig"); -pub const ImageWidget = @import("ImageWidget.zig"); -pub const CanvasWidget = @import("CanvasWidget.zig"); -pub const ReorderWidget = @import("ReorderWidget.zig"); -pub const PanedWidget = @import("PanedWidget.zig"); -pub const FloatingWindowWidget = @import("FloatingWindowWidget.zig"); -pub const TreeWidget = @import("TreeWidget.zig"); -pub const TreeSelection = @import("TreeSelection.zig"); diff --git a/src/fizzy.zig b/src/fizzy.zig index a1876ff3..c443f78c 100644 --- a/src/fizzy.zig +++ b/src/fizzy.zig @@ -1,6 +1,8 @@ const std = @import("std"); -const mach = @import("mach"); -const Core = mach.Core; + +/// Shared infrastructure module (gfx, math, fs, generated atlas, platform, +/// paths, the generic dvui hub + widgets). Consumed by the shell and plugins. +pub const core = @import("core"); pub const version: std.SemanticVersion = .{ .major = 0, @@ -10,80 +12,50 @@ pub const version: std.SemanticVersion = .{ // Generated files, these contain helpers for autocomplete // So you can get a named index into atlas.sprites -pub const atlas = @import("generated/atlas.zig"); +pub const atlas = core.atlas; // Other helpers and namespaces -pub const algorithms = @import("algorithms/algorithms.zig"); -pub const fa = @import("tools/font_awesome.zig"); -pub const fs = @import("tools/fs.zig"); -pub const image = @import("gfx/image.zig"); -pub const render = @import("gfx/render.zig"); -pub const perf = @import("gfx/perf.zig"); -pub const water_surface = @import("gfx/water_surface.zig"); -pub const math = @import("math/math.zig"); +pub const fs = core.fs; +pub const image = core.image; +pub const perf = core.perf; +pub const water_surface = core.water_surface; +pub const math = core.math; pub const App = @import("App.zig"); -pub const Assets = @import("Assets.zig"); pub const Editor = @import("editor/Editor.zig"); pub const Explorer = @import("editor/explorer/Explorer.zig"); -pub const Fling = @import("editor/Fling.zig"); -pub const Packer = @import("tools/Packer.zig"); +pub const Fling = core.Fling; //pub const Popups = @import("editor/popups/Popups.zig"); pub const Sidebar = @import("editor/Sidebar.zig"); +/// Pixel-art plugin module. Shell code should `@import("pixi")` directly. +pub const pixi_mod = @import("pixi"); + // Global pointers pub var app: *App = undefined; pub var editor: *Editor = undefined; -pub var packer: *Packer = undefined; -pub var assets: *Assets = undefined; - -/// Internal types -/// These types contain additional data to support the editor -/// An example of this is File. fizzy.File matches the file type to read from JSON, -/// while the fizzy.Internal.File contains cameras, timers, file-specific editor fields. -pub const Internal = struct { - pub const Animation = @import("internal/Animation.zig"); - pub const Atlas = @import("internal/Atlas.zig"); - pub const Buffers = @import("internal/Buffers.zig"); - pub const File = @import("internal/File.zig"); - pub const History = @import("internal/History.zig"); - pub const Layer = @import("internal/Layer.zig"); - pub const Palette = @import("internal/Palette.zig"); - pub const Sprite = @import("internal/Sprite.zig"); -}; - -/// Frame-by-frame sprite animation -pub const Animation = @import("Animation.zig"); - -/// Contains lists of sprites and animations -pub const Atlas = @import("Atlas.zig"); - -/// The data that gets written to disk in a .pixi file and read back into this type -pub const File = @import("File.zig"); - -/// Contains information such as the name, visibility and collapse settings of a texture layer -pub const Layer = @import("Layer.zig"); - -/// Source location within the atlas texture and origin location -pub const Sprite = @import("Sprite.zig"); +pub var packer: *pixi_mod.Packer = undefined; /// Runtime platform detection (`isMacOS()` etc.) that's accurate on wasm web /// builds, where `builtin.os.tag` is always `.freestanding`. -pub const platform = @import("platform.zig"); +pub const platform = core.platform; + +/// Plugin SDK surface +pub const sdk = @import("sdk"); /// Custom dvui stuff -pub const dvui = @import("dvui.zig"); +pub const dvui = core.dvui; /// Custom backend stuff. Split per-arch: native uses SDL3 + objc + win32; web is a /// no-op stub layer (no window chrome, no native dialogs, no native menu bar). /// Zig only semantically analyzes the chosen branch, so the wasm build never sees -/// the SDL3 / objc / win32 imports inside `backend_native.zig`. +/// the SDL3 / objc / win32 imports inside `backend/backend_native.zig`. pub const backend = if (@import("builtin").target.cpu.arch == .wasm32) - @import("backend_web.zig") + @import("backend/backend_web.zig") else - @import("backend_native.zig"); + @import("backend/backend_native.zig"); -pub const paths = @import("paths.zig"); +pub const paths = core.paths; /// Returns a `std.process.Environ` populated from the libc `environ` global. /// Used to bridge APIs (like `known-folders.getPath`) that require an diff --git a/src/gfx/gfx.zig b/src/gfx/gfx.zig deleted file mode 100644 index 0673a5b8..00000000 --- a/src/gfx/gfx.zig +++ /dev/null @@ -1,2 +0,0 @@ -const std = @import("std"); -const fizzy = @import("../fizzy.zig"); diff --git a/src/plugins/code/build.zig b/src/plugins/code/build.zig new file mode 100644 index 00000000..d1a87ba6 --- /dev/null +++ b/src/plugins/code/build.zig @@ -0,0 +1,20 @@ +//! Standalone build for the code plugin — the canonical third-party shape. +//! `cd src/plugins/code && zig build` produces `code.`. Identical in form to +//! any external plugin: declare `fizzy`, call `fizzy.plugin.create` + `.install`. The +//! fizzy-internal static-embed build lives separately in `static/` and is driven by the +//! root build. See docs/PLUGINS.md. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = fizzy.plugin.create(b, .{ + .name = "code", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/code/build.zig.zon b/src/plugins/code/build.zig.zon new file mode 100644 index 00000000..359e73c3 --- /dev/null +++ b/src/plugins/code/build.zig.zon @@ -0,0 +1,20 @@ +.{ + .name = .code, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "code.zig", + "src", + "queries", + "static", + }, + .fingerprint = 0x77153098cc8cce17, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + }, +} diff --git a/src/plugins/code/code.zig b/src/plugins/code/code.zig new file mode 100644 index 00000000..b4af1e06 --- /dev/null +++ b/src/plugins/code/code.zig @@ -0,0 +1,22 @@ +//! Code plugin root module **and** intra-plugin import hub. +//! +//! - The shell resolves `@import("code")` to this file when the plugin is compiled into the app +//! (static embed) and reaches its public surface here (`plugin`, document types). +//! - Files under `src/` import it as `../code.zig` for the shared deps (`sdk`/`core`/`dvui`) +//! and sibling types — the conventional `.zig` namespace. +//! +//! It must sit at the plugin root: a Zig module cannot import files above its root file's +//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed +//! glue lives in `static/`. A minimal/third-party plugin only needs this file if it embeds +//! statically or wants a shared import hub. +const std = @import("std"); + +pub const sdk = @import("sdk"); +pub const core = @import("core"); +pub const dvui = @import("dvui"); + +pub const plugin = @import("src/plugin.zig"); +pub const State = @import("src/State.zig"); +pub const Document = @import("src/Document.zig"); +pub const CodeEditor = @import("src/CodeEditor.zig"); +pub const SyntaxHighlight = @import("src/SyntaxHighlight.zig"); diff --git a/src/plugins/code/queries/zig.scm b/src/plugins/code/queries/zig.scm new file mode 100644 index 00000000..f4d1217a --- /dev/null +++ b/src/plugins/code/queries/zig.scm @@ -0,0 +1,289 @@ +; Variables — catch-all first; more specific rules below override (last capture wins). +(identifier) @variable + +; Parameters +(parameter + name: (identifier) @variable.parameter) + +(payload + (identifier) @variable.parameter) + +; Types +(parameter + type: (identifier) @type) + +(variable_declaration + (identifier) @type + "=" + [ + (struct_declaration) + (enum_declaration) + (union_declaration) + (opaque_declaration) + ]) + +[ + (builtin_type) + "anyframe" +] @type.builtin + +; Constants +[ + "null" + "unreachable" + "undefined" +] @constant.builtin + +(field_expression + . + member: (identifier) @constant) + +(enum_declaration + (container_field + type: (identifier) @constant)) + +; Labels +(block_label + (identifier) @label) + +(break_label + (identifier) @label) + +; Fields +(field_initializer + . + (identifier) @variable.member) + +(field_expression + (_) + member: (identifier) @variable.member) + +(container_field + name: (identifier) @variable.member) + +(initializer_list + (assignment_expression + left: (field_expression + . + member: (identifier) @variable.member))) + +; Functions +(call_expression + function: (builtin_function + (builtin_identifier) @function.call)) + +(call_expression + function: (identifier) @function.call) + +(call_expression + function: (field_expression + member: (identifier) @function.call)) + +(function_declaration + name: (identifier) @function) + +; Modules (@import / @cImport — builtin stays @function.builtin) +(variable_declaration + (identifier) @module + (builtin_function + (builtin_identifier) @function.builtin + (#any-of? @function.builtin "@import" "@cImport"))) + +; Builtins +[ + "c" + "..." +] @variable.builtin + +((identifier) @variable.builtin + (#eq? @variable.builtin "_")) + +(calling_convention + (identifier) @variable.builtin) + +; Keywords +[ + "asm" + "defer" + "errdefer" + "test" + "error" + "const" + "var" +] @keyword + +[ + "struct" + "union" + "enum" + "opaque" +] @keyword.type + +[ + "async" + "await" + "suspend" + "nosuspend" + "resume" +] @keyword.coroutine + +"fn" @keyword.function + +[ + "and" + "or" + "orelse" +] @keyword.operator + +"return" @keyword.return + +[ + "if" + "else" + "switch" +] @keyword.conditional + +[ + "for" + "while" + "break" + "continue" +] @keyword.repeat + +[ + "usingnamespace" + "export" +] @keyword.import + +[ + "try" + "catch" +] @keyword.exception + +[ + "volatile" + "allowzero" + "noalias" + "addrspace" + "align" + "callconv" + "linksection" + "pub" + "inline" + "noinline" + "extern" + "comptime" + "packed" + "threadlocal" +] @keyword.modifier + +; Operator +[ + "=" + "*=" + "*%=" + "*|=" + "/=" + "%=" + "+=" + "+%=" + "+|=" + "-=" + "-%=" + "-|=" + "<<=" + "<<|=" + ">>=" + "&=" + "^=" + "|=" + "!" + "~" + "-" + "-%" + "&" + "==" + "!=" + ">" + ">=" + "<=" + "<" + "&" + "^" + "|" + "<<" + ">>" + "<<|" + "+" + "++" + "+%" + "-%" + "+|" + "-|" + "*" + "/" + "%" + "**" + "*%" + "*|" + "||" + ".*" + ".?" + "?" + ".." +] @operator + +; Literals +(character) @character + +([ + (string) + (multiline_string) +] @string + (#set! "priority" 95)) + +(integer) @number + +(float) @number.float + +(boolean) @boolean + +(escape_sequence) @string.escape + +; Punctuation +[ + "[" + "]" + "(" + ")" + "{" + "}" +] @punctuation.bracket + +[ + ";" + "." + "," + ":" + "=>" + "->" +] @punctuation.delimiter + +(payload + "|" @punctuation.bracket) + +; Comments +(comment) @comment + +((comment) @comment.documentation + (#lua-match? @comment.documentation "^//!")) + +; PascalCase identifiers (last capture wins over @variable) +((identifier) @type + (#lua-match? @type "^[A-Z_][a-zA-Z0-9_]*")) + +; @ builtins (must be last — wins over module/import and variable rules) +(builtin_identifier) @function.builtin + +((identifier) @function.builtin + (#match? @function.builtin "^@")) diff --git a/src/plugins/code/root.zig b/src/plugins/code/root.zig new file mode 100644 index 00000000..bd6a675b --- /dev/null +++ b/src/plugins/code/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the code plugin — identical in shape to the canonical third-party +//! `src/plugins/root.zig`: one `exportEntry` call wired to `src/plugin.zig`. +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/code/src/CodeEditor.zig b/src/plugins/code/src/CodeEditor.zig new file mode 100644 index 00000000..9d6b7610 --- /dev/null +++ b/src/plugins/code/src/CodeEditor.zig @@ -0,0 +1,163 @@ +//! Monospace code editor: line numbers + local `TextEntryWidget` with tree-sitter highlighting. +const std = @import("std"); +const code = @import("../code.zig"); +const dvui = code.dvui; +const core = code.core; +const Document = code.Document; +const SyntaxHighlight = @import("SyntaxHighlight.zig"); +const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); + +const editor_pad_y: f32 = 8; +const editor_pad_right: f32 = 8; +const line_number_pad_left: f32 = 4; +const code_gap_after_numbers: f32 = 12; + +const text_color = dvui.Color{ .r = 0xdd, .g = 0xdc, .b = 0xd3, .a = 255 }; +const line_number_color = dvui.Color{ .r = 0x58, .g = 0x58, .b = 0x5f, .a = 255 }; + +/// Tree-sitter + per-token layout is O(file size) each frame without layout caching. +const syntax_highlight_max_bytes: usize = 512 * 1024; + +const chromeless = dvui.Options{ + .background = false, + .margin = dvui.Rect{}, + .padding = null, + .border = dvui.Rect{}, + .corner_radius = dvui.Rect{}, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, +}; + +pub fn draw(doc: *Document, id_extra: u64, gpa: std.mem.Allocator) !bool { + const font = dvui.Font.theme(.mono); + const line_height = font.lineHeight(); + const line_num_col = lineNumberColumnWidth(doc.line_count, font); + + var row = dvui.box(@src(), .{ .dir = .horizontal }, chromeless.override(.{ + .expand = .both, + .font = font, + .id_extra = @intCast(id_extra), + })); + defer row.deinit(); + + // Reserve fixed width for the line-number gutter before the text entry init. + const gutter_wd = dvui.spacer(@src(), chromeless.override(.{ + .min_size_content = .{ .w = line_num_col, .h = 1 }, + .expand = .vertical, + .id_extra = @intCast(id_extra + 2), + })); + const gutter_rs = gutter_wd.borderRectScale(); + + var te: TextEntryWidget = undefined; + te.init(@src(), .{ + .multiline = true, + .break_lines = false, + .cache_layout = true, + .scroll_horizontal = true, + .focus_border = false, + .text = .{ .array_list = .{ .backing = &doc.text, .allocator = gpa, .limit = max_text_bytes } }, + .tree_sitter = if (doc.text.items.len <= syntax_highlight_max_bytes) + SyntaxHighlight.treeSitterOption(doc.path) + else + null, + }, chromeless.override(.{ + .expand = .both, + .font = font, + .padding = .{ + .x = 0, + .y = editor_pad_y, + .w = editor_pad_right, + .h = editor_pad_y, + }, + .color_text = text_color, + .id_extra = @intCast(id_extra + 1), + })); + defer te.deinit(); + te.processEvents(); + te.draw(); + + drawLineNumbers( + gutter_rs, + doc.line_count, + te.scroll.si.viewport.y, + font, + line_height, + ); + + const editor_rs = row.data().borderRectScale(); + const scroll_rs = te.scroll.data().contentRectScale(); + drawScrollEdgeShadows(editor_rs, scroll_rs, te.scroll.si); + + if (te.text_changed) doc.refreshLineCount(); + return te.text_changed; +} + +const max_text_bytes: usize = 64 * 1024 * 1024; + +fn lineNumberColumnWidth(line_count: usize, font: dvui.Font) f32 { + var buf: [16]u8 = undefined; + const sample = std.fmt.bufPrint(&buf, "{d}", .{line_count}) catch "9999"; + return line_number_pad_left + font.textSize(sample).w + code_gap_after_numbers; +} + +fn drawScrollEdgeShadows( + vertical_rs: dvui.RectScale, + horizontal_rs: dvui.RectScale, + si: *const dvui.ScrollInfo, +) void { + const vertical_scroll = si.offset(.vertical); + const horizontal_scroll = si.offset(.horizontal); + + if (vertical_scroll > 0.0 and !vertical_rs.r.empty()) { + core.dvui.drawEdgeShadow(vertical_rs, .top, .{}); + } + if (si.virtual_size.h > si.viewport.h and !vertical_rs.r.empty()) { + core.dvui.drawEdgeShadow(vertical_rs, .bottom, .{}); + } + if (si.virtual_size.w > si.viewport.w and !horizontal_rs.r.empty()) { + core.dvui.drawEdgeShadow(horizontal_rs, .right, .{}); + } + if (horizontal_scroll > 0.0 and !horizontal_rs.r.empty()) { + core.dvui.drawEdgeShadow(horizontal_rs, .left, .{}); + } +} + +fn drawLineNumbers( + rs: dvui.RectScale, + line_count: usize, + scroll_y: f32, + font: dvui.Font, + line_height: f32, +) void { + if (rs.r.empty()) return; + + const prev_clip = dvui.clip(rs.r); + defer dvui.clipSet(prev_clip); + + const first_line: usize = @intCast(@max(0, @as(i64, @intFromFloat((scroll_y - editor_pad_y) / line_height)))); + + var line: usize = first_line; + var y: f32 = editor_pad_y + @as(f32, @floatFromInt(line)) * line_height - scroll_y; + + var num_buf: [32]u8 = undefined; + + while (line < line_count and y < rs.r.h + line_height) : ({ + line += 1; + y += line_height; + }) { + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{line + 1}) catch continue; + const text_size = font.textSize(num_str).scale(rs.s, dvui.Size.Physical); + const x = rs.r.x + line_number_pad_left * rs.s; + const y_phys = rs.r.y + y * rs.s; + + dvui.renderText(.{ + .font = font, + .text = num_str, + .rs = .{ .r = .{ .x = x, .y = y_phys, .w = text_size.w, .h = text_size.h }, .s = rs.s }, + .color = line_number_color, + }) catch |err| { + dvui.log.err("line number text: {any}", .{err}); + }; + } +} diff --git a/src/plugins/code/src/Document.zig b/src/plugins/code/src/Document.zig new file mode 100644 index 00000000..86e35d42 --- /dev/null +++ b/src/plugins/code/src/Document.zig @@ -0,0 +1,72 @@ +//! A single open text document: its path, contents, and grouping. The contents are kept +//! in an `ArrayList(u8)` so the editing widget can grow/shrink it in place; the shell stores +//! only an opaque `DocHandle` whose `id` maps back to the registered `Document`. +const std = @import("std"); +const builtin = @import("builtin"); +const internal = @import("../code.zig"); +const dvui = internal.dvui; +const sdk = internal.sdk; + +const is_wasm = builtin.target.cpu.arch == .wasm32; + +const Document = @This(); + +/// Shell document id (monotonic, allocated from the host). +id: u64, +/// Absolute path on disk, heap-owned. +path: []u8, +/// Tab grouping (which split/tab group this document lives in). +grouping: u64 = 0, +/// File contents. The text-editing widget reads from and writes back to `items`. +text: std.ArrayList(u8) = .empty, +/// Cached `\n` count + 1; refreshed on load and when the editor reports edits. +line_count: usize = 1, +/// Unsaved-edits flag, set when the editing widget reports a change. +dirty: bool = false, + +/// 64 MiB — generous for source files; guards against opening something huge by mistake. +const max_file_bytes: usize = 64 * 1024 * 1024; + +/// Build a document from in-memory bytes (browser file picker, or after reading from disk). +pub fn fromBytes(path: []const u8, bytes: []const u8) !Document { + const gpa = sdk.allocator(); + var text: std.ArrayList(u8) = .empty; + errdefer text.deinit(gpa); + try text.appendSlice(gpa, bytes); + const path_copy = try gpa.dupe(u8, path); + errdefer gpa.free(path_copy); + var doc = Document{ + .id = sdk.host().allocDocId(), + .path = path_copy, + .text = text, + }; + doc.refreshLineCount(); + return doc; +} + +pub fn refreshLineCount(self: *Document) void { + self.line_count = if (self.text.items.len == 0) 1 else std.mem.count(u8, self.text.items, "\n") + 1; +} + +/// Build a document by reading `path` from disk. Runs on the shell's load worker thread. +/// Web has no filesystem; documents there are opened from bytes (`fromBytes`) instead. +pub fn fromPath(path: []const u8) !Document { + if (comptime is_wasm) return error.Unsupported; + const gpa = sdk.allocator(); + const bytes = try std.Io.Dir.cwd().readFileAlloc(dvui.io, path, gpa, .limited(max_file_bytes)); + defer gpa.free(bytes); + return fromBytes(path, bytes); +} + +pub fn deinit(self: *Document) void { + const gpa = sdk.allocator(); + gpa.free(self.path); + self.text.deinit(gpa); +} + +/// Write the current contents back to `path`. +pub fn save(self: *Document) !void { + if (comptime is_wasm) return error.Unsupported; + try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = self.path, .data = self.text.items }); + self.dirty = false; +} diff --git a/src/plugins/code/src/State.zig b/src/plugins/code/src/State.zig new file mode 100644 index 00000000..db4bb32e --- /dev/null +++ b/src/plugins/code/src/State.zig @@ -0,0 +1,28 @@ +//! Code plugin runtime state: open text document registry. +const std = @import("std"); +const sdk = @import("sdk"); +const Document = @import("Document.zig"); + +const State = @This(); + +docs: std.AutoArrayHashMapUnmanaged(u64, Document) = .empty, + +pub fn deinit(self: *State, allocator: std.mem.Allocator) void { + for (self.docs.values()) |*doc| doc.deinit(); + self.docs.deinit(allocator); +} + +pub fn docById(self: *State, id: u64) ?*Document { + return self.docs.getPtr(id); +} + +pub fn docFrom(self: *State, doc: sdk.DocHandle) ?*Document { + return self.docs.getPtr(doc.id); +} + +pub fn docByPath(self: *State, path: []const u8) ?*Document { + for (self.docs.values()) |*doc| { + if (std.mem.eql(u8, doc.path, path)) return doc; + } + return null; +} diff --git a/src/plugins/code/src/SyntaxHighlight.zig b/src/plugins/code/src/SyntaxHighlight.zig new file mode 100644 index 00000000..dc16e9d2 --- /dev/null +++ b/src/plugins/code/src/SyntaxHighlight.zig @@ -0,0 +1,129 @@ +//! Tree-sitter syntax highlighting via dvui's built-in TextEntry support. +const std = @import("std"); +const internal = @import("../code.zig"); +const dvui = internal.dvui; +const TextEntryWidget = @import("widgets/TextEntryWidget.zig"); + +const SyntaxHighlight = @This(); + +pub const Language = enum { + plain, + zig, + zon, + json, + atlas, + + pub fn fromPath(path: []const u8) Language { + const ext = std.fs.path.extension(path); + if (std.ascii.eqlIgnoreCase(ext, ".zig")) return .zig; + if (std.ascii.eqlIgnoreCase(ext, ".zon")) return .zon; + if (std.ascii.eqlIgnoreCase(ext, ".json")) return .json; + if (std.ascii.eqlIgnoreCase(ext, ".atlas")) return .atlas; + return .plain; + } +}; + +fn rgb(r: u8, g: u8, b: u8) dvui.Color { + return .{ .r = r, .g = g, .b = b, .a = 255 }; +} + +const ident_gold = rgb(0xd5, 0xc6, 0x83); +const keyword_brown = rgb(0x87, 0x65, 0x60); +const keyword_modifier_brown = rgb(0x61, 0x53, 0x53); +const type_orange = rgb(0xce, 0xa4, 0x7f); +const type_color = rgb(199, 140, 122); +const function_green = rgb(0x4d, 0xa5, 0x86); + +fn hi(name: []const u8, color: dvui.Color) TextEntryWidget.SyntaxHighlight { + return .{ .name = name, .opts = .{ .color_text = color } }; +} + +/// Zig — capture names match `queries/zig.scm`. +const zig_highlights = [_]TextEntryWidget.SyntaxHighlight{ + hi("comment", rgb(0x57, 0x5b, 0x65)), + hi("keyword", keyword_brown), + hi("keyword.type", keyword_brown), + hi("keyword.function", keyword_brown), + hi("keyword.modifier", keyword_modifier_brown), + hi("keyword.conditional", type_orange), + hi("keyword.repeat", type_orange), + hi("keyword.return", type_orange), + hi("keyword.operator", type_orange), + hi("keyword.import", keyword_brown), + hi("keyword.exception", type_orange), + hi("keyword.coroutine", type_orange), + hi("variable", ident_gold), + hi("variable.parameter", ident_gold), + hi("variable.member", ident_gold), + hi("variable.builtin", rgb(0x6a, 0x66, 0x56)), + hi("module", ident_gold), + hi("type", type_color), + hi("type.builtin", type_color), + hi("function", function_green), + hi("function.call", function_green), + hi("function.builtin", function_green), + hi("constant", rgb(0x60, 0x74, 0xd2)), + hi("constant.builtin", rgb(0x53, 0x5c, 0x90)), + hi("string", rgb(0x60, 0xc0, 0xd2)), + hi("string.escape", rgb(0x58, 0x8e, 0x9a)), + hi("character", rgb(0x60, 0xd2, 0xbe)), + hi("number", rgb(0x60, 0x9a, 0xd2)), + hi("number.float", rgb(0x60, 0x9a, 0xd2)), + hi("boolean", rgb(0x53, 0x5c, 0x90)), + hi("operator", rgb(0xb9, 0xb9, 0xb5)), + hi("label", rgb(0xc8, 0xc8, 0xc8)), + hi("punctuation", rgb(0x9c, 0x9d, 0x9d)), +}; + +/// JSON — inline query (same shape as dvui Examples/text_entry.zig). +const json_queries = + \\(string) @string + \\ + \\(pair + \\ key: (_) @string.special.key) + \\ + \\(number) @number + \\ + \\[ + \\ (null) + \\ (true) + \\ (false) + \\] @constant.builtin + \\ + \\(escape_sequence) @escape + \\ + \\(comment) @comment +; + +const json_highlights = [_]TextEntryWidget.SyntaxHighlight{ + hi("constant", rgb(0x53, 0x5c, 0x90)), + hi("string", rgb(0x60, 0xc0, 0xd2)), + hi("string.special.key", rgb(0xb6, 0x77, 0x6b)), + hi("comment", rgb(0x57, 0x5b, 0x65)), + hi("number", rgb(0x60, 0x9a, 0xd2)), + hi("escape", rgb(0x58, 0x8e, 0x9a)), +}; + +const zig_queries = @embedFile("../queries/zig.scm"); + +const TreeSitter = if (dvui.useTreeSitter) struct { + extern fn tree_sitter_zig() callconv(.c) *dvui.c.TSLanguage; + extern fn tree_sitter_json() callconv(.c) *dvui.c.TSLanguage; +} else struct {}; + +pub fn treeSitterOption(path: []const u8) ?TextEntryWidget.InitOptions.TreeSitterOption { + if (!dvui.useTreeSitter) return null; + return switch (Language.fromPath(path)) { + .zig, .zon => .{ + .language = TreeSitter.tree_sitter_zig(), + .queries = zig_queries, + .highlights = &zig_highlights, + }, + .json, .atlas => .{ + .language = TreeSitter.tree_sitter_json(), + .queries = json_queries, + .highlights = &json_highlights, + }, + .plain => null, + }; +} diff --git a/src/plugins/code/src/plugin.zig b/src/plugins/code/src/plugin.zig new file mode 100644 index 00000000..3b9f26c5 --- /dev/null +++ b/src/plugins/code/src/plugin.zig @@ -0,0 +1,201 @@ +//! The code editor plugin: fallback owner for plain-text documents and renders them as +//! editable, monospace tabs. Registration + the document vtable. Registered from +//! `Editor.postInit`; document state lives in `State.docs`. +const std = @import("std"); +const internal = @import("../code.zig"); +const sdk = internal.sdk; +const dvui = internal.dvui; +const State = internal.State; +const Document = internal.Document; +const CodeEditor = internal.CodeEditor; +const DocHandle = sdk.DocHandle; + +pub const manifest = sdk.PluginManifest{ + .id = "code", + .name = "Code", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "code", + .display_name = "Code", +}; + +const vtable: sdk.Plugin.VTable = .{ + .deinit = deinit, + .fileTypePriority = fileTypePriority, + // document staging buffer (shell allocates, plugin fills, then registers) + .documentStackSize = documentStackSize, + .documentStackAlign = documentStackAlign, + .loadDocument = loadDocument, + .loadDocumentFromBytes = loadDocumentFromBytes, + .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer, + .documentIdFromBuffer = documentIdFromBuffer, + .deinitDocumentBuffer = deinitDocumentBuffer, + // open-document registry + .registerOpenDocument = registerOpenDocument, + .documentPtr = documentPtr, + .documentByPath = documentByPath, + .unregisterDocument = unregisterDocument, + // document metadata (shell/workbench routing) + .documentGrouping = documentGrouping, + .setDocumentGrouping = setDocumentGrouping, + .documentPath = documentPath, + .setDocumentPath = setDocumentPath, + .bindDocumentToPane = bindDocumentToPane, + .documentHasNativeExtension = documentHasNativeExtension, + .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, + // rendering + lifecycle + .drawDocument = drawDocument, + .closeDocument = closeDocument, + .isDirty = isDirty, + .saveDocument = saveDocument, + // text saves are small and synchronous, so the async path just saves in place + .saveDocumentAsync = saveDocument, + .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, +}; + +comptime { + sdk.Plugin.assertEditorVTable(vtable); +} + +pub fn register(host: *sdk.Host) !void { + const gpa = host.allocator; + + const st = try gpa.create(State); + errdefer gpa.destroy(st); + st.* = .{}; + plugin.state = @ptrCast(st); + + try host.registerPlugin(&plugin); +} + +/// Stable `*Plugin` for constructing `DocHandle.owner` fields / lookups. +pub fn pluginPtr() *sdk.Plugin { + return &plugin; +} + +fn deinit(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + const gpa = sdk.allocator(); + st.deinit(gpa); + gpa.destroy(st); +} + +// ---- file type ownership ----------------------------------------------------- + +/// Fallback text editor: opens any file when no other plugin claims the extension. +/// Pixel-art wins for `.fiz`/`.pixi` (0) and flat images (10); everything else +/// opens here — including extensionless paths and renamed `.txt` → `.foo`. +fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { + _ = ext; + return sdk.Plugin.file_type_fallback_priority; +} + +// ---- document staging buffer ------------------------------------------------- + +fn documentStackSize(_: *anyopaque) usize { + return @sizeOf(Document); +} +fn documentStackAlign(_: *anyopaque) usize { + return @alignOf(Document); +} +fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void { + try sdk.document.loadPathInto(Document, path, docBuf(out_doc)); +} +fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { + try sdk.document.loadBytesInto(Document, path, bytes, docBuf(out_doc)); +} +fn setDocumentGroupingOnBuffer(_: *anyopaque, doc: *anyopaque, grouping: u64) void { + docBuf(doc).grouping = grouping; +} +fn documentIdFromBuffer(_: *anyopaque, doc: *anyopaque) u64 { + return docBuf(doc).id; +} +fn deinitDocumentBuffer(_: *anyopaque, doc: *anyopaque) void { + docBuf(doc).deinit(); +} + +// ---- open-document registry -------------------------------------------------- + +fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + const doc = docBuf(file); + try st.docs.put(sdk.allocator(), doc.id, doc.*); + return st.docs.getPtr(doc.id).?; +} +fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return st.docById(id); +} +fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return st.docByPath(path); +} +fn unregisterDocument(state: *anyopaque, id: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + _ = st.docs.swapRemove(id); +} + +// ---- document metadata ------------------------------------------------------- + +fn documentGrouping(_: *anyopaque, handle: DocHandle) u64 { + return (docFrom(handle) orelse return 0).grouping; +} +fn setDocumentGrouping(_: *anyopaque, handle: DocHandle, grouping: u64) void { + (docFrom(handle) orelse return).grouping = grouping; +} +fn documentPath(_: *anyopaque, handle: DocHandle) []const u8 { + return (docFrom(handle) orelse return "").path; +} +fn setDocumentPath(_: *anyopaque, handle: DocHandle, path: []const u8) anyerror!void { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + const gpa = sdk.allocator(); + const new_path = try gpa.dupe(u8, path); + gpa.free(doc.path); + doc.path = new_path; +} +fn bindDocumentToPane(_: *anyopaque, _: DocHandle, _: dvui.Id, _: *anyopaque, _: bool) void { + // Text editing needs no pane/canvas binding; the text widget manages its own state. +} +fn documentHasNativeExtension(_: *anyopaque, _: DocHandle) bool { + return true; +} +fn documentHasRecognizedSaveExtension(_: *anyopaque, _: DocHandle) bool { + return true; // a text document always saves in place over its own file +} + +// ---- rendering + lifecycle --------------------------------------------------- + +fn drawDocument(_: *anyopaque, handle: DocHandle) anyerror!void { + const doc = docFrom(handle) orelse return; + if (try CodeEditor.draw(doc, handle.id, sdk.allocator())) { + doc.dirty = true; + } +} + +fn closeDocument(_: *anyopaque, handle: DocHandle) void { + (docFrom(handle) orelse return).deinit(); +} +fn isDirty(_: *anyopaque, handle: DocHandle) bool { + return (docFrom(handle) orelse return false).dirty; +} +fn saveDocument(_: *anyopaque, handle: DocHandle) anyerror!void { + try (docFrom(handle) orelse return).save(); +} +fn documentDefaultSaveAsFilename(_: *anyopaque, handle: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 { + const doc = docFrom(handle) orelse return error.DocumentNotFound; + return allocator.dupe(u8, std.fs.path.basename(doc.path)); +} + +// ---- helpers ----------------------------------------------------------------- + +fn docBuf(buf: *anyopaque) *Document { + return @ptrCast(@alignCast(buf)); +} +fn docFrom(handle: DocHandle) ?*Document { + const st: *State = @ptrCast(@alignCast(plugin.state)); + return st.docById(handle.id); +} diff --git a/src/plugins/code/src/widgets/TextEntryWidget.zig b/src/plugins/code/src/widgets/TextEntryWidget.zig new file mode 100644 index 00000000..3f64d4f1 --- /dev/null +++ b/src/plugins/code/src/widgets/TextEntryWidget.zig @@ -0,0 +1,1592 @@ +//! Vendored from dvui `widgets/TextEntryWidget.zig` with code-editor extensions: +//! tree-sitter predicate filtering, query error fallback, optional focus ring. +const builtin = @import("builtin"); +const std = @import("std"); +const internal = @import("../../code.zig"); +const dvui = internal.dvui; + +const Event = dvui.Event; +const Options = dvui.Options; +const Rect = dvui.Rect; +const RectScale = dvui.RectScale; +const ScrollInfo = dvui.ScrollInfo; +const Size = dvui.Size; +const Widget = dvui.Widget; +const WidgetData = dvui.WidgetData; +const ScrollAreaWidget = dvui.ScrollAreaWidget; +const TextLayoutWidget = dvui.TextLayoutWidget; +const AccessKit = dvui.AccessKit; + +const TreeSitterQueryPredicates = if (dvui.useTreeSitter) @import("TreeSitterQueryPredicates.zig") else struct { + pub fn matchApplies(_: *const dvui.c.TSQuery, _: dvui.c.TSQueryMatch, _: []const u8) bool { + return true; + } +}; + +const TextEntryWidget = @This(); + +/// If min_size_content is not given, use Font.sizeM(defaultMWidth, 1). +/// If multiline is false and max_size_content is not given, use min_size_content. +pub var defaultMWidth: f32 = 14; + +pub var defaults: Options = .{ + .name = "TextEntry", + .role = .text_input, // can change to multiline in init + .margin = Rect.all(4), + .corner_radius = Rect.all(5), + .border = Rect.all(1), + .padding = Rect.all(6), + .background = true, + .style = .content, + // min_size_content/max_size_content is calculated in init() +}; + +const realloc_bin_size = 100; + +pub const SyntaxHighlight = struct { + name: []const u8, + opts: dvui.Options, +}; + +pub const TreeSitterParser = if (dvui.useTreeSitter) struct { + parser: *dvui.c.TSParser, + tree: *dvui.c.TSTree, + query: *dvui.c.TSQuery, + + pub fn deinit(ptr: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(ptr)); + + dvui.c.ts_query_delete(self.query); + dvui.c.ts_tree_delete(self.tree); + dvui.c.ts_parser_delete(self.parser); + } + + pub fn queryCursorCaptureIterator(self: *const TreeSitterParser, qc: *dvui.c.TSQueryCursor, text: []const u8) QueryCursorCaptureIterator { + return .{ + .query_cursor = qc, + .prev_match = null, + .query = self.query, + .text = text, + }; + } + + pub const QueryCursorCaptureIterator = struct { + pub const Match = struct { + iter: *const QueryCursorCaptureIterator, + node: dvui.c.TSNode, + capture_index: u32, + + pub fn captureName(self: *const Match) []const u8 { + var len: u32 = undefined; + const name = dvui.c.ts_query_capture_name_for_id(self.iter.query, self.capture_index, &len); + return name[0..len]; + } + + pub fn debugLog(self: *const Match, comptime kind: []const u8) void { + const start = dvui.c.ts_node_start_byte(self.node); + const end = dvui.c.ts_node_end_byte(self.node); + dvui.log.debug(kind ++ " capture @{s} : {s}", .{ self.captureName(), self.iter.text[start..end] }); + } + }; + + query_cursor: *dvui.c.TSQueryCursor, + prev_match: ?Match, + + // used for debugging + debug: bool = false, + query: *dvui.c.TSQuery, + text: []const u8, + + pub fn next(self: *QueryCursorCaptureIterator) ?Match { + var match: dvui.c.TSQueryMatch = undefined; + var captureIdx: u32 = undefined; + loop: while (dvui.c.ts_query_cursor_next_capture(self.query_cursor, &match, &captureIdx)) { + if (!TreeSitterQueryPredicates.matchApplies(self.query, match, self.text)) + continue :loop; + const capture = match.captures[captureIdx]; + if (self.prev_match) |pm| { + if (dvui.c.ts_node_eq(pm.node, capture.node)) { + // same node as previous + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts same "); + continue :loop; + } + + // not the same + const ret = self.prev_match; + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts new "); + return ret; + } else { + // first time + self.prev_match = .{ .iter = self, .node = capture.node, .capture_index = capture.index }; + if (self.debug) self.prev_match.?.debugLog("ts first"); + continue :loop; + } + } + + const ret = self.prev_match; + if (ret) |r| { + if (self.debug) r.debugLog("ts last "); + } + self.prev_match = null; + return ret; + } + }; +} else void; + +pub const InitOptions = struct { + pub const TextOption = union(enum) { + /// Use this slice of bytes, cannot add more. + buffer: []u8, + + /// Use and grow with realloc and shrink with resize as needed. + buffer_dynamic: struct { + backing: *[]u8, + allocator: std.mem.Allocator, + limit: usize = 10_000, + }, + + /// Use std.ArrayList(u8). The limit is total characters, the + /// arraylist might allocate more capacity. ArrayList.items is updated + /// in deinit() (file an issue if this is a problem). + array_list: struct { + backing: *std.ArrayList(u8), + allocator: std.mem.Allocator, + limit: usize = 10_000, + }, + + /// Use internal buffer up to limit. + /// - use getText() to get contents. + internal: struct { + limit: usize = 10_000, + }, + }; + + pub const TreeSitterOption = if (dvui.useTreeSitter) struct { + language: *dvui.c.TSLanguage, + queries: []const u8, + highlights: []const SyntaxHighlight, + /// If true dump all captures to dvui.log.debug + log_captures: bool = false, + } else void; + + text: TextOption = .{ .internal = .{} }, + tree_sitter: ?TreeSitterOption = null, + /// Faded text shown when the textEntry is empty + placeholder: ?[]const u8 = null, + + /// If true, assume text (and text height) is the same (excepting edits we + /// do internally) as we saw last frame and only process what is needed for + /// visibility (and copy). + cache_layout: bool = false, + + break_lines: bool = false, + kerning: ?bool = null, + scroll_vertical: ?bool = null, // default is value of multiline + scroll_vertical_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto + scroll_horizontal: ?bool = null, // default true + scroll_horizontal_bar: ?ScrollInfo.ScrollBarMode = null, // default .auto if multiline, .hide if not + + // must be a single utf8 character + password_char: ?[]const u8 = null, + multiline: bool = false, + /// Draw the theme focus ring when this text entry has keyboard focus. + focus_border: bool = true, +}; + +wd: WidgetData, +prevClip: Rect.Physical = undefined, +scroll: ScrollAreaWidget = undefined, +scrollClip: Rect.Physical = undefined, +textLayout: TextLayoutWidget = undefined, +textClip: Rect.Physical = undefined, +padding: Rect, + +init_opts: InitOptions, +text: []u8, +len: usize, +enter_pressed: bool = false, // not valid if multiline +text_changed: bool = false, + +// see textChanged() +text_changed_start: usize = std.math.maxInt(usize), +text_changed_end: usize = 0, // index of bytes before edits (so matches previous frame) +text_changed_added: i64 = 0, // bytes added +edited_outside_last_frame: *bool = undefined, + +/// It's expected to call this when `self` is `undefined` +pub fn init(self: *TextEntryWidget, src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) void { + var scroll_init_opts = ScrollAreaWidget.InitOpts{ + .vertical = if (init_opts.scroll_vertical orelse init_opts.multiline) .auto else .none, + .vertical_bar = init_opts.scroll_vertical_bar orelse .auto, + .horizontal = if (init_opts.scroll_horizontal orelse true) .auto else .none, + .horizontal_bar = init_opts.scroll_horizontal_bar orelse (if (init_opts.multiline) .auto else .hide), + }; + + var options = defaults.themeOverride(opts.theme).min_sizeM(defaultMWidth, 1); + + if (init_opts.password_char != null) { + options.role = .password_input; + } else if (init_opts.multiline) { + options.role = .multiline_text_input; + } + + options = options.override(opts); + if (!init_opts.multiline and options.max_size_content == null) { + options = options.override(.{ .max_size_content = .size(options.min_size_contentGet()) }); + } + + // padding is interpreted as the padding for the TextLayoutWidget, but + // we also need to add it to content size because TextLayoutWidget is + // inside the scroll area + const padding = options.paddingGet(); + options.padding = null; + options.min_size_content.?.w += padding.x + padding.w; + options.min_size_content.?.h += padding.y + padding.h; + if (options.max_size_content != null) { + options.max_size_content.?.w += padding.x + padding.w; + options.max_size_content.?.h += padding.y + padding.h; + } + + const wd = WidgetData.init(src, .{}, options); + scroll_init_opts.focus_id = wd.id; + + var text: []u8 = undefined; + var find_zero = true; + var len_utf8_boundary: usize = undefined; + switch (init_opts.text) { + .buffer => |b| text = b, + .buffer_dynamic => |b| text = b.backing.*, + .internal => text = dvui.dataGetSliceDefault(null, wd.id, "_buffer", []u8, &.{}), + .array_list => |al| { + find_zero = false; + text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + len_utf8_boundary = dvui.findUtf8Start(text, al.backing.items.len); + }, + } + + if (find_zero) { + const len_byte = std.mem.findScalar(u8, text, 0) orelse text.len; + len_utf8_boundary = dvui.findUtf8Start(text[0..len_byte], len_byte); + } + + self.* = .{ + .wd = wd, + .padding = padding, + .init_opts = init_opts, + .text = text, + .len = len_utf8_boundary, + + // SAFETY: The following fields are set bellow + .prevClip = undefined, + .scroll = undefined, + .scrollClip = undefined, + .textLayout = undefined, + .textClip = undefined, + }; + + self.data().register(); + + dvui.tabIndexSet(self.data().id, self.data().options.tab_index, self.data().rectScale().r); + + dvui.parentSet(self.widget()); + + self.data().borderAndBackground(.{}); + + self.prevClip = dvui.clip(self.data().borderRectScale().r); + const borderClip = dvui.clipGet(); + + // We do this dance with last_focused_id_this_frame so scroll will process + // key events we skip (like page up/down). Normally it would not (text + // entry is not a child of scroll). So with this we make scroll think that + // text entry ran as a child. + const focused = (self.data().id == dvui.lastFocusedIdInFrame()); + if (focused) dvui.currentWindow().last_focused_id_this_frame = .zero; + + // scrollbars process mouse events here + self.scroll.init(@src(), scroll_init_opts, self.data().options.strip().override(.{ .role = .none, .expand = .both })); + + if (focused) dvui.currentWindow().last_focused_id_this_frame = self.data().id; + + self.scrollClip = dvui.clipGet(); + + self.edited_outside_last_frame = dvui.dataGetPtrDefault(null, self.data().id, "_edited_outside", bool, false); + if (self.init_opts.cache_layout and self.edited_outside_last_frame.*) { + dvui.log.debug("TextEntryWidget forcing cache_layout false due to text being edited after drawing last frame", .{}); + self.init_opts.cache_layout = false; + self.edited_outside_last_frame.* = false; + self.text_changed = true; // trigger tree_sitter full reparse + } + + self.textLayout.init(@src(), .{ + .break_lines = self.init_opts.break_lines, + .kerning = self.init_opts.kerning, + .touch_edit_just_focused = false, + .cache_layout = self.init_opts.cache_layout, + .focused = self.data().id == dvui.focusedWidgetId(), + .show_touch_draggables = (self.len > 0), + }, self.data().options.strip().override(.{ + .role = .none, + .expand = .both, + .padding = self.padding, + })); + + // if textLayout forced cache_layout to false, we need to honor that + self.init_opts.cache_layout = self.textLayout.cache_layout; + + self.textClip = dvui.clipGet(); + + if (self.textLayout.touchEditing()) |floating_widget| { + defer floating_widget.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .corner_radius = dvui.ButtonWidget.defaults.themeOverride(opts.theme).corner_radiusGet(), + .background = true, + .border = dvui.Rect.all(1), + }); + defer hbox.deinit(); + + if (dvui.buttonIcon(@src(), "paste", dvui.entypo.clipboard, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.paste(); + } + + if (dvui.buttonIcon(@src(), "select all", dvui.entypo.swap, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.textLayout.selection.selectAll(); + } + + if (dvui.buttonIcon(@src(), "cut", dvui.entypo.scissors, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.cut(); + } + + if (dvui.buttonIcon(@src(), "copy", dvui.entypo.copy, .{}, .{}, .{ + .min_size_content = .{ .h = 20 }, + .margin = Rect.all(2), + })) { + self.copy(); + } + } + + // don't call textLayout.processEvents here, we forward events inside our own processEvents + + // textLayout is maintaining the selection for us, but if the text + // changed, we need to update the selection to be valid before we + // process any events + var sel = self.textLayout.selection; + sel.start = dvui.findUtf8Start(self.text[0..self.len], sel.start); + sel.cursor = dvui.findUtf8Start(self.text[0..self.len], sel.cursor); + sel.end = dvui.findUtf8Start(self.text[0..self.len], sel.end); + + // textLayout clips to its content, but we need to get events out to our border + dvui.clipSet(borderClip); + if (self.data().accesskit_node()) |ak_node| { + AccessKit.nodeAddAction(ak_node, AccessKit.Action.focus); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_value); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.set_text_selection); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.replace_selected_text); + AccessKit.nodeAddAction(ak_node, AccessKit.Action.scroll_into_view); // AK TODO - not yet implemented + AccessKit.nodeSetClipsChildren(ak_node); // AK TODO: Check this is correct? + + if (self.data().options.role != .password_input) { + const str = self.text[0..self.len]; + AccessKit.nodeSetValueWithLength(ak_node, str.ptr, str.len); + } + } +} + +pub fn matchEvent(self: *TextEntryWidget, e: *Event) bool { + // textLayout could be passively listening to events in matchEvent, so + // don't short circuit + const match1 = dvui.eventMatchSimple(e, self.data()); + const match2 = self.scroll.scroll.?.matchEvent(e); + const match3 = self.textLayout.matchEvent(e); + return match1 or match2 or match3; +} + +pub fn processEvents(self: *TextEntryWidget) void { + const evts = dvui.events(); + for (evts) |*e| { + if (!self.matchEvent(e)) + continue; + + self.processEvent(e); + } +} + +pub fn draw(self: *TextEntryWidget) void { + self.drawBeforeText(); + + if (self.len == 0) { + if (self.init_opts.placeholder) |placeholder| { + if (self.data().accesskit_node()) |ak_node| { + AccessKit.nodeSetPlaceholderWithLength(ak_node, placeholder.ptr, placeholder.len); + + // Create an empty text run for the empty text entry. + dvui.currentWindow().accesskit.text_run_parent = self.data().id; + self.textLayout.textRunCreateEmpty(self.data().id, true); + // prevent textLayout from making a text run for the placeholder text + dvui.currentWindow().accesskit.text_run_parent = null; + } + self.textLayout.addText(placeholder, .{ .color_text = self.textLayout.data().options.color(.text).opacity(0.65) }); + } + } + + if (dvui.accesskit_enabled) { + // parent text runs to us + dvui.currentWindow().accesskit.text_run_parent = self.data().id; + } + + if (self.init_opts.password_char) |pc| { + { + // adjust selection for obfuscation + var count: usize = 0; + var bytes: usize = 0; + var sel = self.textLayout.selection; + var sstart: ?usize = null; + var scursor: ?usize = null; + var send: ?usize = null; + var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); + while (utf8it.nextCodepoint()) |codepoint| { + if (sstart == null and sel.start == bytes) sstart = count * pc.len; + if (scursor == null and sel.cursor == bytes) scursor = count * pc.len; + if (send == null and sel.end == bytes) send = count * pc.len; + count += 1; + bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; + } else { + if (sstart == null and sel.start >= bytes) sstart = count * pc.len; + if (scursor == null and sel.cursor >= bytes) scursor = count * pc.len; + if (send == null and sel.end >= bytes) send = count * pc.len; + } + sel.start = sstart.?; + sel.cursor = scursor.?; + sel.end = send.?; + const password_str: ?[]u8 = dvui.currentWindow().lifo().alloc(u8, count * pc.len) catch null; + if (password_str) |pstr| { + defer dvui.currentWindow().lifo().free(pstr); + for (0..count) |i| { + for (0..pc.len) |pci| { + pstr[i * pc.len + pci] = pc[pci]; + } + } + self.textLayout.addText(pstr, self.data().options.strip()); + } else { + dvui.log.warn("Could not allocate password_str, falling back to one single password_str", .{}); + self.textLayout.addText(pc, self.data().options.strip()); + } + } + + self.textLayout.addTextDone(self.data().options.strip()); + + { + // reset selection + var count: usize = 0; + var bytes: usize = 0; + var sel = self.textLayout.selection; + var sstart: ?usize = null; + var scursor: ?usize = null; + var send: ?usize = null; + // NOTE: We assume that all text in the area it valid utf8, loop with exit early on invalid utf8 + var utf8it = (std.unicode.Utf8View.initUnchecked(self.text[0..self.len])).iterator(); + while (utf8it.nextCodepoint()) |codepoint| { + if (sstart == null and sel.start == count * pc.len) sstart = bytes; + if (scursor == null and sel.cursor == count * pc.len) scursor = bytes; + if (send == null and sel.end == count * pc.len) send = bytes; + count += 1; + bytes += std.unicode.utf8CodepointSequenceLength(codepoint) catch unreachable; + } else { + if (sstart == null and sel.start >= count * pc.len) sstart = bytes; + if (scursor == null and sel.cursor >= count * pc.len) scursor = bytes; + if (send == null and sel.end >= count * pc.len) send = bytes; + } + sel.start = sstart.?; + sel.cursor = scursor.?; + sel.end = send.?; + } + + self.drawAfterText(); + return; + } + + if (dvui.useTreeSitter) { + if (self.init_opts.tree_sitter) |ts| { + if (dvui.dataGet(null, self.data().id, "ts_query_failed", bool)) |failed| { + if (failed) { + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + } + + // syntax highlighting + const parser = dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser) orelse blk: { + const p = dvui.c.ts_parser_new(); + _ = dvui.c.ts_parser_set_language(p, ts.language); + const tree = dvui.c.ts_parser_parse_string(p, null, self.text.ptr, @intCast(self.len)); + + var errorOffset: u32 = undefined; + var errorType: dvui.c.TSQueryError = undefined; + const query = dvui.c.ts_query_new(ts.language, ts.queries.ptr, @intCast(ts.queries.len), &errorOffset, &errorType); + + if (query == null) { + dvui.log.err("TextEntry tree-sitter query error {} at offset {}", .{ errorType, errorOffset }); + if (tree) |t| dvui.c.ts_tree_delete(t); + if (p) |parser_ptr| dvui.c.ts_parser_delete(parser_ptr); + dvui.dataSet(null, self.data().id, "ts_query_failed", true); + break :blk null; + } + + const parser: TreeSitterParser = .{ .parser = p.?, .tree = tree.?, .query = query.? }; + dvui.dataSet(null, self.data().id, "parser", parser); + dvui.dataSetDeinitFunction(null, self.data().id, "parser", &TreeSitterParser.deinit); + break :blk dvui.dataGetPtr(null, self.data().id, "parser", TreeSitterParser).?; + }; + + if (parser == null) { + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + + var ts_parser = parser.?; + + // used to output text that's not highlighted + var start: usize = 0; + + if (self.text_changed and !dvui.firstFrame(self.data().id)) { + if (self.init_opts.cache_layout) { + var edit: dvui.c.TSInputEdit = undefined; + edit.start_byte = @intCast(self.text_changed_start); + edit.old_end_byte = @intCast(self.text_changed_end); + edit.new_end_byte = @intCast(@as(i64, @intCast(self.text_changed_end)) + self.text_changed_added); + + edit.start_point = .{ .row = 0, .column = 0 }; + edit.old_end_point = .{ .row = 0, .column = 0 }; + edit.new_end_point = .{ .row = 0, .column = 0 }; + + dvui.c.ts_tree_edit(ts_parser.tree, &edit); + + const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, ts_parser.tree, self.text.ptr, @intCast(self.len)); + dvui.c.ts_tree_delete(ts_parser.tree); + ts_parser.tree = tree.?; + } else { + const tree = dvui.c.ts_parser_parse_string(ts_parser.parser, null, self.text.ptr, @intCast(self.len)); + dvui.c.ts_tree_delete(ts_parser.tree); + ts_parser.tree = tree.?; + } + } + + // parsing + const root = dvui.c.ts_tree_root_node(ts_parser.tree); + + // queries + const qc = dvui.c.ts_query_cursor_new(); + defer dvui.c.ts_query_cursor_delete(qc); + + if (self.textLayout.cache_layout_bytes) |clb| { + _ = dvui.c.ts_query_cursor_set_byte_range(qc, @intCast(clb.start), @intCast(clb.end)); + } + + dvui.c.ts_query_cursor_exec(qc, ts_parser.query, root); + + var iter = ts_parser.queryCursorCaptureIterator(qc.?, self.text); + iter.debug = ts.log_captures; + while (iter.next()) |match| { + const nstart = dvui.c.ts_node_start_byte(match.node); + const nend = dvui.c.ts_node_end_byte(match.node); + if (start < nstart) { + // render non highlighted text up to this node + self.textLayout.addText(self.text[start..nstart], .{}); + } else if (nstart < start) { + // this match is inside (or overlapping) the previous match + // maybe we could be smarter here, but for now drop it + continue; + } + + var opts: dvui.Options = .{}; + const capture_name = match.captureName(); + for (0..ts.highlights.len) |i| { + const sh = ts.highlights[ts.highlights.len - i - 1]; + if (std.mem.startsWith(u8, capture_name, sh.name)) { + opts = sh.opts; + break; + } + } + + self.textLayout.addText(self.text[nstart..nend], opts); + + start = nend; + } + + if (start < self.len) { + // any leftover non highlighted text + self.textLayout.addText(self.text[start..self.len], .{}); + } + + self.textLayout.addTextDone(self.data().options.strip()); + self.drawAfterText(); + return; + } + } + + // simple text + self.textLayout.addText(self.text[0..self.len], self.data().options.strip()); + self.textLayout.addTextDone(self.data().options.strip()); + + self.drawAfterText(); +} + +pub fn drawBeforeText(self: *TextEntryWidget) void { + const focused = (self.data().id == dvui.focusedWidgetId()); + + if (focused) { + dvui.wantTextInput(self.data().borderRectScale().r.toNatural()); + } + + // set clip back to what textLayout had, so we don't draw over the scrollbars + dvui.clipSet(self.textClip); + + if (self.init_opts.cache_layout) { + self.textLayout.cache_layout_bytes = self.textLayout.bytesNeeded( + self.text_changed_start, + self.text_changed_end, + self.text_changed_added, + ); + } +} + +pub fn drawAfterText(self: *TextEntryWidget) void { + const focused = (self.data().id == dvui.focusedWidgetId()); + if (focused) { + self.drawCursor(); + } + + dvui.clipSet(self.prevClip); + + if (focused and self.init_opts.focus_border) { + self.data().focusBorder(); + } +} + +pub fn drawCursor(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (sel.empty()) { + // the cursor can be slightly outside the textLayout clip + dvui.clipSet(self.scrollClip); + + var crect = self.textLayout.cursor_rect.plus(.{ .x = -1 }); + crect.w = 2; + self.textLayout.screenRectScale(crect).r.fill(.{}, .{ .color = dvui.themeGet().focus, .fade = 1.0 }); + } +} + +pub fn widget(self: *TextEntryWidget) Widget { + return Widget.init(self, data, rectFor, screenRectScale, minSizeForChild); +} + +pub fn data(self: *TextEntryWidget) *WidgetData { + return self.wd.validate(); +} + +pub fn rectFor(self: *TextEntryWidget, id: dvui.Id, min_size: Size, e: Options.Expand, g: Options.Gravity) Rect { + _ = id; + return dvui.placeIn(self.data().contentRect().justSize(), min_size, e, g); +} + +pub fn screenRectScale(self: *TextEntryWidget, rect: Rect) RectScale { + return self.data().contentRectScale().rectToRectScale(rect); +} + +pub fn minSizeForChild(self: *TextEntryWidget, s: Size) void { + self.data().minSizeMax(self.data().options.padSize(s)); +} + +pub fn textChangedRemoved(self: *TextEntryWidget, start: usize, end: usize) void { + self.textChanged(start, end, @as(i64, @intCast(start)) - @as(i64, @intCast(end))); +} + +// Inserting text is at a single point in the previous frame's indexing. +pub fn textChangedAdded(self: *TextEntryWidget, pos: usize, added: usize) void { + self.textChanged(pos, pos, @intCast(added)); +} + +// Only needed when cache_layout is true. We are maintaining an interval of +// bytes from last frame plus a total number added (might be negative) in that +// interval. This is sent to textLayout so it will process at least this +// interval (plus whatever is visible). +pub fn textChanged(self: *TextEntryWidget, start: usize, end: usize, added: i64) void { + self.text_changed = true; + if (end > self.text_changed_start) { + // end is in current bytes, so we update it to previous frame's indexing + var end_old: usize = undefined; + if (self.text_changed_added >= 0) { + end_old = end - @as(usize, @intCast(self.text_changed_added)); + } else { + end_old = end + @as(usize, @intCast(-self.text_changed_added)); + } + // This assumes that the current update happens after (in bytes) all + // previous updates. This is not exact, but will always give an + // interval that includes all the updates. + self.text_changed_end = @max(self.text_changed_end, end_old); + } else { + // before previous updates then indexing is the same + self.text_changed_end = @max(self.text_changed_end, end); + } + + // if we are before the previous updates then the indexing is the same + self.text_changed_start = @min(self.text_changed_start, start); + self.text_changed_added += added; + + if (self.textLayout.add_text_done) { + self.edited_outside_last_frame.* = true; + } + + //std.debug.print("textChanged {d} {d} {d}\n", .{ self.text_changed_start, self.text_changed_end, self.text_changed_added }); +} + +/// Return text as a slice to the backing storage. The returned slice is +/// valid after `deinit`, and is only invalidated by events or functions that +/// change the text (like `textSet` or `paste`). +pub fn textGet(self: *const TextEntryWidget) []u8 { + return self.text[0..self.len]; +} + +/// Deprecated in favor of `textGet`. +pub fn getText(self: *const TextEntryWidget) []u8 { + return self.textGet(); +} + +pub fn textSet(self: *TextEntryWidget, text: []const u8, selected: bool) void { + self.textLayout.selection.selectAll(); + self.textTyped(text, selected); +} + +pub fn textTyped(self: *TextEntryWidget, new: []const u8, selected: bool) void { + // strip out carriage returns, which we get from copy/paste on windows + if (std.mem.findScalar(u8, new, '\r')) |idx| { + self.textTyped(new[0..idx], selected); + self.textTyped(new[idx + 1 ..], selected); + return; + } + + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.len -= (sel.end - sel.start); + sel.end = sel.start; + sel.cursor = sel.start; + } + + const space_left = self.text.len - self.len; + if (space_left < new.len) { + var new_size = realloc_bin_size * (@divTrunc(self.len + new.len, realloc_bin_size) + 1); + switch (self.init_opts.text) { + .buffer => {}, + .buffer_dynamic => |b| { + new_size = @min(new_size, b.limit); + b.backing.* = b.allocator.realloc(self.text, new_size) catch |err| blk: { + dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); + break :blk b.backing.*; + }; + self.text = b.backing.*; + }, + .array_list => |al| { + new_size = @min(new_size, al.limit); + al.backing.ensureTotalCapacity(al.allocator, new_size) catch |err| { + dvui.logError(@src(), err, "{x} TextEntryWidget.textTyped failed to realloc ArrayList backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_size }); + }; + self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + }, + .internal => |i| { + new_size = @min(new_size, i.limit); + // If we are the same size then there is no work to do + // This is important because same sized data allocations will be reused + if (new_size != self.text.len) { + // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame + const prev_text = self.text; + dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_size); + self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; + const min_len = @min(prev_text.len, self.text.len); + if (self.text.ptr != prev_text.ptr) { + @memcpy(self.text[0..min_len], prev_text[0..min_len]); + } + } + }, + } + } + var new_len = @min(new.len, self.text.len - self.len); + + // find start of last utf8 char + var last: usize = new_len -| 1; + while (last < new_len and new[last] & 0xc0 == 0x80) { + last -|= 1; + } + + // if the last utf8 char can't fit, don't include it + if (last < new_len) { + const utf8_size = std.unicode.utf8ByteSequenceLength(new[last]) catch 0; + if (utf8_size != (new_len - last)) { + new_len = last; + } + } + + // make room if we can + if (new_len > 0 and sel.cursor + new_len < self.text.len) { + @memmove(self.text[sel.cursor + new_len ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + } + + if (new_len > 0) { + self.textChangedAdded(sel.cursor, new_len); + } + + // update our len and maintain 0 termination if possible + self.setLen(self.len + new_len); + + // insert + @memmove(self.text[sel.cursor..][0..new_len], new[0..new_len]); + if (selected) { + sel.start = sel.cursor; + sel.cursor += new_len; + sel.end = sel.cursor; + } else { + sel.cursor += new_len; + sel.end = sel.cursor; + sel.start = sel.cursor; + } + if (std.mem.findScalar(u8, new[0..new_len], '\n') != null) { + sel.affinity = .after; + } + + // we might have dropped to a new line, so make sure the cursor is visible + self.textLayout.scroll_to_cursor_next_frame = true; + dvui.refresh(null, @src(), self.data().id); +} + +/// Remove all characters that not present in filter_chars. +/// Designed to run after event processing and before drawing. +pub fn filterIn(self: *TextEntryWidget, filter_chars: []const u8) void { + if (filter_chars.len == 0) { + return; + } + + var i: usize = 0; + var j: usize = 0; + const n = self.len; + while (i < n) { + if (std.mem.findScalar(u8, filter_chars, self.text[i]) == null) { + self.len -= 1; + var sel = self.textLayout.selection; + if (sel.start > i) sel.start -= 1; + if (sel.cursor > i) sel.cursor -= 1; + if (sel.end > i) sel.end -= 1; + self.text_changed = true; + + i += 1; + } else { + self.text[j] = self.text[i]; + i += 1; + j += 1; + } + } + + if (j < self.text.len) + self.text[j] = 0; +} + +/// Remove all instances of the string needle. +/// Designed to run after event processing and before drawing. +pub fn filterOut(self: *TextEntryWidget, needle: []const u8) void { + if (needle.len == 0) { + return; + } + + var i: usize = 0; + var j: usize = 0; + const n = self.len; + while (i < n) { + if (std.mem.startsWith(u8, self.text[i..], needle)) { + self.len -= needle.len; + var sel = self.textLayout.selection; + if (sel.start > i) sel.start -= needle.len; + if (sel.cursor > i) sel.cursor -= needle.len; + if (sel.end > i) sel.end -= needle.len; + self.text_changed = true; + + i += needle.len; + } else { + self.text[j] = self.text[i]; + i += 1; + j += 1; + } + } + + if (j < self.text.len) + self.text[j] = 0; +} + +/// Sets the new length and does fixups: +/// - add null terminator if there is space +/// - shrink allocation if needed +/// - fixup array_list backing +pub fn setLen(self: *TextEntryWidget, newlen: usize) void { + self.len = newlen; + + // add null terminator if there is space + if (self.len < self.text.len) { + self.text[self.len] = 0; + } + + // shrink allocation if needed + const needed_binds = @divTrunc(self.len, realloc_bin_size) + 1; + const current_bins = @divTrunc(self.text.len, realloc_bin_size); + // dvui.log.debug("TextEntry {x} needs {d} bins, has {d}", .{ self.data().id, needed_binds, current_bins }); + if (self.len == 0 or needed_binds < current_bins) { + // we want to shrink the allocation + const new_len = if (self.len == 0) 0 else realloc_bin_size * needed_binds; + switch (self.init_opts.text) { + .buffer => {}, + .buffer_dynamic => |b| { + if (b.allocator.resize(self.text, new_len)) { + b.backing.*.len = new_len; + self.text.len = new_len; + } else { + dvui.logError(@src(), std.mem.Allocator.Error.OutOfMemory, "{x} TextEntryWidget.textTyped failed to realloc backing (current size {d}, new size {d})", .{ self.data().id, self.text.len, new_len }); + } + }, + .array_list => |al| { + if (new_len < al.backing.capacity / 2) { + al.backing.items.len = al.backing.capacity; + al.backing.shrinkAndFree(al.allocator, new_len); + self.text = al.backing.items.ptr[0..@min(al.limit, al.backing.capacity)]; + } + }, + .internal => { + // NOTE: Using prev_text is safe because data is trashed and stays valid until the end of the frame + const prev_text = self.text; + dvui.dataSetSliceCopies(null, self.data().id, "_buffer", &[_]u8{0}, new_len); + self.text = dvui.dataGetSlice(null, self.data().id, "_buffer", []u8).?; + const min_len = @min(prev_text.len, self.text.len); + @memcpy(self.text[0..min_len], prev_text[0..min_len]); + }, + } + } + + // fixup array_list backing + switch (self.init_opts.text) { + .array_list => |al| { + al.backing.items.len = self.len; + }, + else => {}, + } +} + +pub fn processEvent(self: *TextEntryWidget, e: *Event) void { + // scroll gets first crack, because it is logically outside the text area + self.scroll.scroll.?.processEvent(e); + if (e.handled) return; + + switch (e.evt) { + .key => |ke| blk: { + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("next_widget")) { + e.handle(@src(), self.data()); + dvui.tabIndexNext(e.num); + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("prev_widget")) { + e.handle(@src(), self.data()); + dvui.tabIndexPrev(e.num); + break :blk; + } + + if (ke.action == .down and ke.matchBind("paste")) { + e.handle(@src(), self.data()); + self.paste(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("cut")) { + e.handle(@src(), self.data()); + self.cut(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("copy")) { + e.handle(@src(), self.data()); + self.copy(); + break :blk; + } + + if (ke.action == .down and ke.matchBind("text_start")) { + e.handle(@src(), self.data()); + self.textLayout.selection.moveCursor(0, false); + self.textLayout.scroll_to_cursor = true; + break :blk; + } + + if (ke.action == .down and ke.matchBind("text_end")) { + e.handle(@src(), self.data()); + self.textLayout.selection.moveCursor(std.math.maxInt(usize), false); + self.textLayout.scroll_to_cursor = true; + break :blk; + } + + if (ke.action == .down and ke.matchBind("line_start")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .home } }; + } + break :blk; + } + + if (ke.action == .down and ke.matchBind("line_end")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .expand_pt = .{ .select = false, .which = .end } }; + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_left")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .word_left_right) { + self.textLayout.sel_move.word_left_right.count -= 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("word_right")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); + self.textLayout.selection.affinity = .before; + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .word_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .word_left_right) { + self.textLayout.sel_move.word_left_right.count += 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_left")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.start, false); + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .char_left_right) { + self.textLayout.sel_move.char_left_right.count -= 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_right")) { + e.handle(@src(), self.data()); + if (!self.textLayout.selection.empty()) { + self.textLayout.selection.moveCursor(self.textLayout.selection.end, false); + self.textLayout.selection.affinity = .before; + } else { + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .char_left_right = .{ .select = false } }; + } + if (self.textLayout.sel_move == .char_left_right) { + self.textLayout.sel_move.char_left_right.count += 1; + } + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_up")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; + } + if (self.textLayout.sel_move == .cursor_updown) { + self.textLayout.sel_move.cursor_updown.count -= 1; + } + break :blk; + } + + if ((ke.action == .down or ke.action == .repeat) and ke.matchBind("char_down")) { + e.handle(@src(), self.data()); + if (self.textLayout.sel_move == .none) { + self.textLayout.sel_move = .{ .cursor_updown = .{ .select = false } }; + } + if (self.textLayout.sel_move == .cursor_updown) { + self.textLayout.sel_move.cursor_updown.count += 1; + } + break :blk; + } + + switch (ke.code) { + .backspace => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // just delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_prev_word")) { + // delete word before cursor + + const oldcur = sel.cursor; + // find end of last word + if (sel.cursor > 0 and std.mem.findAny(u8, self.text[sel.cursor - 1 ..][0..1], " \n") != null) { + sel.cursor = std.mem.findLastNone(u8, self.text[0..sel.cursor], " \n") orelse 0; + } + + // find start of word + if (std.mem.findLastAny(u8, self.text[0..sel.cursor], " \n")) |last_space| { + sel.cursor = last_space + 1; + } else { + sel.cursor = 0; + } + + // delete from sel.cursor to oldcur + if (sel.cursor != oldcur) self.textChangedRemoved(sel.cursor, oldcur); + @memmove(self.text[sel.cursor..][0 .. self.len - oldcur], self.text[oldcur..self.len]); + self.setLen(self.len - (oldcur - sel.cursor)); + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } else if (sel.cursor > 0) { + // delete character just before cursor + // + // A utf8 char might consist of more than one byte. + // Find the beginning of the last byte by iterating over + // the string backwards. The first byte of a utf8 char + // does not have the pattern 10xxxxxx. + var i: usize = 1; + while (sel.cursor - i > 0 and self.text[sel.cursor - i] & 0xc0 == 0x80) : (i += 1) {} + self.textChangedRemoved(sel.cursor - i, sel.cursor); + @memmove(self.text[sel.cursor - i ..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + self.setLen(self.len - i); + sel.cursor -= i; + sel.start = sel.cursor; + sel.end = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } + } + }, + .delete => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // just delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } else if (ke.matchBind("delete_next_word")) { + // delete word after cursor + + const oldcur = sel.cursor; + // find start of next word + if (sel.cursor < self.len and std.mem.findAny(u8, self.text[sel.cursor..][0..1], " \n") != null) { + sel.cursor = std.mem.findNonePos(u8, self.text, sel.cursor, " \n") orelse self.len; + } + + // find end of word + if (std.mem.findAny(u8, self.text[sel.cursor..self.len], " \n")) |last_space| { + sel.cursor = sel.cursor + last_space; + } else { + sel.cursor = self.len; + } + + // delete from oldcur to sel.cursor + if (sel.cursor != oldcur) self.textChangedRemoved(oldcur, sel.cursor); + @memmove(self.text[oldcur..][0 .. self.len - sel.cursor], self.text[sel.cursor..self.len]); + self.setLen(self.len - (sel.cursor - oldcur)); + sel.cursor = oldcur; + sel.end = sel.cursor; + sel.start = sel.cursor; + self.textLayout.scroll_to_cursor = true; + } else if (sel.cursor < self.len) { + // delete the character just after the cursor + // + // A utf8 char might consist of more than one byte. + const ii = std.unicode.utf8ByteSequenceLength(self.text[sel.cursor]) catch 1; + const i = @min(ii, self.len - sel.cursor); + + self.textChangedRemoved(sel.cursor, sel.cursor + i); + const remaining = self.len - (sel.cursor + i); + @memmove(self.text[sel.cursor..][0..remaining], self.text[sel.cursor + i ..][0..remaining]); + self.setLen(self.len - i); + self.textLayout.scroll_to_cursor = true; + } + } + }, + .enter => { + if (ke.action == .down or ke.action == .repeat) { + e.handle(@src(), self.data()); + if (self.init_opts.multiline) { + self.textTyped("\n", false); + } else if (ke.action == .down) { + self.enter_pressed = true; + dvui.refresh(null, @src(), self.data().id); + } + } + }, + else => {}, + } + }, + .text => |te| { + switch (te.action) { + .value => |set| { + e.handle(@src(), self.data()); + var new = std.mem.sliceTo(set.txt, 0); + if (self.init_opts.multiline) { + self.textTyped(new, set.selected); + } else { + var i: usize = 0; + while (i < new.len) { + if (std.mem.findScalar(u8, new[i..], '\n')) |idx| { + self.textTyped(new[i..][0..idx], set.selected); + i += idx + 1; + } else { + self.textTyped(new[i..], set.selected); + break; + } + } + } + }, + else => {}, + } + }, + .mouse => |me| { + if (me.action == .focus) { + e.handle(@src(), self.data()); + dvui.focusWidget(self.data().id, null, e.num); + } + }, + else => {}, + } + + if (!e.handled) { + self.textLayout.processEvent(e); + + if (!e.handled and e.evt == .key) { + switch (e.evt.key.code) { + .page_up, .page_down => {}, // handled by scroll container + else => { + // Mark all remaining key events as handled. This allows + // checking a keybind (like "d") after the textEntry, but + // where textEntry will get it first. + e.handle(@src(), self.data()); + }, + } + } + } +} + +pub fn paste(self: *TextEntryWidget) void { + const clip_text = dvui.clipboardText(); + + if (self.init_opts.multiline) { + self.textTyped(clip_text, false); + } else { + var i: usize = 0; + while (i < clip_text.len) { + if (std.mem.findScalar(u8, clip_text[i..], '\n')) |idx| { + self.textTyped(clip_text[i..][0..idx], false); + i += idx + 1; + } else { + self.textTyped(clip_text[i..], false); + break; + } + } + } +} + +pub fn cut(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // copy selection to clipboard + dvui.clipboardTextSet(self.text[sel.start..sel.end]); + + // delete selection + self.textChangedRemoved(sel.start, sel.end); + @memmove(self.text[sel.start..][0 .. self.len - sel.end], self.text[sel.end..self.len]); + self.setLen(self.len - (sel.end - sel.start)); + sel.end = sel.start; + sel.cursor = sel.start; + self.textLayout.scroll_to_cursor = true; + } +} + +/// This could use textLayout.copy(), but that doesn't work if we have a masked +/// password field (textLayout only sees the password char). +pub fn copy(self: *TextEntryWidget) void { + var sel = self.textLayout.selectionGet(self.len); + if (!sel.empty()) { + // copy selection to clipboard + dvui.clipboardTextSet(self.text[sel.start..sel.end]); + } +} + +pub fn deinit(self: *TextEntryWidget) void { + defer if (dvui.widgetIsAllocated(self)) dvui.widgetFree(self); + defer self.* = undefined; + + // set clip back to what textLayout had, because it might need it to set + // the mouse cursor + dvui.clipSet(self.textClip); + self.textLayout.deinit(); + self.scroll.deinit(); + + dvui.clipSet(self.prevClip); + self.data().minSizeSetAndRefresh(); + self.data().minSizeReportToParent(); + dvui.parentReset(self.data().id, self.data().parent); +} + +/// Same lifecycle as `dvui.textEntry`. +pub fn textEntry(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Options) *TextEntryWidget { + var ret = dvui.widgetAlloc(TextEntryWidget); + ret.init(src, init_opts, opts); + ret.processEvents(); + ret.draw(); + return ret; +} + +test { + @import("std").testing.refAllDecls(@This()); +} + +test "text internal" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .internal = .{ .limit = limit } }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // text length should not be a multiple of the limit or bin size + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text dynamic buffer" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + var buffer: [limit]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&buffer); + var backing: []u8 = &.{}; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .buffer_dynamic = .{ + .backing = &backing, + .allocator = fba.allocator(), + .limit = limit, + } }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // limit should not be a multiple of the text length + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + // This verifies that any OOM error is handled by writing past the buffer size + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text buffer" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + + // Set a limit that is not a multiple of the bin size + const limit = realloc_bin_size * 5 / 2; + + var buffer: [limit]u8 = undefined; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ + .text = .{ .buffer = &buffer }, + }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + return .ok; + } + }; + + try dvui.testing.settle(Local.frame); + try dvui.testing.pressKey(.tab, .none); + try dvui.testing.settle(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "This is some short sample text!"; + // limit should not be a multiple of the text length + try std.testing.expect(Local.limit % text.len != 0); + try std.testing.expect(realloc_bin_size % text.len != 0); + + try dvui.testing.writeText(text); + try dvui.testing.settle(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); + + for (0..@divFloor(Local.limit, text.len)) |_| { + // Fill the internal buffer + // This verifies that any OOM error is handled by writing past the buffer size + try dvui.testing.writeText(text); + } + try dvui.testing.settle(Local.frame); + + const full_text_buffer = comptime blk: { + var text_buf: []const u8 = text; + while (text_buf.len < Local.limit) text_buf = text_buf ++ text; + break :blk text_buf; + }[0..Local.limit]; + try std.testing.expectEqualStrings(full_text_buffer, Local.text); +} + +test "text array_list" { + var t = try dvui.testing.init(.{}); + defer t.deinit(); + + const Local = struct { + var text: []const u8 = ""; + var al: std.ArrayList(u8) = .empty; + + fn frame() !dvui.App.Result { + var entry: TextEntryWidget = undefined; + entry.init(@src(), .{ .text = .{ .array_list = .{ + .backing = &al, + .allocator = std.testing.allocator, + } } }, .{ .tag = "entry" }); + defer entry.deinit(); + + entry.processEvents(); + entry.draw(); + text = entry.getText(); + + return .ok; + } + }; + + defer Local.al.deinit(std.testing.allocator); + + _ = try dvui.testing.step(Local.frame); + try dvui.testing.pressKey(.tab, .none); + _ = try dvui.testing.step(Local.frame); + try dvui.testing.expectFocused("entry"); + + const text = "Testing text"; + try dvui.testing.writeText(text); + _ = try dvui.testing.step(Local.frame); + try std.testing.expectEqualStrings(text, Local.text); +} diff --git a/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig b/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig new file mode 100644 index 00000000..3f2d258b --- /dev/null +++ b/src/plugins/code/src/widgets/TreeSitterQueryPredicates.zig @@ -0,0 +1,147 @@ +//! Evaluate standard tree-sitter query text predicates (#eq?, #match?, #lua-match?, #any-of?). +const std = @import("std"); +const internal = @import("../../code.zig"); +const dvui = internal.dvui; + +const c = dvui.c; + +const Step = c.TSQueryPredicateStep; +const step_done = c.TSQueryPredicateStepTypeDone; +const step_capture = c.TSQueryPredicateStepTypeCapture; +const step_string = c.TSQueryPredicateStepTypeString; + +fn captureText(source: []const u8, node: c.TSNode) []const u8 { + const start: usize = @intCast(c.ts_node_start_byte(node)); + const end: usize = @intCast(c.ts_node_end_byte(node)); + return source[start..end]; +} + +fn textForCaptureId(match: c.TSQueryMatch, source: []const u8, capture_id: u32) ?[]const u8 { + var i: u16 = 0; + while (i < match.capture_count) : (i += 1) { + const cap = match.captures[i]; + if (cap.index == capture_id) return captureText(source, cap.node); + } + return null; +} + +fn queryString(query: *const c.TSQuery, id: u32) []const u8 { + var len: u32 = undefined; + const ptr = c.ts_query_string_value_for_id(query, id, &len); + return ptr[0..len]; +} + +fn isIdentChar(ch: u8) bool { + return std.ascii.isAlphanumeric(ch) or ch == '_'; +} + +fn isPascalTypeName(text: []const u8) bool { + if (text.len == 0) return false; + const c0 = text[0]; + if (c0 != '_' and (c0 < 'A' or c0 > 'Z')) return false; + for (text[1..]) |ch| if (!isIdentChar(ch)) return false; + return true; +} + +fn isCamelFunctionName(text: []const u8) bool { + if (text.len == 0) return false; + const c0 = text[0]; + if (c0 != '_' and (c0 < 'a' or c0 > 'z')) return false; + for (text[1..]) |ch| if (!isIdentChar(ch)) return false; + return true; +} + +fn isScreamingConstant(text: []const u8) bool { + if (text.len == 0) return false; + if (text[0] < 'A' or text[0] > 'Z') return false; + for (text) |ch| { + if (ch >= 'A' and ch <= 'Z') continue; + if (ch >= '0' and ch <= '9') continue; + if (ch == '_') continue; + return false; + } + return true; +} + +fn regexMatch(text: []const u8, pattern: []const u8) bool { + if (std.mem.eql(u8, pattern, "^[A-Z_][a-zA-Z0-9_]*")) return isPascalTypeName(text); + if (std.mem.eql(u8, pattern, "^[A-Z][A-Z_0-9]+$")) return isScreamingConstant(text); + if (std.mem.eql(u8, pattern, "^[a-z_][a-zA-Z0-9_]*$")) return isCamelFunctionName(text); + if (std.mem.eql(u8, pattern, "^//!")) return std.mem.startsWith(u8, text, "//!"); + if (std.mem.startsWith(u8, pattern, "^") and std.mem.endsWith(u8, pattern, "$")) { + return std.mem.eql(u8, text, pattern[1 .. pattern.len - 1]); + } + if (std.mem.startsWith(u8, pattern, "^")) { + return std.mem.startsWith(u8, text, pattern[1..]); + } + return std.mem.eql(u8, text, pattern); +} + +fn evalPredicate( + query: *const c.TSQuery, + match: c.TSQueryMatch, + source: []const u8, + steps: []const Step, +) bool { + if (steps.len == 0) return true; + if (steps[0].type != step_string) return true; + + const op = queryString(query, steps[0].value_id); + + if (std.mem.eql(u8, op, "set!")) return true; + + if (std.mem.eql(u8, op, "eq?") or std.mem.eql(u8, op, "not-eq?")) { + if (steps.len != 3 or steps[1].type != step_capture) return true; + const positive = std.mem.eql(u8, op, "eq?"); + const cap_text = textForCaptureId(match, source, steps[1].value_id) orelse return !positive; + const expected = if (steps[2].type == step_string) + queryString(query, steps[2].value_id) + else + textForCaptureId(match, source, steps[2].value_id) orelse return !positive; + const matched = std.mem.eql(u8, cap_text, expected); + return if (positive) matched else !matched; + } + + if (std.mem.eql(u8, op, "match?") or std.mem.eql(u8, op, "not-match?") or + std.mem.eql(u8, op, "lua-match?") or std.mem.eql(u8, op, "not-lua-match?")) + { + if (steps.len != 3 or steps[1].type != step_capture or steps[2].type != step_string) return true; + const positive = std.mem.eql(u8, op, "match?") or std.mem.eql(u8, op, "lua-match?"); + const cap_text = textForCaptureId(match, source, steps[1].value_id) orelse return !positive; + const pattern = queryString(query, steps[2].value_id); + const matched = regexMatch(cap_text, pattern); + return if (positive) matched else !matched; + } + + if (std.mem.eql(u8, op, "any-of?") or std.mem.eql(u8, op, "not-any-of?")) { + if (steps.len < 3 or steps[1].type != step_capture) return true; + const positive = std.mem.eql(u8, op, "any-of?"); + const cap_text = textForCaptureId(match, source, steps[1].value_id) orelse return !positive; + var i: usize = 2; + while (i < steps.len) : (i += 1) { + if (steps[i].type != step_string) continue; + if (std.mem.eql(u8, cap_text, queryString(query, steps[i].value_id))) { + return positive; + } + } + return !positive; + } + + return true; +} + +pub fn matchApplies(query: *const c.TSQuery, match: c.TSQueryMatch, source: []const u8) bool { + var step_count: u32 = undefined; + const steps = c.ts_query_predicates_for_pattern(query, match.pattern_index, &step_count); + if (step_count == 0) return true; + + var i: u32 = 0; + while (i < step_count) { + const start = i; + while (i < step_count and steps[i].type != step_done) : (i += 1) {} + const pred = steps[start..i]; + if (pred.len > 0 and !evalPredicate(query, match, source, pred)) return false; + i += 1; + } + return true; +} diff --git a/src/plugins/code/static/integration.zig b/src/plugins/code/static/integration.zig new file mode 100644 index 00000000..8c7fecc0 --- /dev/null +++ b/src/plugins/code/static/integration.zig @@ -0,0 +1,59 @@ +//! Code plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); + +pub const id = "code"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/code/code.zig"; +const dylib_path = "src/plugins/code/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); +} + +/// Static `@import("code")` module for exe / web / tests. +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + }, consumer); + applyImports(mod, imports); + return mod; +} + +/// Native dynamic library bundled beside the app (`code.dylib` / `.dll` / `.so`). +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/example/build.zig b/src/plugins/example/build.zig new file mode 100644 index 00000000..d84c11fc --- /dev/null +++ b/src/plugins/example/build.zig @@ -0,0 +1,19 @@ +//! Standalone build for the example plugin — the canonical third-party shape, and the simplest +//! possible one: declare `fizzy`, call `fizzy.plugin.create` (defaults its root to `root.zig`), +//! then `fizzy.plugin.install`. `cd src/plugins/example && zig build` produces +//! `example.`. Copy this for a new pure-Zig plugin. See docs/PLUGINS.md. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = fizzy.plugin.create(b, .{ + .name = "example", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/example/build.zig.zon b/src/plugins/example/build.zig.zon new file mode 100644 index 00000000..77821601 --- /dev/null +++ b/src/plugins/example/build.zig.zon @@ -0,0 +1,19 @@ +.{ + .name = .example, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "example.zig", + "src", + "static", + }, + .fingerprint = 0x6eec9b9f328e055f, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + }, +} diff --git a/src/plugins/example/example.zig b/src/plugins/example/example.zig new file mode 100644 index 00000000..b1428978 --- /dev/null +++ b/src/plugins/example/example.zig @@ -0,0 +1,14 @@ +//! Example plugin root module **and** intra-plugin import hub — the conventional `.zig`. +//! +//! - The shell resolves `@import("example")` to this file when the plugin is compiled into the +//! app (static embed); `example.plugin` is its entry. +//! - Files under `src/` import it as `../example.zig` for shared deps (`sdk`/`dvui`) and types. +//! +//! A minimal plugin keeps this tiny — it grows into the plugin's shared namespace as `src/` +//! gains files. It must sit at the plugin root (a Zig module can't import above its root file's +//! directory). The build-side static-embed glue lives in `static/`. +pub const sdk = @import("sdk"); +pub const dvui = @import("dvui"); + +pub const plugin = @import("src/plugin.zig"); +pub const State = @import("src/State.zig"); diff --git a/src/plugins/example/root.zig b/src/plugins/example/root.zig new file mode 100644 index 00000000..49583a65 --- /dev/null +++ b/src/plugins/example/root.zig @@ -0,0 +1,8 @@ +//! Dylib entry for the example plugin — the canonical third-party shape (identical to +//! `src/plugins/root.zig`): one `exportEntry` call wired to `src/plugin.zig`. Copy this verbatim +//! into a new plugin; you never edit it. +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/example/src/State.zig b/src/plugins/example/src/State.zig new file mode 100644 index 00000000..79a6b72f --- /dev/null +++ b/src/plugins/example/src/State.zig @@ -0,0 +1,11 @@ +//! Example plugin state. A plugin owns whatever state it needs; the host injects only the +//! allocator and `*Host` (read via `sdk.allocator()` / `sdk.host()`), so this is just a plain +//! struct the plugin holds. Trivial here — a real plugin keeps documents, caches, settings, etc. +const std = @import("std"); + +clicks: u64 = 0, + +pub fn deinit(self: *@This(), gpa: std.mem.Allocator) void { + _ = self; + _ = gpa; +} diff --git a/src/plugins/example/src/plugin.zig b/src/plugins/example/src/plugin.zig new file mode 100644 index 00000000..7b7305f0 --- /dev/null +++ b/src/plugins/example/src/plugin.zig @@ -0,0 +1,80 @@ +//! Example plugin — the canonical, minimal Fizzy plugin and the copy-me template for new +//! plugins. It registers a single sidebar view that renders a greeting and a click counter: +//! the smallest useful shape, namely identity + `register` + one `Host.register*` contribution +//! + plugin-owned state. The host injects only the allocator and `*Host` (read through +//! `sdk.allocator()` / `sdk.host()`), so there is no storage file to write. +//! +//! This plugin implements no document hooks — it is a "shell" plugin (contributes a pane), not +//! an "editor" plugin (opens/saves/draws files). For the editor shape, see the `code` plugin. +//! +//! To start a new plugin: copy this folder, rename the id/name, and implement your feature in +//! `src/plugin.zig`. See docs/PLUGINS.md. +const std = @import("std"); +// Shared deps + sibling types come through the plugin's `.zig` hub (`../example.zig`), +// the conventional `@import("")` namespace. A single-file plugin could import `sdk` +// and `dvui` directly; using the hub is what scales as `src/` grows. +const example = @import("../example.zig"); +const sdk = example.sdk; +const dvui = example.dvui; +const State = example.State; + +/// Identity + versions embedded in the dylib (and read by the host on load). +pub const manifest = sdk.PluginManifest{ + .id = "example", + .name = "Example", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + +/// Stable, plugin-namespaced contribution id. +const view_hello = "example.hello"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "example", + .display_name = "Example", +}; + +/// Only the hooks this plugin needs; every other vtable field stays `null`. +const vtable: sdk.Plugin.VTable = .{ + .deinit = deinit, +}; + +/// The plugin's own singleton state — just a variable it owns. The SDK holds gpa/host. +var plugin_state: State = .{}; + +/// Entry point the host calls once at startup (static) or after dlopen (dynamic). Wire state, +/// register the plugin, then add any sidebar/bottom/center/menu/settings contributions. +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(&plugin_state); + try host.registerPlugin(&plugin); + try host.registerSidebarView(.{ + .id = view_hello, + .owner = &plugin, + .icon = dvui.entypo.rocket, + .title = "Example", + .draw = drawHello, + }); +} + +/// Stable `*Plugin` for constructing `DocHandle.owner` / lookups (unused here, but part of the +/// conventional plugin surface). +pub fn pluginPtr() *sdk.Plugin { + return &plugin; +} + +fn deinit(_: *anyopaque) void { + plugin_state.deinit(sdk.allocator()); +} + +/// Fills the left pane while this sidebar view is active. +fn drawHello(_: ?*anyopaque) anyerror!void { + var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .margin = .all(8) }); + defer box.deinit(); + + dvui.label(@src(), "Hello from the example plugin!", .{}, .{}); + dvui.label(@src(), "Clicks: {d}", .{plugin_state.clicks}, .{}); + if (dvui.button(@src(), "Click me", .{}, .{ .expand = .horizontal })) { + plugin_state.clicks += 1; + } +} diff --git a/src/plugins/example/static/integration.zig b/src/plugins/example/static/integration.zig new file mode 100644 index 00000000..817906c8 --- /dev/null +++ b/src/plugins/example/static/integration.zig @@ -0,0 +1,59 @@ +//! Example plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); + +pub const id = "example"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/example/example.zig"; +const dylib_path = "src/plugins/example/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); +} + +/// Static `@import("example")` module for exe / web / tests. +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + }, consumer); + applyImports(mod, imports); + return mod; +} + +/// Native dynamic library bundled beside the app (`example.dylib` / `.dll` / `.so`). +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/pixi/build.zig b/src/plugins/pixi/build.zig new file mode 100644 index 00000000..fa97a521 --- /dev/null +++ b/src/plugins/pixi/build.zig @@ -0,0 +1,57 @@ +//! Standalone build for the pixi plugin — the canonical third-party shape. +//! `cd src/plugins/pixi && zig build` produces `pixi.`. Pixi has vendored C +//! deps (stbi, msf_gif, zip) and a packed `assets` module, so its `build.zig` attaches a few +//! extra modules onto the `fizzy.plugin.create` lib — exactly how any third-party plugin with +//! native deps would. +//! +//! `zstbi`/`msf_gif` are built with the shared `fizzy.plugin.addCModule` helper (the same one a +//! third-party C plugin uses), so the build logic isn't duplicated — only the plugin-relative +//! paths are supplied here. `zip` is wired inline (its `zip.zig` module + C compiled into this +//! dylib); it isn't shared with fizzy's own build graph, so there's no two-modules collision. +const std = @import("std"); +const fizzy = @import("fizzy"); +const assetpack = @import("assetpack"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const lib = fizzy.plugin.create(b, .{ + .name = "pixi", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + + // Packed assets (the repo's assets/ tree, three levels up from this plugin). + lib.root_module.addImport("assets", assetpack.pack(b, b.path("../../../assets"), .{})); + + // zstbi (image decode/resize + rect pack) + msf_gif (GIF export) via the shared helper. + lib.root_module.addImport("zstbi", fizzy.plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/deps/stbi/zstbi.zig"), + .c_sources = &.{.{ .file = b.path("src/deps/stbi/zstbi.c") }}, + })); + lib.root_module.addImport("msf_gif", fizzy.plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/deps/msf_gif/msf_gif.zig"), + .c_sources = &.{.{ .file = b.path("src/deps/msf_gif/msf_gif.c") }}, + })); + + // zip (atlas/project archives). + lib.root_module.addImport("zip", b.createModule(.{ .root_source_file = b.path("src/deps/zip/zip.zig") })); + lib.root_module.link_libc = true; + lib.root_module.addIncludePath(b.path("src/deps/zip/src")); + lib.root_module.addCSourceFile(.{ + .file = b.path("src/deps/zip/src/zip.c"), + .flags = &.{"-fno-sanitize=undefined"}, + }); + + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + lib.root_module.addImport("icons", dep.module("icons")); + } + + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/pixi/build.zig.zon b/src/plugins/pixi/build.zig.zon new file mode 100644 index 00000000..24c0e25e --- /dev/null +++ b/src/plugins/pixi/build.zig.zon @@ -0,0 +1,28 @@ +.{ + .name = .pixi, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "pixi.zig", + "src", + "static", + }, + .fingerprint = 0xdef5a52dbae70684, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + .assetpack = .{ + .url = "https://github.com/foxnne/assetpack/archive/ac7592f3f5988857840d0df4610e1e1fad690e2e.tar.gz", + .hash = "assetpack-0.2.0-5DA2d1ZkAADJanNVdWrUBOGMhOzUENhrUiqXcHADxY2x", + }, + .icons = .{ + .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz", + .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", + .lazy = true, + }, + }, +} diff --git a/src/plugins/pixi/pixi.zig b/src/plugins/pixi/pixi.zig new file mode 100644 index 00000000..3bb94b55 --- /dev/null +++ b/src/plugins/pixi/pixi.zig @@ -0,0 +1,80 @@ +//! Pixi plugin root module **and** intra-plugin import hub. +//! +//! - The shell resolves `@import("pixi")` to this file when the plugin is compiled into the app +//! (static embed) and reaches its public surface here. +//! - Files under `src/` import it as `../pixi.zig` for the shared deps (`sdk`/`core`/`dvui` + +//! core conveniences) and sibling types — the conventional `.zig` namespace. +//! +//! It must sit at the plugin root: a Zig module cannot import files above its root file's +//! directory, so this has to be beside `src/` to re-export from it. The build-side static-embed +//! glue lives in `static/`. +const std = @import("std"); + +pub const sdk = @import("sdk"); +pub const core = @import("core"); +pub const dvui = @import("dvui"); + +pub const atlas = core.atlas; +pub const math = core.math; +pub const image = core.image; +pub const fs = core.fs; +pub const perf = core.perf; +pub const Fling = core.Fling; +pub const water_surface = core.water_surface; +pub const core_sprite = core.Sprite; + +/// On-disk file format version stamp (kept in sync with `fizzy.version`). +pub const version: std.SemanticVersion = .{ .major = 0, .minor = 2, .patch = 0 }; +/// Layer rename buffer size (was `Editor.Constants.max_name_len`). +pub const max_name_len = 256; + +pub const plugin = @import("src/plugin.zig"); +pub const runtime = @import("src/runtime.zig"); + +pub const State = @import("src/State.zig"); +pub const Settings = @import("src/Settings.zig"); +pub const Docs = @import("src/Docs.zig"); +pub const Tools = @import("src/Tools.zig"); +pub const Transform = @import("src/Transform.zig"); +pub const Project = @import("src/Project.zig"); +pub const Colors = @import("src/Colors.zig"); +pub const Packer = @import("src/Packer.zig"); +pub const PackJob = @import("src/PackJob.zig"); +pub const File = @import("src/File.zig"); +pub const Layer = @import("src/Layer.zig"); +pub const Sprite = @import("src/Sprite.zig"); +pub const Atlas = @import("src/Atlas.zig"); +pub const Animation = @import("src/Animation.zig"); + +pub const render = @import("src/render.zig"); +pub const sprite_render = @import("src/sprite_render.zig"); +pub const algorithms = @import("src/algorithms/algorithms.zig"); + +pub const dialogs = struct { + pub const NewFile = @import("src/dialogs/NewFile.zig"); + pub const Export = @import("src/dialogs/Export.zig"); + pub const GridLayout = @import("src/dialogs/GridLayout.zig"); + pub const FlatRasterSaveWarning = @import("src/dialogs/FlatRasterSaveWarning.zig"); + pub const DimensionsLabel = @import("src/dialogs/dimensions_label.zig"); +}; + +pub const explorer = struct { + pub const project = @import("src/explorer/project.zig"); +}; + +pub const widgets = struct { + pub const FileWidget = @import("src/widgets/FileWidget.zig"); + pub const ImageWidget = @import("src/widgets/ImageWidget.zig"); + pub const CanvasBridge = @import("src/widgets/CanvasBridge.zig"); +}; + +pub const internal = struct { + pub const Animation = @import("src/internal/Animation.zig"); + pub const Atlas = @import("src/internal/Atlas.zig"); + pub const Buffers = @import("src/internal/Buffers.zig"); + pub const File = @import("src/internal/File.zig"); + pub const History = @import("src/internal/History.zig"); + pub const Layer = @import("src/internal/Layer.zig"); + pub const Palette = @import("src/internal/Palette.zig"); + pub const Sprite = @import("src/internal/Sprite.zig"); +}; diff --git a/src/plugins/pixi/root.zig b/src/plugins/pixi/root.zig new file mode 100644 index 00000000..08ff548e --- /dev/null +++ b/src/plugins/pixi/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the pixi plugin — canonical shape: one `exportEntry` wired to +//! `src/plugin.zig` (see `src/plugins/root.zig`). +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/Animation.zig b/src/plugins/pixi/src/Animation.zig similarity index 100% rename from src/Animation.zig rename to src/plugins/pixi/src/Animation.zig diff --git a/src/Atlas.zig b/src/plugins/pixi/src/Atlas.zig similarity index 93% rename from src/Atlas.zig rename to src/plugins/pixi/src/Atlas.zig index ff1a5346..6d159e43 100644 --- a/src/Atlas.zig +++ b/src/plugins/pixi/src/Atlas.zig @@ -1,6 +1,4 @@ const std = @import("std"); -const fs = @import("tools/fs.zig"); -const fizzy = @import("fizzy.zig"); const Atlas = @This(); @@ -25,7 +23,13 @@ const AtlasV1 = struct { }; pub fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, file: []const u8) !Atlas { - const read = try fs.read(allocator, io, file); + const cwd = std.Io.Dir.cwd(); + const handle = try cwd.openFile(io, file, .{}); + defer handle.close(io); + + var buf: [4096]u8 = undefined; + var rdr = handle.reader(io, &buf); + const read = try rdr.interface.allocRemaining(allocator, .unlimited); defer allocator.free(read); return loadFromBytes(allocator, read); diff --git a/src/plugins/pixi/src/CanvasData.zig b/src/plugins/pixi/src/CanvasData.zig new file mode 100644 index 00000000..e4c26909 --- /dev/null +++ b/src/plugins/pixi/src/CanvasData.zig @@ -0,0 +1,1270 @@ +//! The pixel-art plugin's per-workspace-pane data. Each plugin that renders documents into a +//! workbench pane will typically want a struct like this to hold its per-pane state; pixel art +//! uses it for the canvas UI that wraps a document inside the workbench-provided content region: +//! the column/row rulers, the floating Edit pill and color-sample button, the transform dialog, +//! and the grid (column/row) reorder drag state, plus the matching draw helpers. +//! +//! It is pixel-art-owned and lives per workspace pane (keyed by workbench `grouping` id on +//! `State.canvas_by_grouping`). The workbench never dereferences it; `State.removeCanvasPane` +//! frees it when a pane is torn down. +//! State the shell itself needs (the pane's physical content rect, used to center load/save +//! toasts) stays on the workbench `Workspace` and is exposed through `WorkbenchPaneView`. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const FileWidget = @import("widgets/FileWidget.zig"); +const Export = @import("dialogs/Export.zig"); +const GridLayout = @import("dialogs/GridLayout.zig"); +const Clipboard = @import("clipboard.zig"); +const TransformOp = @import("transform_op.zig"); +const DocLifecycle = @import("doc_lifecycle.zig"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); + +const File = pixi_mod.internal.File; + +const CanvasData = @This(); + +// Grid (column/row) reorder drag state. Set by the rulers (`drawRulerContent`), consumed by +// `FileWidget` (reorder preview) and committed by `processColumnReorder`/`processRowReorder`. +columns_drag_name: []const u8 = undefined, +columns_drag_index: ?usize = null, +columns_target_id: ?dvui.Id = null, +columns_target_index: ?usize = null, +columns_removed_index: ?usize = null, +columns_insert_before_index: ?usize = null, + +rows_drag_name: []const u8 = undefined, +rows_drag_index: ?usize = null, +rows_target_id: ?dvui.Id = null, +rows_target_index: ?usize = null, +rows_removed_index: ?usize = null, +rows_insert_before_index: ?usize = null, + +horizontal_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, +vertical_scroll_info: dvui.ScrollInfo = .{ .vertical = .given, .horizontal = .given }, + +horizontal_ruler_height: f32 = 0.0, +vertical_ruler_width: f32 = 0.0, + +/// Floating Edit-pill quick-access bar collapse state. Starts collapsed (single hamburger +/// button); the user toggles to expand the full action row. +edit_pill_expanded: bool = false, + +pub fn init(grouping: u64) CanvasData { + return .{ + .columns_drag_name = std.fmt.allocPrint(runtime.allocator(), "column_drag_{d}", .{grouping}) catch "column_drag", + .rows_drag_name = std.fmt.allocPrint(runtime.allocator(), "row_drag_{d}", .{grouping}) catch "row_drag", + }; +} + +/// The drag names are intentionally not freed here: `init` may have fallen back to a static +/// string literal on (effectively impossible) OOM, and freeing a literal is UB. The names are +/// short-lived and never freed. +pub fn deinit(_: *CanvasData) void {} + +/// Per-pane chrome for `grouping`, lazily allocated on first document draw. +pub fn forGrouping(grouping: u64) *CanvasData { + return runtime.state().canvasForGrouping(grouping); +} + +pub const RulerOrientation = enum { + horizontal, + vertical, +}; + +pub fn drawRuler(self: *CanvasData, file: *File, orientation: RulerOrientation) void { + const font = dvui.Font.theme(.body).larger(-1); + + const largest_label = std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{file.rows - 1}) catch { + dvui.log.err("Failed to allocate largest label", .{}); + return; + }; + const largest_label_size = font.textSize(largest_label); + const natural_scale = dvui.currentWindow().natural_scale; + const largest_label_phys = largest_label_size.scale(natural_scale, dvui.Size.Physical); + const base_ruler_size = largest_label_size.w + runtime.state().settings.ruler_padding; + + const ruler_thickness: f32 = switch (orientation) { + .horizontal => blk: { + self.horizontal_ruler_height = font.textSize("M").h + runtime.state().settings.ruler_padding; + break :blk self.horizontal_ruler_height; + }, + .vertical => blk: { + self.vertical_ruler_width = @max(base_ruler_size, font.textSize("M").h + runtime.state().settings.ruler_padding); + break :blk self.vertical_ruler_width; + }, + }; + + switch (orientation) { + .horizontal => { + var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + }); + defer canvas_hbox.deinit(); + + var corner_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .min_size_content = .{ .h = self.vertical_ruler_width, .w = self.vertical_ruler_width }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill), + }); + corner_box.deinit(); + + var top_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .h = ruler_thickness, .w = ruler_thickness }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill), + }); + defer top_box.deinit(); + + self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, null); + }, + .vertical => { + var ruler_box = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .vertical, + .min_size_content = .{ .w = ruler_thickness, .h = 1.0 }, + .background = true, + .color_fill = dvui.themeGet().color(.window, .fill), + }); + defer ruler_box.deinit(); + + self.drawRulerContent(file, font, orientation, ruler_thickness, largest_label, largest_label_phys); + }, + } +} + +/// `largest_row_index_*` come from `drawRuler` (widest row index string and its measured size in physical pixels). +fn drawRulerContent( + self: *CanvasData, + file: *File, + font: dvui.Font, + orientation: RulerOrientation, + ruler_size: f32, + largest_row_index_label: []const u8, + largest_row_index_size_phys: ?dvui.Size.Physical, +) void { + const scale = file.editor.canvas.scale; + const canvas = file.editor.canvas; + + switch (orientation) { + .horizontal => { + self.horizontal_scroll_info.virtual_size.w = canvas.scroll_info.virtual_size.w; + self.horizontal_scroll_info.virtual_size.h = ruler_size; + self.horizontal_scroll_info.viewport.w = canvas.scroll_info.viewport.w; + self.horizontal_scroll_info.viewport.x = canvas.scroll_info.viewport.x; + }, + .vertical => { + self.vertical_scroll_info.virtual_size.h = canvas.scroll_info.virtual_size.h; + self.vertical_scroll_info.virtual_size.w = ruler_size; + self.vertical_scroll_info.viewport.h = canvas.scroll_info.viewport.h; + self.vertical_scroll_info.viewport.y = canvas.scroll_info.viewport.y; + }, + } + + const scroll_info = switch (orientation) { + .horizontal => &self.horizontal_scroll_info, + .vertical => &self.vertical_scroll_info, + }; + + var scroll_area = dvui.scrollArea(@src(), .{ + .scroll_info = scroll_info, + .container = true, + .process_events_after = true, + .horizontal_bar = .hide, + .vertical_bar = .hide, + }, .{ .expand = .both }); + defer scroll_area.deinit(); + + const scale_rect = switch (orientation) { + .horizontal => dvui.Rect{ .x = -canvas.origin.x, .y = 0, .w = 0, .h = 0 }, + .vertical => dvui.Rect{ .x = 0, .y = -canvas.origin.y, .w = 0, .h = 0 }, + }; + var scaler = dvui.scale(@src(), .{ .scale = &file.editor.canvas.scale }, .{ .rect = scale_rect }); + defer scaler.deinit(); + + const outer_rect: dvui.Rect = switch (orientation) { + .horizontal => .{ + .x = 0, + .y = 0, + .w = @as(f32, @floatFromInt(file.width())), + .h = ruler_size / scale, + }, + .vertical => .{ + .x = 0, + .y = 0, + .w = ruler_size / scale, + .h = @as(f32, @floatFromInt(file.height())), + }, + }; + var outer_box = dvui.box(@src(), .{ .dir = switch (orientation) { + .horizontal => .horizontal, + .vertical => .horizontal, + } }, .{ + .expand = .none, + .rect = outer_rect, + }); + defer outer_box.deinit(); + + const drag_name = switch (orientation) { + .horizontal => self.columns_drag_name, + .vertical => self.rows_drag_name, + }; + + var reorder = pixi_mod.core.dvui.reorder(@src(), .{ .drag_name = drag_name }, .{ + .expand = .both, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .background = false, + .corner_radius = dvui.Rect.all(0), + }); + defer reorder.deinit(); + + const reorder_box_dir: dvui.enums.Direction = switch (orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }; + var reorder_box = dvui.box(@src(), .{ .dir = reorder_box_dir }, .{ + .expand = .both, + .background = false, + .corner_radius = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + }); + defer reorder_box.deinit(); + + const ruler_stroke_color = dvui.themeGet().color(.control, .fill_hover).lighten(switch (orientation) { + .horizontal => 2.0, + .vertical => 0.0, + }); + + const edge_stroke_points = switch (orientation) { + .horizontal => .{ + reorder_box.data().rectScale().r.topRight(), + reorder_box.data().rectScale().r.bottomRight(), + }, + .vertical => .{ + reorder_box.data().rectScale().r.bottomRight(), + reorder_box.data().rectScale().r.bottomLeft(), + }, + }; + defer dvui.Path.stroke(.{ .points = &edge_stroke_points }, .{ + .color = ruler_stroke_color, + .thickness = 1.0, + }); + + const count = switch (orientation) { + .horizontal => file.columns, + .vertical => file.rows, + }; + const cell_min_size: dvui.Size = switch (orientation) { + .horizontal => .{ .w = @as(f32, @floatFromInt(file.column_width)), .h = 1.0 }, + .vertical => .{ .w = 1.0, .h = @as(f32, @floatFromInt(file.row_height)) }, + }; + const reorder_mode: pixi_mod.core.dvui.ReorderWidget.Reorderable.Mode = switch (orientation) { + .horizontal => .any_y, + .vertical => .any_x, + }; + const reorder_expand: dvui.Options.Expand = switch (orientation) { + .horizontal => .vertical, + .vertical => .horizontal, + }; + + // Shared layout width for every row tick (widest index string); actual glyph size may differ per cell. + const vertical_row_layout_size_phys: ?dvui.Size.Physical = switch (orientation) { + .vertical => largest_row_index_size_phys, + .horizontal => null, + }; + + // Captured during iteration: the highlighted target slot (drop location) screen rect. + var target_rs_screen: ?dvui.RectScale = null; + + var index: usize = 0; + while (index < count) : (index += 1) { + var reorderable = reorder.reorderable(@src(), .{ + .mode = reorder_mode, + .clamp_to_edges = true, + }, .{ + .expand = reorder_expand, + .id_extra = index, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .min_size_content = cell_min_size, + }); + defer reorderable.deinit(); + + if (reorderable.targetRectScale()) |trs| { + target_rs_screen = trs; + } + + var button_color = if (reorder.drag_point != null) dvui.themeGet().color(.control, .fill).opacity(0.85) else dvui.themeGet().color(.window, .fill); + + if (pixi_mod.core.dvui.hovered(reorderable.data())) { + button_color = dvui.themeGet().color(.control, .fill_hover); + dvui.cursorSet(.hand); + } + + var cell_box: dvui.BoxWidget = undefined; + cell_box.init(@src(), .{ .dir = .horizontal }, .{ + .expand = .both, + .background = true, + .color_fill = button_color, + .id_extra = index, + }); + + switch (orientation) { + .horizontal => { + if (reorderable.floating()) { + self.columns_drag_index = index; + reorder.reorderable_size.h = 0.0; + dvui.cursorSet(.hand); + } + if (reorderable.removed()) self.columns_removed_index = index; + if (reorderable.insertBefore()) self.columns_insert_before_index = index; + if (reorderable.targetID()) |target_id| self.columns_target_id = target_id; + if (self.columns_drag_index) |_| { + var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); + mouse_pt.y = 0.0; + mouse_pt.x = std.math.clamp(mouse_pt.x, 0.0, @as(f32, @floatFromInt(file.width() - 1))); + self.columns_target_index = file.columnIndex(mouse_pt); + } + }, + .vertical => { + if (reorderable.floating()) { + self.rows_drag_index = index; + reorder.reorderable_size.w = 0.0; + dvui.cursorSet(.hand); + } + if (reorderable.removed()) self.rows_removed_index = index; + if (reorderable.insertBefore()) self.rows_insert_before_index = index; + if (reorderable.targetID()) |target_id| self.rows_target_id = target_id; + if (self.rows_drag_index) |_| { + var mouse_pt = @constCast(&file.editor.canvas).dataFromScreenPoint(dvui.currentWindow().mouse_pt); + mouse_pt.x = 0.0; + mouse_pt.y = std.math.clamp(mouse_pt.y, 0.0, @as(f32, @floatFromInt(file.height() - 1))); + self.rows_target_index = file.rowIndex(mouse_pt); + } + }, + } + + { + defer cell_box.deinit(); + + // The dragged item's cell_box is parented to the reorderable's floating widget + // (rendered at the mouse position). We collapse that floating widget to h/w = 0 + // above, but `dvui.renderText` is not clipped by that, so the label would still + // appear at the cursor. Skip the visible cell rendering entirely while floating; + // the dragged label is drawn over the highlighted target slot below instead. + if (!reorderable.floating()) { + cell_box.drawBackground(); + + const label = switch (orientation) { + .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(index)) catch { + dvui.log.err("Failed to allocate label", .{}); + return; + }, + .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{index}) catch { + dvui.log.err("Failed to allocate label", .{}); + return; + }, + }; + + self.drawRulerLabel(.{ + .font = font, + .label = label, + .rect = cell_box.data().rectScale().r, + .color = dvui.themeGet().color(.control, .text).opacity(0.5), + .mode = switch (orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }, + .largest_label = if (orientation == .vertical) largest_row_index_label else null, + .ref_size_physical = vertical_row_layout_size_phys, + }); + + const cell_rect = cell_box.data().rectScale().r; + const cell_stroke_points = switch (orientation) { + .horizontal => .{ cell_rect.topLeft(), cell_rect.bottomLeft() }, + .vertical => .{ cell_rect.topLeft(), cell_rect.topRight() }, + }; + dvui.Path.stroke(.{ .points = &cell_stroke_points }, .{ .color = ruler_stroke_color, .thickness = 2.0 }); + } + + loop: for (dvui.events()) |*e| { + if (!cell_box.matchEvent(e)) continue; + + switch (e.evt) { + .mouse => |me| { + if (me.action == .press and me.button.pointer()) { + e.handle(@src(), cell_box.data()); + dvui.captureMouse(cell_box.data(), e.num); + dvui.dragPreStart(me.p, .{ + .size = reorderable.data().rectScale().r.size(), + .offset = reorderable.data().rectScale().r.topLeft().diff(me.p), + }); + } else if (me.action == .release and me.button.pointer()) { + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + switch (orientation) { + .horizontal => self.columns_drag_index = null, + .vertical => self.rows_drag_index = null, + } + } else if (me.action == .motion) { + if (dvui.captured(cell_box.data().id)) { + e.handle(@src(), cell_box.data()); + if (dvui.dragging(me.p, null)) |_| { + reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); + break :loop; + } + } + } + }, + else => {}, + } + } + } + } + + const final_slot_id = switch (orientation) { + .horizontal => file.columns, + .vertical => file.rows, + }; + if (reorder.needFinalSlot()) { + var reorderable = reorder.reorderable(@src(), .{ + .mode = reorder_mode, + .last_slot = true, + .clamp_to_edges = true, + }, .{ + .expand = reorder_expand, + .id_extra = final_slot_id, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .min_size_content = cell_min_size, + }); + defer reorderable.deinit(); + + if (reorderable.targetRectScale()) |trs| { + target_rs_screen = trs; + } + + if (reorderable.insertBefore()) { + switch (orientation) { + .horizontal => self.columns_insert_before_index = final_slot_id, + .vertical => self.rows_insert_before_index = final_slot_id, + } + } + } + + // Drag overlay: draw the dragged column/row label on the highlighted target slot in + // highlight-text color (no extra fill, the reorderable's own focus fill is the + // background) and a thick err-colored marker line at the dragged-from position in the + // ruler that lines up with the equivalent indicator in the file canvas. + const drag_idx_for_overlay = switch (orientation) { + .horizontal => self.columns_drag_index, + .vertical => self.rows_drag_index, + }; + if (drag_idx_for_overlay) |di| { + const target_idx_opt = switch (orientation) { + .horizontal => self.columns_target_index, + .vertical => self.rows_target_index, + }; + const same_slot = target_idx_opt == di; + + if (target_rs_screen) |trs| { + const drag_label_opt: ?[]const u8 = switch (orientation) { + .horizontal => file.fmtColumn(dvui.currentWindow().arena(), @intCast(di)) catch null, + .vertical => std.fmt.allocPrint(dvui.currentWindow().arena(), "{d}", .{di}) catch null, + }; + if (drag_label_opt) |drag_label| { + if (same_slot) { + // Reorderable still draws theme focus fill for the drop target; paint control + // hover on top so "no move" matches ruler button hover styling. + trs.r.fill(.all(0), .{ .color = dvui.themeGet().color(.control, .fill_hover), .fade = 1.0 }); + } + self.drawRulerLabel(.{ + .font = font, + .label = drag_label, + .rect = trs.r, + .color = if (same_slot) + dvui.themeGet().color(.control, .text).opacity(0.5) + else + dvui.themeGet().color(.highlight, .text), + .mode = switch (orientation) { + .horizontal => .horizontal, + .vertical => .vertical, + }, + .largest_label = if (orientation == .vertical) largest_row_index_label else null, + .ref_size_physical = vertical_row_layout_size_phys, + }); + } + } + + // Use the canvas data->screen mapping for the cross-axis position so the marker + // line aligns exactly with the err indicator drawn over the file canvas grid. + // The other axis uses the ruler's own screen extents so the line fills the ruler. + const target_idx_for_line = switch (orientation) { + .horizontal => self.columns_target_index, + .vertical => self.rows_target_index, + }; + if (target_idx_for_line) |ti| { + if (di != ti) { + const removed_data_rect = switch (orientation) { + .horizontal => file.columnRect(di), + .vertical => file.rowRect(di), + }; + const removed_canvas_screen = file.editor.canvas.screenFromDataRect(removed_data_rect); + const ruler_screen = outer_box.data().contentRectScale().r; + const err_color = dvui.themeGet().color(.err, .fill); + const thickness = 3.0 * dvui.currentWindow().natural_scale; + switch (orientation) { + .horizontal => { + const edge_x = if (di < ti) + removed_canvas_screen.x + else + removed_canvas_screen.x + removed_canvas_screen.w; + dvui.Path.stroke(.{ .points = &.{ + .{ .x = edge_x, .y = ruler_screen.y }, + .{ .x = edge_x, .y = ruler_screen.y + ruler_screen.h }, + } }, .{ .thickness = thickness, .color = err_color }); + }, + .vertical => { + const edge_y = if (di < ti) + removed_canvas_screen.y + else + removed_canvas_screen.y + removed_canvas_screen.h; + dvui.Path.stroke(.{ .points = &.{ + .{ .x = ruler_screen.x, .y = edge_y }, + .{ .x = ruler_screen.x + ruler_screen.w, .y = edge_y }, + } }, .{ .thickness = thickness, .color = err_color }); + }, + } + } + } + } +} + +pub const TextLabelOptions = struct { + pub const Mode = enum { + horizontal, + vertical, + }; + + font: dvui.Font, + label: []const u8, + rect: dvui.Rect.Physical, + color: dvui.Color, + mode: Mode = .horizontal, + /// Widest row index string (e.g. `"99"`); layout cell size uses this, text may be a shorter index. + largest_label: ?[]const u8 = null, + /// When set, layout size for that widest string (already × `natural_scale`); skips `textSize(largest_label)` per cell. + ref_size_physical: ?dvui.Size.Physical = null, +}; + +pub fn drawRulerLabel(_: *CanvasData, options: TextLabelOptions) void { + const font = options.font; + const label = options.label; + const rect = options.rect; + const color = options.color; + const natural = dvui.currentWindow().natural_scale; + + const ref_for_layout = options.largest_label orelse label; + const label_size = options.ref_size_physical orelse font.textSize(ref_for_layout).scale(natural, dvui.Size.Physical); + const actual_label_size = if (std.mem.eql(u8, ref_for_layout, label)) + label_size + else + font.textSize(label).scale(natural, dvui.Size.Physical); + + const padding = runtime.state().settings.ruler_padding * natural; + + var label_rect = rect; + + if (label_size.w + padding <= label_rect.w and options.mode == .horizontal) { + label_rect.h = label_size.h + padding; + label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; + label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; + + dvui.renderText(.{ + .text = label, + .font = font, + .color = color, + .rs = .{ + .r = label_rect, + .s = natural, + }, + }) catch { + dvui.log.err("Failed to render text", .{}); + }; + } else if (label_size.h + padding <= label_rect.h and options.mode == .vertical) { + label_rect.w = label_size.h + padding; + label_rect.x += (label_rect.w - actual_label_size.w) / 2.0; + label_rect.y += (label_rect.h - actual_label_size.h) / 2.0; + + dvui.renderText(.{ + .text = label, + .font = font, + .color = color, + .rs = .{ + .r = label_rect, + .s = natural, + }, + }) catch { + dvui.log.err("Failed to render text", .{}); + }; + } +} + +pub fn processColumnReorder(self: *CanvasData, file: *File) void { + if (self.columns_removed_index) |columns_removed_index| { + if (self.columns_insert_before_index) |columns_insert_before_index| { + defer self.columns_removed_index = null; + defer self.columns_insert_before_index = null; + + if (columns_removed_index == columns_insert_before_index or columns_removed_index + 1 == columns_insert_before_index) return; + + file.reorderColumns(columns_removed_index, columns_insert_before_index) catch { + dvui.log.err("Failed to reorder columns", .{}); + return; + }; + + // We'll store the previous indices for clarity. + const prev_removed_index = columns_removed_index; + const prev_insert_before_index = columns_insert_before_index; + + if (prev_removed_index < prev_insert_before_index) { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .columns, + .removed_index = prev_insert_before_index - 1, + .insert_before_index = prev_removed_index, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } else { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .columns, + .removed_index = prev_insert_before_index, + .insert_before_index = prev_removed_index + 1, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } + } + } +} + +pub fn processRowReorder(self: *CanvasData, file: *File) void { + if (self.rows_removed_index) |rows_removed_index| { + if (self.rows_insert_before_index) |rows_insert_before_index| { + defer self.rows_removed_index = null; + defer self.rows_insert_before_index = null; + if (rows_removed_index == rows_insert_before_index or rows_removed_index + 1 == rows_insert_before_index) return; + + file.reorderRows(rows_removed_index, rows_insert_before_index) catch { + dvui.log.err("Failed to reorder rows", .{}); + return; + }; + + // We'll store the previous indices for clarity. + const prev_removed_index = rows_removed_index; + const prev_insert_before_index = rows_insert_before_index; + + if (prev_removed_index < prev_insert_before_index) { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .rows, + .removed_index = prev_insert_before_index - 1, + .insert_before_index = prev_removed_index, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } else { + file.history.append(.{ + .reorder_col_row = .{ + .mode = .rows, + .removed_index = prev_insert_before_index, + .insert_before_index = prev_removed_index + 1, + }, + }) catch { + dvui.log.err("Failed to append history", .{}); + }; + } + } + } +} + +pub fn drawTransformDialog(_: *CanvasData, file: *File, container: *dvui.WidgetData) void { + if (file.editor.transform) |*transform| { + var rect = container.rect; + rect.w = 0; + rect.h = 0; + + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .{ .x = container.rectScale().r.toNatural().x + 10, .y = container.rectScale().r.toNatural().y + 10, .w = 0, .h = 0 }, + .expand = .none, + .background = true, + .color_fill = dvui.themeGet().color(.control, .fill), + .corner_radius = dvui.Rect.all(8), + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 8, + .corner_radius = dvui.Rect.all(8), + }, + }); + defer fw.deinit(); + + var anim = dvui.animate(@src(), .{ .kind = .vertical, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); + defer anim.deinit(); + + var anim_box = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + }); + defer anim_box.deinit(); + + dvui.labelNoFmt(@src(), "TRANSFORM", .{ .align_x = 0.5 }, .{ + .padding = dvui.Rect.all(4), + .expand = .horizontal, + .font = dvui.Font.theme(.heading).withWeight(.bold), + }); + _ = dvui.separator(@src(), .{ .expand = .horizontal }); + + _ = dvui.spacer(@src(), .{ .expand = .horizontal }); + + var degrees: f32 = std.math.radiansToDegrees(transform.rotation); + + var slider_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .background = false, + }); + + if (dvui.sliderEntry(@src(), "{d:0.0}°", .{ + .value = °rees, + .min = 0, + .max = 360, + .interval = 1, + }, .{ .expand = .horizontal, .color_fill = dvui.themeGet().color(.window, .fill) })) { + transform.rotation = std.math.degreesToRadians(degrees); + } + slider_box.deinit(); + + if (transform.ortho) { + var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ + .expand = .horizontal, + .background = false, + }); + defer box.deinit(); + dvui.label(@src(), "Width: {d:0.0}", .{transform.point(.bottom_left).diff(transform.point(.bottom_right).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); + dvui.label(@src(), "Height: {d:0.0}", .{transform.point(.top_left).diff(transform.point(.bottom_left).*).length()}, .{ .expand = .horizontal, .font = dvui.Font.theme(.heading) }); + } + + { + var box = dvui.box(@src(), .{ .dir = .horizontal, .equal_space = true }, .{ + .expand = .horizontal, + .background = false, + }); + defer box.deinit(); + if (dvui.buttonIcon(@src(), "transform_cancel", icons.tvg.lucide.@"trash-2", .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .err, .expand = .horizontal })) { + DocLifecycle.cancelEdit(runtime.state()); + } + if (dvui.buttonIcon(@src(), "transform_accept", icons.tvg.lucide.check, .{}, .{ .stroke_color = dvui.themeGet().color(.window, .fill) }, .{ .style = .highlight, .expand = .horizontal })) { + DocLifecycle.acceptEdit(runtime.state()); + } + } + } +} + +/// Floating rounded-pill quick-access bar anchored to the top-right of the workspace +/// canvas. Mirrors the Edit menu (Undo / Redo / Copy / Paste / Transform / Grid Layout) +/// with icon-only round buttons sized to match the toolbox buttons. Starts collapsed as a +/// single hamburger circle; tapping toggles the row of action buttons in/out with a +/// width animation. +pub fn drawEditPill(self: *CanvasData, container: *dvui.WidgetData) void { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; + + const button_size: f32 = 36; + const button_gap: f32 = 6; + const pill_padding: f32 = 6; + const margin: f32 = 10; + // Canvas scroll area uses a non-overlay vertical bar on the right edge; keep the + // pill clear of it (see `CanvasWidget.install` + dvui `ScrollBarWidget` width). + const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; + // Icons render at ~60% of their previous size — previous padding was 0.22 (icon + // ≈ 56% of button); new padding is 0.33 so the icon ends up ≈ 34% of the button, + // which is roughly 60% of the prior icon footprint. + const icon_padding: f32 = button_size * 0.33; + + const Action = enum { save, exportd, undo, redo, copy, paste, transform, grid_layout }; + const Entry = struct { + action: Action, + tvg: []const u8, + tooltip: []const u8, + }; + + const entries = [_]Entry{ + .{ .action = .save, .tvg = icons.tvg.lucide.save, .tooltip = "Save" }, + .{ .action = .exportd, .tvg = icons.tvg.lucide.@"file-output", .tooltip = "Export" }, + .{ .action = .undo, .tvg = icons.tvg.lucide.undo, .tooltip = "Undo" }, + .{ .action = .redo, .tvg = icons.tvg.lucide.redo, .tooltip = "Redo" }, + .{ .action = .copy, .tvg = icons.tvg.lucide.copy, .tooltip = "Copy" }, + .{ .action = .paste, .tvg = icons.tvg.lucide.@"clipboard-paste", .tooltip = "Paste" }, + .{ .action = .transform, .tvg = icons.tvg.lucide.scaling, .tooltip = "Transform" }, + .{ .action = .grid_layout, .tvg = icons.tvg.lucide.@"layout-grid", .tooltip = "Grid Layout" }, + }; + + // Vertical pill: width is fixed (one button + padding), height animates between a + // single-button "collapsed" state and the full-stack "expanded" state. Most screens + // have more vertical real estate than horizontal, so growing the pill downward keeps + // it from eating into the canvas's working width. + const pill_w: f32 = button_size + 2 * pill_padding; + const collapsed_h: f32 = button_size + 2 * pill_padding; + const expanded_h: f32 = @as(f32, @floatFromInt(entries.len + 1)) * button_size + + @as(f32, @floatFromInt(entries.len)) * button_gap + 2 * pill_padding; + const pill_radius: f32 = pill_w / 2; + const btn_radius: f32 = button_size / 2; + + // Drive the expand/collapse with a dvui animation. Look up the current value, and on + // a toggle click kick off a new animation between the current value and the target. + const anim_id = dvui.Id.update(container.id, "edit_pill_expand"); + var anim_value: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; + if (dvui.animationGet(anim_id, "_t")) |a| anim_value = std.math.clamp(a.value(), 0.0, 1.0); + + const pill_h: f32 = collapsed_h + (expanded_h - collapsed_h) * anim_value; + + // Compute the scroll-area rect — the canvas region inside the rulers. We pull this + // off the live `canvas_vbox` (so the values are this frame's, not a stale latch) and + // subtract the ruler thickness from the top/left. Anchoring against this rect means + // the pill follows the workspace exactly: as a split is dragged shut the canvas area + // shrinks, and once it's narrower than the pill we bail and draw nothing this frame — + // so closing splits cleanly hides the menu. + const wb = container.rectScale().r.toNatural(); + const ruler_top: f32 = if (runtime.state().settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (runtime.state().settings.show_rulers) self.vertical_ruler_width else 0; + const canvas_nat = dvui.Rect{ + .x = wb.x + ruler_left, + .y = wb.y + ruler_top, + .w = wb.w - ruler_left, + .h = wb.h - ruler_top, + }; + + if (canvas_nat.w < pill_w + margin + right_margin or canvas_nat.h < collapsed_h + 2 * margin) return; + + const pill_x: f32 = canvas_nat.x + canvas_nat.w - right_margin - pill_w; + const pill_y: f32 = canvas_nat.y + margin; + + // Clamp the bottom edge so the expanded pill never spills past the canvas area — + // FloatingWidget bypasses parent clipping, so we cap the height explicitly. + const max_pill_h: f32 = canvas_nat.h - 2 * margin; + const effective_pill_h: f32 = @min(pill_h, max_pill_h); + + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .{ + .x = pill_x, + .y = pill_y, + .w = pill_w, + .h = effective_pill_h, + }, + .expand = .none, + .background = self.edit_pill_expanded, + .color_fill = dvui.themeGet().color(.window, .fill), + .corner_radius = dvui.Rect.all(pill_radius), + .box_shadow = if (self.edit_pill_expanded) .{ + .color = .black, + .alpha = 0.25, + .fade = 10, + .offset = .{ .x = 0, .y = 3 }, + .corner_radius = dvui.Rect.all(pill_radius), + } else null, + }); + defer fw.deinit(); + + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = false, + .padding = dvui.Rect.all(pill_padding), + }); + defer vbox.deinit(); + + // Hamburger toggle is always present at the top of the pill; the stack of action + // buttons grows downward beneath it as the pill expands. + { + var btn: dvui.ButtonWidget = undefined; + btn.init(@src(), .{}, .{ + .id_extra = entries.len, // distinct from action button ids below + .min_size_content = .{ .w = button_size, .h = button_size }, + .expand = .none, + .gravity_x = 0.5, + .gravity_y = 0.0, + .background = true, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = dvui.themeGet().color(.content, .fill), + .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + .padding = .all(0), + .margin = .{}, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + btn.processEvents(); + btn.drawBackground(); + + const icon_color = dvui.themeGet().color(.content, .text); + dvui.icon( + @src(), + "edit_pill_toggle", + icons.tvg.lucide.menu, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = 1.0 }, + .padding = dvui.Rect.all(icon_padding), + }, + ); + + if (btn.clicked()) { + self.edit_pill_expanded = !self.edit_pill_expanded; + const target: f32 = if (self.edit_pill_expanded) 1.0 else 0.0; + dvui.animation(anim_id, "_t", .{ + .start_val = anim_value, + .end_val = target, + .end_time = 250_000, + .easing = dvui.easing.outBack, + }); + } + } + + // Action buttons live inside a scroll area so the pill stays the right width and + // never visually "squishes" when there isn't enough vertical room — instead the + // overflow buttons become reachable via vertical scroll inside the pill. Bars are + // hidden to preserve the rounded-pill look; touch / wheel still drives the scroll. + var actions_scroll = dvui.scrollArea(@src(), .{ + .vertical_bar = .hide, + .horizontal_bar = .hide, + }, .{ + .expand = .both, + .background = false, + .padding = .{}, + .margin = .{}, + .border = dvui.Rect.all(0), + .color_fill = .transparent, + }); + defer actions_scroll.deinit(); + + // Action buttons stacked below the hamburger. We draw them all and let the + // scrollArea handle any overflow when the pill is clamped to the canvas height. + for (entries, 0..) |entry, i| { + const enabled: bool = switch (entry.action) { + .save => file.dirty(), + .undo => file.history.undo_stack.items.len > 0, + .redo => file.history.redo_stack.items.len > 0, + else => true, + }; + + var btn: dvui.ButtonWidget = undefined; + btn.init(@src(), .{}, .{ + .id_extra = i, + .min_size_content = .{ .w = button_size, .h = button_size }, + .expand = .none, + .gravity_x = 0.5, + .background = true, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = dvui.themeGet().color(.content, .fill), + .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + .padding = .all(0), + .margin = .{ .y = button_gap }, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + btn.processEvents(); + btn.drawBackground(); + + const icon_color = if (enabled) dvui.themeGet().color(.content, .text) else dvui.themeGet().color(.content, .text).opacity(0.35); + + dvui.icon( + @src(), + entry.tooltip, + entry.tvg, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = 1.0 }, + .padding = dvui.Rect.all(icon_padding), + }, + ); + + // Suppress activation while collapsed (or mid-animation) so a stray tap on a + // partially-visible button doesn't fire an Edit action behind the hamburger. + const fully_expanded = anim_value >= 0.999; + if (btn.clicked() and enabled and fully_expanded) { + switch (entry.action) { + .save => runtime.state().host.save() catch { + dvui.log.err("Failed to save", .{}); + }, + .exportd => { + // Open the Export dialog (same configuration the `export` keybind uses). + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ + .displayFn = Export.dialog, + .callafterFn = Export.callAfter, + .title = "Export...", + .ok_label = "Export", + .cancel_label = "Cancel", + .resizeable = false, + .modal = false, + .header_kind = .info, + .default = .ok, + }); + mutex.mutex.unlock(dvui.io); + }, + .undo => file.history.undoRedo(file, .undo) catch { + dvui.log.err("Failed to undo", .{}); + }, + .redo => file.history.undoRedo(file, .redo) catch { + dvui.log.err("Failed to redo", .{}); + }, + .copy => Clipboard.copy(runtime.state()) catch { + dvui.log.err("Failed to copy", .{}); + }, + .paste => Clipboard.paste(runtime.state()) catch { + dvui.log.err("Failed to paste", .{}); + }, + .transform => TransformOp.begin(runtime.state()) catch { + dvui.log.err("Failed to start transform", .{}); + }, + .grid_layout => { + if (runtime.state().host.activeDoc()) |doc| GridLayout.request(doc.id); + }, + } + } + } +} + +/// Floating round button anchored just to the left of the Edit pill at the top-right of +/// the canvas. Tapping it shows a tooltip explaining the gesture; the primary action is +/// to drag from the button toward whatever pixel you want to sample. The button itself +/// stays put — instead, while the drag is in progress, we route the touch position +/// through to `file.editor.canvas.sample_data_point` so `FileWidget.drawSample` renders +/// the existing color-dropper magnifier at the touch location. On release we read the +/// color underneath the sample point and apply it to the primary color slot. +pub fn drawSampleButton(self: *CanvasData, container: *dvui.WidgetData) void { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; + + const pill_button_size: f32 = 36; + const pill_padding: f32 = 6; + const pill_outer_w: f32 = pill_button_size + 2 * pill_padding; + const button_size: f32 = 36; + const btn_radius: f32 = button_size / 2; + const icon_padding: f32 = button_size * 0.33; + const margin: f32 = 10; + const right_margin: f32 = margin + dvui.ScrollBarWidget.defaults.min_sizeGet().w; + const gap: f32 = 6; + + // Anchor against the same canvas-scroll-area rect the pill uses. + const wb = container.rectScale().r.toNatural(); + const ruler_top: f32 = if (runtime.state().settings.show_rulers) self.horizontal_ruler_height else 0; + const ruler_left: f32 = if (runtime.state().settings.show_rulers) self.vertical_ruler_width else 0; + const canvas_nat = dvui.Rect{ + .x = wb.x + ruler_left, + .y = wb.y + ruler_top, + .w = wb.w - ruler_left, + .h = wb.h - ruler_top, + }; + + // Only draw when the canvas area can fit pill + gap + sample button + margins. + if (canvas_nat.w < pill_outer_w + gap + button_size + margin + right_margin) return; + if (canvas_nat.h < button_size + 2 * margin) return; + + const btn_x = canvas_nat.x + canvas_nat.w - right_margin - pill_outer_w - gap - button_size; + // Match the hamburger row inside the pill (pill top + inner vbox padding). + const btn_y = canvas_nat.y + margin + pill_padding; + + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .{ .x = btn_x, .y = btn_y, .w = button_size, .h = button_size }, + .expand = .none, + .background = false, + }); + defer fw.deinit(); + + var btn: dvui.ButtonWidget = undefined; + // `touch_drag = true` keeps `ButtonWidget`'s own capture alive while the touch is + // dragging away from the button — without it, dvui's default `clickedEx` releases + // capture as soon as the drag crosses the threshold (treating the gesture as a + // canceled scroll), which would also cancel our custom drag-to-sample handler. + btn.init(@src(), .{ .touch_drag = true }, .{ + .expand = .both, + .background = true, + .min_size_content = .{ .w = button_size, .h = button_size }, + .corner_radius = dvui.Rect.all(btn_radius), + .color_fill = dvui.themeGet().color(.content, .fill), + .color_fill_hover = dvui.themeGet().color(.content, .fill).lighten(if (dvui.themeGet().dark) 10.0 else -10.0), + .color_border = .transparent, + .padding = .all(0), + .margin = .{}, + .box_shadow = .{ + .color = .black, + .alpha = 0.2, + .fade = 4, + .offset = .{ .x = 0, .y = 2 }, + .corner_radius = dvui.Rect.all(btn_radius), + }, + }); + defer btn.deinit(); + + // Persistent drag state (a press is "drag-sampling" once motion clears the dvui drag + // threshold). Stored via dataSet because the button widget is recreated each frame. + const drag_state_id = dvui.Id.update(container.id, "sample_button_drag"); + var is_drag_sampling = dvui.dataGet(null, drag_state_id, "active", bool) orelse false; + var did_sample = dvui.dataGet(null, drag_state_id, "did_sample", bool) orelse false; + + // The button's screen rect is the "press home base"; events that happen here belong + // to us regardless of whether motion has carried the pointer away. + const btn_rs = btn.data().rectScale(); + + // Custom event handling runs *before* `btn.processEvents()` so we can claim the + // press / motion / release events first. `ButtonWidget.clickedEx` ALWAYS releases + // mouse capture and ends the drag on a release event (regardless of touch_drag) — + // if we ran after it, our release branch would see `dvui.captured(...)` already + // false and the magnifier would stay stuck on screen. Calling `e.handle(...)` here + // makes `clickedEx`'s match-event check skip these events entirely, so the button + // leaves our gesture alone. + for (dvui.events()) |*e| { + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + + switch (me.action) { + .press => { + if (!me.button.pointer()) continue; + if (!btn_rs.r.contains(me.p)) continue; + e.handle(@src(), btn.data()); + dvui.captureMouse(btn.data(), e.num); + dvui.dragPreStart(me.p, .{ .name = "sample_button_drag" }); + is_drag_sampling = false; + did_sample = false; + }, + .motion => { + if (!dvui.captured(btn.data().id)) continue; + if (dvui.dragging(me.p, "sample_button_drag")) |_| { + is_drag_sampling = true; + if (file.editor.canvas.samplePointerInViewport(me.p)) { + const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); + dvui.dataSet(null, file.editor.canvas.id, "sample_data_point", data_pt); + did_sample = true; + } else { + dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); + } + dvui.refresh(null, @src(), file.editor.canvas.id); + e.handle(@src(), btn.data()); + } + }, + .release => { + if (!me.button.pointer()) continue; + if (!dvui.captured(btn.data().id)) continue; + e.handle(@src(), btn.data()); + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + + if (is_drag_sampling and did_sample and file.editor.canvas.samplePointerInViewport(me.p)) { + const data_pt = file.editor.canvas.dataFromScreenPoint(me.p); + FileWidget.sampleColorAtPoint(file, data_pt, false, true, true); + } + + // Clear sample state so the magnifier disappears on the next frame. + dvui.dataRemove(null, file.editor.canvas.id, "sample_data_point"); + is_drag_sampling = false; + did_sample = false; + dvui.refresh(null, @src(), file.editor.canvas.id); + }, + else => {}, + } + } + + // Persist the drag state for the next frame's widget recreate. + dvui.dataSet(null, drag_state_id, "active", is_drag_sampling); + dvui.dataSet(null, drag_state_id, "did_sample", did_sample); + + // Now let the button run its own pass to handle hover styling against any remaining + // (non-claimed) events — i.e. plain mouse hover when we're not in a drag. + btn.processEvents(); + btn.drawBackground(); + + const icon_color = dvui.themeGet().color(.content, .text); + dvui.icon( + @src(), + "sample_dropper", + icons.tvg.lucide.pipette, + .{ .stroke_color = icon_color, .fill_color = icon_color }, + .{ + .expand = .ratio, + .gravity_x = 0.5, + .gravity_y = 0.5, + .min_size_content = .{ .w = 1.0, .h = 1.0 }, + .padding = dvui.Rect.all(icon_padding), + }, + ); + + // While the drag is in progress, hide the OS cursor entirely so only the canvas + // magnifier (drawn at the touch point via `FileWidget.drawSample`) communicates + // where the sample is happening. Set after `btn.processEvents()` so it overrides + // the `.hand` hover cursor `clickedEx` would otherwise leave in place. + if (is_drag_sampling) { + dvui.cursorSet(.hidden); + } + + // Tooltip prompting the gesture. We hide it during an active sample drag so it + // doesn't compete with the magnifier on screen. + if (!is_drag_sampling) { + var tooltip: dvui.FloatingTooltipWidget = undefined; + tooltip.init(@src(), .{ + .active_rect = btn.data().rectScale().r, + .delay = 350_000, + }, .{ + .color_fill = dvui.themeGet().color(.window, .fill), + .border = dvui.Rect.all(0), + .box_shadow = .{ + .color = .black, + .shrink = 0, + .corner_radius = dvui.Rect.all(8), + .offset = .{ .x = 0, .y = 2 }, + .fade = 4, + .alpha = 0.2, + }, + }); + defer tooltip.deinit(); + + if (tooltip.shown()) { + var anim = dvui.animate(@src(), .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both }); + defer anim.deinit(); + + var tl = dvui.textLayout(@src(), .{}, .{ + .background = false, + .padding = dvui.Rect.all(6), + }); + tl.format("Drag to sample color", .{}, .{ .font = dvui.Font.theme(.body) }); + tl.deinit(); + } + } +} diff --git a/src/plugins/pixi/src/Colors.zig b/src/plugins/pixi/src/Colors.zig new file mode 100644 index 00000000..c55951a7 --- /dev/null +++ b/src/plugins/pixi/src/Colors.zig @@ -0,0 +1,11 @@ +const std = @import("std"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); + +const Self = @This(); + +primary: [4]u8 = .{ 255, 255, 255, 255 }, +secondary: [4]u8 = .{ 0, 0, 0, 255 }, +height: u8 = 0, +palette: ?pixi_mod.internal.Palette = null, +file_tree_palette: ?pixi_mod.internal.Palette = null, diff --git a/src/plugins/pixi/src/Docs.zig b/src/plugins/pixi/src/Docs.zig new file mode 100644 index 00000000..19d845fa --- /dev/null +++ b/src/plugins/pixi/src/Docs.zig @@ -0,0 +1,37 @@ +//! Open-document registry for the pixel-art plugin. +//! +//! The shell stores opaque `DocHandle`s in `Editor.open_files`; this map owns the +//! concrete `Internal.File` values their `ptr` fields point at. +const std = @import("std"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const sdk = pixi_mod.sdk; +const Internal = pixi_mod.internal; + +const Docs = @This(); + +files: std.AutoArrayHashMapUnmanaged(u64, Internal.File) = .{}, + +pub fn fileFrom(self: *Docs, doc: sdk.DocHandle) *Internal.File { + return self.files.getPtr(doc.id).?; +} + +pub fn activeFile(self: *Docs, host: *sdk.Host) ?*Internal.File { + const doc = host.activeDoc() orelse return null; + return self.fileById(doc.id); +} + +pub fn fileById(self: *Docs, id: u64) ?*Internal.File { + return self.files.getPtr(id); +} + +pub fn fileFromPath(self: *Docs, path: []const u8) ?*Internal.File { + for (self.files.values()) |*file| { + if (std.mem.eql(u8, file.path, path)) return file; + } + return null; +} + +pub fn deinit(self: *Docs, allocator: std.mem.Allocator) void { + self.files.deinit(allocator); +} diff --git a/src/File.zig b/src/plugins/pixi/src/File.zig similarity index 84% rename from src/File.zig rename to src/plugins/pixi/src/File.zig index bb4cd017..df2157cf 100644 --- a/src/File.zig +++ b/src/plugins/pixi/src/File.zig @@ -1,5 +1,8 @@ const std = @import("std"); -const fizzy = @import("fizzy.zig"); + +const Layer = @import("Layer.zig"); +const Sprite = @import("Sprite.zig"); +const Animation = @import("Animation.zig"); const File = @This(); @@ -13,11 +16,11 @@ column_width: u32, row_height: u32, // Layer data -layers: []fizzy.Layer, +layers: []Layer, // Origins of sprites -sprites: []fizzy.Sprite, +sprites: []Sprite, // Lists of sprite indexes and timings -animations: []fizzy.Animation, +animations: []Animation, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { @@ -39,9 +42,9 @@ pub const FileV3 = struct { rows: u32, column_width: u32, row_height: u32, - layers: []fizzy.Layer, - sprites: []fizzy.Sprite, - animations: []fizzy.Animation.AnimationV2, + layers: []Layer, + sprites: []Sprite, + animations: []Animation.AnimationV2, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { @@ -63,9 +66,9 @@ pub const FileV2 = struct { height: u32, tile_width: u32, tile_height: u32, - layers: []fizzy.Layer, - sprites: []fizzy.Sprite, - animations: []fizzy.Animation.AnimationV2, + layers: []Layer, + sprites: []Sprite, + animations: []Animation.AnimationV2, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { @@ -87,9 +90,9 @@ pub const FileV1 = struct { height: u32, tile_width: u32, tile_height: u32, - layers: []fizzy.Layer, - sprites: []fizzy.Sprite, - animations: []fizzy.Animation.AnimationV1, + layers: []Layer, + sprites: []Sprite, + animations: []Animation.AnimationV1, pub fn deinit(self: *File, allocator: std.mem.Allocator) void { for (self.layers) |*layer| { diff --git a/src/tools/LDTKTileset.zig b/src/plugins/pixi/src/LDTKTileset.zig similarity index 88% rename from src/tools/LDTKTileset.zig rename to src/plugins/pixi/src/LDTKTileset.zig index 86c67b96..216a59d6 100644 --- a/src/tools/LDTKTileset.zig +++ b/src/plugins/pixi/src/LDTKTileset.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const core = @import("mach").core; pub const LDTKCompatibility = struct { diff --git a/src/Layer.zig b/src/plugins/pixi/src/Layer.zig similarity index 100% rename from src/Layer.zig rename to src/plugins/pixi/src/Layer.zig diff --git a/src/editor/PackJob.zig b/src/plugins/pixi/src/PackJob.zig similarity index 93% rename from src/editor/PackJob.zig rename to src/plugins/pixi/src/PackJob.zig index d5202743..43d4ee5d 100644 --- a/src/editor/PackJob.zig +++ b/src/plugins/pixi/src/PackJob.zig @@ -7,7 +7,7 @@ //! worker only ever touches its own `PackFile` values plus the app allocator. //! //! The worker produces a finished `Internal.Atlas` (RGBA pixels + sprite/animation data). The -//! main thread swaps it into `fizzy.packer.atlas` via `Editor.processPackJob` once `done` is +//! main thread swaps it into `runtime.packer().atlas` via `Editor.processPackJob` once `done` is //! published. //! //! Ownership / threading model: @@ -17,11 +17,12 @@ //! - `phase` / `cancelled` are atomic; either side may read or write them. const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const zstbi = @import("zstbi"); -const perf = @import("../gfx/perf.zig"); -const reduce_alg = @import("../algorithms/reduce.zig"); +const perf = pixi_mod.perf; +const reduce_alg = @import("algorithms/reduce.zig"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); const PackJob = @This(); @@ -60,7 +61,7 @@ pub const PackSprite = struct { pub const PackAnimation = struct { name: []u8, - frames: []fizzy.Animation.Frame, + frames: []pixi_mod.Animation.Frame, fn deinit(self: *PackAnimation, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -81,7 +82,7 @@ pub const PackFile = struct { /// Deep-copy the pack-relevant fields of an in-memory file. Caller must run on the main /// thread (reads the file's pixel buffers, which the editor may otherwise mutate). - pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const fizzy.Internal.File) !PackFile { + pub fn fromOpenFile(allocator: std.mem.Allocator, file: *const pixi_mod.internal.File) !PackFile { const src_layers = file.layers.slice(); var layers = try allocator.alloc(PackLayer, src_layers.len); @@ -97,7 +98,7 @@ pub const PackFile = struct { const sz = dvui.imageSize(layer.source) catch dvui.Size{ .w = 0, .h = 0 }; const layer_w: u32 = @intFromFloat(sz.w); const layer_h: u32 = @intFromFloat(sz.h); - const src_pixels = fizzy.image.pixels(layer.source); + const src_pixels = pixi_mod.image.pixels(layer.source); const name_copy = try allocator.dupe(u8, layer.name); errdefer allocator.free(name_copy); @@ -135,7 +136,7 @@ pub const PackFile = struct { const anim = src_anims.get(a); const name_copy = try allocator.dupe(u8, anim.name); errdefer allocator.free(name_copy); - const frames_copy = try allocator.dupe(fizzy.Animation.Frame, anim.frames); + const frames_copy = try allocator.dupe(pixi_mod.Animation.Frame, anim.frames); anims[a] = .{ .name = name_copy, .frames = frames_copy }; anims_initialized = a + 1; } @@ -155,7 +156,7 @@ pub const PackFile = struct { /// Build a snapshot by loading the file from disk. Safe to call from any thread. pub fn fromPath(allocator: std.mem.Allocator, path: []const u8) !?PackFile { - const maybe_file = try fizzy.Internal.File.fromPath(path); + const maybe_file = try pixi_mod.internal.File.fromPath(path); var file = maybe_file orelse return null; defer file.deinit(); return try PackFile.fromOpenFile(allocator, &file); @@ -213,7 +214,7 @@ done: std.atomic.Value(bool) = .init(false), /// Worker output. Read only after `done.load(.acquire)`. The main thread takes ownership of /// the inner allocations when it consumes the job; subsequent `destroy()` will leave the /// fields alone. -result_atlas: ?fizzy.Internal.Atlas = null, +result_atlas: ?pixi_mod.internal.Atlas = null, /// Set to `true` once the main thread has consumed `result_atlas` (so `destroy()` knows not /// to free the moved-out atlas allocations). @@ -238,11 +239,11 @@ pub fn destroy(job: *PackJob) void { a.free(job.inputs); // Free any unconsumed result. `result_consumed` is set by the main thread when it moves - // the atlas into `fizzy.packer.atlas`; in that case the new owner is responsible for the + // the atlas into `runtime.packer().atlas`; in that case the new owner is responsible for the // allocations and we must not double-free. if (job.result_atlas != null and !job.result_consumed) { const atlas = job.result_atlas.?; - a.free(fizzy.image.bytes(atlas.source)); + a.free(pixi_mod.image.bytes(atlas.source)); for (atlas.data.animations) |*anim| a.free(anim.name); a.free(atlas.data.animations); a.free(atlas.data.sprites); @@ -294,10 +295,10 @@ pub fn workerMain(job: *PackJob) void { dvui.refresh(job.window, @src(), null); } - // Worker-local scratch. The final atlas allocations are made through `fizzy.app.allocator` + // Worker-local scratch. The final atlas allocations are made through `runtime.allocator()` // so they outlive the job; everything else (sprite refs, frames, animations, any // `.path`-loaded `PackFile`s, collapse carry-overs) lives in `ws` and is freed below. - const work = WorkerState.init(fizzy.app.allocator) catch |e| { + const work = WorkerState.init(runtime.allocator()) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -341,7 +342,7 @@ pub fn workerMain(job: *PackJob) void { return; } job.phase.store(@intFromEnum(Phase.loading), .release); - const maybe_pf = PackFile.fromPath(fizzy.app.allocator, path) catch |e| { + const maybe_pf = PackFile.fromPath(runtime.allocator(), path) catch |e| { job.err = e; job.phase.store(@intFromEnum(Phase.failed), .release); return; @@ -403,10 +404,10 @@ pub fn workerMain(job: *PackJob) void { if (job.cancelled.load(.monotonic)) { // Free the atlas we just built since the consumer won't take it. - fizzy.app.allocator.free(fizzy.image.bytes(atlas.source)); - for (atlas.data.animations) |*anim| fizzy.app.allocator.free(anim.name); - fizzy.app.allocator.free(atlas.data.animations); - fizzy.app.allocator.free(atlas.data.sprites); + runtime.allocator().free(pixi_mod.image.bytes(atlas.source)); + for (atlas.data.animations) |*anim| runtime.allocator().free(anim.name); + runtime.allocator().free(atlas.data.animations); + runtime.allocator().free(atlas.data.sprites); job.phase.store(@intFromEnum(Phase.cancelled), .release); return; } @@ -440,7 +441,7 @@ const WorkerSprite = struct { const WorkerAnimation = struct { name: []u8, - frames: []fizzy.Animation.Frame, + frames: []pixi_mod.Animation.Frame, fn deinit(self: *WorkerAnimation, allocator: std.mem.Allocator) void { allocator.free(self.name); @@ -590,7 +591,7 @@ const WorkerState = struct { if (anim.frames.len == 0) continue; if (anim.frames[0].sprite_index != sprite_index) continue; - const frames = try self.allocator.alloc(fizzy.Animation.Frame, anim.frames.len); + const frames = try self.allocator.alloc(pixi_mod.Animation.Frame, anim.frames.len); for (frames, anim.frames, 0..) |*current_frame, src_frame, i| { current_frame.* = .{ .sprite_index = new_sprite_index + i, @@ -668,10 +669,10 @@ const WorkerState = struct { /// and panics off the main thread. Build the atlas as a plain pixel buffer + raw /// `pixelsPMA` ImageSource directly; first use of the source on the main thread will pick /// up a fresh texture-cache key because `.invalidation = .ptr` keys on the pixel pointer. - fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !fizzy.Internal.Atlas { + fn buildAtlas(self: *WorkerState, tex_size: [2]u16) !pixi_mod.internal.Atlas { const num_pixels: usize = @as(usize, tex_size[0]) * @as(usize, tex_size[1]); - const pixels = try fizzy.app.allocator.alloc([4]u8, num_pixels); - errdefer fizzy.app.allocator.free(pixels); + const pixels = try runtime.allocator().alloc([4]u8, num_pixels); + errdefer runtime.allocator().free(pixels); @memset(pixels, .{ 0, 0, 0, 0 }); const tex_w: usize = tex_size[0]; @@ -698,23 +699,23 @@ const WorkerState = struct { } } - const sprites_out = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, self.sprites.items.len); - errdefer fizzy.app.allocator.free(sprites_out); + const sprites_out = try runtime.allocator().alloc(pixi_mod.Atlas.Sprite, self.sprites.items.len); + errdefer runtime.allocator().free(sprites_out); for (sprites_out, self.sprites.items, self.frames.items) |*dst, src, src_rect| { dst.source = .{ src_rect.x, src_rect.y, src_rect.w, src_rect.h }; dst.origin = src.origin; } - const animations_out = try fizzy.app.allocator.alloc(fizzy.Animation, self.animations.items.len); + const animations_out = try runtime.allocator().alloc(pixi_mod.Animation, self.animations.items.len); var anims_initialized: usize = 0; errdefer { - for (animations_out[0..anims_initialized]) |*anim| fizzy.app.allocator.free(anim.name); - fizzy.app.allocator.free(animations_out); + for (animations_out[0..anims_initialized]) |*anim| runtime.allocator().free(anim.name); + runtime.allocator().free(animations_out); } for (animations_out, self.animations.items) |*dst, src| { - dst.name = try fizzy.app.allocator.dupe(u8, src.name); - errdefer fizzy.app.allocator.free(dst.name); - dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames); + dst.name = try runtime.allocator().dupe(u8, src.name); + errdefer runtime.allocator().free(dst.name); + dst.frames = try runtime.allocator().dupe(pixi_mod.Animation.Frame, src.frames); anims_initialized += 1; } diff --git a/src/tools/Packer.zig b/src/plugins/pixi/src/Packer.zig similarity index 81% rename from src/tools/Packer.zig rename to src/plugins/pixi/src/Packer.zig index b6b5ef01..11db6f3f 100644 --- a/src/tools/Packer.zig +++ b/src/plugins/pixi/src/Packer.zig @@ -1,8 +1,9 @@ const std = @import("std"); const zstbi = @import("zstbi"); const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); -const fizzy = @import("../fizzy.zig"); pub const LDTKTileset = @import("LDTKTileset.zig"); @@ -31,16 +32,16 @@ pub const Sprite = struct { frames: std.array_list.Managed(zstbi.Rect), sprites: std.array_list.Managed(Sprite), -animations: std.array_list.Managed(fizzy.Animation), +animations: std.array_list.Managed(pixi_mod.Animation), id_counter: u32 = 0, placeholder: Image, contains_height: bool = false, -open_files: std.array_list.Managed(fizzy.Internal.File), +open_files: std.array_list.Managed(pixi_mod.internal.File), target: PackTarget = .project, //camera: fizzy.gfx.Camera = .{}, -atlas: ?fizzy.Internal.Atlas = null, +atlas: ?pixi_mod.internal.Atlas = null, -/// Monotonic time (`fizzy.perf.nanoTimestamp`) when the current in-memory atlas was last installed. +/// Monotonic time (`pixi_mod.perf.nanoTimestamp`) when the current in-memory atlas was last installed. last_packed_at_ns: ?i128 = null, ldtk: bool = false, @@ -61,8 +62,8 @@ pub fn init(allocator: std.mem.Allocator) !Packer { return .{ .sprites = std.array_list.Managed(Sprite).init(allocator), .frames = std.array_list.Managed(zstbi.Rect).init(allocator), - .animations = std.array_list.Managed(fizzy.Animation).init(allocator), - .open_files = std.array_list.Managed(fizzy.Internal.File).init(allocator), + .animations = std.array_list.Managed(pixi_mod.Animation).init(allocator), + .open_files = std.array_list.Managed(pixi_mod.internal.File).init(allocator), .placeholder = .{ .width = 2, .height = 2, .pixels = pixels }, .ldtk_tilesets = std.array_list.Managed(LDTKTileset).init(allocator), }; @@ -75,7 +76,7 @@ pub fn newId(self: *Packer) u32 { } pub fn deinit(self: *Packer) void { - fizzy.app.allocator.free(self.placeholder.pixels); + runtime.allocator().free(self.placeholder.pixels); self.clearAndFree(); self.sprites.deinit(); self.frames.deinit(); @@ -85,17 +86,17 @@ pub fn deinit(self: *Packer) void { pub fn clearAndFree(self: *Packer) void { for (self.sprites.items) |*sprite| { - sprite.deinit(fizzy.app.allocator); + sprite.deinit(runtime.allocator()); } for (self.animations.items) |*animation| { - fizzy.app.allocator.free(animation.name); + runtime.allocator().free(animation.name); } for (self.ldtk_tilesets.items) |*tileset| { for (tileset.layer_paths) |path| { - fizzy.app.allocator.free(path); + runtime.allocator().free(path); } - fizzy.app.allocator.free(tileset.sprites); - fizzy.app.allocator.free(tileset.layer_paths); + runtime.allocator().free(tileset.sprites); + runtime.allocator().free(tileset.layer_paths); } self.frames.clearAndFree(); self.sprites.clearAndFree(); @@ -109,9 +110,9 @@ pub fn clearAndFree(self: *Packer) void { self.open_files.clearAndFree(); } -pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { +pub fn append(self: *Packer, file: *pixi_mod.internal.File) !void { std.log.info("Appending file with sprites: {d}", .{file.sprites.slice().len}); - var layer_opt: ?fizzy.Internal.Layer = null; + var layer_opt: ?pixi_mod.Layer = null; var index: usize = 0; while (index < file.layers.slice().len) : (index += 1) { var layer = file.layers.get(index); @@ -121,7 +122,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { // If this layer is collapsed, we need to record its texture to survive the next loop if ((layer.collapse and !last_item) or ((index != 0 and file.layers.slice().get(index - 1).collapse))) { - const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try fizzy.Internal.Layer.init( + const current_layer = if (layer_opt) |carry_over_layer| carry_over_layer else try pixi_mod.Layer.init( 0, "", file.width(), @@ -176,7 +177,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { var image: Image = .{ .width = reduced_src_width, .height = reduced_src_height, - .pixels = try fizzy.app.allocator.alloc([4]u8, reduced_src_width * reduced_src_height), + .pixels = try runtime.allocator().alloc([4]u8, reduced_src_width * reduced_src_height), }; @memset(image.pixels, .{ 0, 0, 0, 0 }); @@ -204,13 +205,13 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { for (0..file.animations.len) |animation_index| { const animation = file.animations.get(animation_index); if (animation.frames[0].sprite_index == sprite_index) { - const frames = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len); + const frames = try runtime.allocator().alloc(pixi_mod.Animation.Frame, animation.frames.len); for (frames, animation.frames, 0..) |*current_frame, file_anim_frame, i| { current_frame.sprite_index = new_sprite_index + i; current_frame.ms = file_anim_frame.ms; } try self.animations.append(.{ - .name = try std.fmt.allocPrint(fizzy.app.allocator, "{s}_{s}", .{ animation.name, layer.name }), + .name = try std.fmt.allocPrint(runtime.allocator(), "{s}_{s}", .{ animation.name, layer.name }), .frames = frames, }); } @@ -249,7 +250,7 @@ pub fn append(self: *Packer, file: *fizzy.Internal.File) !void { } pub fn appendProject(packer: *Packer) !void { - if (fizzy.editor.folder) |root_directory| { + if (runtime.state().host.folder()) |root_directory| { try recurseFiles(packer, root_directory); } } @@ -265,22 +266,22 @@ pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void { while (try iter.next(io)) |entry| { if (entry.kind == .file) { const ext = std.fs.path.extension(entry.name); - if (fizzy.Internal.File.isFizzyExtension(ext)) { - const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &.{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); + if (pixi_mod.internal.File.isFizzyExtension(ext)) { + const abs_path = try std.fs.path.joinZ(runtime.allocator(), &.{ directory, entry.name }); + defer runtime.allocator().free(abs_path); - if (fizzy.editor.getFileFromPath(abs_path)) |file| { + if (runtime.state().docs.fileFromPath(abs_path)) |file| { try p.append(file); } else { - if (try fizzy.Internal.File.fromPath(abs_path)) |file| { + if (try pixi_mod.internal.File.fromPath(abs_path)) |file| { try p.open_files.append(file); try p.append(&p.open_files.items[p.open_files.items.len - 1]); } } } } else if (entry.kind == .directory) { - const abs_path = try std.fs.path.joinZ(fizzy.app.allocator, &[_][]const u8{ directory, entry.name }); - defer fizzy.app.allocator.free(abs_path); + const abs_path = try std.fs.path.joinZ(runtime.allocator(), &[_][]const u8{ directory, entry.name }); + defer runtime.allocator().free(abs_path); try search(p, abs_path); } } @@ -295,7 +296,7 @@ pub fn recurseFiles(packer: *Packer, root_directory: []const u8) !void { pub fn packAndClear(packer: *Packer) !void { if (try packer.packRects()) |size| { //var atlas_texture = try fizzy.gfx.Texture.createEmpty(size[0], size[1], .{}); - var atlas_layer = try fizzy.Internal.Layer.init( + var atlas_layer = try pixi_mod.Layer.init( 0, "", size[0], @@ -318,9 +319,9 @@ pub fn packAndClear(packer: *Packer) !void { } atlas_layer.invalidate(); - const atlas: fizzy.Atlas = .{ - .sprites = try fizzy.app.allocator.alloc(fizzy.Atlas.Sprite, packer.sprites.items.len), - .animations = try fizzy.app.allocator.alloc(fizzy.Animation, packer.animations.items.len), + const atlas: pixi_mod.internal.Atlas = .{ + .sprites = try runtime.allocator().alloc(pixi_mod.internal.Atlas.Sprite, packer.sprites.items.len), + .animations = try runtime.allocator().alloc(pixi_mod.Animation, packer.animations.items.len), }; for (atlas.sprites, packer.sprites.items, packer.frames.items) |*dst, src, src_rect| { @@ -329,8 +330,8 @@ pub fn packAndClear(packer: *Packer) !void { } for (atlas.animations, packer.animations.items) |*dst, src| { - dst.name = try fizzy.app.allocator.dupe(u8, src.name); - dst.frames = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, src.frames); + dst.name = try runtime.allocator().dupe(u8, src.name); + dst.frames = try runtime.allocator().dupe(pixi_mod.Animation.Frame, src.frames); //dst.length = src.length; // dst.start = src.start; } @@ -338,12 +339,12 @@ pub fn packAndClear(packer: *Packer) !void { if (packer.atlas) |*current_atlas| { current_atlas.deinitCheckerboardTile(); for (current_atlas.data.animations) |*animation| { - fizzy.app.allocator.free(animation.name); + runtime.allocator().free(animation.name); } - fizzy.app.allocator.free(current_atlas.data.sprites); - fizzy.app.allocator.free(current_atlas.data.animations); + runtime.allocator().free(current_atlas.data.sprites); + runtime.allocator().free(current_atlas.data.animations); - fizzy.app.allocator.free(fizzy.image.bytes(current_atlas.source)); + runtime.allocator().free(pixi_mod.image.bytes(current_atlas.source)); current_atlas.data = atlas; current_atlas.source = atlas_layer.source; @@ -356,7 +357,7 @@ pub fn packAndClear(packer: *Packer) !void { packer.atlas.?.initCheckerboardTile(); } - packer.last_packed_at_ns = fizzy.perf.nanoTimestamp(); + packer.last_packed_at_ns = pixi_mod.perf.nanoTimestamp(); packer.clearAndFree(); } } diff --git a/src/editor/Project.zig b/src/plugins/pixi/src/Project.zig similarity index 79% rename from src/editor/Project.zig rename to src/plugins/pixi/src/Project.zig index f7c63df3..61b1666f 100644 --- a/src/editor/Project.zig +++ b/src/plugins/pixi/src/Project.zig @@ -1,7 +1,8 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); const Project = @This(); @@ -22,10 +23,10 @@ pack_on_save: bool = false, pub fn load(allocator: std.mem.Allocator) !?Project { if (comptime builtin.target.cpu.arch == .wasm32) return null; - if (fizzy.editor.folder) |folder| { - const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" }); + if (runtime.state().host.folder()) |folder| { + const file = try std.fs.path.join(runtime.state().host.arena(), &.{ folder, ".fizproject" }); - if (fizzy.fs.read(allocator, dvui.io, file) catch null) |r| { + if (pixi_mod.fs.read(allocator, dvui.io, file) catch null) |r| { read = r; const options = std.json.ParseOptions{ .duplicate_field_behavior = .use_first, .ignore_unknown_fields = true }; @@ -60,11 +61,12 @@ pub fn load(allocator: std.mem.Allocator) !?Project { pub fn save(project: *Project) !void { if (comptime builtin.target.cpu.arch == .wasm32) return; - if (fizzy.editor.folder) |folder| { - const file = try std.fs.path.join(fizzy.editor.arena.allocator(), &.{ folder, ".fizproject" }); + if (runtime.state().host.folder()) |folder| { + const file = try std.fs.path.join(runtime.allocator(), &.{ folder, ".fizproject" }); + defer runtime.allocator().free(file); const options = std.json.Stringify.Options{}; - const str = try std.json.Stringify.valueAlloc(fizzy.app.allocator, Project{ + const str = try std.json.Stringify.valueAlloc(runtime.allocator(), Project{ .packed_atlas_output = project.packed_atlas_output, .packed_image_output = project.packed_image_output, //.packed_heightmap_output = project.packed_heightmap_output, @@ -81,17 +83,19 @@ pub fn save(project: *Project) !void { /// Project output assets will be exported to a join of parent_folder and the individual output paths for each asset pub fn exportAssets(project: *Project) !void { + const atlas = runtime.packer().atlas orelse return; + if (project.packed_atlas_output) |packed_atlas_output| { - try fizzy.editor.atlas.save(packed_atlas_output, .data); + try atlas.save(packed_atlas_output, .data); } if (project.packed_image_output) |packed_image_output| { - try fizzy.editor.atlas.save(packed_image_output, .source); + try atlas.save(packed_image_output, .source); } // if (project.packed_heightmap_output) |packed_heightmap_output| { - // const path = try std.fs.path.joinZ(fizzy.editor.arena.allocator(), &.{ parent_folder, packed_heightmap_output }); - // try fizzy.editor.atlas.save(path, .heightmap); + // const path = try std.fs.path.joinZ(runtime.state().host.arena(), &.{ parent_folder, packed_heightmap_output }); + // try atlas.save(path, .heightmap); // } } diff --git a/src/plugins/pixi/src/Settings.zig b/src/plugins/pixi/src/Settings.zig new file mode 100644 index 00000000..95d8d155 --- /dev/null +++ b/src/plugins/pixi/src/Settings.zig @@ -0,0 +1,212 @@ +//! Pixel-art plugin settings: the canvas / sprite-editing preferences formerly stored +//! as top-level fields on the shell `Settings`. Persisted via the shell's per-plugin +//! settings store (the `Host`), keyed by the plugin id, as an opaque JSON blob the shell +//! never interprets. +const std = @import("std"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const sdk = pixi_mod.sdk; + +const PixelArtSettings = @This(); + +/// Per-plugin settings store key (matches `plugin.id`). +pub const plugin_id = "pixi"; + +pub const InputScheme = enum { auto, mouse, trackpad }; + +/// Resolved zoom/pan control style after applying `auto` (`dvui.mouseType`). +pub const ResolvedPanZoomScheme = enum { mouse, trackpad }; + +/// How sprite-cell transparency (checkerboard) is tinted behind the canvas. +pub const TransparencyEffect = enum { + /// Uniform default tone only (no hue gradient). + none, + /// Mouse-smoothed corner gradient. + rainbow, + /// Per-cell tone shifted toward the animation's palette color. + animation, +}; + +/// Zoom/pan control scheme (`auto` picks mouse vs trackpad from `dvui.mouseType()` after scroll events). +input_scheme: InputScheme = .auto, + +/// Whether or not to show rulers on each canvas. +show_rulers: bool = true, + +/// Sprites panel: when true, show side cards in the cover-flow strip; when false, +/// fly them away for single-card focus (snap scroll). +scrolling_cards: bool = true, + +/// Padding to include in the size of the ruler outside of the font height. +ruler_padding: f32 = 4.0, + +/// Overall zoom sensitivity (0 - 1). +zoom_sensitivity: f32 = 1.0, + +/// Predetermined zoom steps, each pixel perfect. +zoom_steps: [23]f32 = [_]f32{ 0.125, 0.167, 0.2, 0.25, 0.333, 0.5, 1, 2, 3, 4, 5, 6, 8, 12, 18, 28, 38, 50, 70, 90, 128, 256, 512 }, + +/// Maximum file size. +max_file_size: [2]i32 = .{ 4096, 4096 }, + +/// Color for the even squares of the checkerboard pattern. +checker_color_even: [4]u8 = .{ 255, 255, 255, 255 }, +/// Color for the odd squares of the checkerboard pattern. +checker_color_odd: [4]u8 = .{ 175, 175, 175, 255 }, + +/// Checkerboard / transparency tint behind sprites (grid cells). +transparency_effect: TransparencyEffect = .none, + +pub fn resolvedPanZoomScheme(settings: *const PixelArtSettings, host: *sdk.Host) ResolvedPanZoomScheme { + return switch (settings.input_scheme) { + .auto => switch (dvui.mouseType()) { + // Runtime platform detection so macOS web users get the trackpad default. + .unknown => if (host.isMacOS()) .trackpad else .mouse, + .mouse => .mouse, + .trackpad => .trackpad, + }, + .mouse => .mouse, + .trackpad => .trackpad, + }; +} + +/// Load from the host's per-plugin store, or defaults if absent/unparsable. Unknown keys +/// are ignored, so the one-time legacy-migration blob (which still carries shell fields) +/// parses fine — only the pixel-art fields are picked up. +pub fn load(host: *sdk.Host) PixelArtSettings { + const blob = host.loadPluginSettings(plugin_id) orelse return .{}; + const parsed = std.json.parseFromSlice(PixelArtSettings, host.allocator, blob, .{ + .ignore_unknown_fields = true, + }) catch return .{}; + defer parsed.deinit(); + // PixelArtSettings has no heap-owned fields (all values/arrays/enums), so the parsed + // value is safe to return after freeing the parse arena. + return parsed.value; +} + +/// Serialize and persist to the host store (marks shell settings dirty for autosave). +pub fn save(settings: *const PixelArtSettings, host: *sdk.Host) void { + const json = std.json.Stringify.valueAlloc(host.allocator, settings, .{}) catch return; + defer host.allocator.free(json); + host.storePluginSettings(plugin_id, json) catch {}; +} + +/// The plugin's Settings section body (registered as a `SettingsSection`). Renders the +/// canvas / control prefs and persists on change. +pub fn draw(_: ?*anyopaque) !void { + const pa = runtime.state(); + + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal }); + defer vbox.deinit(); + + { + var box = dvui.groupBox(@src(), "Canvas", .{ .expand = .horizontal }); + defer box.deinit(); + + { + var dropdown: dvui.DropdownWidget = undefined; + dropdown.init(@src(), .{ .label = "Transparency effect" }, .{ + .expand = .horizontal, + .corner_radius = dvui.Rect.all(1000), + }); + defer dropdown.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .vertical, + .gravity_x = 1.0, + }); + + const label_text = switch (pa.settings.transparency_effect) { + .none => "None", + .rainbow => "Rainbow", + .animation => "Animation", + }; + dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); + + dvui.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); + + hbox.deinit(); + + if (dropdown.dropped()) { + if (dropdown.addChoiceLabel("None")) { + pa.settings.transparency_effect = .none; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Rainbow")) { + pa.settings.transparency_effect = .rainbow; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Animation")) { + pa.settings.transparency_effect = .animation; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); + } + + if (dvui.checkbox(@src(), &pa.settings.show_rulers, "Show Rulers", .{ .expand = .none })) { + pa.settings.save(pa.host); + } + + if (dvui.checkbox(@src(), &pa.settings.scrolling_cards, "Show sprite cover-flow cards", .{ .expand = .none })) { + pa.settings.save(pa.host); + } + } + + { + var box = dvui.groupBox(@src(), "Controls", .{ .expand = .horizontal }); + defer box.deinit(); + + var dropdown: dvui.DropdownWidget = undefined; + dropdown.init(@src(), .{ .label = "Control scheme" }, .{ + .expand = .horizontal, + .corner_radius = dvui.Rect.all(1000), + }); + defer dropdown.deinit(); + + var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .vertical, + .gravity_x = 1.0, + }); + + const label_text: []const u8 = switch (pa.settings.input_scheme) { + .auto => switch (dvui.mouseType()) { + // Pre-classification (no scroll events seen yet) — drop the parenthetical. + .unknown => "Auto", + .mouse, .trackpad => |hint| try std.fmt.allocPrint(dvui.currentWindow().lifo(), "Auto ({s})", .{@tagName(hint)}), + }, + .mouse => "Mouse", + .trackpad => "Trackpad", + }; + dvui.label(@src(), "{s}", .{label_text}, .{ .margin = .all(0), .padding = .all(0) }); + + dvui.icon(@src(), "dropdown_triangle", dvui.entypo.triangle_down, .{}, .{ .gravity_y = 0.5 }); + + hbox.deinit(); + + if (dropdown.dropped()) { + if (dropdown.addChoiceLabel("Auto")) { + pa.settings.input_scheme = .auto; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Mouse")) { + pa.settings.input_scheme = .mouse; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + if (dropdown.addChoiceLabel("Trackpad")) { + pa.settings.input_scheme = .trackpad; + pa.settings.save(pa.host); + dvui.refresh(null, @src(), vbox.data().id); + } + } + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 10 } }); + } +} diff --git a/src/Sprite.zig b/src/plugins/pixi/src/Sprite.zig similarity index 100% rename from src/Sprite.zig rename to src/plugins/pixi/src/Sprite.zig diff --git a/src/plugins/pixi/src/State.zig b/src/plugins/pixi/src/State.zig new file mode 100644 index 00000000..95a02fc1 --- /dev/null +++ b/src/plugins/pixi/src/State.zig @@ -0,0 +1,147 @@ +//! Pixel-art plugin runtime state. +//! +//! Owns the pixel-art-specific editor state that used to live as top-level fields +//! on `src/editor/Editor.zig`: the active tools, color/palette state, the open +//! project's pack config, the sprite clipboard, and the background pack-job queue. +//! +//! Each plugin has a `State.zig` holding its live state. The shell still reaches +//! plugin code uses `runtime.state`. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const assets = @import("assets"); +const sdk = @import("sdk"); +const Colors = @import("Colors.zig"); +const Project = @import("Project.zig"); +const Tools = @import("Tools.zig"); +const PackJob = @import("PackJob.zig"); +const Packer = @import("Packer.zig"); +const ToolsPane = @import("explorer/tools.zig"); +const SpritesPane = @import("explorer/sprites.zig"); +const SpritesPanel = @import("panel/sprites.zig"); +const Palette = @import("internal/Palette.zig"); +const CanvasData = @import("CanvasData.zig"); +const runtime = @import("runtime.zig"); +pub const Settings = @import("Settings.zig"); +pub const Docs = @import("Docs.zig"); + +const State = @This(); + +/// A floating sprite cut/copied from the canvas, pasted relative to `offset`. +pub const SpriteClipboard = struct { + source: dvui.ImageSource, + offset: dvui.Point, +}; + +/// The shell host (service locator + per-plugin settings store). Set in `init`. +host: *sdk.Host, + +/// Open pixel-art documents (shell `open_files` holds matching `DocHandle`s). +docs: Docs = .{}, + +/// Pixel-art editing preferences, loaded from the host's per-plugin settings store. +settings: Settings = .{}, + +tools: Tools, +colors: Colors = .{}, + +/// Explorer sidebar panes. The "tools" +/// view (layers + palette) and the "sprites" view (animations/frames) are pixel-art-specific +/// UI state; the shell only routes the registered sidebar view's `draw` to them. +tools_pane: ToolsPane = .{}, +sprites_pane: SpritesPane = .{}, + +/// Sprites cover-flow bottom panel (scroll/fly state; was `editor.panel.sprites`). +sprites_panel: SpritesPanel = .{}, + +/// Whether the palette pane is pinned open in the tools sidebar (pixel-art UI state). +pinned_palettes: bool = false, +/// Split ratio between the layers list and the palette in the tools sidebar. +layers_ratio: f32 = 0.5, + +/// The open project's `.fizproject` pack config, or null when no project folder is open. +project: ?Project = null, + +sprite_clipboard: ?SpriteClipboard = null, + +/// Background project-pack jobs. Each `Editor.startPackProject` cancels any predecessors and +/// pushes a new job; only the newest job's result is installed. Cancelled jobs are still kept +/// here until their worker observes the flag and publishes `done`, at which point +/// `Editor.processPackJob` reaps them. This way rapid Pack-Project clicks coalesce: only the +/// most recent request produces a visible atlas update. +pack_jobs: std.ArrayListUnmanaged(*PackJob) = .empty, + +/// Project texture atlas packer (owned by App; wired after init). +packer: ?*Packer = null, + +/// Per-workspace-pane canvas chrome (rulers, edit pill, grid reorder), keyed by grouping id. +canvas_by_grouping: std.AutoArrayHashMapUnmanaged(u64, *CanvasData) = .{}, + +pub fn canvasForGrouping(st: *State, grouping: u64) *CanvasData { + const gpa = runtime.allocator(); + if (st.canvas_by_grouping.get(grouping)) |existing| return existing; + const cd = gpa.create(CanvasData) catch @panic("OOM allocating CanvasData"); + cd.* = CanvasData.init(grouping); + st.canvas_by_grouping.put(gpa, grouping, cd) catch @panic("OOM allocating CanvasData"); + return cd; +} + +pub fn removeCanvasPane(st: *State, allocator: std.mem.Allocator, grouping: u64) void { + const cd = st.canvas_by_grouping.get(grouping) orelse return; + cd.deinit(); + allocator.destroy(cd); + _ = st.canvas_by_grouping.swapRemove(grouping); +} + +pub fn init(allocator: std.mem.Allocator, host: *sdk.Host) !State { + var st: State = .{ + .host = host, + .settings = Settings.load(host), + .tools = try .init(allocator), + }; + st.colors.file_tree_palette = Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + st.colors.palette = Palette.loadFromBytes(allocator, "fizzy.hex", assets.files.palettes.@"fizzy.hex") catch null; + return st; +} + +/// Write `.fizproject` while the shell `host` and project folder are still live. +/// Called from `AppDeinit` before `editor.deinit`. +pub fn persistProject(st: *State) void { + if (comptime builtin.target.cpu.arch == .wasm32) return; + if (st.project) |*project| { + project.save() catch { + dvui.log.err("Failed to save project file", .{}); + }; + } +} + +/// Load `.fizproject` for the shell's currently-open project folder. +pub fn reloadProjectForFolder(st: *State, allocator: std.mem.Allocator) void { + st.project = Project.load(allocator) catch null; +} + +pub fn deinit(st: *State, allocator: std.mem.Allocator) void { + for (st.pack_jobs.items) |job| { + // Detached workers still reference each job. Signal cancellation and leak the structs + // on hard quit — better than a use-after-free if a worker hasn't yet observed it. + job.cancelled.store(true, .monotonic); + } + st.pack_jobs.deinit(allocator); + + if (st.colors.palette) |*palette| palette.deinit(); + if (st.colors.file_tree_palette) |*palette| palette.deinit(); + + if (st.project) |*project| { + project.deinit(allocator); + } + + var canvas_it = st.canvas_by_grouping.iterator(); + while (canvas_it.next()) |entry| { + entry.value_ptr.*.deinit(); + allocator.destroy(entry.value_ptr.*); + } + st.canvas_by_grouping.deinit(allocator); + + st.tools.deinit(allocator); + st.docs.deinit(allocator); +} diff --git a/src/editor/Tools.zig b/src/plugins/pixi/src/Tools.zig similarity index 93% rename from src/editor/Tools.zig rename to src/plugins/pixi/src/Tools.zig index 68555989..9ea11f2c 100644 --- a/src/editor/Tools.zig +++ b/src/plugins/pixi/src/Tools.zig @@ -1,6 +1,7 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); const Tools = @This(); @@ -162,7 +163,7 @@ pub fn set(self: *Tools, tool: Tool) void { self.current = tool; self.setStrokeSize(self.strokeSizeFor(tool)); if (tool == .pencil or tool == .eraser) { - fizzy.editor.requestCompositeWarmup(); + runtime.state().host.requestPrepareFrame(); } } } @@ -194,8 +195,8 @@ pub fn getIndex(_: *Tools, point: dvui.Point) ?usize { /// Only used for handling getting the pixels surrounding the origin /// for stroke sizes larger than 1 pub fn getIndexShapeOffset(self: *Tools, origin: dvui.Point, current_index: usize) ?usize { - const shape = fizzy.editor.tools.stroke_shape; - const s: i32 = @intCast(fizzy.editor.tools.stroke_size); + const shape = self.stroke_shape; + const s: i32 = @intCast(self.stroke_size); if (s == 1) { if (current_index != 0) @@ -298,7 +299,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 })); defer vbox2.deinit(); - fizzy.dvui.labelWithKeybind( + pixi_mod.core.dvui.labelWithKeybind( tool_name, switch (tool) { .pointer => dvui.currentWindow().keybinds.get("pointer") orelse .{}, @@ -312,7 +313,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 .font = dvui.Font.theme(.heading), }, .{ - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = dvui.Font.theme(.mono), .margin = dvui.Rect.all(4), }, ); @@ -334,10 +335,10 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }); defer mode_row.deinit(); - const atlas_size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 }; + const atlas_size: dvui.Size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; var mode_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { mode_color = palette.getDVUIColor(4); } @@ -367,7 +368,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 2 => "COLOR", else => unreachable, }; - const selected = fizzy.editor.tools.selection_mode == mode; + const selected = runtime.state().tools.selection_mode == mode; var mode_col = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .none, @@ -377,9 +378,9 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 defer mode_col.deinit(); const sprite = switch (mode) { - .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + .box => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default], + .pixel => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .color => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default], }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_size.w, @@ -430,7 +431,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 rs.r.w = width; rs.r.h = height; - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ + dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { @@ -438,7 +439,7 @@ pub fn drawTooltip(_: Tools, tool: Tool, rect: dvui.Rect.Physical, id_extra: u64 }; if (mode_button.clicked()) { - fizzy.editor.tools.selection_mode = mode; + runtime.state().tools.selection_mode = mode; } } } diff --git a/src/editor/Transform.zig b/src/plugins/pixi/src/Transform.zig similarity index 85% rename from src/editor/Transform.zig rename to src/plugins/pixi/src/Transform.zig index 38d58931..32835f42 100644 --- a/src/editor/Transform.zig +++ b/src/plugins/pixi/src/Transform.zig @@ -1,6 +1,7 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); pub const Transform = @This(); @@ -34,24 +35,24 @@ pub fn point(self: *Transform, transform_point: TransformPoint) *dvui.Point { /// Note: `textureReadTarget` reads the full render target; the dominant cost is often GPU→CPU /// bandwidth rather than the merge loops below. pub fn accept(self: *Transform) void { - if (fizzy.editor.open_files.getPtr(self.file_id)) |file| { + if (runtime.state().docs.fileById(self.file_id)) |file| { var layer = file.getLayer(self.layer_id) orelse return; - const t_all: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_all: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; const layer_px: u64 = @as(u64, file.width()) * @as(u64, file.height()); const pix = dvui.textureReadTarget(dvui.currentWindow().arena(), self.target_texture) catch { dvui.log.err("Failed to read target texture", .{}); return; }; - const t_after_gpu: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_after_gpu: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; file.buffers.stroke.clearAndReserveCapacity(@intCast(layer_px)) catch { dvui.log.err("Failed to reserve stroke map for transform accept", .{}); return; }; - const t_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_loop: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; // Two passes: undo keys use the pre-write layer; writes are independent per index, so order // matches the original interleaved loop without mutating layer between undo decisions. for (pix, file.editor.transform_layer.pixels(), layer.pixels(), 0..) |temp_pixel, transform_pixel, layer_pixel, pixel_index| { @@ -70,7 +71,7 @@ pub fn accept(self: *Transform) void { // Paste / transform accept writes new pixels but does not go through `processSelection`; the // overlay uses `selection_layer.mask ∩ active_layer.mask`. Keep the mask aligned with the // committed transform so copied/pasted (and moved) pixels show the selection outline. - if (fizzy.editor.tools.current == .selection) { + if (runtime.state().tools.current == .selection) { file.editor.selection_layer.clearMask(); for (pix, 0..) |temp_pixel, pixel_index| { if (temp_pixel.a != 0) { @@ -79,28 +80,28 @@ pub fn accept(self: *Transform) void { } } - const t_after_loop: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_after_loop: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; - const t_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_to_change: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; const change = file.buffers.stroke.toChange(self.layer_id) catch null; - const t_after_to_change: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_after_to_change: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; - const t_hist: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t_hist: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; if (change) |c| { file.history.append(c) catch { dvui.log.err("Failed to append stroke change to history", .{}); }; } - const t_end: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; - - if (fizzy.perf.record) { - fizzy.perf.transform_accept_last_total_ns = @intCast(t_end - t_all); - fizzy.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all); - fizzy.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop); - fizzy.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change); - fizzy.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist); - fizzy.perf.transform_accept_last_layer_pixels = layer_px; - fizzy.perf.logTransformAcceptIf(); + const t_end: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; + + if (pixi_mod.perf.record) { + pixi_mod.perf.transform_accept_last_total_ns = @intCast(t_end - t_all); + pixi_mod.perf.transform_accept_last_gpu_read_ns = @intCast(t_after_gpu - t_all); + pixi_mod.perf.transform_accept_last_merge_loop_ns = @intCast(t_after_loop - t_loop); + pixi_mod.perf.transform_accept_last_to_change_ns = @intCast(t_after_to_change - t_to_change); + pixi_mod.perf.transform_accept_last_history_append_ns = @intCast(t_end - t_hist); + pixi_mod.perf.transform_accept_last_layer_pixels = layer_px; + pixi_mod.perf.logTransformAcceptIf(); } layer.invalidate(); @@ -109,14 +110,14 @@ pub fn accept(self: *Transform) void { file.editor.transform_layer.clearMask(); file.editor.transform_layer.invalidate(); file.editor.transform = null; - fizzy.app.allocator.free(fizzy.image.bytes(self.source)); + runtime.allocator().free(pixi_mod.image.bytes(self.source)); self.* = undefined; } } /// Cancels the transform and restores the layer to its original state pub fn cancel(self: *Transform) void { - if (fizzy.editor.open_files.getPtr(self.file_id)) |file| { + if (runtime.state().docs.fileById(self.file_id)) |file| { var layer = file.getLayer(self.layer_id) orelse return; var iterator = file.editor.transform_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); while (iterator.next()) |pixel_index| { @@ -129,7 +130,7 @@ pub fn cancel(self: *Transform) void { file.editor.transform_layer.clearMask(); file.editor.transform_layer.invalidate(); file.editor.transform = null; - fizzy.app.allocator.free(fizzy.image.bytes(self.source)); + runtime.allocator().free(pixi_mod.image.bytes(self.source)); self.* = undefined; } } diff --git a/src/algorithms/algorithms.zig b/src/plugins/pixi/src/algorithms/algorithms.zig similarity index 100% rename from src/algorithms/algorithms.zig rename to src/plugins/pixi/src/algorithms/algorithms.zig diff --git a/src/algorithms/brezenham.zig b/src/plugins/pixi/src/algorithms/brezenham.zig similarity index 85% rename from src/algorithms/brezenham.zig rename to src/plugins/pixi/src/algorithms/brezenham.zig index f61ab318..189e68e6 100644 --- a/src/algorithms/brezenham.zig +++ b/src/plugins/pixi/src/algorithms/brezenham.zig @@ -1,10 +1,11 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); pub fn process(start: dvui.Point, end: dvui.Point) ![]dvui.Point { // Bresenham's line algorithm for integer grid points - var output = std.array_list.Managed(dvui.Point).init(fizzy.editor.arena.allocator()); + var output = std.array_list.Managed(dvui.Point).init(runtime.state().host.arena()); // Round input points to nearest integer grid const x0: i32 = @intFromFloat(@floor(start.x)); diff --git a/src/algorithms/reduce.zig b/src/plugins/pixi/src/algorithms/reduce.zig similarity index 100% rename from src/algorithms/reduce.zig rename to src/plugins/pixi/src/algorithms/reduce.zig diff --git a/src/plugins/pixi/src/clipboard.zig b/src/plugins/pixi/src/clipboard.zig new file mode 100644 index 00000000..03496ce4 --- /dev/null +++ b/src/plugins/pixi/src/clipboard.zig @@ -0,0 +1,220 @@ +//! Sprite copy/paste for the pixel-art plugin. Backs the `pixi_mod.copy` / `pixi_mod.paste` +//! commands and pixel-art's own canvas handlers; the shell never owns this logic. +const std = @import("std"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; + +fn activeFile(st: *State) ?*Internal.File { + const doc = st.host.activeDoc() orelse return null; + return st.docs.fileById(doc.id); +} + +pub fn copy(st: *State) !void { + const file = activeFile(st) orelse return; + if (file.editor.transform != null) return; + + if (st.sprite_clipboard) |*clipboard| { + runtime.allocator().free(pixi_mod.image.bytes(clipboard.source)); + st.sprite_clipboard = null; + } + + file.editor.transform_layer.clear(); + + var selected_layer = file.layers.get(file.selected_layer_index); + switch (st.tools.current) { + .selection => { + var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); + while (pixel_iterator.next()) |pixel_index| { + @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); + file.editor.transform_layer.mask.set(pixel_index); + } + }, + else => { + if (file.editor.selected_sprites.count() > 0) { + var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + while (sprite_iterator.next()) |index| { + const source_rect = file.spriteRect(index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + source_rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + source_rect, + .{ .transparent = true, .mask = true }, + ); + } + } + } else { + if (file.editor.canvas.hovered) { + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { + const rect = file.spriteRect(sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + rect, + .{ .transparent = true, .mask = true }, + ); + } + } + } else if (file.selected_animation_index) |animation_index| { + const animation = file.animations.get(animation_index); + if (file.selected_animation_frame_index < animation.frames.len) { + const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + rect, + .{ .transparent = true, .mask = true }, + ); + } + } + } + } + }, + } + + const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); + if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { + const sprite_tl = file.spritePoint(reduced_data_rect.topLeft()); + const gpa = runtime.allocator(); + + st.sprite_clipboard = .{ + .source = pixi_mod.image.fromPixelsPMA( + @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)), + @intFromFloat(reduced_data_rect.w), + @intFromFloat(reduced_data_rect.h), + .ptr, + ) catch return error.MemoryAllocationFailed, + .offset = reduced_data_rect.topLeft().diff(sprite_tl), + }; + + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, file.editor.canvas.id, pixi_mod.core.dvui.toastDisplay, 2_000_000); + const id = id_mutex.id; + const message = std.fmt.allocPrint(dvui.currentWindow().arena(), "Copied selection", .{}) catch "Copied selection."; + dvui.dataSetSlice(dvui.currentWindow(), id, "_message", message); + id_mutex.mutex.unlock(dvui.io); + } +} + +pub fn paste(st: *State) !void { + if (st.sprite_clipboard) |*clipboard| { + const file = activeFile(st) orelse return; + const active_layer = file.layers.get(file.selected_layer_index); + + var dst_rect: dvui.Rect = .fromSize(pixi_mod.image.size(clipboard.source)); + + var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + while (sprite_iterator.next()) |sprite_index| { + const sprite_rect = file.spriteRect(sprite_index); + + dst_rect.x = sprite_rect.x + clipboard.offset.x; + dst_rect.y = sprite_rect.y + clipboard.offset.y; + + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = active_layer.id, + .data_points = .{ + dst_rect.topLeft(), + dst_rect.topRight(), + dst_rect.bottomRight(), + dst_rect.bottomLeft(), + dst_rect.center(), + dst_rect.center(), + }, + .source = clipboard.source, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + + return; + } + + dst_rect.x = clipboard.offset.x; + dst_rect.y = clipboard.offset.y; + + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { + const rect = file.spriteRect(sprite_index); + dst_rect.x = rect.x + clipboard.offset.x; + dst_rect.y = rect.y + clipboard.offset.y; + } else if (file.selected_animation_index) |animation_index| { + const animation = file.animations.get(animation_index); + + if (file.selected_animation_frame_index < animation.frames.len) { + const rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); + dst_rect.x = rect.x + clipboard.offset.x; + dst_rect.y = rect.y + clipboard.offset.y; + + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = active_layer.id, + .data_points = .{ + dst_rect.topLeft(), + dst_rect.topRight(), + dst_rect.bottomRight(), + dst_rect.bottomLeft(), + dst_rect.center(), + dst_rect.center(), + }, + .source = clipboard.source, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + + return; + } + } + + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = active_layer.id, + .data_points = .{ + dst_rect.topLeft(), + dst_rect.topRight(), + dst_rect.bottomRight(), + dst_rect.bottomLeft(), + dst_rect.center(), + dst_rect.center(), + }, + .source = clipboard.source, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + } +} diff --git a/src/deps/msf_gif/fizzy_msf_gif_wasm.c b/src/plugins/pixi/src/deps/msf_gif/fizzy_msf_gif_wasm.c similarity index 100% rename from src/deps/msf_gif/fizzy_msf_gif_wasm.c rename to src/plugins/pixi/src/deps/msf_gif/fizzy_msf_gif_wasm.c diff --git a/src/deps/msf_gif/msf_gif.c b/src/plugins/pixi/src/deps/msf_gif/msf_gif.c similarity index 100% rename from src/deps/msf_gif/msf_gif.c rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.c diff --git a/src/deps/msf_gif/msf_gif.h b/src/plugins/pixi/src/deps/msf_gif/msf_gif.h similarity index 100% rename from src/deps/msf_gif/msf_gif.h rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.h diff --git a/src/deps/msf_gif/msf_gif.zig b/src/plugins/pixi/src/deps/msf_gif/msf_gif.zig similarity index 100% rename from src/deps/msf_gif/msf_gif.zig rename to src/plugins/pixi/src/deps/msf_gif/msf_gif.zig diff --git a/src/deps/msf_gif/wasm_shim/string.h b/src/plugins/pixi/src/deps/msf_gif/wasm_shim/string.h similarity index 100% rename from src/deps/msf_gif/wasm_shim/string.h rename to src/plugins/pixi/src/deps/msf_gif/wasm_shim/string.h diff --git a/src/deps/stbi/fizzy_stbi_libc.c b/src/plugins/pixi/src/deps/stbi/fizzy_stbi_libc.c similarity index 100% rename from src/deps/stbi/fizzy_stbi_libc.c rename to src/plugins/pixi/src/deps/stbi/fizzy_stbi_libc.c diff --git a/src/deps/stbi/stb_image_resize2.h b/src/plugins/pixi/src/deps/stbi/stb_image_resize2.h similarity index 100% rename from src/deps/stbi/stb_image_resize2.h rename to src/plugins/pixi/src/deps/stbi/stb_image_resize2.h diff --git a/src/deps/stbi/stb_rect_pack.h b/src/plugins/pixi/src/deps/stbi/stb_rect_pack.h similarity index 100% rename from src/deps/stbi/stb_rect_pack.h rename to src/plugins/pixi/src/deps/stbi/stb_rect_pack.h diff --git a/src/deps/stbi/zstbi.c b/src/plugins/pixi/src/deps/stbi/zstbi.c similarity index 100% rename from src/deps/stbi/zstbi.c rename to src/plugins/pixi/src/deps/stbi/zstbi.c diff --git a/src/deps/stbi/zstbi.zig b/src/plugins/pixi/src/deps/stbi/zstbi.zig similarity index 100% rename from src/deps/stbi/zstbi.zig rename to src/plugins/pixi/src/deps/stbi/zstbi.zig diff --git a/src/deps/zip/build.zig b/src/plugins/pixi/src/deps/zip/build.zig similarity index 100% rename from src/deps/zip/build.zig rename to src/plugins/pixi/src/deps/zip/build.zig diff --git a/src/deps/zip/fizzy_zip_libc.c b/src/plugins/pixi/src/deps/zip/fizzy_zip_libc.c similarity index 100% rename from src/deps/zip/fizzy_zip_libc.c rename to src/plugins/pixi/src/deps/zip/fizzy_zip_libc.c diff --git a/src/deps/zip/fizzy_zip_strings.c b/src/plugins/pixi/src/deps/zip/fizzy_zip_strings.c similarity index 100% rename from src/deps/zip/fizzy_zip_strings.c rename to src/plugins/pixi/src/deps/zip/fizzy_zip_strings.c diff --git a/src/deps/zip/fizzy_zip_wasm.h b/src/plugins/pixi/src/deps/zip/fizzy_zip_wasm.h similarity index 100% rename from src/deps/zip/fizzy_zip_wasm.h rename to src/plugins/pixi/src/deps/zip/fizzy_zip_wasm.h diff --git a/src/deps/zip/src/miniz.h b/src/plugins/pixi/src/deps/zip/src/miniz.h similarity index 100% rename from src/deps/zip/src/miniz.h rename to src/plugins/pixi/src/deps/zip/src/miniz.h diff --git a/src/deps/zip/src/zip.c b/src/plugins/pixi/src/deps/zip/src/zip.c similarity index 100% rename from src/deps/zip/src/zip.c rename to src/plugins/pixi/src/deps/zip/src/zip.c diff --git a/src/deps/zip/src/zip.h b/src/plugins/pixi/src/deps/zip/src/zip.h similarity index 100% rename from src/deps/zip/src/zip.h rename to src/plugins/pixi/src/deps/zip/src/zip.h diff --git a/src/deps/zip/zip.zig b/src/plugins/pixi/src/deps/zip/zip.zig similarity index 100% rename from src/deps/zip/zip.zig rename to src/plugins/pixi/src/deps/zip/zip.zig diff --git a/src/editor/dialogs/Export.zig b/src/plugins/pixi/src/dialogs/Export.zig similarity index 84% rename from src/editor/dialogs/Export.zig rename to src/plugins/pixi/src/dialogs/Export.zig index 7f009fe4..03b99c46 100644 --- a/src/editor/dialogs/Export.zig +++ b/src/plugins/pixi/src/dialogs/Export.zig @@ -1,17 +1,16 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const zigimg = @import("zigimg"); const msf_gif = @import("msf_gif"); const zstbi = @import("zstbi"); -const WebFileIo = if (builtin.target.cpu.arch == .wasm32) @import("../WebFileIo.zig") else struct {}; +const DimensionsLabel = @import("dimensions_label.zig"); +const WebFileIo = @import("../web_file_io.zig"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const ExportImageFormat = enum { png, jpg }; -const Dialogs = @import("Dialogs.zig"); - pub var mode: enum(usize) { single, animation, @@ -39,7 +38,7 @@ pub const min_scale: u32 = 1; pub var anim_frame_index: usize = 0; /// Animation to export/preview: uses the animation selected in the editor. -fn exportAnimationIndex(file: *fizzy.Internal.File) ?usize { +fn exportAnimationIndex(file: *pixi_mod.internal.File) ?usize { const idx = file.selected_animation_index orelse return null; if (idx >= file.animations.len) return null; return idx; @@ -49,7 +48,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { // Export stays non-modal so the user can click the canvas to adjust selections. Switch to // the pointer tool on open so marquee/sprite picks work; drawing tools stay off until close. if (dvui.firstFrame(id)) { - fizzy.editor.tools.set(.pointer); + runtime.state().tools.set(.pointer); } var outer_box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); @@ -145,7 +144,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { .all => try allDialog(id), }; - return mode_valid and (fizzy.editor.activeFile() != null); + return mode_valid and (runtime.state().docs.activeFile(runtime.state().host) != null); } pub fn singleDialog(_: dvui.Id) anyerror!bool { @@ -153,14 +152,14 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool { var max_scale: f32 = 16.0; var valid: bool = false; - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.editor.selected_sprites.findFirstSet() != null) { max_scale = @min(@divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))), @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height)))); valid = true; } } - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.editor.selected_sprites.findFirstSet()) |sprite_index| { renderExportPreviewSprite(file, sprite_index); } @@ -168,7 +167,7 @@ pub fn singleDialog(_: dvui.Id) anyerror!bool { exportScaleSlider(max_scale); - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.editor.selected_sprites.findFirstSet() != null) { const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale); const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); @@ -184,7 +183,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { var max_scale: f32 = 16.0; var preview_sprite: ?usize = null; - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { max_scale = @min( @divTrunc(max_gif_size[0], @as(f32, @floatFromInt(file.column_width))), @divTrunc(max_gif_size[1], @as(f32, @floatFromInt(file.row_height))), @@ -222,7 +221,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { } } - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (preview_sprite) |sprite_index| { renderExportPreviewSprite(file, sprite_index); } @@ -231,7 +230,7 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { exportScaleSlider(max_scale); if (preview_sprite) |_| { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const column_width: u32 = @intFromFloat(@as(f32, @floatFromInt(file.column_width)) * scale); const row_height: u32 = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); exportDimensionsLabelForExport(column_width, row_height); @@ -242,20 +241,20 @@ pub fn animationDialog(id: dvui.Id) anyerror!bool { } pub fn layerDialog(_: dvui.Id) anyerror!bool { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { renderExportPreview(file, .layer); } - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { exportDimensionsLabelForExport(file.width(), file.height()); } return true; } pub fn allDialog(_: dvui.Id) anyerror!bool { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { renderExportPreview(file, .composite); } - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { exportDimensionsLabelForExport(file.width(), file.height()); } return true; @@ -267,11 +266,11 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (mode) { .animation => { const default = blk: { - const file = fizzy.editor.activeFile() orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { break :blk "animation.gif"; }; - const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.gif", .{ + const default_filename: [:0]const u8 = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.gif", .{ if (exportAnimationIndex(file)) |animation_index| file.animations.items(.name)[animation_index] else "animation", }, 0) catch { dvui.log.err("Failed to allocate filename", .{}); @@ -281,32 +280,32 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void break :blk default_filename; }; - fizzy.backend.showSaveFileDialog( + runtime.state().host.showSaveDialog( saveAnimationCallback, - &[_]fizzy.backend.DialogFileFilter{.{ .name = "GIF", .pattern = "gif" }}, + &[_]pixi_mod.sdk.SaveDialogFilter{.{ .name = "GIF", .pattern = "gif" }}, default, null, // Passing null here means use the last save folder location ); }, .single => { - const file = fizzy.editor.activeFile() orelse return; + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; const sprite_index = file.editor.selected_sprites.findFirstSet() orelse return; - const base = file.spriteExportName(fizzy.app.allocator, sprite_index) catch { + const base = file.spriteExportName(runtime.allocator(), sprite_index) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer fizzy.app.allocator.free(base); + defer runtime.allocator().free(base); - const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer fizzy.app.allocator.free(default); + defer runtime.allocator().free(default); - fizzy.backend.showSaveFileDialog( + runtime.state().host.showSaveDialog( exportCurrentSpriteCallback, - &[_]fizzy.backend.DialogFileFilter{ + &[_]pixi_mod.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -315,22 +314,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void ); }, .layer => { - const file = fizzy.editor.activeFile() orelse return; - const base = file.layerExportBaseName(fizzy.app.allocator) catch { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; + const base = file.layerExportBaseName(runtime.allocator()) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer fizzy.app.allocator.free(base); + defer runtime.allocator().free(base); - const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer fizzy.app.allocator.free(default); + defer runtime.allocator().free(default); - fizzy.backend.showSaveFileDialog( + runtime.state().host.showSaveDialog( exportLayerCallback, - &[_]fizzy.backend.DialogFileFilter{ + &[_]pixi_mod.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -339,22 +338,22 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void ); }, .all => { - const file = fizzy.editor.activeFile() orelse return; - const base = file.allExportBaseName(fizzy.app.allocator) catch { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse return; + const base = file.allExportBaseName(runtime.allocator()) catch { dvui.log.err("Failed to allocate default export name", .{}); return; }; - defer fizzy.app.allocator.free(base); + defer runtime.allocator().free(base); - const default = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{base}, 0) catch { + const default = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{base}, 0) catch { dvui.log.err("Failed to allocate filename", .{}); return; }; - defer fizzy.app.allocator.free(default); + defer runtime.allocator().free(default); - fizzy.backend.showSaveFileDialog( + runtime.state().host.showSaveDialog( exportAllCallback, - &[_]fizzy.backend.DialogFileFilter{ + &[_]pixi_mod.sdk.SaveDialogFilter{ .{ .name = "PNG", .pattern = "png" }, .{ .name = "JPEG", .pattern = "jpg;jpeg" }, }, @@ -372,7 +371,7 @@ pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void /// One call site for the export preview scroll+tile so widget ids (and first-frame layout) stay /// stable when switching between Single and Animation. Otherwise `renderLayers` early-outs for /// one frame with `content_rs.s == 0` on a fresh scroll id. -fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) void { +fn renderExportPreviewSprite(file: *pixi_mod.internal.File, sprite_index: usize) void { const sprite_rect = file.spriteRect(sprite_index); const max_size_content: dvui.Size = .{ .w = (dvui.currentWindow().rect_pixels.w / dvui.currentWindow().natural_scale) / 2, @@ -413,7 +412,7 @@ fn renderExportPreviewSprite(file: *fizzy.Internal.File, sprite_index: usize) vo const local_natural = dvui.Rect{ .x = 0, .y = 0, .w = sprite_rect.w * scale, .h = sprite_rect.h * scale }; drawCheckerboardCell(file, sprite_index, local_natural, box.data().rectScale()); - fizzy.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = box.data().rectScale(), .uv = uv, @@ -441,8 +440,8 @@ fn exportScaleSlider(max_scale_val: f32) void { } fn exportDimensionsLabelForExport(column_w: u32, row_h: u32) void { - const entry_font = dvui.Font.theme(.mono).larger(-2); - Dialogs.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 }); + const entry_font = dvui.Font.theme(.mono); + DimensionsLabel.drawDimensionsLabel(@src(), column_w, row_h, entry_font, "px", .{ .gravity_x = 0.5 }); } const ExportFullPreviewKind = enum { layer, composite }; @@ -497,8 +496,8 @@ fn exportCheckerboardVertexColor( return tone.lerp(c_corner, t); } -fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color { - if (fizzy.editor.colors.file_tree_palette) |*palette| { +fn exportSpriteAnimationPaletteColor(file: *pixi_mod.internal.File, sprite_index: usize) ?dvui.Color { + if (runtime.state().colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -530,13 +529,13 @@ fn exportSpriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: u } fn exportCheckerboardCellCornerColor( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, pal: CheckerboardPalette, u: f32, v: f32, ) dvui.Color { - switch (fizzy.editor.settings.transparency_effect) { + switch (runtime.state().settings.transparency_effect) { .none => return pal.tone, .rainbow => return exportCheckerboardVertexColor(pal.c_tl, pal.c_tr, pal.c_bl, pal.c_br, u, v, 0.5, 0.5, pal.tone), .animation => { @@ -558,7 +557,7 @@ fn exportCheckerboardCellCornerColor( fn appendCheckerboardCellQuad( builder: *dvui.Triangles.Builder, quad_idx: *usize, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, pal: CheckerboardPalette, geometry_natural: dvui.Rect, @@ -597,7 +596,7 @@ fn appendCheckerboardCellQuad( } fn drawCheckerboardCell( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, geometry_natural: dvui.Rect, rs_box: dvui.RectScale, @@ -619,7 +618,7 @@ fn drawCheckerboardCell( }; } -fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) void { +fn drawCheckerboardFileGrid(file: *pixi_mod.internal.File, rs_box: dvui.RectScale) void { const n = file.spriteCount(); if (n == 0) return; @@ -645,13 +644,13 @@ fn drawCheckerboardFileGrid(file: *fizzy.Internal.File, rs_box: dvui.RectScale) /// Full-canvas preview at 1:1 logical pixels: checkerboard + either the selected layer only or the /// flattened composite (all visible layers). One scroll + box `call site for stable widget ids. -fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) void { +fn renderExportPreview(file: *pixi_mod.internal.File, kind: ExportFullPreviewKind) void { const w = file.width(); const h = file.height(); if (w == 0 or h == 0) return; if (kind == .composite) { - fizzy.render.syncLayerComposite(file) catch { + pixi_mod.render.syncLayerComposite(file) catch { dvui.log.err("Export preview: failed to build layer composite", .{}); return; }; @@ -689,13 +688,13 @@ fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) const full_uv = dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; const rs = box.data().rectScale(); - var path_tris: dvui.Path.Builder = .init(fizzy.app.allocator); + var path_tris: dvui.Path.Builder = .init(runtime.allocator()); defer path_tris.deinit(); path_tris.addRect(rs.r, .all(0)); - var tris = path_tris.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0.0 }) catch { + var tris = path_tris.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0.0 }) catch { return; }; - defer tris.deinit(fizzy.app.allocator); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(rs.r, full_uv); switch (kind) { @@ -724,20 +723,20 @@ fn renderExportPreview(file: *fizzy.Internal.File, kind: ExportFullPreviewKind) fn writeImageToPath(source: dvui.ImageSource, path: []const u8, format: ExportImageFormat) !void { if (comptime builtin.target.cpu.arch == .wasm32) { - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); switch (format) { - .png => try fizzy.image.writePngToWriter(source, &out.writer, 0), - .jpg => try fizzy.image.writeJpgPpiToWriter(source, &out.writer, 0), + .png => try pixi_mod.image.writePngToWriter(source, &out.writer, 0), + .jpg => try pixi_mod.image.writeJpgPpiToWriter(source, &out.writer, 0), } const bytes = try out.toOwnedSlice(); - defer fizzy.app.allocator.free(bytes); + defer runtime.allocator().free(bytes); try WebFileIo.downloadBytes(path, bytes); return; } switch (format) { - .png => try fizzy.image.writeToPngResolution(source, path, 0), - .jpg => try fizzy.image.writeToJpgPpi(source, path, 0), + .png => try pixi_mod.image.writeToPngResolution(source, path, 0), + .jpg => try pixi_mod.image.writeToJpgPpi(source, path, 0), } } @@ -751,7 +750,7 @@ fn writeGifBytes(path: []const u8, data: []const u8) !void { /// Flatten visible layers for one sprite tile. Layer index `0` is the front (drawn last on canvas); /// higher indices sit behind. `blitData` composites its **first** buffer (upper) over the **second** (lower). -fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.File, sprite_index: usize) ![][4]u8 { +fn compositedSpritePixels(allocator: std.mem.Allocator, file: *pixi_mod.internal.File, sprite_index: usize) ![][4]u8 { const sprite_rect = file.spriteRect(sprite_index); const w: usize = @intFromFloat(sprite_rect.w); const h: usize = @intFromFloat(sprite_rect.h); @@ -772,7 +771,7 @@ fn compositedSpritePixels(allocator: std.mem.Allocator, file: *fizzy.Internal.Fi const layer_pixels = lower.pixelsFromRect(allocator, sprite_rect) orelse continue; defer allocator.free(layer_pixels); - fizzy.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true); + pixi_mod.image.blitData(pixels, w, h, layer_pixels, sprite_rect.justSize(), true); } return pixels; @@ -832,7 +831,7 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -848,14 +847,14 @@ pub fn exportCurrentSprite(path: []const u8) anyerror!void { export_height = @intFromFloat(@as(f32, @floatFromInt(file.row_height)) * scale); } - const pixels = try compositedSpritePixels(fizzy.app.allocator, file, sprite_index); - defer fizzy.app.allocator.free(pixels); + const pixels = try compositedSpritePixels(runtime.allocator(), file, sprite_index); + defer runtime.allocator().free(pixels); if (scale != 1.0) { - const resized = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch { + const resized = runtime.allocator().alloc([4]u8, export_width * export_height) catch { return error.OutOfMemory; }; - defer fizzy.app.allocator.free(resized); + defer runtime.allocator().free(resized); if (zstbi.resize( pixels, file.column_width, @@ -894,7 +893,7 @@ pub fn exportLayerToPath(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -914,7 +913,7 @@ pub fn exportAllToPath(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -923,18 +922,18 @@ pub fn exportAllToPath(path: []const u8) anyerror!void { const h = file.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try fizzy.render.syncLayerComposite(file); + try pixi_mod.render.syncLayerComposite(file); const target = file.editor.layer_composite_target orelse { return error.NoLayerComposite; }; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr); + var tmp_layer: pixi_mod.internal.Layer = try .fromPixelsPMA(0, "export", pma_read, w, h, .ptr); defer tmp_layer.deinit(); const format: ExportImageFormat = if (is_png) .png else .jpg; @@ -950,7 +949,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { return error.InvalidExtension; } - const file = fizzy.editor.activeFile() orelse { + const file = runtime.state().docs.activeFile(runtime.state().host) orelse { dvui.log.err("Export: No active file", .{}); return error.NoActiveFile; }; @@ -962,7 +961,7 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { const animation_index = exportAnimationIndex(file) orelse return error.NoSelectedAnimation; { - const anim: fizzy.Internal.Animation = file.animations.get(animation_index); + const anim: pixi_mod.internal.Animation = file.animations.get(animation_index); var export_width = file.column_width; var export_height = file.row_height; @@ -981,11 +980,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { msf_gif.msf_gif_alpha_threshold = 240; for (anim.frames) |frame| { - const pixels = compositedSpritePixels(fizzy.app.allocator, file, frame.sprite_index) catch |err| { + const pixels = compositedSpritePixels(runtime.allocator(), file, frame.sprite_index) catch |err| { if (err == error.NoPixels) continue; return err; }; - defer fizzy.app.allocator.free(pixels); + defer runtime.allocator().free(pixels); { // msf_gif will error if there are only transparent pixels const valid = blk: { @@ -1005,11 +1004,11 @@ pub fn createAnimationGif(path: []const u8) anyerror!void { } if (scale != 1.0) { - const resized_pixels = fizzy.app.allocator.alloc([4]u8, export_width * export_height) catch { + const resized_pixels = runtime.allocator().alloc([4]u8, export_width * export_height) catch { dvui.log.err("Failed to allocate resized pixels", .{}); continue; }; - defer fizzy.app.allocator.free(resized_pixels); + defer runtime.allocator().free(resized_pixels); _ = zstbi.resize( pixels, diff --git a/src/editor/dialogs/FlatRasterSaveWarning.zig b/src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig similarity index 69% rename from src/editor/dialogs/FlatRasterSaveWarning.zig rename to src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig index 26de3119..66cfcafa 100644 --- a/src/editor/dialogs/FlatRasterSaveWarning.zig +++ b/src/plugins/pixi/src/dialogs/FlatRasterSaveWarning.zig @@ -1,23 +1,18 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); -/// When `pending_mode == .save_and_close`, resume `Editor.advanceSaveAllQuit` after flat save. -pub var pending_from_save_all_quit: bool = false; +pub const Mode = pixi_mod.sdk.Plugin.SaveConfirmMode; pub var pending_mode: Mode = .editor_save; -pub const Mode = enum { - editor_save, - save_and_close, -}; - -pub fn request(file_id: u64, mode: Mode) void { +/// Open the flat-raster save confirmation for `file_id`. `from_save_all_quit` (whether this +/// request was issued during the shell's quit walk) is captured per-dialog in a data slot so +/// no externally-mutated module flag has to be reset when the quit walk aborts. +pub fn request(file_id: u64, mode: Mode, from_save_all_quit: bool) void { pending_mode = mode; - if (mode == .editor_save) { - pending_from_save_all_quit = false; - } - var mutex = fizzy.dvui.dialog(@src(), .{ + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ .displayFn = dialog, .callafterFn = callAfter, .title = "Save as .fiz or current extension?", @@ -30,11 +25,12 @@ pub fn request(file_id: u64, mode: Mode) void { .header_kind = .warning, }); dvui.dataSet(null, mutex.id, "_flat_raster_file_id", file_id); + dvui.dataSet(null, mutex.id, "_flat_raster_from_quit", from_save_all_quit); mutex.mutex.unlock(dvui.io); } -fn fileRef(file_id: u64) ?*fizzy.Internal.File { - return fizzy.editor.open_files.getPtr(file_id); +fn fileRef(file_id: u64) ?*pixi_mod.internal.File { + return runtime.state().docs.fileById(file_id); } fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: dvui.Theme.Style.Name, tab_idx: u16, id_extra: usize) bool { @@ -61,6 +57,7 @@ fn dialogButton(src: std.builtin.SourceLocation, label_text: []const u8, style: pub fn dialog(id: dvui.Id) anyerror!bool { const file_id = dvui.dataGet(null, id, "_flat_raster_file_id", u64) orelse return false; + const from_quit = dvui.dataGet(null, id, "_flat_raster_from_quit", bool) orelse false; const file = fileRef(file_id) orelse return false; const ext_raw = std.fs.path.extension(file.path); @@ -102,7 +99,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); if (dialogButton(@src(), ext_disp, .control, 2, 1)) { - try onChooseFlatRaster(file_id); + try onChooseFlatRaster(file_id, from_quit); } _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 10, .h = 1 } }); if (dialogButton(@src(), "Cancel", .control, 3, 2)) { @@ -113,24 +110,24 @@ pub fn dialog(id: dvui.Id) anyerror!bool { } fn onChooseFizzy(file_id: u64) !void { - const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; - fizzy.editor.setActiveFile(idx); + const idx = runtime.state().host.docIndex(file_id) orelse return; + runtime.state().host.setActiveDocIndex(idx); if (pending_mode == .save_and_close) { - fizzy.editor.pending_close_file_id = file_id; + runtime.state().host.setPendingCloseDocId(file_id); } - fizzy.dvui.closeFloatingDialogAnchored(); - fizzy.editor.requestSaveAs(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); + runtime.state().host.requestSaveAs(); } -fn onChooseFlatRaster(file_id: u64) !void { +fn onChooseFlatRaster(file_id: u64, from_save_all_quit: bool) !void { const f = fileRef(file_id) orelse return; switch (pending_mode) { .editor_save => { - fizzy.dvui.closeFloatingDialogAnchored(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); if (comptime @import("builtin").target.cpu.arch == .wasm32) { - const idx = fizzy.editor.open_files.getIndex(file_id) orelse return; - fizzy.editor.setActiveFile(idx); - fizzy.editor.requestWebSaveDialog(.save); + const idx = runtime.state().host.docIndex(file_id) orelse return; + runtime.state().host.setActiveDocIndex(idx); + runtime.state().host.requestWebSave(.save); } else { try f.saveAsync(); } @@ -143,32 +140,32 @@ fn onChooseFlatRaster(file_id: u64) !void { // otherwise this is a single-doc save-and-close. f.saveAsync() catch |err| { dvui.log.err("Save failed: {s}", .{@errorName(err)}); - if (pending_from_save_all_quit) fizzy.editor.abortSaveAllQuit(); + if (from_save_all_quit) runtime.state().host.abortSaveAllQuit(); return; }; - if (pending_from_save_all_quit) { - fizzy.editor.quit_saves_in_flight.put(fizzy.app.allocator, file_id, {}) catch |err| { + if (from_save_all_quit) { + runtime.state().host.trackQuitSaveInFlight(file_id) catch |err| { dvui.log.err("Save all quit track: {s}", .{@errorName(err)}); - fizzy.editor.abortSaveAllQuit(); + runtime.state().host.abortSaveAllQuit(); return; }; - fizzy.editor.pending_quit_continue = true; + runtime.state().host.resumeSaveAllQuit(); } else { - try fizzy.editor.pending_close_after_save.put(fizzy.app.allocator, file_id, {}); + try runtime.state().host.queueCloseAfterSave(file_id); } - fizzy.dvui.closeFloatingDialogAnchored(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); }, } } fn onCancel() void { - fizzy.editor.cancelPendingSaveDialog(); - fizzy.dvui.closeFloatingDialogAnchored(); + runtime.state().host.cancelPendingSaveDialog(); + pixi_mod.core.dvui.closeFloatingDialogAnchored(); } pub fn callAfter(_: dvui.Id, response: dvui.enums.DialogResponse) !void { switch (response) { - .cancel => fizzy.editor.cancelPendingSaveDialog(), + .cancel => runtime.state().host.cancelPendingSaveDialog(), else => {}, } } diff --git a/src/editor/dialogs/GridLayout.zig b/src/plugins/pixi/src/dialogs/GridLayout.zig similarity index 91% rename from src/editor/dialogs/GridLayout.zig rename to src/plugins/pixi/src/dialogs/GridLayout.zig index bb55014e..bda528fd 100644 --- a/src/editor/dialogs/GridLayout.zig +++ b/src/plugins/pixi/src/dialogs/GridLayout.zig @@ -6,14 +6,15 @@ //! preview on the right that expands with the window. The preview uses `CanvasWidget` so //! panning / zooming honour `Settings.resolvedPanZoomScheme` (`auto` follows DVUI scroll heuristics). -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const std = @import("std"); const NewFile = @import("NewFile.zig"); -const CanvasWidget = @import("../widgets/CanvasWidget.zig"); -const FloatingWindowWidget = @import("../widgets/FloatingWindowWidget.zig"); -const builtin = @import("builtin"); +const CanvasWidget = pixi_mod.core.dvui.CanvasWidget; +const CanvasBridge = @import("../widgets/CanvasBridge.zig"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const FloatingWindowWidget = pixi_mod.core.dvui.FloatingWindowWidget; /// Editable grid fields for one mode (Slice vs Resize each keep their own backing). pub const GridFormState = struct { @@ -76,7 +77,7 @@ var preview_prev_slice_full_layer: bool = false; /// a trackpad). Small epsilon tracks real layout drift; fit only runs when dimensions actually move. const preview_layout_min_delta: f32 = 0.01; -const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{ +const anchors: [9]pixi_mod.math.layout_anchor.LayoutAnchor = .{ .nw, .n, .ne, .w, .c, .e, .sw, .s, .se, @@ -84,8 +85,35 @@ const anchors: [9]fizzy.math.layout_anchor.LayoutAnchor = .{ const anchor_labels = [_][]const u8{ "NW", "N", "NE", "W", "C", "E", "SW", "S", "SE" }; +/// Open the Grid Layout dialog for the document `file_id`. Seeds the form from the file's +/// current grid, then launches the floating dialog. Uses a custom `windowFn` that matches +/// `dialogWindow`'s open animation while capping the window to half the main window size. +/// The `_grid_layout_file_id` slot rebinds the active file so the form/preview survive frames +/// where the active document momentarily resolves null. +pub fn request(file_id: u64) void { + const file = runtime.state().docs.fileById(file_id) orelse return; + presetFromFile(file); + + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .windowFn = windowFn, + .title = "Grid Layout...", + .ok_label = "Apply", + .cancel_label = "Cancel", + .resizeable = true, + .header_kind = .info, + .default = .ok, + }); + dvui.dataSet(null, mutex.id, "_grid_layout_file_id", file_id); + // Let `windowFn` run `autoSize` only until the open animation finishes; otherwise + // `auto_size` stays true every frame and the shell snaps back to content min (user resize breaks). + dvui.dataSet(null, mutex.id, "_grid_dialog_open_done", false); + mutex.mutex.unlock(dvui.io); +} + /// Seed both mode forms with the active file's current grid so the dialog opens "no-op" by default. -pub fn presetFromFile(file: *fizzy.Internal.File) void { +pub fn presetFromFile(file: *pixi_mod.internal.File) void { resize_form = .{ .column_width = file.column_width, .row_height = file.row_height, @@ -108,7 +136,7 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void { // `prev_size` matches `data_size` and `second_center` is false, so `install` skips the // rescale/recenter pass and the preview ends up offscreen / at a stale zoom. Resetting to // a fresh widget forces a fit-to-pane on the next frame. - preview_canvas = .{ .pointer_scope = .dialog }; + preview_canvas = .{}; left_scroll = .{ .horizontal = .auto }; dialog_middle_scroll = .{ .horizontal = .auto, .vertical = .auto }; preview_pane_fit_w = 0; @@ -124,14 +152,11 @@ pub fn presetFromFile(file: *fizzy.Internal.File) void { /// Same as `Workspace.drawCanvas` / `workspaceMainCanvasVbox` behind the file widget. fn workspaceCanvasChromeColor() dvui.Color { var content_color = dvui.themeGet().color(.window, .fill); - switch (builtin.os.tag) { - .macos, .windows => { - content_color = if (!fizzy.backend.isMaximized(dvui.currentWindow())) - content_color.opacity(fizzy.editor.settings.content_opacity) - else - content_color; - }, - else => {}, + if (runtime.state().host.appliesNativeWindowOpacity()) { + content_color = if (!runtime.state().host.isMaximized()) + content_color.opacity(runtime.state().host.contentOpacity()) + else + content_color; } return content_color; } @@ -210,7 +235,7 @@ fn font() dvui.Font { /// Checkerboard behind the preview: one quad per grid cell with UV 0..1 (same as /// `FileWidget.drawCheckerboardCellsBatched`). Per-cell so vertex colors can vary. fn drawCheckerboardPreviewTiled( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, cv: *CanvasWidget, rs_box: dvui.RectScale, cols: u32, @@ -221,7 +246,7 @@ fn drawCheckerboardPreviewTiled( if (cell_w <= 0 or cell_h <= 0 or cols == 0 or rows == 0) return; const pal = previewCheckerboardPalette(); - const te = fizzy.editor.settings.transparency_effect; + const te = runtime.state().settings.transparency_effect; const cols_f = @max(@as(f32, @floatFromInt(cols)), 1.0); const rows_f = @max(@as(f32, @floatFromInt(rows)), 1.0); const nw = cell_w * cols_f; @@ -393,7 +418,7 @@ fn appendTexturedRectQuad( /// Samples the layer composite texture per **old grid cell**, mapping each sprite through `cellAnchoredBlit` /// so the preview matches the result of `applyGridLayout` independently in every tile. fn drawCompositePreviewPerCells( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, rs_box: dvui.RectScale, old_cols: u32, old_rows: u32, @@ -403,9 +428,9 @@ fn drawCompositePreviewPerCells( new_rows: u32, new_cw_: u32, new_rh_: u32, - anchor_vis: fizzy.math.layout_anchor.LayoutAnchor, + anchor_vis: pixi_mod.math.layout_anchor.LayoutAnchor, ) void { - fizzy.render.syncLayerComposite(file) catch { + pixi_mod.render.syncLayerComposite(file) catch { dvui.log.err("Grid layout preview: composite failed", .{}); return; }; @@ -425,7 +450,7 @@ fn drawCompositePreviewPerCells( defer builder.deinit(arena); const tint = dvui.Color.PMA.fromColor(dvui.Color.white.opacity(dvui.currentWindow().alpha)); - const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis); + const blk = pixi_mod.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw_, new_rh_, anchor_vis); if (blk.sw == 0 or blk.sh == 0) return; var nrow: u32 = 0; @@ -458,9 +483,9 @@ fn drawCompositePreviewPerCells( } /// One quad for the full layer composite (slice preview — no per-cell remapping). -fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void { +fn drawCompositePreviewFullLayer(file: *pixi_mod.internal.File, rs_box: dvui.RectScale, nw: f32, nh: f32) void { if (nw <= 0 or nh <= 0) return; - fizzy.render.syncLayerComposite(file) catch { + pixi_mod.render.syncLayerComposite(file) catch { dvui.log.err("Grid layout preview: composite failed", .{}); return; }; @@ -484,7 +509,7 @@ fn drawCompositePreviewFullLayer(file: *fizzy.Internal.File, rs_box: dvui.RectSc /// When entering Slice, keep the current form values if they already tile the layer exactly; /// otherwise snap from the file's authoritative grid (never force 1×1 unless metadata disagrees /// with pixel dimensions). -fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void { +fn harmonizeSliceStateWithLayer(file: *pixi_mod.internal.File) void { const canvas = file.canvasPixelSize(); const tw = canvas.w; const th = canvas.h; @@ -514,14 +539,14 @@ fn harmonizeSliceStateWithLayer(file: *fizzy.Internal.File) void { fn renderPreview( mutex_id: dvui.Id, dlg_id: dvui.Id, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, nw: u32, nh: u32, new_cw_: u32, new_rh_: u32, new_cols: u32, new_rows: u32, - anchor_vis: fizzy.math.layout_anchor.LayoutAnchor, + anchor_vis: pixi_mod.math.layout_anchor.LayoutAnchor, slice_full_layer: bool, host_rect: dvui.Rect, ) void { @@ -594,6 +619,8 @@ fn renderPreview( .id = dlg_id.update("glp_cv"), .data_size = .{ .w = @floatFromInt(nw), .h = @floatFromInt(nh) }, .center = false, + .pan_zoom_scheme = CanvasBridge.scheme(), + .hooks = .{ .pointerInputSuppressed = CanvasBridge.dialogSuppressed }, }, .{ .expand = .both, .background = true, @@ -827,7 +854,7 @@ fn gridLayoutDrawModePill(dlg_id: dvui.Id) void { if (button.clicked()) { const new_mode: Mode = @enumFromInt(i); if (new_mode == .slice and mode != .slice) { - if (file_id_for_dialog) |fid| if (fizzy.editor.open_files.getPtr(fid)) |tf| + if (file_id_for_dialog) |fid| if (runtime.state().docs.fileById(fid)) |tf| harmonizeSliceStateWithLayer(tf); } mode = new_mode; @@ -843,8 +870,8 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const form_font = font(); const file_id_for_dialog = dvui.dataGet(null, id, "_grid_layout_file_id", u64); - const target_file: ?*fizzy.Internal.File = if (file_id_for_dialog) |fid| - fizzy.editor.open_files.getPtr(fid) + const target_file: ?*pixi_mod.internal.File = if (file_id_for_dialog) |fid| + runtime.state().docs.fileById(fid) else null; @@ -875,10 +902,10 @@ pub fn dialog(id: dvui.Id) anyerror!bool { defer { if (dialog_middle_scroll.offset(.vertical) > 0.0) - fizzy.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .top, .{}); if (dialog_middle_scroll.virtual_size.h > dialog_middle_scroll.viewport.h) - fizzy.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(mid_scroll.data().contentRectScale(), .bottom, .{}); } // Form (intrinsic width, full height) + preview (expands horizontally with the window). @@ -938,18 +965,18 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const v_scroll = left_scroll.offset(.vertical); const h_scroll = left_scroll.offset(.horizontal); if (v_scroll > 0.0) { - fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .top, .{}); } if (left_scroll.virtual_size.h > left_scroll.viewport.h) { - fizzy.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(pane_left.data().contentRectScale(), .bottom, .{}); } pane_left.deinit(); if (left_scroll.virtual_size.w > left_scroll.viewport.w) { - fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{}); + pixi_mod.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .right, .{}); } if (h_scroll > 0.0) { - fizzy.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(shell_left.data().contentRectScale(), .left, .{}); } shell_left.deinit(); } @@ -985,7 +1012,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { slice_form.rows, anchors[@min(anchor_ix, anchors.len - 1)], }; - break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(fizzy.math.layout_anchor.LayoutAnchor, .nw) }; + break :blk .{ tf.column_width, tf.row_height, tf.columns, tf.rows, @as(pixi_mod.math.layout_anchor.LayoutAnchor, .nw) }; } break :blk switch (mode) { .slice => .{ @@ -1016,15 +1043,15 @@ pub fn dialog(id: dvui.Id) anyerror!bool { defer { const rs_scroll = preview_host.data().rectScale(); - fizzy.dvui.drawEdgeShadow(rs_scroll, .top, .{}); - fizzy.dvui.drawEdgeShadow(rs_scroll, .bottom, .{}); - fizzy.dvui.drawEdgeShadow(rs_scroll, .left, .{}); - fizzy.dvui.drawEdgeShadow(rs_scroll, .right, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(rs_scroll, .right, .{}); } if (target_file) |tf| { const host_rect = preview_host.data().contentRect(); - const dims_ok = fizzy.Internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows); + const dims_ok = pixi_mod.internal.File.validateGridLayoutProposedDims(pv_cw, pv_rh, pv_cols, pv_rows); if (dims_ok) { renderPreview( id, @@ -1081,7 +1108,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { /// Resize-mode form: cell width (x), cell height (y), columns (x), rows (y); 9-way anchor; current vs after readout. fn drawResizeForm( unique_id: dvui.Id, - target_file: ?*fizzy.Internal.File, + target_file: ?*pixi_mod.internal.File, form_font: dvui.Font, ) bool { var valid: bool = true; @@ -1107,7 +1134,7 @@ fn drawResizeForm( .color_text = dvui.themeGet().color(.control, .text), }); - if (!fizzy.Internal.File.validateGridLayoutProposedDims( + if (!pixi_mod.internal.File.validateGridLayoutProposedDims( resize_form.column_width, resize_form.row_height, resize_form.columns, @@ -1300,7 +1327,7 @@ fn drawResizeForm( /// multiply back to the locked total. fn drawSliceForm( unique_id: dvui.Id, - target_file: ?*fizzy.Internal.File, + target_file: ?*pixi_mod.internal.File, form_font: dvui.Font, ) bool { var valid: bool = true; @@ -1463,7 +1490,7 @@ fn drawSliceForm( return valid; } -/// Custom window shell for the grid-layout dialog: matches `fizzy.dvui.dialogWindow` (open +/// Custom window shell for the grid-layout dialog: matches `pixi_mod.core.dvui.dialogWindow` (open /// `autoSize()` animation, nudge + center on modal rect). `min_size_content` is half the main /// window so the first layout pass does not collapse the shell; DVUI then grows to fit content /// (see `FloatingWindowWidget` `Size.max(min_size, min_sizeGet)`). Do not use `max_size_content` @@ -1476,7 +1503,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { }; if (modal) { - fizzy.editor.dim_titlebar = true; + pixi_mod.core.dvui.modal_dim_titlebar = true; } const title = dvui.dataGetSlice(null, id, "_title", []u8) orelse { @@ -1489,8 +1516,8 @@ pub fn windowFn(id: dvui.Id) anyerror!void { }; const cancel_label = dvui.dataGetSlice(null, id, "_cancel_label", []u8); const default = dvui.dataGet(null, id, "_default", dvui.enums.DialogResponse); - const callafter = dvui.dataGet(null, id, "_callafter", fizzy.dvui.CallAfterFn); - const displayFn = dvui.dataGet(null, id, "_displayFn", fizzy.dvui.DisplayFn); + const callafter = dvui.dataGet(null, id, "_callafter", pixi_mod.core.dvui.CallAfterFn); + const displayFn = dvui.dataGet(null, id, "_displayFn", pixi_mod.core.dvui.DisplayFn); // Default shell: wide enough for form + preview; DVUI autoSize grows to content if larger. const wr = dvui.windowRect(); @@ -1498,7 +1525,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { const init_h = @round(wr.h * 0.52); const center_on = dvui.currentWindow().subwindows.current_rect; - var win = fizzy.dvui.floatingWindow(@src(), .{ + var win = pixi_mod.core.dvui.floatingWindow(@src(), .{ .modal = modal, .center_on = center_on, .window_avoid = .nudge, @@ -1530,12 +1557,12 @@ pub fn windowFn(id: dvui.Id) anyerror!void { if (dvui.animationGet(win.data().id, "_close_x")) |a| { if (a.done()) { - fizzy.Editor.Explorer.files.new_file_close_rect = null; + pixi_mod.core.dvui.dialog_close_rect_override = null; dvui.dialogRemove(id); } - } else if (fizzy.Editor.Explorer.files.new_file_close_rect) |close_rect| { + } else if (pixi_mod.core.dvui.dialog_close_rect_override) |close_rect| { dvui.dataSet(null, win.data().id, "_close_rect", close_rect); - fizzy.Editor.Explorer.files.new_file_close_rect = null; + pixi_mod.core.dvui.dialog_close_rect_override = null; } else { // Call `autoSize` only while opening. Doing it every frame leaves `auto_size` true and the // window keeps animating/snapping to content min size — user resize appears "locked". @@ -1560,16 +1587,16 @@ pub fn windowFn(id: dvui.Id) anyerror!void { var shell = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both }); defer shell.deinit(); - const header_kind: fizzy.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) { - @intFromEnum(fizzy.dvui.DialogHeaderKind.none) => .none, - @intFromEnum(fizzy.dvui.DialogHeaderKind.info) => .info, - @intFromEnum(fizzy.dvui.DialogHeaderKind.warning) => .warning, - @intFromEnum(fizzy.dvui.DialogHeaderKind.err) => .err, + const header_kind: pixi_mod.core.dvui.DialogHeaderKind = switch (dvui.dataGet(null, id, "_header_kind", u8) orelse 0) { + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.none) => .none, + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.info) => .info, + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.warning) => .warning, + @intFromEnum(pixi_mod.core.dvui.DialogHeaderKind.err) => .err, else => .none, }; var header_openflag = true; - win.dragAreaSet(fizzy.dvui.windowHeader(title, "", &header_openflag, header_kind)); + win.dragAreaSet(pixi_mod.core.dvui.windowHeader(title, "", &header_openflag, header_kind)); if (!header_openflag) { if (callafter) |ca| { ca(id, .cancel) catch { @@ -1602,7 +1629,7 @@ pub fn windowFn(id: dvui.Id) anyerror!void { } } - { // Footer — match `fizzy.dvui.dialogWindow` (horizontal strip, gravity_x centered). + { // Footer — match `pixi_mod.core.dvui.dialogWindow` (horizontal strip, gravity_x centered). var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_x = 0.5, .padding = .{ .y = 6, .h = 8 }, @@ -1692,12 +1719,12 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (response) { .ok => { const file_id = dvui.dataGet(null, id, "_grid_layout_file_id", u64) orelse return; - const file = fizzy.editor.open_files.getPtr(file_id) orelse return; + const file = runtime.state().docs.fileById(file_id) orelse return; switch (mode) { .slice => { const s = slice_form; - if (!fizzy.Internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) + if (!pixi_mod.internal.File.validateGridLayoutProposedDims(s.column_width, s.row_height, s.columns, s.rows)) return; file.applyGridSliceOnly(.{ .column_width = s.column_width, @@ -1711,7 +1738,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void }, .resize => { const r = resize_form; - if (!fizzy.Internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) + if (!pixi_mod.internal.File.validateGridLayoutProposedDims(r.column_width, r.row_height, r.columns, r.rows)) return; file.applyGridLayout(.{ .column_width = r.column_width, @@ -1727,7 +1754,7 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void } dvui.refresh(null, @src(), dvui.currentWindow().data().id); - fizzy.editor.requestCompositeWarmup(); + runtime.state().host.requestPrepareFrame(); }, .cancel => {}, else => {}, diff --git a/src/editor/dialogs/NewFile.zig b/src/plugins/pixi/src/dialogs/NewFile.zig similarity index 83% rename from src/editor/dialogs/NewFile.zig rename to src/plugins/pixi/src/dialogs/NewFile.zig index a4a0a462..10c068e3 100644 --- a/src/editor/dialogs/NewFile.zig +++ b/src/plugins/pixi/src/dialogs/NewFile.zig @@ -1,8 +1,9 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); -const Dialogs = @import("Dialogs.zig"); +const DimensionsLabel = @import("dimensions_label.zig"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); pub var mode: enum(usize) { single, @@ -17,8 +18,29 @@ pub var row_height: u32 = 32; pub const max_size: [2]u32 = .{ 4096, 4096 }; pub const min_size: [2]u32 = .{ 1, 1 }; +/// Open the "New File" dimensions dialog. When `parent_path` is set the new document is created +/// on disk inside that folder (explorer-initiated); otherwise an in-memory `untitled-n` is made. +/// `id_extra` disambiguates dialogs launched from distinct explorer rows. +pub fn request(parent_path: ?[]const u8, id_extra: usize) void { + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ + .displayFn = dialog, + .callafterFn = callAfter, + .title = "New File...", + .ok_label = "Create", + .cancel_label = "Cancel", + .resizeable = false, + .header_kind = .info, + .default = .ok, + .id_extra = id_extra, + }); + // `dataSetSlice` copies the bytes into dvui's per-widget store, so the borrowed slice + // only needs to be valid for this call. + if (parent_path) |p| dvui.dataSetSlice(null, mutex.id, "_parent_path", p); + mutex.mutex.unlock(dvui.io); +} + pub fn dialog(id: dvui.Id) anyerror!bool { - const entry_font = dvui.Font.theme(.mono).larger(-2); + const entry_font = dvui.Font.theme(.mono); // Touch explorer target path every frame so dvui does not drop it at Window.end before OK. _ = dvui.dataGetSlice(null, id, "_parent_path", []u8); @@ -174,7 +196,7 @@ pub fn dialog(id: dvui.Id) anyerror!bool { const width = column_width * (if (mode == .single) 1 else columns); const height = row_height * (if (mode == .single) 1 else rows); - Dialogs.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 }); + DimensionsLabel.drawDimensionsLabel(@src(), width, height, entry_font, "px", .{ .gravity_x = 0.5 }); return valid; } @@ -188,18 +210,16 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void switch (response) { .ok => { if (parent_path) |parent| { - const new_path = try std.fs.path.join(fizzy.app.allocator, &.{ parent, "untitled.fiz" }); - defer fizzy.app.allocator.free(new_path); + const new_path = try std.fs.path.join(runtime.allocator(), &.{ parent, "untitled.fiz" }); + defer runtime.allocator().free(new_path); - const file = fizzy.editor.newFile(new_path, .{ + const doc = try runtime.state().host.createDocument(new_path, .{ .column_width = column_width, .row_height = row_height, .columns = if (mode == .single) 1 else columns, .rows = if (mode == .single) 1 else rows, - }) catch { - dvui.log.err("Failed to create file in folder: {s}", .{parent}); - return error.FailedToCreateFile; - }; + }); + const file = runtime.state().docs.fileFrom(doc); // Save synchronously so the tree's directory scan sees the new file on the next draw // (saveAsync would finish later and the fly-to / rename row would never match). @@ -208,23 +228,17 @@ pub fn callAfter(id: dvui.Id, response: dvui.enums.DialogResponse) anyerror!void return error.FailedToSaveFile; }; - if (fizzy.Editor.Explorer.files.new_file_path) |old| { - fizzy.app.allocator.free(old); - } - fizzy.Editor.Explorer.files.new_file_path = try fizzy.app.allocator.dupe(u8, file.path); + try runtime.state().host.setExplorerNewFilePath(file.path); dvui.refresh(null, @src(), dvui.currentWindow().data().id); } else { - const new_path = try fizzy.editor.allocNextUntitledPath(); - defer fizzy.app.allocator.free(new_path); - _ = fizzy.editor.newFile(new_path, .{ + const new_path = try runtime.state().host.allocUntitledPath(); + defer runtime.allocator().free(new_path); + _ = try runtime.state().host.createDocument(new_path, .{ .column_width = column_width, .row_height = row_height, .columns = if (mode == .single) 1 else columns, .rows = if (mode == .single) 1 else rows, - }) catch { - dvui.log.err("Failed to create new untitled file", .{}); - return error.FailedToCreateFile; - }; + }); } }, .cancel => {}, diff --git a/src/plugins/pixi/src/dialogs/dimensions_label.zig b/src/plugins/pixi/src/dialogs/dimensions_label.zig new file mode 100644 index 00000000..42db8da8 --- /dev/null +++ b/src/plugins/pixi/src/dialogs/dimensions_label.zig @@ -0,0 +1,73 @@ +//! Shared "W x H unit" label row for New File / Export dialogs. +const std = @import("std"); +const dvui = @import("dvui"); + +pub fn drawDimensionsLabel(src: std.builtin.SourceLocation, width: u32, height: u32, font: dvui.Font, unit: []const u8, opts: dvui.Options) void { + var hbox = dvui.box(src, .{ .dir = .horizontal }, opts); + defer hbox.deinit(); + + dvui.label( + src, + "{d}", + .{width}, + .{ + .font = font, + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 1.0, + .id_extra = 1, + }, + ); + + dvui.label( + src, + "{s}", + .{unit}, + .{ + .font = dvui.Font.theme(.body).withSize(font.size - 1.0), + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 2, + }, + ); + + dvui.label( + src, + "x", + .{}, + .{ + .font = dvui.Font.theme(.body).withSize(font.size - 1.0), + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 3, + }, + ); + + dvui.label( + src, + "{d}", + .{height}, + .{ + .font = font, + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 4, + }, + ); + + dvui.label( + src, + "{s}", + .{unit}, + .{ + .font = dvui.Font.theme(.body).withSize(font.size - 1.0), + .margin = .{ .x = 1, .w = 1 }, + .padding = .all(0), + .gravity_y = 0.5, + .id_extra = 5, + }, + ); +} diff --git a/src/plugins/pixi/src/doc_bridge.zig b/src/plugins/pixi/src/doc_bridge.zig new file mode 100644 index 00000000..64df28eb --- /dev/null +++ b/src/plugins/pixi/src/doc_bridge.zig @@ -0,0 +1,93 @@ +//! Document metadata + pane-binding hooks for shell/workbench routing without +//! typing `Internal.File` at the SDK boundary. +const std = @import("std"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; +const DocHandle = pixi_mod.sdk.DocHandle; + +fn docFile(st: *State, doc: DocHandle) ?*Internal.File { + return st.docs.fileById(doc.id); +} + +pub fn bindDocumentToWorkspace( + st: *State, + doc: DocHandle, + canvas_id: dvui.Id, + workspace_handle: *anyopaque, + center: bool, +) void { + const file = docFile(st, doc) orelse return; + file.editor.canvas.id = canvas_id; + file.editor.workspace_handle = workspace_handle; + file.editor.center = center; +} + +pub fn documentGrouping(st: *State, doc: DocHandle) u64 { + const file = docFile(st, doc) orelse return 0; + return file.editor.grouping; +} + +pub fn setDocumentGrouping(st: *State, doc: DocHandle, grouping: u64) void { + const file = docFile(st, doc) orelse return; + file.editor.grouping = grouping; +} + +pub fn documentPath(st: *State, doc: DocHandle) []const u8 { + const file = docFile(st, doc) orelse return ""; + return file.path; +} + +pub fn setDocumentPath(st: *State, doc: DocHandle, path: []const u8) !void { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + const gpa = runtime.allocator(); + gpa.free(file.path); + file.path = try gpa.dupe(u8, path); +} + +pub fn documentHasNativeExtension(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return Internal.File.isFizzyExtension(std.fs.path.extension(file.path)); +} + +pub fn documentHasRecognizedSaveExtension(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return Internal.File.hasRecognizedSaveExtension(file.path); +} + +pub fn canUndo(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.history.undo_stack.items.len > 0; +} + +pub fn canRedo(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.history.redo_stack.items.len > 0; +} + +pub fn showsSaveStatusIndicator(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.showsSaveStatusIndicator(); +} + +pub fn isDocumentSaving(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.isSaving(); +} + +pub fn shouldConfirmFlatRasterSave(st: *State, doc: DocHandle) bool { + const file = docFile(st, doc) orelse return false; + return file.shouldConfirmFlatRasterSave(); +} + +pub fn saveDocumentAsync(st: *State, doc: DocHandle) !void { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + try file.saveAsync(); +} + +pub fn timeSinceSaveCompleteNs(st: *State, doc: DocHandle) ?i128 { + const file = docFile(st, doc) orelse return null; + return file.timeSinceSaveComplete(); +} diff --git a/src/plugins/pixi/src/doc_lifecycle.zig b/src/plugins/pixi/src/doc_lifecycle.zig new file mode 100644 index 00000000..85eb1c15 --- /dev/null +++ b/src/plugins/pixi/src/doc_lifecycle.zig @@ -0,0 +1,155 @@ +//! Document create/load buffer contract + shell frame hooks without typing +//! `Internal.File` at the SDK boundary. +const std = @import("std"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; +const DocHandle = pixi_mod.sdk.DocHandle; +const NewDocGrid = pixi_mod.sdk.EditorAPI.NewDocGrid; + +fn docFile(st: *State, doc: DocHandle) ?*Internal.File { + return st.docs.fileById(doc.id); +} + +fn activeFile(st: *State) ?*Internal.File { + const doc = st.host.activeDoc() orelse return null; + return docFile(st, doc); +} + +pub fn sizeOfDocument(_: *State) usize { + return @sizeOf(Internal.File); +} + +pub fn alignOfDocument(_: *State) usize { + return @alignOf(Internal.File); +} + +pub fn documentIdFromBuffer(_: *State, doc: *anyopaque) u64 { + const file: *Internal.File = @ptrCast(@alignCast(doc)); + return file.id; +} + +pub fn deinitDocumentBuffer(_: *State, doc: *anyopaque) void { + const file: *Internal.File = @ptrCast(@alignCast(doc)); + file.deinit(); +} + +pub fn setDocumentGroupingOnBuffer(_: *State, doc: *anyopaque, grouping: u64) void { + const file: *Internal.File = @ptrCast(@alignCast(doc)); + file.editor.grouping = grouping; +} + +pub fn createDocument(_: *State, path: []const u8, grid: NewDocGrid, out_doc: *anyopaque) !void { + const file: *Internal.File = @ptrCast(@alignCast(out_doc)); + file.* = try Internal.File.init(path, .{ + .columns = grid.columns, + .rows = grid.rows, + .column_width = grid.column_width, + .row_height = grid.row_height, + }); +} + +pub fn documentDefaultSaveAsFilename(st: *State, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + return Internal.File.defaultSaveAsFilename(allocator, file.path); +} + +pub fn saveDocumentAs(st: *State, doc: DocHandle, path: []const u8, window: *dvui.Window) !void { + const file = docFile(st, doc) orelse return error.DocumentNotFound; + const ext = std.fs.path.extension(path); + if (Internal.File.isFizzyExtension(ext)) { + try file.saveAsFizzy(path, window); + } else if (std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { + try file.saveAsFlattened(path, window); + } else { + return error.UnsupportedSaveExtension; + } +} + +pub fn resetDocumentSaveUIState(st: *State, doc: DocHandle) void { + const file = docFile(st, doc) orelse return; + file.resetSaveUIState(); +} + +pub fn tickOpenDocuments(st: *State) bool { + var needs_save_status_anim_tick = false; + for (st.docs.files.values()) |*file| { + file.tickSaveDoneFlash(); + if (file.showsSaveStatusIndicator()) needs_save_status_anim_tick = true; + } + return needs_save_status_anim_tick; +} + +pub fn resetDocumentPeekLayers(st: *State) void { + for (st.docs.files.values()) |*file| { + if (file.editor.isolate_layer) { + file.peek_layer_index = file.selected_layer_index; + } else { + file.peek_layer_index = null; + } + } +} + +pub fn tickActiveDocumentPlayback(st: *State, timer_host_id: dvui.Id) void { + const file = activeFile(st) orelse return; + if (!file.editor.playing) return; + if (file.selected_animation_index) |index| { + const animation = file.animations.get(index); + if (animation.frames.len == 0) return; + if (dvui.timerDoneOrNone(timer_host_id)) { + if (file.selected_animation_frame_index >= animation.frames.len - 1) { + file.selected_animation_frame_index = 0; + } else { + file.selected_animation_frame_index += 1; + } + const millis_per_frame = animation.frames[file.selected_animation_frame_index].ms; + dvui.timer(timer_host_id, @intCast(millis_per_frame * 1000)); + } + } +} + +pub fn warmupActiveDocumentComposites(st: *State) void { + const file = activeFile(st) orelse return; + const w = file.width(); + const h = file.height(); + if (w == 0 or h == 0) return; + const area = @as(u64, w) * @as(u64, h); + if (area < 512 * 512) return; + pixi_mod.render.warmupDrawingComposites(file) catch |err| { + dvui.log.err("Composite warmup failed: {any}", .{err}); + }; +} + +pub fn isAnyDocumentActivelyDrawing(st: *State) bool { + for (st.docs.files.values()) |*file| { + if (file.editor.active_drawing) return true; + } + return false; +} + +pub fn acceptEdit(st: *State) void { + const file = activeFile(st) orelse return; + if (file.editor.transform) |*t| t.accept(); +} + +pub fn cancelEdit(st: *State) void { + const file = activeFile(st) orelse return; + if (file.editor.transform) |*t| t.cancel(); + if (file.editor.selected_sprites.count() > 0) file.clearSelectedSprites(); + if (file.selected_animation_index != null) file.selected_animation_index = null; +} + +pub fn deleteSelection(st: *State) void { + const file = activeFile(st) orelse return; + file.deleteSelectedContents(); +} + +pub fn initPlugin(_: *State) !void { + try Internal.File.initSaveQueue(); +} + +pub fn deinitPlugin(_: *State) void { + Internal.File.deinitSaveQueue(); +} diff --git a/src/plugins/pixi/src/docs_registry.zig b/src/plugins/pixi/src/docs_registry.zig new file mode 100644 index 00000000..c28ef928 --- /dev/null +++ b/src/plugins/pixi/src/docs_registry.zig @@ -0,0 +1,32 @@ +//! Open-document registry bridge: the shell stores `DocHandle`s; this owns `Internal.File`. +const std = @import("std"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; + +pub fn registerOpenDocument(st: *State, file: *Internal.File) !*Internal.File { + const gpa = runtime.allocator(); + try st.docs.files.put(gpa, file.id, file.*); + return st.docs.files.getPtr(file.id).?; +} + +pub fn documentFromId(st: *State, id: u64) ?*Internal.File { + return st.docs.fileById(id); +} + +pub fn documentFromPath(st: *State, path: []const u8) ?*Internal.File { + return st.docs.fileFromPath(path); +} + +pub fn unregisterDocument(st: *State, id: u64) void { + _ = st.docs.files.swapRemove(id); +} + +pub fn persistProjectFolder(st: *State) void { + st.persistProject(); +} + +pub fn reloadProjectFolder(st: *State, allocator: std.mem.Allocator) void { + st.reloadProjectForFolder(allocator); +} diff --git a/src/editor/explorer/project.zig b/src/plugins/pixi/src/explorer/project.zig similarity index 88% rename from src/editor/explorer/project.zig rename to src/plugins/pixi/src/explorer/project.zig index ccc1bfe5..0aec0f03 100644 --- a/src/editor/explorer/project.zig +++ b/src/plugins/pixi/src/explorer/project.zig @@ -2,8 +2,10 @@ const std = @import("std"); const builtin = @import("builtin"); const icons = @import("icons"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const PackProject = @import("../pack_project.zig"); pub fn draw() !void { // On web there's no project folder concept. Render a simplified pane that @@ -14,8 +16,8 @@ pub fn draw() !void { return; } - if (fizzy.editor.folder) |folder| { - if (fizzy.editor.project) |_| { + if (runtime.state().host.folder()) |folder| { + if (runtime.state().project) |_| { const tl = dvui.textLayout(@src(), .{}, .{ .expand = .none, .margin = dvui.Rect.all(0), @@ -34,7 +36,7 @@ pub fn draw() !void { } else { var box = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .horizontal, - .max_size_content = .{ .w = fizzy.editor.explorer.scroll_info.virtual_size.w, .h = std.math.floatMax(f32) }, + .max_size_content = .{ .w = runtime.state().host.explorerVirtualSize().w, .h = std.math.floatMax(f32) }, }); defer box.deinit(); @@ -44,19 +46,19 @@ pub fn draw() !void { tl.deinit(); if (dvui.button(@src(), "Create Project", .{}, .{ .expand = .horizontal })) { - fizzy.editor.project = .{}; + runtime.state().project = .{}; } return; } - const packing = fizzy.editor.isPackingActive(); + const packing = PackProject.isActive(runtime.state()); if (packProjectButton(packing)) { - fizzy.editor.startPackProject() catch |err| { + PackProject.start(runtime.state()) catch |err| { dvui.log.err("Failed to start project pack: {any}", .{err}); }; } - if (fizzy.packer.atlas != null) { + if (runtime.packer().atlas != null) { drawPackedAtlasStats(); } @@ -67,8 +69,8 @@ pub fn draw() !void { dvui.log.err("Failed to draw path text entry", .{}); }; - if (fizzy.editor.project) |project| { - if (fizzy.packer.atlas) |atlas| { + if (runtime.state().project) |project| { + if (runtime.packer().atlas) |atlas| { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 6 } }); if (dvui.button(@src(), "Export Project", .{ .draw_focus = false }, .{ .expand = .horizontal, @@ -129,13 +131,13 @@ pub fn draw() !void { // break :blk true; // }; - // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{ + // if (dvui.dialogNativeFileSave(runtime.allocator(), .{ // .title = "Select Atlas Data Output", // .filters = &.{".atlas"}, // .filter_description = "Atlas file", // .path = if (valid_path) project.packed_atlas_output else null, // }) catch null) |path| { - // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null; + // project.packed_atlas_output = runtime.allocator().dupe(u8, path[0..]) catch null; // set_text = true; // } else { // dvui.log.err("Project failed to copy new path", .{}); @@ -162,7 +164,7 @@ pub fn draw() !void { // if (te.text_changed) { // const t = te.getText(); // if (t.len > 0) { - // project.packed_atlas_output = fizzy.app.allocator.dupe(u8, t) catch null; + // project.packed_atlas_output = runtime.allocator().dupe(u8, t) catch null; // } else { // project.packed_atlas_output = null; // } @@ -210,13 +212,13 @@ pub fn draw() !void { // break :blk true; // }; - // if (dvui.dialogNativeFileSave(fizzy.app.allocator, .{ + // if (dvui.dialogNativeFileSave(runtime.allocator(), .{ // .title = "Select Atlas Image Output", // .filters = &.{".png"}, // .filter_description = "Image file", // .path = if (valid_path) project.packed_image_output else null, // }) catch null) |path| { - // project.packed_image_output = fizzy.app.allocator.dupe(u8, path[0..]) catch null; + // project.packed_image_output = runtime.allocator().dupe(u8, path[0..]) catch null; // set_text = true; // } else { // dvui.log.err("Project failed to copy new path", .{}); @@ -243,7 +245,7 @@ pub fn draw() !void { // if (te.text_changed) { // const t = te.getText(); // if (t.len > 0) { - // project.packed_image_output = fizzy.app.allocator.dupe(u8, t) catch null; + // project.packed_image_output = runtime.allocator().dupe(u8, t) catch null; // } else { // project.packed_image_output = null; // } @@ -258,7 +260,7 @@ const PathType = enum { }; fn pathTextEntry(path_type: PathType) !void { - if (fizzy.editor.project) |*project| { + if (runtime.state().project) |*project| { const output_path = switch (path_type) { .atlas => &project.packed_atlas_output, .image => &project.packed_image_output, @@ -315,7 +317,7 @@ fn pathTextEntry(path_type: PathType) !void { break :blk true; }; - fizzy.backend.showSaveFileDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ + runtime.state().host.showSaveDialog(if (path_type == .atlas) packedAtlasOutputCallback else packedImageOutputCallback, &.{ if (path_type == .atlas) .{ .name = "Atlas Data", .pattern = "atlas" } else .{ .name = "Atlas Image", .pattern = "png;jpg;jpeg" }, }, "", if (valid_path) output_path.* else null); set_text = true; @@ -342,7 +344,7 @@ fn pathTextEntry(path_type: PathType) !void { if (te.text_changed) { const t = te.getText(); if (t.len > 0) { - output_path.* = fizzy.app.allocator.dupe(u8, t) catch null; + output_path.* = runtime.allocator().dupe(u8, t) catch null; } else { output_path.* = null; } @@ -351,8 +353,8 @@ fn pathTextEntry(path_type: PathType) !void { } fn drawPackedAtlasStats() void { - const atlas = &fizzy.packer.atlas.?; - const image_size = fizzy.image.size(atlas.source); + const atlas = &runtime.packer().atlas.?; + const image_size = pixi_mod.image.size(atlas.source); const atlas_w: u32 = @intFromFloat(image_size.w); const atlas_h: u32 = @intFromFloat(image_size.h); @@ -371,7 +373,7 @@ fn drawPackedAtlasStats() void { const label_opts: dvui.Options = .{ .font = body, .color_text = label_color }; const value_opts: dvui.Options = .{ .font = body, .color_text = value_color }; - if (fizzy.packer.last_packed_at_ns) |packed_at_ns| { + if (runtime.packer().last_packed_at_ns) |packed_at_ns| { var when_buf: [64]u8 = undefined; const when = formatLastPacked(&when_buf, packed_at_ns); tl.addText("Last packed: ", label_opts); @@ -396,7 +398,7 @@ fn drawPackedAtlasStats() void { } fn formatLastPacked(buf: []u8, packed_at_ns: i128) []const u8 { - const elapsed_s = @divTrunc(fizzy.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s); + const elapsed_s = @divTrunc(pixi_mod.perf.nanoTimestamp() - packed_at_ns, std.time.ns_per_s); if (elapsed_s < 10) { return std.fmt.bufPrint(buf, "just now", .{}) catch "recently"; } @@ -442,7 +444,7 @@ fn packProjectButton(packing: bool) bool { // Spinner overlays at the right edge — same content rect as the label, but anchored to // `gravity_x = 1.0`. Sized to roughly match the cap height so it doesn't fight the label. if (packing) { - fizzy.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{ + pixi_mod.core.dvui.bubbleSpinner(@src(), (dvui.Options{}).strip().override(bw.style()).override(.{ .min_size_content = .{ .w = 16, .h = 16 }, .gravity_x = 1.0, .gravity_y = 0.5, @@ -455,24 +457,24 @@ fn packProjectButton(packing: bool) bool { } pub fn packedAtlasOutputCallback(paths: ?[][:0]const u8) void { - if (fizzy.editor.project) |*project| { + if (runtime.state().project) |*project| { const output_path = &project.packed_atlas_output; if (paths) |paths_| { for (paths_) |path| { - output_path.* = fizzy.app.allocator.dupe(u8, path) catch null; + output_path.* = runtime.allocator().dupe(u8, path) catch null; } } } } pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { - if (fizzy.editor.project) |*project| { + if (runtime.state().project) |*project| { const output_path = &project.packed_image_output; if (paths) |paths_| { for (paths_) |path| { - output_path.* = fizzy.app.allocator.dupe(u8, path) catch null; + output_path.* = runtime.allocator().dupe(u8, path) catch null; } } } @@ -482,7 +484,7 @@ pub fn packedImageOutputCallback(paths: ?[][:0]const u8) void { /// the Pack button (operates on currently-open files) and Download buttons for /// the resulting atlas/image data. fn drawWeb() !void { - if (fizzy.editor.open_files.count() == 0) { + if (runtime.state().host.openDocCount() == 0) { dvui.labelNoFmt( @src(), "Open one or more files to pack.", @@ -500,19 +502,19 @@ fn drawWeb() !void { .style = .highlight, }; - const packing = fizzy.editor.isPackingActive(); + const packing = PackProject.isActive(runtime.state()); if (packProjectButton(packing)) { - fizzy.editor.startPackProject() catch |err| { + PackProject.start(runtime.state()) catch |err| { dvui.log.err("Failed to pack open files: {any}", .{err}); }; } - if (fizzy.packer.atlas != null) { + if (runtime.packer().atlas != null) { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); drawPackedAtlasStats(); } - if (fizzy.packer.atlas) |atlas| { + if (runtime.packer().atlas) |atlas| { _ = dvui.spacer(@src(), .{ .min_size_content = .{ .h = 4 } }); if (dvui.button(@src(), "Download Atlas JSON", .{ .draw_focus = false }, btn_opts)) { atlas.save("atlas.atlas", .data) catch { diff --git a/src/editor/explorer/sprites.zig b/src/plugins/pixi/src/explorer/sprites.zig similarity index 90% rename from src/editor/explorer/sprites.zig rename to src/plugins/pixi/src/explorer/sprites.zig index 8e0caea8..121571da 100644 --- a/src/editor/explorer/sprites.zig +++ b/src/plugins/pixi/src/explorer/sprites.zig @@ -1,9 +1,8 @@ const std = @import("std"); const dvui = @import("dvui"); const icons = @import("icons"); - -const fizzy = @import("../../fizzy.zig"); -const Editor = fizzy.Editor; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Sprites = @This(); @@ -91,7 +90,7 @@ pub fn init() Sprites { return .{}; } -fn selectionUiKey(file: *fizzy.Internal.File) u64 { +fn selectionUiKey(file: *pixi_mod.internal.File) u64 { const c = file.editor.selected_sprites.count(); if (c == 0) return 0; const first = file.editor.selected_sprites.findFirstSet() orelse return 0; @@ -101,7 +100,7 @@ fn selectionUiKey(file: *fizzy.Internal.File) u64 { return (@as(u64, c) << 48) ^ (@as(u64, first) << 24) ^ @as(u64, last); } -fn selectionOriginsDifferFrom(file: *fizzy.Internal.File, indices: []const usize, old_vals: []const [2]f32) bool { +fn selectionOriginsDifferFrom(file: *pixi_mod.internal.File, indices: []const usize, old_vals: []const [2]f32) bool { for (indices, old_vals) |si, ov| { const cur = file.sprites.get(si).origin; if (cur[0] != ov[0] or cur[1] != ov[1]) return true; @@ -113,36 +112,36 @@ fn freeOriginAxisDragSnapshot(self: *Sprites, axis: enum { x, y }) void { switch (axis) { .x => { if (self.origin_x_drag_indices) |s| { - fizzy.app.allocator.free(s); + runtime.allocator().free(s); self.origin_x_drag_indices = null; } if (self.origin_x_drag_old_vals) |v| { - fizzy.app.allocator.free(v); + runtime.allocator().free(v); self.origin_x_drag_old_vals = null; } }, .y => { if (self.origin_y_drag_indices) |s| { - fizzy.app.allocator.free(s); + runtime.allocator().free(s); self.origin_y_drag_indices = null; } if (self.origin_y_drag_old_vals) |v| { - fizzy.app.allocator.free(v); + runtime.allocator().free(v); self.origin_y_drag_old_vals = null; } }, } } -fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.Internal.File, axis: enum { x, y }) !void { +fn beginOriginAxisDragSnapshot(self: *Sprites, file: *pixi_mod.internal.File, axis: enum { x, y }) !void { switch (axis) { .x => if (self.origin_x_drag_indices != null) return, .y => if (self.origin_y_drag_indices != null) return, } const count = file.editor.selected_sprites.count(); - const indices = try fizzy.app.allocator.alloc(usize, count); - errdefer fizzy.app.allocator.free(indices); - const old_vals = try fizzy.app.allocator.alloc([2]f32, count); + const indices = try runtime.allocator().alloc(usize, count); + errdefer runtime.allocator().free(indices); + const old_vals = try runtime.allocator().alloc([2]f32, count); var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; while (iter.next()) |si| : (i += 1) { @@ -161,15 +160,15 @@ fn beginOriginAxisDragSnapshot(self: *Sprites, file: *fizzy.Internal.File, axis: } } -fn appendOriginsHistory(file: *fizzy.Internal.File, indices: []usize, old_vals: [][2]f32) !void { +fn appendOriginsHistory(file: *pixi_mod.internal.File, indices: []usize, old_vals: [][2]f32) !void { file.history.append(.{ .origins = .{ .indices = indices, .values = old_vals } }) catch |err| { - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); return err; }; } -fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) void { +fn applySpriteOriginAxisNoHistory(file: *pixi_mod.internal.File, axis: enum { x, y }, new_val: f32) void { const cw = @as(f32, @floatFromInt(file.column_width)); const rh = @as(f32, @floatFromInt(file.row_height)); const max_v: f32 = switch (axis) { @@ -186,7 +185,7 @@ fn applySpriteOriginAxisNoHistory(file: *fizzy.Internal.File, axis: enum { x, y } } -fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_val: f32) !void { +fn commitSpriteOriginAxis(file: *pixi_mod.internal.File, axis: enum { x, y }, new_val: f32) !void { const cw = @as(f32, @floatFromInt(file.column_width)); const rh = @as(f32, @floatFromInt(file.row_height)); const max_v: f32 = switch (axis) { @@ -198,10 +197,10 @@ fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_v const count = file.editor.selected_sprites.count(); if (count == 0) return; - const indices = try fizzy.app.allocator.alloc(usize, count); - errdefer fizzy.app.allocator.free(indices); - const old_vals = try fizzy.app.allocator.alloc([2]f32, count); - errdefer fizzy.app.allocator.free(old_vals); + const indices = try runtime.allocator().alloc(usize, count); + errdefer runtime.allocator().free(indices); + const old_vals = try runtime.allocator().alloc([2]f32, count); + errdefer runtime.allocator().free(old_vals); var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; @@ -221,14 +220,14 @@ fn commitSpriteOriginAxis(file: *fizzy.Internal.File, axis: enum { x, y }, new_v for (indices, 0..) |si, j| { file.sprites.items(.origin)[si] = old_vals[j]; } - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); return err; }; } pub fn draw(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const parent_height = dvui.parentGet().data().rect.h - 2.0 * dvui.currentWindow().natural_scale; const parent_data = dvui.parentGet().data(); @@ -289,7 +288,7 @@ pub fn draw(self: *Sprites) !void { } pub fn drawOriginControls(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.editor.selected_sprites.count() == 0) return; const key = selectionUiKey(file); @@ -418,8 +417,8 @@ pub fn drawOriginControls(self: *Sprites) !void { if (selectionOriginsDifferFrom(file, indices, old_vals)) { try appendOriginsHistory(file, indices, old_vals); } else { - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); } } } @@ -489,8 +488,8 @@ pub fn drawOriginControls(self: *Sprites) !void { if (selectionOriginsDifferFrom(file, indices, old_vals)) { try appendOriginsHistory(file, indices, old_vals); } else { - fizzy.app.allocator.free(indices); - fizzy.app.allocator.free(old_vals); + runtime.allocator().free(indices); + runtime.allocator().free(old_vals); } } } @@ -507,7 +506,7 @@ pub fn drawAnimationControls(self: *Sprites) !void { const icon_color = dvui.themeGet().color(.control, .text); - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { { var add_animation_button: dvui.ButtonWidget = undefined; add_animation_button.init(@src(), .{}, .{ @@ -698,7 +697,7 @@ pub fn drawAnimations(self: *Sprites) !void { controls_box.deinit(); - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { // Make sure to update the prev anim count! defer self.prev_anim_count = file.animations.len; @@ -732,17 +731,17 @@ pub fn drawAnimations(self: *Sprites) !void { defer { if (file.editor.animations_scroll_info.viewport.w < file.editor.animations_scroll_info.virtual_size.w) { if (file.editor.animations_scroll_info.offset(.horizontal) < file.editor.animations_scroll_info.scrollMax(.horizontal)) { - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .right, .{}); } if (file.editor.animations_scroll_info.offset(.horizontal) > 0.0) { - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .left, .{}); } } } const vertical_scroll = file.editor.animations_scroll_info.offset(.vertical); - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -769,8 +768,8 @@ pub fn drawAnimations(self: *Sprites) !void { } } - var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Animation, sources.len); - defer fizzy.app.allocator.free(moved); + var moved = try runtime.allocator().alloc(pixi_mod.internal.Animation, sources.len); + defer runtime.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = file.animations.get(s); } @@ -781,11 +780,11 @@ pub fn drawAnimations(self: *Sprites) !void { file.animations.orderedRemove(sources[ri]); } - const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, file.animations.len); for (moved, 0..) |anim, i| { - file.animations.insert(fizzy.app.allocator, target + i, anim) catch { + file.animations.insert(runtime.allocator(), target + i, anim) catch { dvui.log.err("Failed to insert animation", .{}); }; } @@ -796,7 +795,7 @@ pub fn drawAnimations(self: *Sprites) !void { file.editor.selected_animation_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_animation_indices.append(fizzy.app.allocator, target + i) catch { + file.editor.selected_animation_indices.append(runtime.allocator(), target + i) catch { dvui.log.err("Failed to update animation selection", .{}); }; } @@ -829,7 +828,7 @@ pub fn drawAnimations(self: *Sprites) !void { const selected = if (self.edit_anim_id) |id| id == anim_id else (is_primary_row or in_multi); var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(anim_id)); } @@ -994,13 +993,13 @@ pub fn drawAnimations(self: *Sprites) !void { file.history.append(.{ .animation_name = .{ .index = anim_index, - .name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[anim_index]), + .name = try runtime.allocator().dupe(u8, file.animations.items(.name)[anim_index]), }, }) catch { dvui.log.err("Failed to append history", .{}); }; - fizzy.app.allocator.free(file.animations.items(.name)[anim_index]); - file.animations.items(.name)[anim_index] = try fizzy.app.allocator.dupe(u8, te.getText()); + runtime.allocator().free(file.animations.items(.name)[anim_index]); + file.animations.items(.name)[anim_index] = try runtime.allocator().dupe(u8, te.getText()); } if (te.enter_pressed) { file.selected_animation_index = anim_index; @@ -1050,10 +1049,10 @@ pub fn drawAnimations(self: *Sprites) !void { const anim_si = file.editor.animations_scroll_info; const anim_v_max = anim_si.scrollMax(.vertical); if (vertical_scroll > scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); if (anim_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < anim_v_max - scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } } @@ -1063,7 +1062,7 @@ pub fn drawFrameControls(_: *Sprites) !void { }); defer box.deinit(); - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const index = if (file.selected_animation_index) |i| i else 0; var animation = file.animations.get(index); @@ -1109,8 +1108,8 @@ pub fn drawFrameControls(_: *Sprites) !void { dvui.alphaSet(alpha); if (sort_anim_asc_button.clicked()) { - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); - std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.asc); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); + std.mem.sort(pixi_mod.Animation.Frame, animation.frames, {}, FrameSort.asc); if (!animation.eqlFrames(prev_order)) { file.history.append(.{ @@ -1124,7 +1123,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1169,8 +1168,8 @@ pub fn drawFrameControls(_: *Sprites) !void { dvui.alphaSet(alpha); if (sort_anim_desc_button.clicked()) { - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); - std.mem.sort(fizzy.Animation.Frame, animation.frames, {}, FrameSort.desc); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); + std.mem.sort(pixi_mod.Animation.Frame, animation.frames, {}, FrameSort.desc); if (!animation.eqlFrames(prev_order)) { file.history.append(.{ @@ -1184,7 +1183,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1232,7 +1231,7 @@ pub fn drawFrameControls(_: *Sprites) !void { if (add_sprite_button.clicked()) { if (file.editor.selected_sprites.count() > 0) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - var frames = std.array_list.Managed(fizzy.Animation.Frame).init(dvui.currentWindow().arena()); + var frames = std.array_list.Managed(pixi_mod.Animation.Frame).init(dvui.currentWindow().arena()); while (iter.next()) |sprite_index| { frames.append(.{ .sprite_index = sprite_index, @@ -1243,9 +1242,9 @@ pub fn drawFrameControls(_: *Sprites) !void { }; } - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); - animation.appendFrames(fizzy.app.allocator, frames.items) catch { + animation.appendFrames(runtime.allocator(), frames.items) catch { dvui.log.err("Failed to append frames", .{}); }; @@ -1261,7 +1260,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1320,12 +1319,12 @@ pub fn drawFrameControls(_: *Sprites) !void { if (duplicate_animation_button.clicked()) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); while (iter.next()) |sprite_index| { for (animation.frames) |frame| { if (frame.sprite_index == sprite_index) { - try animation.appendFrame(fizzy.app.allocator, .{ + try animation.appendFrame(runtime.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms, }); @@ -1346,7 +1345,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.selected_animation_frame_index = 0; file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1392,13 +1391,13 @@ pub fn drawFrameControls(_: *Sprites) !void { if (delete_animation_button.clicked()) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); while (iter.next()) |sprite_index| { var i: usize = animation.frames.len; while (i > 0) : (i -= 1) { if (animation.frames[i - 1].sprite_index == sprite_index) { - animation.removeFrame(fizzy.app.allocator, i - 1); + animation.removeFrame(runtime.allocator(), i - 1); break; } } @@ -1416,7 +1415,7 @@ pub fn drawFrameControls(_: *Sprites) !void { file.selected_animation_frame_index = 0; file.animations.set(index, animation); } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } } } @@ -1424,7 +1423,7 @@ pub fn drawFrameControls(_: *Sprites) !void { } pub fn drawFrames(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { var anim = dvui.animate(@src(), .{ .kind = .horizontal, .duration = 450_000, .easing = dvui.easing.outBack }, .{}); defer anim.deinit(); @@ -1482,7 +1481,7 @@ pub fn drawFrames(self: *Sprites) !void { defer self.prev_sprite_count = animation.frames.len; defer self.prev_anim_id = animation.id; - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -1495,7 +1494,7 @@ pub fn drawFrames(self: *Sprites) !void { if (removed_frame_indices_len > 0) { const sources = removed_frame_indices_buf[0..removed_frame_indices_len]; - const prev_order = try fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames); + const prev_order = try runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames); defer file.animations.set(animation_index, animation); const primary_before = file.selected_animation_frame_index; @@ -1509,14 +1508,14 @@ pub fn drawFrames(self: *Sprites) !void { } } - var moved = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, sources.len); - defer fizzy.app.allocator.free(moved); + var moved = try runtime.allocator().alloc(pixi_mod.Animation.Frame, sources.len); + defer runtime.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = animation.frames[s]; } - var remaining = try fizzy.app.allocator.alloc(fizzy.Animation.Frame, animation.frames.len - sources.len); - defer fizzy.app.allocator.free(remaining); + var remaining = try runtime.allocator().alloc(pixi_mod.Animation.Frame, animation.frames.len - sources.len); + defer runtime.allocator().free(remaining); { var ri: usize = 0; var wi: usize = 0; @@ -1535,7 +1534,7 @@ pub fn drawFrames(self: *Sprites) !void { } } - const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, remaining.len); var wi: usize = 0; @@ -1558,7 +1557,7 @@ pub fn drawFrames(self: *Sprites) !void { file.editor.selected_frame_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_frame_indices.append(fizzy.app.allocator, target + i) catch { + file.editor.selected_frame_indices.append(runtime.allocator(), target + i) catch { dvui.log.err("Failed to update frame selection", .{}); }; } @@ -1576,7 +1575,7 @@ pub fn drawFrames(self: *Sprites) !void { dvui.log.err("Failed to append history", .{}); }; } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } self.sprite_insert_before_index = null; @@ -1600,7 +1599,7 @@ pub fn drawFrames(self: *Sprites) !void { for (animation.frames, 0..) |*frame, frame_index| { var anim_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { anim_color = palette.getDVUIColor(@intCast(animation.id)); } @@ -1705,6 +1704,7 @@ pub fn drawFrames(self: *Sprites) !void { return; }; + const frame_font = dvui.Font.theme(.mono); const result = dvui.textEntryNumber(@src(), u32, .{ .value = &frame.ms, .min = 0, .max = 9999999 }, .{ .expand = .horizontal, .background = false, @@ -1712,10 +1712,10 @@ pub fn drawFrames(self: *Sprites) !void { .margin = dvui.Rect.all(0), .border = dvui.Rect.all(0), .min_size_content = .{ - .w = dvui.Font.theme(.mono).larger(-2.0).textSize(frame_ms_text).w + 2.0, - .h = dvui.Font.theme(.mono).larger(-2.0).textSize(frame_ms_text).h + 2.0, + .w = frame_font.textSize(frame_ms_text).w + 2.0, + .h = frame_font.textSize(frame_ms_text).h + 2.0, }, - .font = dvui.Font.theme(.mono).larger(-2.0), + .font = frame_font, .gravity_y = 0.5, }); @@ -1732,7 +1732,7 @@ pub fn drawFrames(self: *Sprites) !void { dvui.labelNoFmt(@src(), "ms", .{}, .{ .gravity_y = 0.5, .margin = dvui.Rect.all(0), - .font = dvui.Font.theme(.mono).larger(-4.0), + .font = frame_font, .padding = .{ .x = 2, .w = 6 }, }); @@ -1782,10 +1782,10 @@ pub fn drawFrames(self: *Sprites) !void { const frames_si = file.editor.sprites_scroll_info; const frames_v_max = frames_si.scrollMax(.vertical); if (vertical_scroll > scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); if (frames_v_max > scroll_list_shadow_deadzone_ns and vertical_scroll < frames_v_max - scroll_list_shadow_deadzone_ns) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } } @@ -1799,21 +1799,21 @@ const FrameRowHit = struct { hbox_tl: dvui.Point.Physical, }; -fn frameGestureMatches(file: *const fizzy.Internal.File, anim_id: u64) bool { +fn frameGestureMatches(file: *const pixi_mod.internal.File, anim_id: u64) bool { return frame_row_gesture != null and frame_row_gesture.?.file_id == file.id and frame_row_gesture.?.anim_id == anim_id; } -fn frameTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void { +fn frameTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void { frame_row_gesture = null; } -fn frameTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void { +fn frameTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void { dvui.dragEnd(); frame_row_gesture = null; } /// After `selected_frame_indices` changes, make tile selection match exactly those frames' sprites. -fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index: usize) void { +fn syncSpritesFromCurrentFrameSelection(file: *pixi_mod.internal.File, anim_index: usize) void { const frames = file.animations.get(anim_index).frames; file.clearSelectedSprites(); for (file.editor.selected_frame_indices.items) |fi| { @@ -1825,7 +1825,7 @@ fn syncSpritesFromCurrentFrameSelection(file: *fizzy.Internal.File, anim_index: /// Frame selection is scoped to one animation at a time. `selected_frame_indices` always mirrors /// `selected_sprites` for this animation's frames (so canvas changes can't leave stale tree state). -fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64) void { +fn ensureFrameSelection(file: *pixi_mod.internal.File, anim_index: usize, anim_id: u64) void { const frames = file.animations.get(anim_index).frames; if (file.editor.selected_frame_indices_for_animation_id != anim_id) { @@ -1848,7 +1848,7 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: file.editor.selected_frame_indices.clearRetainingCapacity(); for (frames, 0..) |f, i| { if (f.sprite_index < file.editor.selected_sprites.capacity() and file.editor.selected_sprites.isSet(f.sprite_index)) { - file.editor.selected_frame_indices.append(fizzy.app.allocator, i) catch return; + file.editor.selected_frame_indices.append(runtime.allocator(), i) catch return; } } std.sort.pdq(usize, file.editor.selected_frame_indices.items, {}, std.sort.asc(usize)); @@ -1879,11 +1879,11 @@ fn ensureFrameSelection(file: *fizzy.Internal.File, anim_index: usize, anim_id: } fn applyFrameClick( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, anim_index: usize, anim_id: u64, clicked: usize, - mode: fizzy.dvui.TreeSelection.ClickMode, + mode: pixi_mod.core.dvui.TreeSelection.ClickMode, ) !bool { ensureFrameSelection(file, anim_index, anim_id); @@ -1904,7 +1904,7 @@ fn applyFrameClick( } var out: std.ArrayList(usize) = .empty; - defer out.deinit(fizzy.app.allocator); + defer out.deinit(runtime.allocator()); // When anchor is null, shift-extend uses `primary_opt` as the range endpoint. During playback // that index is the animated playhead, not the editor's last stable focus — use a selection @@ -1916,8 +1916,8 @@ fn applyFrameClick( break :blk file.editor.selected_frame_indices.items[0]; } else file.selected_animation_frame_index; - const res = try fizzy.dvui.TreeSelection.applyClickUsize( - fizzy.app.allocator, + const res = try pixi_mod.core.dvui.TreeSelection.applyClickUsize( + runtime.allocator(), prev_multi, primary_for_tree, file.editor.frame_selection_anchor, @@ -1928,7 +1928,7 @@ fn applyFrameClick( ); file.editor.selected_frame_indices.clearRetainingCapacity(); - try file.editor.selected_frame_indices.appendSlice(fizzy.app.allocator, out.items); + try file.editor.selected_frame_indices.appendSlice(runtime.allocator(), out.items); file.editor.selected_frame_indices_for_animation_id = anim_id; file.editor.frame_selection_anchor = res.anchor; if (res.primary) |p| file.selected_animation_frame_index = p; @@ -1936,16 +1936,16 @@ fn applyFrameClick( return false; } -fn narrowFrameSelectionTo(file: *fizzy.Internal.File, anim_index: usize, anim_id: u64, clicked: usize) void { +fn narrowFrameSelectionTo(file: *pixi_mod.internal.File, anim_index: usize, anim_id: u64, clicked: usize) void { file.editor.selected_frame_indices.clearRetainingCapacity(); - file.editor.selected_frame_indices.append(fizzy.app.allocator, clicked) catch return; + file.editor.selected_frame_indices.append(runtime.allocator(), clicked) catch return; file.editor.selected_frame_indices_for_animation_id = anim_id; file.editor.frame_selection_anchor = clicked; file.selected_animation_frame_index = clicked; syncSpritesFromCurrentFrameSelection(file, anim_index); } -fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize { +fn buildFrameMultiDragIds(file: *const pixi_mod.internal.File, animation_index: usize, hits: []const FrameRowHit, out: []usize) []usize { const frames = file.animations.get(animation_index).frames; var len: usize = 0; const playhead = file.selected_animation_frame_index; @@ -1982,8 +1982,8 @@ fn buildFrameMultiDragIds(file: *const fizzy.Internal.File, animation_index: usi } fn processFrameTreePointerEvents( - tree: *fizzy.dvui.TreeWidget, - file: *fizzy.Internal.File, + tree: *pixi_mod.core.dvui.TreeWidget, + file: *pixi_mod.internal.File, anim_id: u64, animation_index: usize, hits: []const FrameRowHit, @@ -2013,7 +2013,7 @@ fn processFrameTreePointerEvents( frameTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod); const narrow_on_release = applyFrameClick(file, animation_index, anim_id, h.frame_index, mode) catch blk: { dvui.log.err("Failed to apply frame click", .{}); break :blk false; @@ -2143,15 +2143,15 @@ const AnimationRowHit = struct { hbox_tl: dvui.Point.Physical, }; -fn animationGestureMatches(file: *const fizzy.Internal.File) bool { +fn animationGestureMatches(file: *const pixi_mod.internal.File) bool { return animation_row_gesture != null and animation_row_gesture.?.file_id == file.id; } -fn animationTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void { +fn animationTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void { animation_row_gesture = null; } -fn animationTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void { +fn animationTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void { dvui.dragEnd(); animation_row_gesture = null; } @@ -2174,7 +2174,7 @@ fn animationPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Re return true; } -fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn animationTreePointerInTreeSurface(tree: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; const tr = tree.data().borderRectScale().r; if (!tr.contains(p)) return false; @@ -2182,12 +2182,12 @@ fn animationTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point return true; } -fn animationTreePointerInTreeBorder(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn animationTreePointerInTreeBorder(tree: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; return tree.data().borderRectScale().r.contains(p); } -fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool { +fn animationTreeMotionAllowsReorder(tree: *pixi_mod.core.dvui.TreeWidget, e: *dvui.Event) bool { if (e.target_widgetId) |fwid| { if (fwid == tree.data().id) return true; } @@ -2199,7 +2199,7 @@ fn animationTreeMotionAllowsReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event return in_surface or in_border; } -fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) void { +fn syncAnimationSelectionFrames(file: *pixi_mod.internal.File, anim_index: usize) void { const anim = file.animations.get(anim_index); if (anim.frames.len > 0) { if (file.selected_animation_frame_index >= anim.frames.len) { @@ -2210,7 +2210,7 @@ fn syncAnimationSelectionFrames(file: *fizzy.Internal.File, anim_index: usize) v } } -fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bool { +fn animationIndexInMulti(file: *const pixi_mod.internal.File, anim_index: usize) bool { for (file.editor.selected_animation_indices.items) |i| { if (i == anim_index) return true; } @@ -2220,7 +2220,7 @@ fn animationIndexInMulti(file: *const fizzy.Internal.File, anim_index: usize) bo /// Keep `selected_animation_indices` consistent with the authoritative single-selection and the /// current animation count. The set may be empty (no animations yet), but if `selected_animation_index` /// is set we guarantee it appears in the set. -fn ensureAnimationSelection(file: *fizzy.Internal.File) void { +fn ensureAnimationSelection(file: *pixi_mod.internal.File) void { const count = file.animations.len; if (count == 0) { file.editor.selected_animation_indices.clearRetainingCapacity(); @@ -2251,7 +2251,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void { } } if (!found) { - file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return; + file.editor.selected_animation_indices.append(runtime.allocator(), p) catch return; std.sort.pdq(usize, file.editor.selected_animation_indices.items, {}, std.sort.asc(usize)); } } @@ -2263,7 +2263,7 @@ fn ensureAnimationSelection(file: *fizzy.Internal.File) void { /// Apply a modifier-aware click to the animation selection. Returns whether the click should defer /// narrowing until release (Finder-style): plain click on an already-multi-selected row. -fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.dvui.TreeSelection.ClickMode) !bool { +fn applyAnimationClick(file: *pixi_mod.internal.File, clicked: usize, mode: pixi_mod.core.dvui.TreeSelection.ClickMode) !bool { const prev_multi = file.editor.selected_animation_indices.items; const was_in_multi = animationIndexInMulti(file, clicked); const was_multi = prev_multi.len > 1; @@ -2271,20 +2271,20 @@ fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.d const defer_narrow = (mode == .replace and was_multi and was_in_multi); var out: std.ArrayList(usize) = .empty; - defer out.deinit(fizzy.app.allocator); + defer out.deinit(runtime.allocator()); if (defer_narrow) { - try out.appendSlice(fizzy.app.allocator, prev_multi); + try out.appendSlice(runtime.allocator(), prev_multi); std.sort.pdq(usize, out.items, {}, std.sort.asc(usize)); file.editor.selected_animation_indices.clearRetainingCapacity(); - try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items); + try file.editor.selected_animation_indices.appendSlice(runtime.allocator(), out.items); file.selected_animation_index = clicked; syncAnimationSelectionFrames(file, clicked); return true; } - const res = try fizzy.dvui.TreeSelection.applyClickUsize( - fizzy.app.allocator, + const res = try pixi_mod.core.dvui.TreeSelection.applyClickUsize( + runtime.allocator(), prev_multi, file.selected_animation_index, file.editor.animation_selection_anchor, @@ -2295,16 +2295,16 @@ fn applyAnimationClick(file: *fizzy.Internal.File, clicked: usize, mode: fizzy.d ); file.editor.selected_animation_indices.clearRetainingCapacity(); - try file.editor.selected_animation_indices.appendSlice(fizzy.app.allocator, out.items); + try file.editor.selected_animation_indices.appendSlice(runtime.allocator(), out.items); file.editor.animation_selection_anchor = res.anchor; file.selected_animation_index = res.primary; if (res.primary) |p| syncAnimationSelectionFrames(file, p); return false; } -fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { +fn narrowAnimationSelectionTo(file: *pixi_mod.internal.File, clicked: usize) void { file.editor.selected_animation_indices.clearRetainingCapacity(); - file.editor.selected_animation_indices.append(fizzy.app.allocator, clicked) catch return; + file.editor.selected_animation_indices.append(runtime.allocator(), clicked) catch return; file.editor.animation_selection_anchor = clicked; file.selected_animation_index = clicked; syncAnimationSelectionFrames(file, clicked); @@ -2312,7 +2312,7 @@ fn narrowAnimationSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { /// Populate `out` with the branch-ids of every selected animation row (primary first), for /// `TreeWidget.dragStartMulti`. Returns a slice into `out` with just the written entries. -fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const AnimationRowHit, out: []usize) []usize { +fn buildAnimationMultiDragIds(file: *const pixi_mod.internal.File, hits: []const AnimationRowHit, out: []usize) []usize { var len: usize = 0; const primary = file.selected_animation_index; if (primary) |p| { @@ -2341,7 +2341,7 @@ fn buildAnimationMultiDragIds(file: *const fizzy.Internal.File, hits: []const An return out[0..len]; } -fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void { +fn processAnimationTreePointerEvents(_: *Sprites, tree: *pixi_mod.core.dvui.TreeWidget, file: *pixi_mod.internal.File, hits: []const AnimationRowHit, viewport_r: ?dvui.Rect.Physical) void { if (!tree.init_options.enable_reordering) return; for (dvui.events()) |*e| { @@ -2367,7 +2367,7 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, animationTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod); const narrow_on_release = applyAnimationClick(file, h.anim_index, mode) catch blk: { dvui.log.err("Failed to apply animation click", .{}); break :blk false; @@ -2489,11 +2489,11 @@ fn processAnimationTreePointerEvents(_: *Sprites, tree: *fizzy.dvui.TreeWidget, } const FrameSort = struct { - pub fn asc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool { + pub fn asc(_: void, a: pixi_mod.Animation.Frame, b: pixi_mod.Animation.Frame) bool { return a.sprite_index < b.sprite_index; } - pub fn desc(_: void, a: fizzy.Animation.Frame, b: fizzy.Animation.Frame) bool { + pub fn desc(_: void, a: pixi_mod.Animation.Frame, b: pixi_mod.Animation.Frame) bool { return a.sprite_index > b.sprite_index; } }; diff --git a/src/editor/explorer/tools.zig b/src/plugins/pixi/src/explorer/tools.zig similarity index 90% rename from src/editor/explorer/tools.zig rename to src/plugins/pixi/src/explorer/tools.zig index 2ec7f3b8..72818f6e 100644 --- a/src/editor/explorer/tools.zig +++ b/src/plugins/pixi/src/explorer/tools.zig @@ -1,9 +1,10 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../../fizzy.zig"); const dvui = @import("dvui"); const icons = @import("icons"); const assets = @import("assets"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Tools = @This(); @@ -68,10 +69,10 @@ pub fn draw(self: *Tools) !void { drawLayerControls() catch {}; // Collect layers length to trigger a refit of the panel - const layer_count: usize = if (fizzy.editor.activeFile()) |file| file.layers.len else 0; + const layer_count: usize = if (runtime.state().docs.activeFile(runtime.state().host)) |file| file.layers.len else 0; defer prev_layer_count = layer_count; - var paned = fizzy.dvui.paned(@src(), .{ + var paned = pixi_mod.core.dvui.paned(@src(), .{ .direction = .vertical, .collapsed_size = 0, .handle_size = 10, @@ -81,7 +82,7 @@ pub fn draw(self: *Tools) !void { if (paned.dragging) { max_split_ratio = paned.split_ratio.*; - fizzy.editor.explorer.layers_ratio = paned.split_ratio.*; + runtime.state().layers_ratio = paned.split_ratio.*; } if (paned.showFirst()) { @@ -97,7 +98,7 @@ pub fn draw(self: *Tools) !void { const autofit = !paned.dragging and !paned.collapsed_state and !paned.animating; // Refit must be done between showFirst and showSecond - if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !fizzy.editor.explorer.pinned_palettes) { + if (((dvui.firstFrame(paned.data().id) or prev_layer_count != layer_count) or autofit) and !runtime.state().pinned_palettes) { if (dvui.firstFrame(paned.data().id) and layer_count == 0) paned.split_ratio.* = 0.0; @@ -108,7 +109,7 @@ pub fn draw(self: *Tools) !void { // next frame when min sizes are valid. if (dvui.firstFrame(paned.data().id) and layer_count > 0) { paned.split_ratio.* = 0.01; - //fizzy.editor.explorer.layers_ratio = paned.split_ratio.*; + //runtime.state().layers_ratio = paned.split_ratio.*; } else { const ratio = paned.getFirstFittedRatio( .{ @@ -129,9 +130,9 @@ pub fn draw(self: *Tools) !void { if (layer_count == 0) paned.split_ratio.* = 0.0 else - paned.split_ratio.* = fizzy.editor.explorer.layers_ratio; + paned.split_ratio.* = runtime.state().layers_ratio; - fizzy.editor.explorer.layers_ratio = paned.split_ratio.*; + runtime.state().layers_ratio = paned.split_ratio.*; } } @@ -159,28 +160,28 @@ pub fn drawTools() !void { .padding = .{ .h = 10.0, .w = 4.0, .x = 4.0, .y = 4.0 }, }); defer toolbox.deinit(); - for (0..std.meta.fields(fizzy.Editor.Tools.Tool).len) |i| { - const tool: fizzy.Editor.Tools.Tool = @enumFromInt(i); + for (0..std.meta.fields(pixi_mod.Tools.Tool).len) |i| { + const tool: pixi_mod.Tools.Tool = @enumFromInt(i); const id_extra = i; - const selected = fizzy.editor.tools.current == tool; + const selected = runtime.state().tools.current == tool; var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(i); } - const selection_sprite = switch (fizzy.editor.tools.selection_mode) { - .pixel => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .box => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .color => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + const selection_sprite = switch (runtime.state().tools.selection_mode) { + .pixel => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .box => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default], + .color => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default], }; const sprite = switch (tool) { - .pointer => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.cursor_default], - .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], + .pointer => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.cursor_default], + .pencil => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pencil_default], + .eraser => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.eraser_default], + .bucket => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.bucket_default], .selection => selection_sprite, }; var button: dvui.ButtonWidget = undefined; @@ -204,13 +205,13 @@ pub fn drawTools() !void { }); defer button.deinit(); - fizzy.editor.tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; + runtime.state().tools.drawTooltip(tool, button.data().rectScale().r, id_extra) catch {}; if (button.hovered()) { button.data().options.color_border = color; } - const size: dvui.Size = dvui.imageSize(fizzy.editor.atlas.source) catch .{ .w = 0, .h = 0 }; + const size: dvui.Size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch .{ .w = 0, .h = 0 }; const uv = dvui.Rect{ .x = @as(f32, @floatFromInt(sprite.source[0])) / size.w, @@ -232,7 +233,7 @@ pub fn drawTools() !void { rs.r.w = width; rs.r.h = height; - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ + dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{ .uv = uv, .fade = 0.0, }) catch { @@ -240,7 +241,7 @@ pub fn drawTools() !void { }; if (button.clicked()) { - fizzy.editor.tools.set(tool); + runtime.state().tools.set(tool); } } } @@ -253,7 +254,7 @@ pub fn drawLayerControls() !void { defer box.deinit(); dvui.labelNoFmt(@src(), "LAYERS", .{}, .{ .font = dvui.Font.theme(.heading), .gravity_y = 0.5 }); - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { var hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .none, .background = false, @@ -402,7 +403,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { }); defer vbox.deinit(); - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { layer_rename_hit_te_id = null; layer_rename_hit_rect = null; file.editor.layer_drag_preview_removed = null; @@ -424,7 +425,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { const vertical_scroll = file.editor.layers_scroll_info.offset(.vertical); - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ + var tree = pixi_mod.core.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true }, .{ .expand = .horizontal, .background = false, }); @@ -438,7 +439,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { if (removed_layer_indices_len > 0) { const sources = removed_layer_indices_buf[0..removed_layer_indices_len]; - const prev_order = try fizzy.app.allocator.alloc(u64, file.layers.len); + const prev_order = try runtime.allocator().alloc(u64, file.layers.len); for (file.layers.items(.id), 0..) |id, i| { prev_order[i] = id; } @@ -455,8 +456,8 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { } // Snapshot moved layers before any removal so indices stay valid. - var moved = try fizzy.app.allocator.alloc(fizzy.Internal.Layer, sources.len); - defer fizzy.app.allocator.free(moved); + var moved = try runtime.allocator().alloc(pixi_mod.internal.Layer, sources.len); + defer runtime.allocator().free(moved); for (sources, 0..) |s, i| { moved[i] = file.layers.get(s); } @@ -468,11 +469,11 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { file.layers.orderedRemove(sources[ri]); } - const target_raw = fizzy.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); + const target_raw = pixi_mod.core.dvui.TreeSelection.adjustInsertBeforeForRemovals(sources, insert_before_raw); const target = @min(target_raw, file.layers.len); for (moved, 0..) |layer, i| { - file.layers.insert(fizzy.app.allocator, target + i, layer) catch { + file.layers.insert(runtime.allocator(), target + i, layer) catch { dvui.log.err("Failed to insert layer", .{}); }; } @@ -487,7 +488,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { // After a group move the moved rows become contiguous; resync multi-selection to reflect that. file.editor.selected_layer_indices.clearRetainingCapacity(); for (0..moved.len) |i| { - file.editor.selected_layer_indices.append(fizzy.app.allocator, target + i) catch { + file.editor.selected_layer_indices.append(runtime.allocator(), target + i) catch { dvui.log.err("Failed to update layer selection", .{}); }; } @@ -505,7 +506,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { dvui.log.err("Failed to append history", .{}); }; } else { - fizzy.app.allocator.free(prev_order); + runtime.allocator().free(prev_order); } insert_before_index = null; @@ -539,7 +540,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { const font = if (visible) dvui.Font.theme(.body) else dvui.Font.theme(.body).withStyle(.italic); var color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(layer_id)); } @@ -589,7 +590,7 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { if (file.editor.isolate_layer) { if (file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.editor.explorer.tools.layersHovered()) { + } else if (!runtime.state().tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -719,13 +720,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { file.history.append(.{ .layer_name = .{ .index = layer_index, - .name = try fizzy.app.allocator.dupe(u8, file.layers.items(.name)[layer_index]), + .name = try runtime.allocator().dupe(u8, file.layers.items(.name)[layer_index]), }, }) catch { dvui.log.err("Failed to append history", .{}); }; - fizzy.app.allocator.free(file.layers.items(.name)[layer_index]); - file.layers.items(.name)[layer_index] = try fizzy.app.allocator.dupe(u8, te.getText()); + runtime.allocator().free(file.layers.items(.name)[layer_index]); + file.layers.items(.name)[layer_index] = try runtime.allocator().dupe(u8, te.getText()); } if (te.enter_pressed) { file.selected_layer_index = layer_index; @@ -917,13 +918,13 @@ pub fn drawLayers(tools: *Tools) !?dvui.Rect.Physical { // Only draw shadow if the scroll bar has been scrolled some if (vertical_scroll > 0.0) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .top, .{}); if (file.editor.layers_scroll_info.virtual_size.h > file.editor.layers_scroll_info.viewport.h + 1 and vertical_scroll < file.editor.layers_scroll_info.scrollMax(.vertical)) - fizzy.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(scroll_area.data().contentRectScale(), .bottom, .{}); } - if (fizzy.dvui.hovered(vbox.data())) { + if (pixi_mod.core.dvui.hovered(vbox.data())) { const mp = dvui.currentWindow().mouse_pt; if (tools.layers_scroll_viewport_rect) |vr| { if (!vr.contains(mp)) return null; @@ -945,8 +946,8 @@ pub fn drawColors() !void { }); defer hbox.deinit(); - const primary: dvui.Color = .{ .r = fizzy.editor.colors.primary[0], .g = fizzy.editor.colors.primary[1], .b = fizzy.editor.colors.primary[2], .a = fizzy.editor.colors.primary[3] }; - const secondary: dvui.Color = .{ .r = fizzy.editor.colors.secondary[0], .g = fizzy.editor.colors.secondary[1], .b = fizzy.editor.colors.secondary[2], .a = fizzy.editor.colors.secondary[3] }; + const primary: dvui.Color = .{ .r = runtime.state().colors.primary[0], .g = runtime.state().colors.primary[1], .b = runtime.state().colors.primary[2], .a = runtime.state().colors.primary[3] }; + const secondary: dvui.Color = .{ .r = runtime.state().colors.secondary[0], .g = runtime.state().colors.secondary[1], .b = runtime.state().colors.secondary[2], .a = runtime.state().colors.secondary[3] }; const button_opts: dvui.Options = .{ .expand = .both, @@ -978,7 +979,7 @@ pub fn drawColors() !void { primary_button.init(@src(), .{}, button_opts); defer primary_button.deinit(); - try drawColorPicker(primary_button.data().rectScale().r, &fizzy.editor.colors.primary, 0); + try drawColorPicker(primary_button.data().rectScale().r, &runtime.state().colors.primary, 0); primary_button.processEvents(); primary_button.drawBackground(); @@ -991,7 +992,7 @@ pub fn drawColors() !void { secondary_button.init(@src(), .{}, button_opts.override(secondary_overrider)); defer secondary_button.deinit(); - try drawColorPicker(secondary_button.data().rectScale().r, &fizzy.editor.colors.secondary, 1); + try drawColorPicker(secondary_button.data().rectScale().r, &runtime.state().colors.secondary, 1); secondary_button.processEvents(); secondary_button.drawBackground(); @@ -1000,7 +1001,7 @@ pub fn drawColors() !void { } if (clicked) { - std.mem.swap([4]u8, &fizzy.editor.colors.primary, &fizzy.editor.colors.secondary); + std.mem.swap([4]u8, &runtime.state().colors.primary, &runtime.state().colors.secondary); } } @@ -1069,9 +1070,9 @@ pub fn drawPaletteControls() !void { .corner_radius = dvui.Rect.all(1000), }, .rotation = std.math.pi * 0.25, - .style = if (fizzy.editor.explorer.pinned_palettes) .highlight else .control, + .style = if (runtime.state().pinned_palettes) .highlight else .control, })) { - fizzy.editor.explorer.pinned_palettes = !fizzy.editor.explorer.pinned_palettes; + runtime.state().pinned_palettes = !runtime.state().pinned_palettes; } } @@ -1103,7 +1104,7 @@ pub fn drawPalettes() !void { .gravity_x = 1.0, }); - if (fizzy.editor.colors.palette) |*palette| { + if (runtime.state().colors.palette) |*palette| { dvui.label(@src(), "{s}", .{palette.name}, .{ .margin = .all(0), .padding = .all(0) }); } else { dvui.label(@src(), "Palette Search", .{}, .{ .margin = .all(0), .padding = .all(0) }); @@ -1133,7 +1134,7 @@ pub fn drawPalettes() !void { const ext = std.fs.path.extension(entry.name); if (std.mem.eql(u8, ext, ".hex")) { if (dropdown.addChoiceLabel(entry.name)) { - fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromBytes(fizzy.app.allocator, entry.name, data) catch |err| { + runtime.state().colors.palette = pixi_mod.internal.Palette.loadFromBytes(runtime.allocator(), entry.name, data) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1157,12 +1158,12 @@ pub fn drawPalettes() !void { } { - if (fizzy.editor.colors.palette) |*palette| { + if (runtime.state().colors.palette) |*palette| { var flex_box = dvui.flexbox(@src(), .{ .justify_content = .start }, .{ .expand = .horizontal, .max_size_content = .{ - .w = fizzy.editor.explorer.rect.w - 20 * dvui.currentWindow().natural_scale, - .h = fizzy.editor.explorer.rect.h - 20 * dvui.currentWindow().natural_scale, + .w = runtime.state().host.explorerRect().w - 20 * dvui.currentWindow().natural_scale, + .h = runtime.state().host.explorerRect().h - 20 * dvui.currentWindow().natural_scale, }, }); @@ -1244,9 +1245,9 @@ pub fn drawPalettes() !void { switch (evt) { .mouse => |mouse_evt| { if (mouse_evt.button.pointer() or mouse_evt.button.touch()) { - @memcpy(&fizzy.editor.colors.primary, &color); + @memcpy(&runtime.state().colors.primary, &color); } else if (mouse_evt.button == .right) { - @memcpy(&fizzy.editor.colors.secondary, &color); + @memcpy(&runtime.state().colors.secondary, &color); } }, else => {}, @@ -1267,7 +1268,8 @@ pub fn drawPalettes() !void { } fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { const io = dvui.io; - var dir_opt = std.Io.Dir.cwd().openDir(io, fizzy.editor.palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null; + const palette_folder = runtime.state().host.paletteFolder() orelse return; + var dir_opt = std.Io.Dir.cwd().openDir(io, palette_folder, .{ .access_sub_paths = false, .iterate = true }) catch null; if (dir_opt) |*dir| { defer dir.close(io); var iter = dir.iterate(); @@ -1277,12 +1279,12 @@ fn searchPalettes(dropdown: *dvui.DropdownWidget) !void { if (std.mem.eql(u8, ext, ".hex")) { const label = try std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{entry.name}); if (dropdown.addChoiceLabel(label)) { - const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ fizzy.editor.palette_folder, entry.name }); + const abs_path = try std.fs.path.join(dvui.currentWindow().arena(), &.{ palette_folder, entry.name }); - if (fizzy.editor.colors.palette) |*palette| + if (runtime.state().colors.palette) |*palette| palette.deinit(); - fizzy.editor.colors.palette = fizzy.Internal.Palette.loadFromFile(fizzy.app.allocator, abs_path) catch |err| { + runtime.state().colors.palette = pixi_mod.internal.Palette.loadFromFile(runtime.allocator(), abs_path) catch |err| { dvui.log.err("Failed to load palette: {s}", .{@errorName(err)}); return error.FailedToLoadPalette; }; @@ -1316,12 +1318,12 @@ fn pointerReleaseInRectWithoutSelectionModifier(r: dvui.Rect.Physical) bool { return false; } -fn layerGestureMatches(file: *const fizzy.Internal.File) bool { +fn layerGestureMatches(file: *const pixi_mod.internal.File) bool { return layer_row_gesture != null and layer_row_gesture.?.file_id == file.id; } /// True if `layer_index` is present in the multi-selection set (the primary index is always implicitly selected). -fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool { +fn layerIndexInMulti(file: *const pixi_mod.internal.File, layer_index: usize) bool { for (file.editor.selected_layer_indices.items) |i| { if (i == layer_index) return true; } @@ -1330,7 +1332,7 @@ fn layerIndexInMulti(file: *const fizzy.Internal.File, layer_index: usize) bool /// Sync the multi-selection list with `file.selected_layer_index` and the current layer count. /// The primary must always be present; stale / out-of-range entries from deletions are dropped. -fn ensureLayerSelection(file: *fizzy.Internal.File) void { +fn ensureLayerSelection(file: *pixi_mod.internal.File) void { var sel = &file.editor.selected_layer_indices; // Drop out-of-range entries. @@ -1357,7 +1359,7 @@ fn ensureLayerSelection(file: *fizzy.Internal.File) void { } } if (!has_primary and file.layers.len > 0) { - sel.append(fizzy.app.allocator, file.selected_layer_index) catch return; + sel.append(runtime.allocator(), file.selected_layer_index) catch return; std.sort.pdq(usize, sel.items, {}, std.sort.asc(usize)); } } @@ -1372,9 +1374,9 @@ const LayerClickApplied = struct { }; fn applyLayerClick( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, clicked: usize, - mode: fizzy.dvui.TreeSelection.ClickMode, + mode: pixi_mod.core.dvui.TreeSelection.ClickMode, ) LayerClickApplied { const count_before = file.editor.selected_layer_indices.items.len; @@ -1385,10 +1387,10 @@ fn applyLayerClick( } var tmp: std.ArrayList(usize) = .empty; - defer tmp.deinit(fizzy.app.allocator); + defer tmp.deinit(runtime.allocator()); - const res = fizzy.dvui.TreeSelection.applyClickUsize( - fizzy.app.allocator, + const res = pixi_mod.core.dvui.TreeSelection.applyClickUsize( + runtime.allocator(), file.editor.selected_layer_indices.items, file.selected_layer_index, file.editor.layer_selection_anchor, @@ -1399,7 +1401,7 @@ fn applyLayerClick( ) catch return .{ .primary = file.selected_layer_index, .narrow_on_release = false }; file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.appendSlice(fizzy.app.allocator, tmp.items) catch {}; + file.editor.selected_layer_indices.appendSlice(runtime.allocator(), tmp.items) catch {}; const new_primary = res.primary orelse clicked; file.selected_layer_index = new_primary; @@ -1410,9 +1412,9 @@ fn applyLayerClick( /// Narrow the multi-selection to just `clicked` — used when the user performed a plain press on an /// already-multi-selected row and released without dragging. Mirrors Finder-style behavior. -fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { +fn narrowLayerSelectionTo(file: *pixi_mod.internal.File, clicked: usize) void { file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.append(fizzy.app.allocator, clicked) catch {}; + file.editor.selected_layer_indices.append(runtime.allocator(), clicked) catch {}; file.selected_layer_index = clicked; file.editor.layer_selection_anchor = clicked; } @@ -1422,7 +1424,7 @@ fn narrowLayerSelectionTo(file: *fizzy.Internal.File, clicked: usize) void { /// in the row-hits buffer are included (out-of-viewport selections are allowed because hits are /// populated for every drawn row, not just hovered ones). fn buildLayerMultiDragIds( - file: *const fizzy.Internal.File, + file: *const pixi_mod.internal.File, hits: []const LayerRowHit, out: []usize, ) usize { @@ -1442,12 +1444,12 @@ fn buildLayerMultiDragIds( } /// Clear in-flight gesture only (no `dragEnd`). Used before arming a new row press. -fn layerTreeClearGestureKeysOnly(_: *const fizzy.Internal.File) void { +fn layerTreeClearGestureKeysOnly(_: *const pixi_mod.internal.File) void { layer_row_gesture = null; } /// Clear gesture and global `Dragging` (stale prestart/drag from other widgets). -fn layerTreeResetRowPointerGesture(_: *const fizzy.Internal.File) void { +fn layerTreeResetRowPointerGesture(_: *const pixi_mod.internal.File) void { dvui.dragEnd(); layer_row_gesture = null; } @@ -1474,7 +1476,7 @@ fn layerPointerInScrollViewport(p: dvui.Point.Physical, viewport_r: ?dvui.Rect.P return true; } -fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn layerTreePointerInTreeSurface(tree: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; const tr = tree.data().borderRectScale().r; if (!tr.contains(p)) return false; @@ -1482,14 +1484,14 @@ fn layerTreePointerInTreeSurface(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Phy return true; } -fn layerTreePointerInTreeBorder(tree: *fizzy.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { +fn layerTreePointerInTreeBorder(tree: *pixi_mod.core.dvui.TreeWidget, p: dvui.Point.Physical, floating_win: dvui.Id) bool { if (floating_win != dvui.subwindowCurrentId()) return false; return tree.data().borderRectScale().r.contains(p); } /// While another widget holds capture, `target_widgetId` may not be the tree. Allow starting a reorder drag /// when the pointer is over the tree border (scroll clip can disagree with visible row geometry). -fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Event) bool { +fn layerTreeMotionAllowsLayerReorder(tree: *pixi_mod.core.dvui.TreeWidget, e: *dvui.Event) bool { if (e.target_widgetId) |fwid| { if (fwid == tree.data().id) return true; } @@ -1503,7 +1505,7 @@ fn layerTreeMotionAllowsLayerReorder(tree: *fizzy.dvui.TreeWidget, e: *dvui.Even /// One pass over `events()` in frame order: press → motion → release. /// Runs after layer rows (and rename `textEntry`) are built so geometry and `e.handled` reflect z-order. -fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void { +fn processLayerTreePointerEvents(tree: *pixi_mod.core.dvui.TreeWidget, file: *pixi_mod.internal.File, hits: []const LayerRowHit, layers_viewport_r: ?dvui.Rect.Physical) void { if (!tree.init_options.enable_reordering) return; for (dvui.events()) |*e| { @@ -1529,7 +1531,7 @@ fn processLayerTreePointerEvents(tree: *fizzy.dvui.TreeWidget, file: *fizzy.Inte layerTreeClearGestureKeysOnly(file); dvui.dragPreStart(me.p, .{ .offset = h.hbox_tl.diff(me.p) }); - const mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + const mode = pixi_mod.core.dvui.TreeSelection.clickModeFromMod(me.mod); const applied = applyLayerClick(file, h.layer_index, mode); layer_row_gesture = .{ diff --git a/src/plugins/pixi/src/infobar_status.zig b/src/plugins/pixi/src/infobar_status.zig new file mode 100644 index 00000000..32e66474 --- /dev/null +++ b/src/plugins/pixi/src/infobar_status.zig @@ -0,0 +1,83 @@ +//! Active-document infobar status (path, dimensions, cursor) for the shell infobar. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; +const DocHandle = pixi_mod.sdk.DocHandle; +const DimensionsLabel = @import("dialogs/dimensions_label.zig"); + +fn docFile(st: *State, doc: DocHandle) ?*Internal.File { + return st.docs.fileById(doc.id); +} + +pub fn drawDocumentInfobar(st: *State, doc: DocHandle) !void { + const file = docFile(st, doc) orelse return; + const font = dvui.Font.theme(.body).larger(-1.0); + const font_mono = dvui.Font.theme(.mono); + + dvui.icon( + @src(), + "file_icon", + icons.tvg.lucide.file, + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + dvui.label(@src(), "{s}", .{std.fs.path.basename(file.path)}, .{ .font = font, .gravity_y = 0.5 }); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); + + dvui.icon( + @src(), + "width_icon", + icons.tvg.lucide.@"ruler-dimension-line", + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + + DimensionsLabel.drawDimensionsLabel(@src(), file.width(), file.height(), font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); + + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); + + dvui.icon( + @src(), + "sprite_icon", + dvui.entypo.grid, + .{ .fill_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + + DimensionsLabel.drawDimensionsLabel(@src(), file.column_width, file.row_height, font_mono, "px", .{ .gravity_y = 0.5, .margin = .{ .x = 4 } }); + + const mouse_pt = dvui.currentWindow().mouse_pt; + const data_pt = file.editor.canvas.dataFromScreenPoint(mouse_pt); + + const file_rect = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); + + if (file_rect.contains(data_pt)) { + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 12 } }); + + dvui.icon( + @src(), + "mouse_icon", + icons.tvg.lucide.@"mouse-pointer", + .{ .stroke_color = dvui.themeGet().color(.window, .text) }, + .{ .gravity_y = 0.5 }, + ); + + const sprite_pt = file.spritePoint(data_pt); + dvui.label( + @src(), + "{d:0.0},{d:0.0} - {d:0.0},{d:0.0}", + .{ + @floor(data_pt.x), + @floor(data_pt.y), + @floor(sprite_pt.x / @as(f32, @floatFromInt(file.column_width))), + @floor(sprite_pt.y / @as(f32, @floatFromInt(file.row_height))), + }, + .{ .gravity_y = 0.5, .font = font_mono }, + ); + } +} diff --git a/src/internal/Animation.zig b/src/plugins/pixi/src/internal/Animation.zig similarity index 100% rename from src/internal/Animation.zig rename to src/plugins/pixi/src/internal/Animation.zig diff --git a/src/internal/Atlas.zig b/src/plugins/pixi/src/internal/Atlas.zig similarity index 76% rename from src/internal/Atlas.zig rename to src/plugins/pixi/src/internal/Atlas.zig index 676e9f1f..3e59872c 100644 --- a/src/internal/Atlas.zig +++ b/src/plugins/pixi/src/internal/Atlas.zig @@ -1,15 +1,16 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const Atlas = @This(); const ExternalAtlas = @import("../Atlas.zig"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const alpha_checkerboard_count: u32 = 8; /// The packed atlas texture source: dvui.ImageSource, -canvas: fizzy.dvui.CanvasWidget = .{}, +canvas: pixi_mod.core.dvui.CanvasWidget = .{}, /// Checkerboard tile for the project-tab atlas preview (not tied to open files). checkerboard_tile: ?dvui.Texture = null, @@ -22,11 +23,11 @@ data: ExternalAtlas, pub fn initCheckerboardTile(atlas: *Atlas) void { deinitCheckerboardTile(atlas); - atlas.checkerboard_tile = fizzy.image.checkerboardTile( + atlas.checkerboard_tile = pixi_mod.image.checkerboardTile( alpha_checkerboard_count, alpha_checkerboard_count, - fizzy.editor.settings.checker_color_even, - fizzy.editor.settings.checker_color_odd, + runtime.state().settings.checker_color_even, + runtime.state().settings.checker_color_odd, ); } @@ -48,23 +49,23 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { // below writes through `std.Io.Dir.cwd()` which requires `posix.AT` (not // available on `wasm32-freestanding`). if (comptime @import("builtin").target.cpu.arch == .wasm32) { - const allocator = fizzy.editor.arena.allocator(); + const allocator = runtime.state().host.arena(); switch (selector) { .source => { const ext = std.fs.path.extension(path); var out = std.Io.Writer.Allocating.init(allocator); errdefer out.deinit(); if (std.mem.eql(u8, ext, ".png")) { - try fizzy.image.writePngToWriter(atlas.source, &out.writer, 72); + try pixi_mod.image.writePngToWriter(atlas.source, &out.writer, 72); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try fizzy.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72); + try pixi_mod.image.writeJpgPpiToWriter(atlas.source, &out.writer, 72); } else { std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); return error.InvalidExtension; } const bytes = try out.toOwnedSlice(); defer allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytes(path, bytes); + try @import("../web_file_io.zig").downloadBytes(path, bytes); }, .data => { if (!std.mem.eql(u8, ".atlas", std.fs.path.extension(path))) { @@ -74,7 +75,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { const options: std.json.Stringify.Options = .{}; const output = try std.json.Stringify.valueAlloc(allocator, atlas.data, options); defer allocator.free(output); - try @import("../editor/WebFileIo.zig").downloadBytes(path, output); + try @import("../web_file_io.zig").downloadBytes(path, output); }, } return; @@ -83,12 +84,12 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { switch (selector) { .source => { const ext = std.fs.path.extension(path); - const write_path = std.fmt.allocPrintSentinel(fizzy.editor.arena.allocator(), "{s}", .{path}, 0) catch unreachable; + const write_path = std.fmt.allocPrintSentinel(runtime.state().host.arena(), "{s}", .{path}, 0) catch unreachable; if (std.mem.eql(u8, ext, ".png")) { - try fizzy.image.writeToPng(atlas.source, write_path); + try pixi_mod.image.writeToPng(atlas.source, write_path); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { - try fizzy.image.writeToJpg(atlas.source, write_path); + try pixi_mod.image.writeToJpg(atlas.source, write_path); } else { std.log.debug("File name must end with .png, .jpg, or .jpeg extension!", .{}); return error.InvalidExtension; @@ -101,7 +102,7 @@ pub fn save(atlas: Atlas, path: []const u8, selector: Selector) !void { } const options: std.json.Stringify.Options = .{}; - const output = try std.json.Stringify.valueAlloc(fizzy.editor.arena.allocator(), atlas.data, options); + const output = try std.json.Stringify.valueAlloc(runtime.state().host.arena(), atlas.data, options); std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = path, .data = output }) catch return error.CouldNotWriteAtlasData; }, diff --git a/src/internal/Buffers.zig b/src/plugins/pixi/src/internal/Buffers.zig similarity index 76% rename from src/internal/Buffers.zig rename to src/plugins/pixi/src/internal/Buffers.zig index 88bb3c4f..48b85a61 100644 --- a/src/internal/Buffers.zig +++ b/src/plugins/pixi/src/internal/Buffers.zig @@ -1,7 +1,8 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const History = @import("History.zig"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Buffers = @This(); stroke: Stroke, @@ -12,7 +13,7 @@ pub const Stroke = struct { //values: std.ArrayList([4]u8), pixels: std.AutoHashMap(usize, [4]u8), - //canvas: fizzy.Internal.file.gui.canvas = .primary, + //canvas: pixi_mod.file.gui.canvas = .primary, pub fn init(allocator: std.mem.Allocator) Stroke { return .{ @@ -24,9 +25,9 @@ pub const Stroke = struct { pub fn append(stroke: *Stroke, index: usize, value: [4]u8) !void { const ptr = try stroke.pixels.getOrPut(index); - if (fizzy.perf.record) { - fizzy.perf.stroke_append_calls += 1; - if (!ptr.found_existing) fizzy.perf.stroke_append_new_keys += 1; + if (pixi_mod.perf.record) { + pixi_mod.perf.stroke_append_calls += 1; + if (!ptr.found_existing) pixi_mod.perf.stroke_append_new_keys += 1; } if (!ptr.found_existing) ptr.value_ptr.* = value; @@ -48,9 +49,9 @@ pub const Stroke = struct { /// Like `append` but the map must already have capacity for new keys (see `clearAndReserveCapacity`). pub fn appendAssumeCapacity(stroke: *Stroke, index: usize, value: [4]u8) void { const gop = stroke.pixels.getOrPutAssumeCapacity(index); - if (fizzy.perf.record) { - fizzy.perf.stroke_append_calls += 1; - if (!gop.found_existing) fizzy.perf.stroke_append_new_keys += 1; + if (pixi_mod.perf.record) { + pixi_mod.perf.stroke_append_calls += 1; + if (!gop.found_existing) pixi_mod.perf.stroke_append_new_keys += 1; } if (!gop.found_existing) gop.value_ptr.* = value; @@ -67,14 +68,14 @@ pub const Stroke = struct { } pub fn toChange(stroke: *Stroke, layer_id: u64) !History.Change { - const t0: i128 = if (fizzy.perf.record) fizzy.perf.nanoTimestamp() else 0; + const t0: i128 = if (pixi_mod.perf.record) pixi_mod.perf.nanoTimestamp() else 0; const n = stroke.pixels.count(); // Exact-size allocations; transform accept pre-reserves the hash map to avoid rehash during fills. - var indices = fizzy.app.allocator.alloc(usize, n) catch return error.MemoryAllocationFailed; - errdefer fizzy.app.allocator.free(indices); - var values = fizzy.app.allocator.alloc([4]u8, n) catch return error.MemoryAllocationFailed; - errdefer fizzy.app.allocator.free(values); + var indices = runtime.allocator().alloc(usize, n) catch return error.MemoryAllocationFailed; + errdefer runtime.allocator().free(indices); + var values = runtime.allocator().alloc([4]u8, n) catch return error.MemoryAllocationFailed; + errdefer runtime.allocator().free(values); var it = stroke.pixels.iterator(); @@ -87,10 +88,10 @@ pub const Stroke = struct { stroke.pixels.clearAndFree(); - if (fizzy.perf.record) { - fizzy.perf.stroke_to_change_ns +%= @intCast(fizzy.perf.nanoTimestamp() - t0); - fizzy.perf.stroke_to_change_calls += 1; - fizzy.perf.stroke_to_change_pixels_out +%= n; + if (pixi_mod.perf.record) { + pixi_mod.perf.stroke_to_change_ns +%= @intCast(pixi_mod.perf.nanoTimestamp() - t0); + pixi_mod.perf.stroke_to_change_calls += 1; + pixi_mod.perf.stroke_to_change_pixels_out +%= n; } return .{ .pixels = .{ diff --git a/src/internal/File.zig b/src/plugins/pixi/src/internal/File.zig similarity index 86% rename from src/internal/File.zig rename to src/plugins/pixi/src/internal/File.zig index 0747db78..01db172b 100644 --- a/src/internal/File.zig +++ b/src/plugins/pixi/src/internal/File.zig @@ -1,15 +1,18 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const zip = @import("zip"); const dvui = @import("dvui"); -const Editor = fizzy.Editor; +const Transform = @import("../Transform.zig"); +const Tools = @import("../Tools.zig"); const File = @This(); const Layer = @import("Layer.zig"); const Sprite = @import("Sprite.zig"); const Animation = @import("Animation.zig"); +const pixi_mod = @import("../../pixi.zig"); +const plugin = @import("../plugin.zig"); +const runtime = @import("../runtime.zig"); const alpha_checkerboard_count: u32 = 8; @@ -52,14 +55,21 @@ editor: EditorData = .{}, /// /// Also, the fields here tend to be directly coupled with the UI library pub const EditorData = struct { - // Only valid while file widget is drawing the file - workspace: *fizzy.Editor.Workspace = undefined, - canvas: fizzy.dvui.CanvasWidget = .{}, + /// Opaque slot handle to the workspace currently drawing this file. Set by the + /// shell each frame before the file is drawn; recovered in the editor layer via + /// `Editor.Workspace.ofFile`. Opaque so this internal data type does not + /// type-depend on the editor's `Workspace` (lets `File` move into a plugin). + /// Only valid while the file widget is drawing the file. + workspace_handle: ?*anyopaque = null, + /// Set by the shell each frame before draw: request the canvas recenter this frame + /// (true while a workspace/panel pane is mid-animation). Read by the document render. + center: bool = false, + canvas: pixi_mod.core.dvui.CanvasWidget = .{}, layers_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, sprites_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, animations_scroll_info: dvui.ScrollInfo = .{ .horizontal = .auto }, animations_scroll_to_index: ?usize = null, - transform: ?Editor.Transform = null, + transform: ?Transform = null, playing: bool = false, saving: bool = false, @@ -176,7 +186,7 @@ pub const EditorData = struct { was_saving: bool = false, /// Set from any thread in `setSaving(false)`; main-thread `tickSaveDoneFlash` arms the flash. save_complete: std.atomic.Value(bool) = .init(false), - /// Monotonic deadline (`fizzy.perf.nanoTimestamp`): save-complete affordance in tab / tree. + /// Monotonic deadline (`pixi_mod.perf.nanoTimestamp`): save-complete affordance in tab / tree. save_complete_show_duration: ?i128 = null, /// Set with `save_complete_show_duration` when the flash arms (`isSaving` → false). save_complete_show_start: ?i128 = null, @@ -192,40 +202,40 @@ pub const InitOptions = struct { row_height: u32, }; -pub fn init(path: []const u8, options: InitOptions) !fizzy.Internal.File { - var internal: fizzy.Internal.File = .{ - .id = fizzy.editor.newFileID(), - .path = try fizzy.app.allocator.dupe(u8, path), +pub fn init(path: []const u8, options: InitOptions) !pixi_mod.internal.File { + var internal: pixi_mod.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), .columns = options.columns, .rows = options.rows, .column_width = options.column_width, .row_height = options.row_height, - .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), + .history = pixi_mod.internal.File.History.init(runtime.allocator()), + .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), }; // Initialize editor layers and selected sprites internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } { // Create a single layer for the file - const layer: fizzy.Internal.Layer = try .init(internal.newLayerID(), "Layer", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.layers.append(fizzy.app.allocator, layer) catch return error.LayerCreateError; + const layer: Layer = try .init(internal.newLayerID(), "Layer", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); + internal.layers.append(runtime.allocator(), layer) catch return error.LayerCreateError; } // Initialize sprites for (0..internal.spriteCount()) |_| { - internal.sprites.append(fizzy.app.allocator, .{ + internal.sprites.append(runtime.allocator(), .{ .origin = .{ 0.0, 0.0 }, }) catch return error.FileLoadError; } @@ -252,11 +262,11 @@ pub fn checkerboardTileTexture(file: *File) ?dvui.Texture { dvui.textureDestroyLater(t); file.editor.checkerboard_tile = null; } - file.editor.checkerboard_tile = fizzy.image.checkerboardTile( + file.editor.checkerboard_tile = pixi_mod.image.checkerboardTile( want.w, want.h, - fizzy.editor.settings.checker_color_even, - fizzy.editor.settings.checker_color_odd, + runtime.state().settings.checker_color_even, + runtime.state().settings.checker_color_odd, ); return file.editor.checkerboard_tile; } @@ -284,7 +294,7 @@ pub fn setSaving(file: *File, v: bool) void { } else { // Arm the finish animation immediately so synchronous wasm saves (and any save // that completes between frames) don't leave `save_complete` stuck true. - const now = fizzy.perf.nanoTimestamp(); + const now = pixi_mod.perf.nanoTimestamp(); file.editor.save_complete_show_start = now; file.editor.save_complete_show_duration = now + save_done_flash_duration_ns; file.editor.save_complete.store(false, .monotonic); @@ -310,7 +320,7 @@ const save_done_flash_duration_ns: i128 = 2 * std.time.ns_per_s; /// Call once per frame from the main thread. Arms save-complete feedback when /// `isSaving()` falls from true to false. pub fn tickSaveDoneFlash(file: *File) void { - const now = fizzy.perf.nanoTimestamp(); + const now = pixi_mod.perf.nanoTimestamp(); const saving = file.isSaving(); const pending = file.editor.save_complete.swap(false, .monotonic); if (!saving and (pending or file.editor.was_saving) and file.editor.save_complete_show_duration == null) { @@ -341,12 +351,12 @@ pub fn showSaveDoneFlash(file: *const File) bool { return timeSinceSaveComplete(file) != null; } -/// Nanoseconds since save finished (`null` when inactive). Drives [`fizzy.dvui.bubbleSpinner`]'s +/// Nanoseconds since save finished (`null` when inactive). Drives [`pixi_mod.core.dvui.bubbleSpinner`]'s /// finish animation (sync → pop → check). pub fn timeSinceSaveComplete(file: *const File) ?i128 { const until = file.editor.save_complete_show_duration orelse return null; const st = file.editor.save_complete_show_start orelse return null; - const now = fizzy.perf.nanoTimestamp(); + const now = pixi_mod.perf.nanoTimestamp(); if (now >= until) return null; return @max(@as(i128, 0), now - st); } @@ -378,7 +388,7 @@ pub fn invalidateActiveLayerTransparencyMaskCache(file: *File) void { pub const layerOrderAfterMove = @import("layer_order.zig").layerOrderAfterMove; /// Load from in-memory bytes (browser file picker). `path` is used for extension detection and display name. -pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File { +pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?pixi_mod.internal.File { const extension = std.fs.path.extension(path); if (isFlatImageExtension(extension)) { return fromBytesFlatImage(path, file_bytes); @@ -390,7 +400,7 @@ pub fn fromBytes(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File } /// Attempts to load a file from the given path to create a new file -pub fn fromPath(path: []const u8) !?fizzy.Internal.File { +pub fn fromPath(path: []const u8) !?pixi_mod.internal.File { const extension = std.fs.path.extension(path[0..path.len]); if (isFlatImageExtension(extension)) { const file = fromPathFlatImage(path) catch |err| { @@ -416,23 +426,23 @@ pub fn isFizzyExtension(ext: []const u8) bool { return std.mem.eql(u8, ext, ".fiz") or std.mem.eql(u8, ext, ".pixi"); } -pub fn fromPathFizzy(path: []const u8) !?fizzy.Internal.File { +pub fn fromPathFizzy(path: []const u8) !?pixi_mod.internal.File { return loadFizzyZip(path, null); } -pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File { +pub fn fromBytesFizzy(path: []const u8, file_bytes: []const u8) !?pixi_mod.internal.File { return loadFizzyZip(path, file_bytes); } -fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File { +fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixi_mod.internal.File { if (!isFizzyExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; const null_terminated_path = if (file_bytes == null) - try fizzy.app.allocator.dupeZ(u8, path) + try runtime.allocator().dupeZ(u8, path) else ""; - defer if (file_bytes == null) fizzy.app.allocator.free(null_terminated_path); + defer if (file_bytes == null) runtime.allocator().free(null_terminated_path); zip_open: { const fizzy_file = if (file_bytes) |bytes| @@ -465,19 +475,19 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File .ignore_unknown_fields = true, }; - var try_parse: ?std.json.Parsed(fizzy.File) = null; - try_parse = std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, content, options) catch null; + var try_parse: ?std.json.Parsed(pixi_mod.File) = null; + try_parse = std.json.parseFromSlice(pixi_mod.File, runtime.allocator(), content, options) catch null; - var ext: fizzy.File = if (try_parse) |parsed| parsed.value else undefined; + var ext: pixi_mod.File = if (try_parse) |parsed| parsed.value else undefined; if (try_parse == null) { // If we are here, we have tried to load the file but hit an issue because the old animation format - if (std.json.parseFromSlice(fizzy.File.FileV3, fizzy.app.allocator, content, options) catch null) |old_file| { + if (std.json.parseFromSlice(pixi_mod.File.FileV3, runtime.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v3: {s}", .{path}); - const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len); + const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len); for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name); - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len); + animation.name = try runtime.allocator().dupe(u8, old_animation.name); + animation.frames = try runtime.allocator().alloc(Animation.Frame, old_animation.frames.len); for (animation.frames, old_animation.frames) |*frame, old_frame| { frame.sprite_index = old_frame; frame.ms = @intFromFloat(1000 / old_animation.fps); @@ -494,12 +504,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File .sprites = old_file.value.sprites, .animations = animations, }; - } else if (std.json.parseFromSlice(fizzy.File.FileV2, fizzy.app.allocator, content, options) catch null) |old_file| { + } else if (std.json.parseFromSlice(pixi_mod.File.FileV2, runtime.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v2: {s}", .{path}); - const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len); + const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len); for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try fizzy.app.allocator.dupe(u8, old_animation.name); - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_animation.frames.len); + animation.name = try runtime.allocator().dupe(u8, old_animation.name); + animation.frames = try runtime.allocator().alloc(Animation.Frame, old_animation.frames.len); for (animation.frames, old_animation.frames) |*frame, old_frame| { frame.sprite_index = old_frame; frame.ms = @intFromFloat(1000 / old_animation.fps); @@ -516,12 +526,12 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File .sprites = old_file.value.sprites, .animations = animations, }; - } else if (std.json.parseFromSlice(fizzy.File.FileV1, fizzy.app.allocator, content, options) catch null) |old_file| { + } else if (std.json.parseFromSlice(pixi_mod.File.FileV1, runtime.allocator(), content, options) catch null) |old_file| { std.log.info("Loading file v1: {s}", .{path}); - const animations = try fizzy.app.allocator.alloc(fizzy.Animation, old_file.value.animations.len); + const animations = try runtime.allocator().alloc(pixi_mod.Animation, old_file.value.animations.len); for (animations, 0..) |*animation, i| { - animation.name = try fizzy.app.allocator.dupe(u8, old_file.value.animations[i].name); - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, old_file.value.animations[i].length); + animation.name = try runtime.allocator().dupe(u8, old_file.value.animations[i].name); + animation.frames = try runtime.allocator().alloc(Animation.Frame, old_file.value.animations[i].length); for (animation.frames, 0..old_file.value.animations[i].length) |*frame, j| { frame.sprite_index = old_file.value.animations[i].start + j; frame.ms = @intFromFloat(1000 / old_file.value.animations[i].fps); @@ -545,15 +555,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File //defer parsed.deinit(); - var internal: fizzy.Internal.File = .{ - .id = fizzy.editor.newFileID(), - .path = try fizzy.app.allocator.dupe(u8, path), + var internal: pixi_mod.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), .columns = ext.columns, .rows = ext.rows, .column_width = ext.column_width, .row_height = ext.row_height, - .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), + .history = pixi_mod.internal.File.History.init(runtime.allocator()), + .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), }; //Initialize editor layers and selected sprites @@ -562,21 +572,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } var set_layer_index: bool = false; for (ext.layers, 0..) |l, i| { - const layer_image_name = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed"; - defer fizzy.app.allocator.free(layer_image_name); - const png_image_name = std.fmt.allocPrintSentinel(fizzy.app.allocator, "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed"; - defer fizzy.app.allocator.free(png_image_name); + const layer_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed"; + defer runtime.allocator().free(layer_image_name); + const png_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed"; + defer runtime.allocator().free(png_image_name); var img_buf: ?*anyopaque = null; var img_len: usize = 0; @@ -585,7 +595,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); const data = img_buf orelse continue; - var new_layer: fizzy.Internal.Layer = try .fromPixelsPMA( + var new_layer: Layer = try .fromPixelsPMA( internal.newLayerID(), l.name, @as([*]dvui.Color.PMA, @ptrCast(@constCast(data)))[0..(internal.width() * internal.height())], @@ -599,7 +609,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File new_layer.setMaskFromTransparency(true); - internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError; + internal.layers.append(runtime.allocator(), new_layer) catch return error.FileLoadError; if (l.visible and !set_layer_index) { internal.selected_layer_index = i; @@ -609,7 +619,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); const data = img_buf orelse continue; - var new_layer: fizzy.Internal.Layer = try .fromImageFileBytes( + var new_layer: Layer = try .fromImageFileBytes( internal.newLayerID(), l.name, @as([*]u8, @ptrCast(data))[0..img_len], @@ -621,7 +631,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File new_layer.setMaskFromTransparency(true); - internal.layers.append(fizzy.app.allocator, new_layer) catch return error.FileLoadError; + internal.layers.append(runtime.allocator(), new_layer) catch return error.FileLoadError; if (l.visible and !set_layer_index) { internal.selected_layer_index = i; @@ -635,21 +645,21 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File for (0..internal.spriteCount()) |sprite_index| { if (sprite_index >= ext.sprites.len) { - internal.sprites.append(fizzy.app.allocator, .{ + internal.sprites.append(runtime.allocator(), .{ .origin = .{ 0, 0 }, }) catch return error.FileLoadError; } else { - internal.sprites.append(fizzy.app.allocator, .{ + internal.sprites.append(runtime.allocator(), .{ .origin = .{ ext.sprites[sprite_index].origin[0], ext.sprites[sprite_index].origin[1] }, }) catch return error.FileLoadError; } } for (ext.animations) |animation| { - internal.animations.append(fizzy.app.allocator, .{ + internal.animations.append(runtime.allocator(), .{ .id = internal.newAnimationID(), - .name = try fizzy.app.allocator.dupe(u8, animation.name), - .frames = try fizzy.app.allocator.dupe(Animation.Frame, animation.frames), + .name = try runtime.allocator().dupe(u8, animation.name), + .frames = try runtime.allocator().dupe(Animation.Frame, animation.frames), }) catch return error.FileLoadError; } return internal; @@ -658,7 +668,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; // var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; - // if (fizzy.fs.read(fizzy.app.allocator, path) catch null) |file_bytes| { + // if (pixi_mod.fs.read(runtime.allocator(), path) catch null) |file_bytes| { // std.log.debug("Read file bytes!", .{}); // var input = std.io.fixedBufferStream(file_bytes); // var iter = std.tar.iterator(input.reader(), .{ @@ -666,7 +676,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // .link_name_buffer = &link_name_buffer, // }); - // var json_content = std.array_list.Managed(u8).init(fizzy.app.allocator); + // var json_content = std.array_list.Managed(u8).init(runtime.allocator()); // defer json_content.deinit(); // while (try iter.next()) |entry| { @@ -681,23 +691,23 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // .ignore_unknown_fields = true, // }; - // if (std.json.parseFromSlice(fizzy.File, fizzy.app.allocator, json_content.items, options) catch null) |parsed| { + // if (std.json.parseFromSlice(pixi_mod.File, runtime.allocator(), json_content.items, options) catch null) |parsed| { // defer parsed.deinit(); // std.log.debug("Parsed fizzydata.json!", .{}); // const ext = parsed.value; - // var internal: fizzy.Internal.File = .{ - // .id = fizzy.editor.newFileID(), - // .path = try fizzy.app.allocator.dupe(u8, path), + // var internal: pixi_mod.internal.File = .{ + // .id = runtime.state().host.allocDocId(), + // .path = try runtime.allocator().dupe(u8, path), // .width = ext.width, // .height = ext.height, // .tile_width = ext.tile_width, // .tile_height = ext.tile_height, - // .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - // .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), - // .checkerboard = fizzy.image.init( + // .history = pixi_mod.internal.File.History.init(runtime.allocator()), + // .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), + // .checkerboard = pixi_mod.image.init( // ext.tile_width * 2, // ext.tile_height * 2, // .{ .r = 0, .g = 0, .b = 0, .a = 0 }, @@ -706,7 +716,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // .temporary_layer = undefined, // .selection_layer = undefined, // .selected_sprites = try std.DynamicBitSet.initEmpty( - // fizzy.app.allocator, + // runtime.allocator(), // @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height), // ), // }; @@ -729,15 +739,15 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File // std.log.debug("Entry name: {s}", .{entry.name}); // if (std.mem.eql(u8, entry.name, layer_image_name)) { - // var layer_content = std.array_list.Managed(u8).init(fizzy.app.allocator); + // var layer_content = std.array_list.Managed(u8).init(runtime.allocator()); // try entry.writeAll(layer_content.writer()); - // var cond: ?fizzy.Internal.Layer = fizzy.Internal.Layer.fromPixels(internal.newID(), fizzy.app.allocator.dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null; + // var cond: ?pixi_mod.Layer = pixi_mod.Layer.fromPixels(internal.newID(), runtime.allocator().dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null; // if (cond) |*new_layer| { // new_layer.visible = ext_layer.visible; // new_layer.collapse = ext_layer.collapse; - // internal.layers.append(fizzy.app.allocator, new_layer.*) catch return error.FileLoadError; + // internal.layers.append(runtime.allocator(), new_layer.*) catch return error.FileLoadError; // } else { // std.log.err("Failed to create layer from pixels", .{}); // } @@ -753,7 +763,7 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?fizzy.Internal.File return error.FileLoadError; } -fn isFlatImageExtension(ext: []const u8) bool { +pub fn isFlatImageExtension(ext: []const u8) bool { return std.mem.eql(u8, ext, ".png") or std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg"); @@ -783,12 +793,12 @@ pub fn shouldConfirmFlatRasterSave(self: File) bool { return requiresFizzyCompatibleSave(self); } -pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.Internal.File { +pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?pixi_mod.internal.File { if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; - const image_layer: fizzy.Internal.Layer = try fizzy.Internal.Layer.fromImageFileBytes( - fizzy.editor.newFileID(), + const image_layer: Layer = try Layer.fromImageFileBytes( + runtime.state().host.allocDocId(), "Layer", file_bytes, .ptr, @@ -798,42 +808,42 @@ pub fn fromBytesFlatImage(path: []const u8, file_bytes: []const u8) !?fizzy.Inte /// Loads a PNG or JPEG as the first layer of a new file, and retains the path /// when saved; layers will be flattened to that file -pub fn fromPathFlatImage(path: []const u8) !?fizzy.Internal.File { +pub fn fromPathFlatImage(path: []const u8) !?pixi_mod.internal.File { if (!isFlatImageExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; - const image_layer: fizzy.Internal.Layer = try fizzy.Internal.Layer.fromImageFilePath(fizzy.editor.newFileID(), "Layer", path, .ptr); + const image_layer: Layer = try Layer.fromImageFilePath(runtime.state().host.allocDocId(), "Layer", path, .ptr); return finishFlatImageFile(path, image_layer); } -fn finishFlatImageFile(path: []const u8, image_layer: fizzy.Internal.Layer) !?fizzy.Internal.File { +fn finishFlatImageFile(path: []const u8, image_layer: Layer) !?pixi_mod.internal.File { const size = image_layer.size(); const column_width: u32 = @intFromFloat(size.w); const row_height: u32 = @intFromFloat(size.h); - var internal: fizzy.Internal.File = .{ - .id = fizzy.editor.newFileID(), - .path = try fizzy.app.allocator.dupe(u8, path), + var internal: pixi_mod.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), .columns = 1, .rows = 1, .column_width = column_width, .row_height = row_height, - .history = fizzy.Internal.File.History.init(fizzy.app.allocator), - .buffers = fizzy.Internal.File.Buffers.init(fizzy.app.allocator), + .history = pixi_mod.internal.File.History.init(runtime.allocator()), + .buffers = pixi_mod.internal.File.Buffers.init(runtime.allocator()), }; - internal.layers.append(fizzy.app.allocator, image_layer) catch return error.LayerCreateError; + internal.layers.append(runtime.allocator(), image_layer) catch return error.LayerCreateError; // Initialize editor layers and selected sprites internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.spriteCount()); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, internal.width() * internal.height()); + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); // Create a layer-sized checkerboard pattern for selection tools for (0..internal.width() * internal.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); internal.editor.checkerboard.setValue(i, value); } @@ -845,7 +855,7 @@ pub const ResizeOptions = struct { rows: u32, history: bool = true, // If true, layer data will be recorded for undo/redo layer_data: ?[][][4]u8 = null, // If provided, the layer data will be applied to the layers after resizing - animation_data: ?[][]fizzy.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing + animation_data: ?[][]pixi_mod.Animation.Frame = null, // If provided, the animation data will be applied to the animations after resizing sprite_data: ?[][2]f32 = null, // If provided, the sprite data will be applied to the sprites after resizing }; @@ -868,22 +878,22 @@ pub fn resize(file: *File, options: ResizeOptions) !void { if (options.history) { file.history.append(.{ .resize = .{ .width = file.width(), .height = file.height() } }) catch return error.HistoryAppendError; - var layer_data = try fizzy.app.allocator.alloc([][4]u8, file.layers.len); + var layer_data = try runtime.allocator().alloc([][4]u8, file.layers.len); for (0..file.layers.len) |layer_index| { var layer = file.layers.get(layer_index); - layer_data[layer_index] = fizzy.app.allocator.dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed; + layer_data[layer_index] = runtime.allocator().dupe([4]u8, layer.pixels()) catch return error.MemoryAllocationFailed; } file.history.undo_layer_data_stack.append(layer_data) catch return error.MemoryAllocationFailed; // Store all the animations before the resize event - var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len); + var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len); for (0..file.animations.len) |anim_index| { const animation = file.animations.get(anim_index); - anim_data[anim_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed; + anim_data[anim_index] = runtime.allocator().dupe(pixi_mod.Animation.Frame, animation.frames) catch return error.MemoryAllocationFailed; } file.history.undo_animation_data_stack.append(anim_data) catch return error.MemoryAllocationFailed; - var sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount()); + var sprite_data = try runtime.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -895,22 +905,22 @@ pub fn resize(file: *File, options: ResizeOptions) !void { var current_animation = file.animations.get(anim_index); const current_data = anim_data[anim_index]; - var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError; + var new_animation = Animation.init(runtime.allocator(), current_animation.id, current_animation.name, &.{}) catch return error.AnimationCreateError; defer file.animations.set(anim_index, new_animation); - defer current_animation.deinit(fizzy.app.allocator); + defer current_animation.deinit(runtime.allocator()); for (current_data) |frame| { - new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError; + new_animation.appendFrame(runtime.allocator(), .{ .sprite_index = frame.sprite_index, .ms = frame.ms }) catch return error.AnimationFrameAppendError; } } } else for (0..file.animations.len) |anim_index| { var animation = file.animations.get(anim_index); - var new_animation = fizzy.Internal.Animation.init(fizzy.app.allocator, animation.id, animation.name, &.{}) catch return error.AnimationCreateError; + var new_animation = Animation.init(runtime.allocator(), animation.id, animation.name, &.{}) catch return error.AnimationCreateError; defer file.animations.set(anim_index, new_animation); - defer animation.deinit(fizzy.app.allocator); + defer animation.deinit(runtime.allocator()); for (0..animation.frames.len) |frame_index| { const old_sprite_index = animation.frames[frame_index].sprite_index; if (file.getResizedIndex(old_sprite_index, new_columns, new_rows)) |new_sprite_index| { - new_animation.appendFrame(fizzy.app.allocator, .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; + new_animation.appendFrame(runtime.allocator(), .{ .sprite_index = new_sprite_index, .ms = animation.frames[frame_index].ms }) catch return error.AnimationFrameAppendError; } } } @@ -919,10 +929,10 @@ pub fn resize(file: *File, options: ResizeOptions) !void { const new_sprite_count = new_columns * new_rows; var old_origins_snapshot: ?[][2]f32 = null; - defer if (old_origins_snapshot) |s| fizzy.app.allocator.free(s); + defer if (old_origins_snapshot) |s| runtime.allocator().free(s); if (options.sprite_data == null) { - const snapshot = try fizzy.app.allocator.alloc([2]f32, old_sprite_count); + const snapshot = try runtime.allocator().alloc([2]f32, old_sprite_count); for (0..old_sprite_count) |i| { snapshot[i] = file.sprites.items(.origin)[i]; } @@ -930,7 +940,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void { } file.sprites.resize( - fizzy.app.allocator, + runtime.allocator(), new_sprite_count, ) catch return error.MemoryAllocationFailed; @@ -974,7 +984,7 @@ pub fn resize(file: *File, options: ResizeOptions) !void { file.editor.checkerboard.resize(new_width * new_height, false) catch return error.MemoryAllocationFailed; for (0..new_width * new_height) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_width), .h = @floatFromInt(new_height) }, i); file.editor.checkerboard.setValue(i, value); } @@ -1302,7 +1312,7 @@ pub fn reorderRows(file: *File, removed_row_index: usize, insert_before_row_inde } pub fn deinit(file: *File) void { - fizzy.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); strokeUndoFreeSnapshot(file); @@ -1310,15 +1320,15 @@ pub fn deinit(file: *File) void { file.buffers.deinit(); for (file.layers.items(.name)) |name| { - fizzy.app.allocator.free(name); + runtime.allocator().free(name); } for (file.animations.items(.name)) |name| { - fizzy.app.allocator.free(name); + runtime.allocator().free(name); } for (file.animations.items(.frames)) |frames| { - fizzy.app.allocator.free(frames); + runtime.allocator().free(frames); } file.editor.temporary_layer.deinit(); @@ -1329,16 +1339,16 @@ pub fn deinit(file: *File) void { file.editor.checkerboard_tile = null; } - file.editor.selected_layer_indices.deinit(fizzy.app.allocator); - file.editor.selected_animation_indices.deinit(fizzy.app.allocator); - file.editor.selected_frame_indices.deinit(fizzy.app.allocator); + file.editor.selected_layer_indices.deinit(runtime.allocator()); + file.editor.selected_animation_indices.deinit(runtime.allocator()); + file.editor.selected_frame_indices.deinit(runtime.allocator()); - file.layers.deinit(fizzy.app.allocator); - file.deleted_layers.deinit(fizzy.app.allocator); - file.sprites.deinit(fizzy.app.allocator); - file.animations.deinit(fizzy.app.allocator); - file.deleted_animations.deinit(fizzy.app.allocator); - fizzy.app.allocator.free(file.path); + file.layers.deinit(runtime.allocator()); + file.deleted_layers.deinit(runtime.allocator()); + file.sprites.deinit(runtime.allocator()); + file.animations.deinit(runtime.allocator()); + file.deleted_animations.deinit(runtime.allocator()); + runtime.allocator().free(file.path); } pub fn dirty(self: File) bool { @@ -1608,7 +1618,7 @@ pub fn promotePrimarySprite(file: *File, sprite_index: usize) void { pub fn collapseAnimationSelectionToPrimary(file: *File) void { if (file.selected_animation_index) |p| { file.editor.selected_animation_indices.clearRetainingCapacity(); - file.editor.selected_animation_indices.append(fizzy.app.allocator, p) catch return; + file.editor.selected_animation_indices.append(runtime.allocator(), p) catch return; file.editor.animation_selection_anchor = p; } } @@ -1670,9 +1680,9 @@ pub fn selectPoint(file: *File, point: dvui.Point, select_options: SelectOptions } } } else { - var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = runtime.state().tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = runtime.state().tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (select_options.constrain_to_tile) { @@ -1716,29 +1726,29 @@ pub fn selectLine(file: *File, point1: dvui.Point, point2: dvui.Point, select_op const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height)); const diff = point2.diff(point1).normalize().scale(4, dvui.Point); - const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size); + const stroke_size: usize = @intCast(Tools.max_brush_size); - const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) }; - var mask = fizzy.editor.tools.stroke; + const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) }; + var mask = runtime.state().tools.stroke; - if (select_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) { + if (select_options.stroke_size > Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (runtime.state().tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } } - if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| { + if (pixi_mod.algorithms.brezenham.process(point1, point2) catch null) |points| { for (points, 0..) |point, point_i| { - if (select_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) { + if (select_options.stroke_size < Tools.min_full_stroke_size) { selectPoint(file, point, select_options); } else { - var stroke = if (point_i == 0) fizzy.editor.tools.stroke else mask; + var stroke = if (point_i == 0) runtime.state().tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = runtime.state().tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (select_options.constrain_to_tile) { @@ -1796,16 +1806,16 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void const bounds = dvui.Rect.fromSize(.{ .w = @floatFromInt(file.width()), .h = @floatFromInt(file.height()) }); if (!bounds.contains(p)) return; - const start_idx = fizzy.image.pixelIndex(read_layer.source, p) orelse return; + const start_idx = pixi_mod.image.pixelIndex(read_layer.source, p) orelse return; const original_color = read_layer.pixels()[start_idx]; const n = read_layer.pixels().len; if (selection_layer.mask.capacity() != n) return; - var visited = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, n); + var visited = try std.DynamicBitSet.initEmpty(runtime.allocator(), n); defer visited.deinit(); - var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator); + var queue = std.array_list.Managed(dvui.Point).init(runtime.allocator()); defer queue.deinit(); try queue.append(p); @@ -1819,7 +1829,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void }; while (queue.pop()) |qp| { - const idx = fizzy.image.pixelIndex(read_layer.source, qp) orelse continue; + const idx = pixi_mod.image.pixelIndex(read_layer.source, qp) orelse continue; if (!std.meta.eql(original_color, read_layer.pixels()[idx])) continue; selection_layer.mask.setValue(idx, value); @@ -1827,7 +1837,7 @@ pub fn selectColorFloodFromPoint(file: *File, p: dvui.Point, value: bool) !void for (directions) |direction| { const np = qp.plus(direction); if (!bounds.contains(np)) continue; - if (fizzy.image.pixelIndex(read_layer.source, np)) |ni| { + if (pixi_mod.image.pixelIndex(read_layer.source, np)) |ni| { if (visited.isSet(ni)) continue; if (!std.meta.eql(original_color, read_layer.pixels()[ni])) continue; visited.set(ni); @@ -1933,7 +1943,7 @@ pub fn brushStampRect(file: *const File, point: dvui.Point, stroke_size: usize) fn strokeUndoFreeSnapshot(file: *File) void { if (file.editor.stroke_undo_pixels) |p| { - fizzy.app.allocator.free(p); + runtime.allocator().free(p); file.editor.stroke_undo_pixels = null; } file.editor.stroke_undo_x = 0; @@ -1960,7 +1970,7 @@ pub fn strokeUndoBegin(file: *File, cover: dvui.Rect) !void { } const n = @as(usize, b.w) * @as(usize, b.h) * 4; - const buf = try fizzy.app.allocator.alloc(u8, n); + const buf = try runtime.allocator().alloc(u8, n); const layer = file.layers.get(file.selected_layer_index); const pix = layer.pixels(); @@ -2011,7 +2021,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void { } const new_n = @as(usize, tw) * @as(usize, th) * 4; - const new_buf = try fizzy.app.allocator.alloc(u8, new_n); + const new_buf = try runtime.allocator().alloc(u8, new_n); const layer = file.layers.get(file.selected_layer_index); const pix = layer.pixels(); @@ -2037,7 +2047,7 @@ pub fn strokeUndoExpandToCoverRect(file: *File, cover: dvui.Rect) !void { } } - fizzy.app.allocator.free(old_buf); + runtime.allocator().free(old_buf); file.editor.stroke_undo_pixels = new_buf; file.editor.stroke_undo_x = tx; file.editor.stroke_undo_y = ty; @@ -2331,9 +2341,9 @@ pub fn drawPoint(file: *File, point: dvui.Point, layer: DrawLayer, draw_options: } } } else { - var iter = fizzy.editor.tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); + var iter = runtime.state().tools.stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = runtime.state().tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2419,26 +2429,26 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw const max_y: f32 = min_y + @as(f32, @floatFromInt(file.row_height)); const diff = point2.diff(point1).normalize().scale(4, dvui.Point); - const stroke_size: usize = @intCast(fizzy.Editor.Tools.max_brush_size); + const stroke_size: usize = @intCast(Tools.max_brush_size); - const center: dvui.Point = .{ .x = @floor(fizzy.Editor.Tools.max_brush_size_float / 2), .y = @floor(fizzy.Editor.Tools.max_brush_size_float / 2) }; - var mask = fizzy.editor.tools.stroke; + const center: dvui.Point = .{ .x = @floor(Tools.max_brush_size_float / 2), .y = @floor(Tools.max_brush_size_float / 2) }; + var mask = runtime.state().tools.stroke; - if (draw_options.stroke_size > fizzy.Editor.Tools.min_full_stroke_size) { + if (draw_options.stroke_size > Tools.min_full_stroke_size) { for (0..(stroke_size * stroke_size)) |index| { - if (fizzy.editor.tools.getIndexShapeOffset(center.diff(diff), index)) |i| { + if (runtime.state().tools.getIndexShapeOffset(center.diff(diff), index)) |i| { mask.unset(i); } } } - if (fizzy.algorithms.brezenham.process(point1, point2) catch null) |points| { + if (pixi_mod.algorithms.brezenham.process(point1, point2) catch null) |points| { for (points, 0..) |point, point_i| { if (clip_rect) |cr| { const br = brushRect(point, draw_options.stroke_size, iw, ih); if (br.intersect(cr).empty()) continue; } - if (draw_options.stroke_size < fizzy.Editor.Tools.min_full_stroke_size) { + if (draw_options.stroke_size < Tools.min_full_stroke_size) { drawPoint(file, point, layer, .{ .color = draw_options.color, .stroke_size = draw_options.stroke_size, @@ -2449,11 +2459,11 @@ pub fn drawLine(file: *File, point1: dvui.Point, point2: dvui.Point, layer: Draw .clip_rect = draw_options.clip_rect, }); } else { - var stroke = if (point_i == 0) fizzy.editor.tools.stroke else mask; + var stroke = if (point_i == 0) runtime.state().tools.stroke else mask; var iter = stroke.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { - const offset = fizzy.editor.tools.offset_table[i]; + const offset = runtime.state().tools.offset_table[i]; const new_point: dvui.Point = .{ .x = point.x + offset[0], .y = point.y + offset[1] }; if (clip_rect) |cr| { @@ -2601,7 +2611,7 @@ pub fn getLayer(self: *File, id: u64) ?Layer { } pub fn deleteLayer(self: *File, index: usize) !void { - try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(index)); + try self.deleted_layers.append(runtime.allocator(), self.layers.slice().get(index)); self.layers.orderedRemove(index); self.editor.layer_composite_dirty = true; self.editor.split_composite_dirty = true; @@ -2637,10 +2647,10 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: const dest_id = self.layers.items(.id)[dest_i]; const src_id = self.layers.items(.id)[src_i]; - const dest_pixels_before = try fizzy.app.allocator.dupe([4]u8, dest.pixels()); - errdefer fizzy.app.allocator.free(dest_pixels_before); + const dest_pixels_before = try runtime.allocator().dupe([4]u8, dest.pixels()); + errdefer runtime.allocator().free(dest_pixels_before); - var dest_mask_before = try dest.mask.clone(fizzy.app.allocator); + var dest_mask_before = try dest.mask.clone(runtime.allocator()); errdefer dest_mask_before.deinit(); for (0..pix_n) |i| { @@ -2655,7 +2665,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: dest.invalidate(); self.layers.set(dest_i, dest); - try self.deleted_layers.append(fizzy.app.allocator, self.layers.slice().get(src_i)); + try self.deleted_layers.append(runtime.allocator(), self.layers.slice().get(src_i)); self.layers.orderedRemove(src_i); self.editor.layer_composite_dirty = true; @@ -2674,7 +2684,7 @@ fn mergeLayerInternal(self: *File, kind: History.Change.LayerMerge.Kind, src_i: .dest_pixels_before = dest_pixels_before, .dest_mask_before = dest_mask_before, } }); - fizzy.editor.explorer.pane = .tools; + runtime.state().host.setActiveSidebarView(plugin.view_tools); } pub fn duplicateLayer(self: *File, index: usize) !u64 { @@ -2689,7 +2699,7 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 { @memcpy(new_layer.pixels(), layer.pixels()); - self.layers.insert(fizzy.app.allocator, 0, new_layer) catch { + self.layers.insert(runtime.allocator(), 0, new_layer) catch { dvui.log.err("Failed to append layer", .{}); }; @@ -2710,8 +2720,8 @@ pub fn duplicateLayer(self: *File, index: usize) !u64 { } pub fn createLayer(self: *File) !u64 { - if (fizzy.Internal.Layer.init(self.newLayerID(), "New Layer", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch null) |layer| { - self.layers.insert(fizzy.app.allocator, 0, layer) catch { + if (Layer.init(self.newLayerID(), "New Layer", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch null) |layer| { + self.layers.insert(runtime.allocator(), 0, layer) catch { dvui.log.err("Failed to append layer", .{}); }; self.selected_layer_index = 0; @@ -2735,14 +2745,14 @@ pub fn createLayer(self: *File) !u64 { pub fn createAnimation(self: *File) !usize { var animation = Animation.init( - fizzy.app.allocator, + runtime.allocator(), self.newAnimationID(), "New Animation", &[_]Animation.Frame{}, ) catch return error.FailedToCreateAnimation; if (self.editor.selected_sprites.count() > 0) { - animation.frames = try fizzy.app.allocator.alloc(Animation.Frame, self.editor.selected_sprites.count()); + animation.frames = try runtime.allocator().alloc(Animation.Frame, self.editor.selected_sprites.count()); var iter = self.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); var i: usize = 0; @@ -2751,7 +2761,7 @@ pub fn createAnimation(self: *File) !usize { } } - self.animations.append(fizzy.app.allocator, animation) catch { + self.animations.append(runtime.allocator(), animation) catch { dvui.log.err("Failed to append animation", .{}); }; return self.animations.len - 1; @@ -2760,15 +2770,15 @@ pub fn createAnimation(self: *File) !usize { pub fn duplicateAnimation(self: *File, index: usize) !usize { const animation = self.animations.slice().get(index); const new_name = try std.fmt.allocPrint(dvui.currentWindow().lifo(), "{s}_copy", .{animation.name}); - const new_animation = Animation.init(fizzy.app.allocator, self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation; - self.animations.insert(fizzy.app.allocator, index + 1, new_animation) catch { + const new_animation = Animation.init(runtime.allocator(), self.newAnimationID(), new_name, animation.frames) catch return error.FailedToDuplicateAnimation; + self.animations.insert(runtime.allocator(), index + 1, new_animation) catch { dvui.log.err("Failed to append animation", .{}); }; return index + 1; } pub fn deleteAnimation(self: *File, index: usize) !void { - try self.deleted_animations.append(fizzy.app.allocator, self.animations.slice().get(index)); + try self.deleted_animations.append(runtime.allocator(), self.animations.slice().get(index)); self.animations.orderedRemove(index); try self.history.append(.{ .animation_restore_delete = .{ .action = .restore, @@ -2787,16 +2797,16 @@ pub fn redo(self: *File) !void { pub fn saveTar(self: *File, window: *dvui.Window) !void { if (self.saving) return; self.saving = true; - var ext = try self.external(fizzy.app.allocator); - defer ext.deinit(fizzy.app.allocator); + var ext = try self.external(runtime.allocator()); + defer ext.deinit(runtime.allocator()); - const output_path = try fizzy.editor.arena.allocator().dupeZ(u8, self.path); + const output_path = try runtime.state().host.arena().dupeZ(u8, self.path); var handle = try std.fs.cwd().createFile(output_path, .{}); defer handle.close(); var wrt = std.tar.writer(handle.writer()); - var json = std.array_list.Managed(u8).init(fizzy.app.allocator); + var json = std.array_list.Managed(u8).init(runtime.allocator()); const out_stream = json.writer(); const options = std.json.StringifyOptions{}; @@ -2818,14 +2828,14 @@ pub fn saveTar(self: *File, window: *dvui.Window) !void { else => return error.InvalidImageSource, }; - try wrt.writeFileBytes(try std.fmt.allocPrintZ(fizzy.editor.arena.allocator(), "{s}.layer", .{layer.name}), data, .{}); + try wrt.writeFileBytes(try std.fmt.allocPrintZ(runtime.state().host.arena(), "{s}.layer", .{layer.name}), data, .{}); } } try wrt.finish(); { - const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), 0, pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s}", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); @@ -2842,26 +2852,26 @@ fn writeFlattenedLayersToPath(self: *File, out_path: []const u8, window: *dvui.W const h = self.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try fizzy.render.syncLayerComposite(self); + try pixi_mod.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse return error.NoLayerComposite; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); + var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); defer tmp_layer.deinit(); switch (kind) { .png => { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try fizzy.image.writeToPngResolution(tmp_layer.source, out_path, r); + try pixi_mod.image.writeToPngResolution(tmp_layer.source, out_path, r); }, .jpg => { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try fizzy.image.writeToJpgPpi(tmp_layer.source, out_path, ppi); + try pixi_mod.image.writeToJpgPpi(tmp_layer.source, out_path, ppi); }, } } @@ -2876,7 +2886,7 @@ pub fn savePng(self: *File, window: *dvui.Window) !void { { // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic // u64s; in practice they fit, so an `@intCast` is safe and panics if not. - const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); @@ -2897,7 +2907,7 @@ pub fn saveJpg(self: *File, window: *dvui.Window) !void { { // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic // u64s; in practice they fit, so an `@intCast` is safe and panics if not. - const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); @@ -2916,8 +2926,8 @@ pub fn saveZip(self: *File, window: *dvui.Window) !void { // already the only writer of `self.layers` — so a snapshot would be pointless // copying. Build the snapshot inline and immediately consume it. We still // use the same code path so there's a single zip-writing function. - var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator); - defer snap.deinit(fizzy.app.allocator); + var snap = try SaveSnapshot.fromFileOnGuiThread(self, runtime.allocator()); + defer snap.deinit(runtime.allocator()); try writeSnapshotToZip(self.id, window, &snap); } @@ -2926,7 +2936,7 @@ pub fn saveZip(self: *File, window: *dvui.Window) !void { /// `*File`, so user edits during the save can't tear `self.layers` mid-iteration /// (manifested as MultiArrayList slice OOB / corrupt layer.name). pub const SaveSnapshot = struct { - ext: fizzy.File, + ext: pixi_mod.File, layer_bytes: [][]u8, layer_entry_names: [][:0]const u8, null_terminated_path: [:0]u8, @@ -2992,7 +3002,7 @@ pub const SaveQueue = struct { pub fn submit(self: *SaveQueue, job: Job) !void { self.mutex.lockUncancelable(dvui.io); defer self.mutex.unlock(dvui.io); - try self.queue.append(fizzy.app.allocator, job); + try self.queue.append(runtime.allocator(), job); self.cond.signal(dvui.io); } }; @@ -3025,10 +3035,10 @@ pub fn deinitSaveQueue() void { // Anything still queued after worker exit is leaked snapshots — shouldn't // happen since the worker drains before exit, but clean up defensively. for (save_queue.queue.items) |*job| { - job.snap.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(job.snap); + job.snap.deinit(runtime.allocator()); + runtime.allocator().destroy(job.snap); } - save_queue.queue.deinit(fizzy.app.allocator); + save_queue.queue.deinit(runtime.allocator()); } fn saveQueueWorker() void { @@ -3052,9 +3062,9 @@ fn saveQueueWorker() void { // becomes stale (silently aliasing a different file) as soon as the GUI // thread closes any earlier file from the in-flight set. defer { - job.snap.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(job.snap); - if (fizzy.editor.open_files.getPtr(job.file_id)) |f| f.setSaving(false); + job.snap.deinit(runtime.allocator()); + runtime.allocator().destroy(job.snap); + if (runtime.state().docs.fileById(job.file_id)) |f| f.setSaving(false); dvui.refresh(job.window, @src(), null); } writeSnapshotToZip(job.file_id, job.window, job.snap) catch |err| { @@ -3087,7 +3097,7 @@ fn writeSnapshotToZip(file_id: u64, window: *dvui.Window, snap: *const SaveSnaps zip.zip_close(z); } - if (fizzy.editor.open_files.getPtr(file_id)) |f| f.history.bookmark = 0; + if (runtime.state().docs.fileById(file_id)) |f| f.history.bookmark = 0; } fn zipEntryOk(rc: c_int) !void { @@ -3096,8 +3106,8 @@ fn zipEntryOk(rc: c_int) !void { fn writeSnapshotEntriesToZip(z: *zip.struct_zip_t, snap: *const SaveSnapshot) !void { const options = std.json.Stringify.Options{}; - const output = try std.json.Stringify.valueAlloc(fizzy.app.allocator, snap.ext, options); - defer fizzy.app.allocator.free(output); + const output = try std.json.Stringify.valueAlloc(runtime.allocator(), snap.ext, options); + defer runtime.allocator().free(output); try zipEntryOk(zip.zip_entry_open(z, "fizzydata.json")); try zipEntryOk(zip.zip_entry_write(z, output.ptr, output.len)); @@ -3139,25 +3149,25 @@ pub fn saveToDownload(self: *File, window: *dvui.Window) !void { const ext = std.fs.path.extension(self.path); if (isFizzyExtension(ext)) { - var snap = try SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator); - defer snap.deinit(fizzy.app.allocator); - const bytes = try writeSnapshotToZipBytes(&snap, fizzy.app.allocator); - defer fizzy.app.allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".fiz", bytes); + var snap = try SaveSnapshot.fromFileOnGuiThread(self, runtime.allocator()); + defer snap.deinit(runtime.allocator()); + const bytes = try writeSnapshotToZipBytes(&snap, runtime.allocator()); + defer runtime.allocator().free(bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".fiz", bytes); } else if (std.mem.eql(u8, ext, ".png")) { const bytes = try flattenedImageBytes(self, window, .png); - defer fizzy.app.allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".png", bytes); + defer runtime.allocator().free(bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".png", bytes); } else if (std.mem.eql(u8, ext, ".jpg") or std.mem.eql(u8, ext, ".jpeg")) { const bytes = try flattenedImageBytes(self, window, .jpg); - defer fizzy.app.allocator.free(bytes); - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(basename, ".jpg", bytes); + defer runtime.allocator().free(bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(basename, ".jpg", bytes); } else { return; } self.history.bookmark = 0; - const id_mutex = dvui.toastAdd(window, @src(), 0, fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), 0, pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Downloaded {s}", .{basename}) catch "Downloaded file"; dvui.dataSetSlice(window, id, "_message", message); @@ -3169,28 +3179,28 @@ fn flattenedImageBytes(self: *File, window: *dvui.Window, comptime kind: enum { const h = self.height(); if (w == 0 or h == 0) return error.InvalidImageSize; - try fizzy.render.syncLayerComposite(self); + try pixi_mod.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse return error.NoLayerComposite; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } - var tmp_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); + var tmp_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "_flat_save", pma_read, w, h, .ptr); defer tmp_layer.deinit(); - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); switch (kind) { .png => { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try fizzy.image.writePngToWriter(tmp_layer.source, &out.writer, r); + try pixi_mod.image.writePngToWriter(tmp_layer.source, &out.writer, r); }, .jpg => { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try fizzy.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); + try pixi_mod.image.writeJpgPpiToWriter(tmp_layer.source, &out.writer, ppi); }, } return out.toOwnedSlice(); @@ -3206,10 +3216,10 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi return saveZip(self, window); } const old_path = self.path; - const new_owned = try fizzy.app.allocator.dupe(u8, new_path); + const new_owned = try runtime.allocator().dupe(u8, new_path); self.path = new_owned; errdefer { - fizzy.app.allocator.free(self.path[0..self.path.len]); + runtime.allocator().free(self.path[0..self.path.len]); self.path = old_path; } if (comptime @import("builtin").target.cpu.arch == .wasm32) { @@ -3217,7 +3227,7 @@ pub fn saveAsFizzy(self: *File, new_path: []const u8, window: *dvui.Window) !voi } else { try saveZip(self, window); } - fizzy.app.allocator.free(old_path[0..old_path.len]); + runtime.allocator().free(old_path[0..old_path.len]); } /// Default filename (with `.fiz`) for a Save As dialog, derived from the current path. @@ -3241,10 +3251,10 @@ fn deinitAllUserLayers(self: *File) void { fn clearAnimationsForSaveAs(self: *File) void { for (self.animations.items(.name)) |n| { - fizzy.app.allocator.free(n); + runtime.allocator().free(n); } for (self.animations.items(.frames)) |frames| { - fizzy.app.allocator.free(frames); + runtime.allocator().free(frames); } self.animations.clearRetainingCapacity(); self.deleted_animations.clearRetainingCapacity(); @@ -3268,15 +3278,15 @@ fn reinitEditorSurfaceForFlatDocument(self: *File) !void { self.editor.temporary_layer = try .init(self.newLayerID(), "Temporary", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); self.editor.selection_layer = try .init(self.newLayerID(), "Selection", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); self.editor.transform_layer = try .init(self.newLayerID(), "Transform", self.width(), self.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.spriteCount()); + self.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), self.spriteCount()); - self.editor.checkerboard = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, self.width() * self.height()); + self.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), self.width() * self.height()); for (0..self.width() * self.height()) |i| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(self.width()), .h = @floatFromInt(self.height()) }, i); self.editor.checkerboard.setValue(i, value); } self.editor.selected_layer_indices.clearRetainingCapacity(); - try self.editor.selected_layer_indices.append(fizzy.app.allocator, 0); + try self.editor.selected_layer_indices.append(runtime.allocator(), 0); } /// Flattens visible layers (via GPU composite), writes PNG or JPEG to `output_path`, and replaces @@ -3294,16 +3304,16 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo return error.InvalidImageSize; } - try fizzy.render.syncLayerComposite(self); + try pixi_mod.render.syncLayerComposite(self); const target = self.editor.layer_composite_target orelse { self.setSaving(false); return error.NoLayerComposite; }; - const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(fizzy.app.allocator, target); + const pma_read: []dvui.Color.PMA = try dvui.Texture.readTarget(runtime.allocator(), target); defer { const byte_len = pma_read.len * @sizeOf(dvui.Color.PMA); - fizzy.app.allocator.free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); + runtime.allocator().free(@as([*]u8, @ptrCast(pma_read.ptr))[0..byte_len]); } const ext = std.fs.path.extension(output_path); @@ -3314,49 +3324,49 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo return error.InvalidExtension; } - var single_layer: fizzy.Internal.Layer = try .fromPixelsPMA(self.newLayerID(), "Layer", pma_read, w, h, .ptr); + var single_layer: Layer = try .fromPixelsPMA(self.newLayerID(), "Layer", pma_read, w, h, .ptr); errdefer single_layer.deinit(); if (comptime @import("builtin").target.cpu.arch == .wasm32) { const bytes = if (is_png) blk: { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); - try fizzy.image.writePngToWriter(single_layer.source, &out.writer, r); + try pixi_mod.image.writePngToWriter(single_layer.source, &out.writer, r); break :blk try out.toOwnedSlice(); } else blk: { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - var out = std.Io.Writer.Allocating.init(fizzy.app.allocator); + var out = std.Io.Writer.Allocating.init(runtime.allocator()); errdefer out.deinit(); - try fizzy.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); + try pixi_mod.image.writeJpgPpiToWriter(single_layer.source, &out.writer, ppi); break :blk try out.toOwnedSlice(); }; - defer fizzy.app.allocator.free(bytes); + defer runtime.allocator().free(bytes); const dl_ext = if (is_png) ".png" else ".jpg"; - try @import("../editor/WebFileIo.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); + try @import("../web_file_io.zig").downloadBytesWithExtension(std.fs.path.basename(output_path), dl_ext, bytes); } else if (is_png) { const r: u32 = @intFromFloat(@round(window.natural_scale * 72.0 / 0.0254)); - try fizzy.image.writeToPngResolution(single_layer.source, output_path, r); + try pixi_mod.image.writeToPngResolution(single_layer.source, output_path, r); } else { const ppi: u16 = @intFromFloat(@round(window.natural_scale * 72.0)); - try fizzy.image.writeToJpgPpi(single_layer.source, output_path, ppi); + try pixi_mod.image.writeToJpgPpi(single_layer.source, output_path, ppi); } - fizzy.render.destroyLayerCompositeResources(self); - fizzy.render.destroySplitCompositeResources(self); + pixi_mod.render.destroyLayerCompositeResources(self); + pixi_mod.render.destroySplitCompositeResources(self); deinitAllUserLayers(self); clearAnimationsForSaveAs(self); self.sprites.clearRetainingCapacity(); for (0..self.spriteCount()) |_| { - self.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0, 0 } }) catch { + self.sprites.append(runtime.allocator(), .{ .origin = .{ 0, 0 } }) catch { single_layer.deinit(); return error.FileLoadError; }; } - const new_path = try fizzy.app.allocator.dupe(u8, output_path); - fizzy.app.allocator.free(self.path[0..self.path.len]); + const new_path = try runtime.allocator().dupe(u8, output_path); + runtime.allocator().free(self.path[0..self.path.len]); self.path = new_path; self.columns = 1; self.rows = 1; @@ -3364,13 +3374,13 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo self.row_height = h; self.selected_layer_index = 0; self.peek_layer_index = null; - self.layers.append(fizzy.app.allocator, single_layer) catch { + self.layers.append(runtime.allocator(), single_layer) catch { single_layer.deinit(); return error.LayerCreateError; }; self.history.deinit(); - self.history = .init(fizzy.app.allocator); + self.history = .init(runtime.allocator()); try reinitEditorSurfaceForFlatDocument(self); self.editor.layer_composite_dirty = true; @@ -3379,13 +3389,13 @@ pub fn saveAsFlattened(self: *File, output_path: []const u8, window: *dvui.Windo { // `id_extra` is `usize` (u32 on wasm32). File IDs are session-local monotonic // u64s; in practice they fit, so an `@intCast` is safe and panics if not. - const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), fizzy.dvui.save_toast_subwindow_id, fizzy.dvui.saveCompleteToastDisplay, 2_500_000); + const id_mutex = dvui.toastAdd(window, @src(), @as(usize, @intCast(self.id)), pixi_mod.core.dvui.save_toast_subwindow_id, pixi_mod.core.dvui.saveCompleteToastDisplay, 2_500_000); const id = id_mutex.id; const message = std.fmt.allocPrint(window.arena(), "Saved {s} to disk", .{std.fs.path.basename(self.path)}) catch "Saved file"; dvui.dataSetSlice(window, id, "_message", message); id_mutex.mutex.unlock(dvui.io); } - fizzy.editor.requestCompositeWarmup(); + runtime.state().host.requestPrepareFrame(); } pub const GridLayoutOptions = struct { @@ -3393,7 +3403,7 @@ pub const GridLayoutOptions = struct { row_height: u32, columns: u32, rows: u32, - anchor: fizzy.math.layout_anchor.LayoutAnchor, + anchor: pixi_mod.math.layout_anchor.LayoutAnchor, /// When true (default), `applyGridLayout` snapshots the previous state and pushes a /// `grid_layout` change to the file's history before mutating. Internal callers driving /// undo/redo restoration should pass `false` so the swap doesn't loop into itself. @@ -3402,34 +3412,34 @@ pub const GridLayoutOptions = struct { /// Captures everything `applyGridLayout` mutates, owning all returned slices. The caller is /// responsible for freeing via `Change.deinit` (see `History.Change.GridLayout.deinit`). -pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.History.Change.GridLayout { +pub fn captureGridLayoutSnapshot(file: *File) !History.Change.GridLayout { const total: usize = @as(usize, file.column_width) * @as(usize, file.columns) * @as(usize, file.row_height) * @as(usize, file.rows); const layer_count = file.layers.len; - var layer_ids = try fizzy.app.allocator.alloc(u64, layer_count); - errdefer fizzy.app.allocator.free(layer_ids); + var layer_ids = try runtime.allocator().alloc(u64, layer_count); + errdefer runtime.allocator().free(layer_ids); - var layer_pixels = try fizzy.app.allocator.alloc([][4]u8, layer_count); + var layer_pixels = try runtime.allocator().alloc([][4]u8, layer_count); var allocated: usize = 0; errdefer { - for (layer_pixels[0..allocated]) |buf| fizzy.app.allocator.free(buf); - fizzy.app.allocator.free(layer_pixels); + for (layer_pixels[0..allocated]) |buf| runtime.allocator().free(buf); + runtime.allocator().free(layer_pixels); } for (0..layer_count) |i| { layer_ids[i] = file.layers.items(.id)[i]; const src = file.layers.get(i).pixels(); std.debug.assert(src.len == total); - const dst = try fizzy.app.allocator.alloc([4]u8, total); + const dst = try runtime.allocator().alloc([4]u8, total); @memcpy(dst, src); layer_pixels[i] = dst; allocated += 1; } const sprite_count = file.sprites.len; - var sprite_origins = try fizzy.app.allocator.alloc([2]f32, sprite_count); - errdefer fizzy.app.allocator.free(sprite_origins); + var sprite_origins = try runtime.allocator().alloc([2]f32, sprite_count); + errdefer runtime.allocator().free(sprite_origins); for (0..sprite_count) |i| sprite_origins[i] = file.sprites.items(.origin)[i]; return .{ @@ -3449,7 +3459,7 @@ pub fn captureGridLayoutSnapshot(file: *File) !fizzy.Internal.History.Change.Gri /// Restores the file to the exact state described by `snap`. Mirrors the structural updates of /// `applyGridLayout` (resize layer buffers, sprite list, scratch layers, checkerboard, composite /// tear-down) but copies pixel data verbatim instead of re-anchoring it. -pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change.GridLayout) !void { +pub fn applyGridLayoutSnapshot(file: *File, snap: History.Change.GridLayout) !void { const new_w: u32 = snap.column_width * snap.columns; const new_h: u32 = snap.row_height * snap.rows; const total: usize = @as(usize, new_w) * @as(usize, new_h); @@ -3465,7 +3475,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change. break :blk null; }; - var rebuilt = fizzy.Internal.Layer.init( + var rebuilt = Layer.init( live.id, live.name, new_w, @@ -3486,25 +3496,25 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change. file.editor.temporary_layer.deinit(); file.editor.selection_layer.deinit(); file.editor.transform_layer.deinit(); - file.editor.temporary_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.selection_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.transform_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.temporary_layer = Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.selection_layer = Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.transform_layer = Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; file.sprites.shrinkRetainingCapacity(0); const new_sprite_count: usize = @as(usize, snap.columns) * @as(usize, snap.rows); var i: usize = 0; while (i < new_sprite_count) : (i += 1) { const origin: [2]f32 = if (i < snap.sprite_origins.len) snap.sprite_origins[i] else .{ 0.0, 0.0 }; - file.sprites.append(fizzy.app.allocator, .{ .origin = origin }) catch return error.MemoryAllocationFailed; + file.sprites.append(runtime.allocator(), .{ .origin = origin }) catch return error.MemoryAllocationFailed; } file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed; + file.editor.selected_sprites = std.DynamicBitSet.initEmpty(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; file.editor.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(fizzy.app.allocator, total) catch return error.MemoryAllocationFailed; + file.editor.checkerboard = std.DynamicBitSet.initEmpty(runtime.allocator(), total) catch return error.MemoryAllocationFailed; for (0..total) |idx| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); file.editor.checkerboard.setValue(idx, value); } @@ -3520,7 +3530,7 @@ pub fn applyGridLayoutSnapshot(file: *File, snap: fizzy.Internal.History.Change. file.columns = snap.columns; file.rows = snap.rows; - fizzy.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); } @@ -3559,12 +3569,12 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { options.rows == file.rows; if (same) return; - var snapshot_opt: ?fizzy.Internal.History.Change.GridLayout = if (options.history) + var snapshot_opt: ?History.Change.GridLayout = if (options.history) try file.captureGridLayoutSnapshot() else null; errdefer if (snapshot_opt) |snap| { - var ch = fizzy.Internal.History.Change{ .grid_layout = snap }; + var ch = History.Change{ .grid_layout = snap }; ch.deinit(); }; @@ -3575,7 +3585,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows); const old_sprite_count = file.sprites.len; - file.sprites.resize(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed; + file.sprites.resize(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; if (new_sprite_count > old_sprite_count) { var i: usize = old_sprite_count; @@ -3584,7 +3594,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { } } - var new_selected = try std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count); + var new_selected = try std.DynamicBitSet.initEmpty(runtime.allocator(), new_sprite_count); const sel_copy = @min(old_sprite_count, new_sprite_count); for (0..sel_copy) |i| { if (file.editor.selected_sprites.isSet(i)) new_selected.set(i); @@ -3597,7 +3607,7 @@ pub fn applyGridSliceOnly(file: *File, options: GridSliceOptions) !void { file.columns = new_cols; file.rows = new_rows; - fizzy.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); if (snapshot_opt) |snap| { @@ -3630,12 +3640,12 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { // Capture undo state up front. If allocation fails we abort *before* mutating, so the file // is left untouched and the user can retry. - var snapshot_opt: ?fizzy.Internal.History.Change.GridLayout = if (options.history) + var snapshot_opt: ?History.Change.GridLayout = if (options.history) try file.captureGridLayoutSnapshot() else null; errdefer if (snapshot_opt) |snap| { - var ch = fizzy.Internal.History.Change{ .grid_layout = snap }; + var ch = History.Change{ .grid_layout = snap }; ch.deinit(); }; @@ -3664,7 +3674,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { var old_layer = file.layers.get(layer_index); const old_pix = old_layer.pixels(); - var new_layer = fizzy.Internal.Layer.init( + var new_layer = Layer.init( old_layer.id, old_layer.name, new_w, @@ -3685,7 +3695,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { while (nrow < @min(new_rows, old_rows)) : (nrow += 1) { var ncol: u32 = 0; while (ncol < @min(new_cols, old_cols)) : (ncol += 1) { - const blk = fizzy.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor); + const blk = pixi_mod.math.layout_anchor.cellAnchoredBlit(old_cw, old_rh, new_cw, new_rh, options.anchor); if (blk.sw == 0 or blk.sh == 0) continue; const src_x0: u32 = ncol * old_cw + blk.sx; @@ -3715,25 +3725,25 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { file.editor.temporary_layer.deinit(); file.editor.selection_layer.deinit(); file.editor.transform_layer.deinit(); - file.editor.temporary_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.selection_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; - file.editor.transform_layer = fizzy.Internal.Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.temporary_layer = Layer.init(file.newLayerID(), "Temporary", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.selection_layer = Layer.init(file.newLayerID(), "Selection", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; + file.editor.transform_layer = Layer.init(file.newLayerID(), "Transform", new_w, new_h, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr) catch return error.LayerCreateError; // Sprite origins reset: cell positions and meaning change with cell size, so re-anchoring is undefined. file.sprites.shrinkRetainingCapacity(0); const new_sprite_count: usize = @as(usize, new_cols) * @as(usize, new_rows); var i: usize = 0; while (i < new_sprite_count) : (i += 1) { - file.sprites.append(fizzy.app.allocator, .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed; + file.sprites.append(runtime.allocator(), .{ .origin = .{ 0.0, 0.0 } }) catch return error.MemoryAllocationFailed; } file.editor.selected_sprites.deinit(); - file.editor.selected_sprites = std.DynamicBitSet.initEmpty(fizzy.app.allocator, new_sprite_count) catch return error.MemoryAllocationFailed; + file.editor.selected_sprites = std.DynamicBitSet.initEmpty(runtime.allocator(), new_sprite_count) catch return error.MemoryAllocationFailed; file.editor.checkerboard.deinit(); - file.editor.checkerboard = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed; + file.editor.checkerboard = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, new_w) * @as(usize, new_h)) catch return error.MemoryAllocationFailed; for (0..@as(usize, new_w) * @as(usize, new_h)) |idx| { - const value = fizzy.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); + const value = pixi_mod.math.checker(.{ .w = @floatFromInt(new_w), .h = @floatFromInt(new_h) }, idx); file.editor.checkerboard.setValue(idx, value); } @@ -3748,7 +3758,7 @@ pub fn applyGridLayout(file: *File, options: GridLayoutOptions) !void { file.columns = new_cols; file.rows = new_rows; - fizzy.render.destroyLayerCompositeResources(file); + pixi_mod.render.destroyLayerCompositeResources(file); file.invalidateActiveLayerTransparencyMaskCache(); if (snapshot_opt) |snap| { @@ -3780,12 +3790,12 @@ pub fn saveAsync(self: *File) !void { // Snapshot all save-relevant data on the GUI thread NOW, before the worker // could observe a torn `self.layers` (the user can still draw / add layers // while the async save runs). Worker reads only the snapshot. - const snap_ptr = fizzy.app.allocator.create(SaveSnapshot) catch |err| { + const snap_ptr = runtime.allocator().create(SaveSnapshot) catch |err| { self.setSaving(false); return err; }; - snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, fizzy.app.allocator) catch |err| { - fizzy.app.allocator.destroy(snap_ptr); + snap_ptr.* = SaveSnapshot.fromFileOnGuiThread(self, runtime.allocator()) catch |err| { + runtime.allocator().destroy(snap_ptr); self.setSaving(false); return err; }; @@ -3804,8 +3814,8 @@ pub fn saveAsync(self: *File) !void { .window = dvui.currentWindow(), .snap = snap_ptr, }) catch |err| { - snap_ptr.deinit(fizzy.app.allocator); - fizzy.app.allocator.destroy(snap_ptr); + snap_ptr.deinit(runtime.allocator()); + runtime.allocator().destroy(snap_ptr); self.setSaving(false); return err; }; @@ -3817,10 +3827,10 @@ pub fn saveAsync(self: *File) !void { } } -pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File { - const layers = try allocator.alloc(fizzy.Layer, self.layers.slice().len); - const sprites = try allocator.alloc(fizzy.Sprite, self.sprites.slice().len); - const animations = try allocator.alloc(fizzy.Animation, self.animations.slice().len); +pub fn external(self: File, allocator: std.mem.Allocator) !pixi_mod.File { + const layers = try allocator.alloc(pixi_mod.Layer, self.layers.slice().len); + const sprites = try allocator.alloc(pixi_mod.Sprite, self.sprites.slice().len); + const animations = try allocator.alloc(pixi_mod.Animation, self.animations.slice().len); for (layers, 0..) |*working_layer, i| { working_layer.name = try allocator.dupe(u8, self.layers.items(.name)[i]); @@ -3838,7 +3848,7 @@ pub fn external(self: File, allocator: std.mem.Allocator) !fizzy.File { } return .{ - .version = fizzy.version, + .version = pixi_mod.version, .columns = self.columns, .rows = self.rows, .column_width = self.column_width, diff --git a/src/internal/History.zig b/src/plugins/pixi/src/internal/History.zig similarity index 87% rename from src/internal/History.zig rename to src/plugins/pixi/src/internal/History.zig index dc0efa2c..e19563a1 100644 --- a/src/internal/History.zig +++ b/src/plugins/pixi/src/internal/History.zig @@ -1,10 +1,11 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const zgui = @import("zgui"); const History = @This(); -const Editor = fizzy.Editor; const dvui = @import("dvui"); const Layer = @import("Layer.zig"); +const pixi_mod = @import("../../pixi.zig"); +const plugin = @import("../plugin.zig"); +const runtime = @import("../runtime.zig"); pub const Action = enum { undo, redo }; pub const RestoreDelete = enum { restore, delete }; @@ -57,7 +58,7 @@ pub const Change = union(ChangeType) { pub const AnimationFrames = struct { index: usize, - frames: []fizzy.Animation.Frame, + frames: []pixi_mod.Animation.Frame, }; pub const AnimationRestoreDelete = struct { @@ -187,7 +188,7 @@ pub const Change = union(ChangeType) { .selected = 0, } }, .layer_name => .{ .animation_name = .{ - .name = [_:0]u8{0} ** Editor.Constants.max_name_len, + .name = [_:0]u8{0} ** pixi_mod.max_name_len, .index = 0, } }, else => error.NotSupported, @@ -197,25 +198,25 @@ pub const Change = union(ChangeType) { pub fn deinit(self: *Change) void { switch (self.*) { .pixels => |*pixels| { - fizzy.app.allocator.free(pixels.indices); - fizzy.app.allocator.free(pixels.values); + runtime.allocator().free(pixels.indices); + runtime.allocator().free(pixels.values); }, .origins => |*origins| { - fizzy.app.allocator.free(origins.indices); - fizzy.app.allocator.free(origins.values); + runtime.allocator().free(origins.indices); + runtime.allocator().free(origins.values); }, .layers_order => |*layers_order| { - fizzy.app.allocator.free(layers_order.order); + runtime.allocator().free(layers_order.order); }, .layer_merge => |*layer_merge| { - fizzy.app.allocator.free(layer_merge.dest_pixels_before); + runtime.allocator().free(layer_merge.dest_pixels_before); layer_merge.dest_mask_before.deinit(); }, .grid_layout => |*gl| { - for (gl.layer_pixels) |buf| fizzy.app.allocator.free(buf); - fizzy.app.allocator.free(gl.layer_pixels); - fizzy.app.allocator.free(gl.layer_ids); - fizzy.app.allocator.free(gl.sprite_origins); + for (gl.layer_pixels) |buf| runtime.allocator().free(buf); + runtime.allocator().free(gl.layer_pixels); + runtime.allocator().free(gl.layer_ids); + runtime.allocator().free(gl.sprite_origins); }, else => {}, } @@ -229,8 +230,8 @@ redo_stack: std.array_list.Managed(Change), undo_layer_data_stack: std.array_list.Managed([][][4]u8), redo_layer_data_stack: std.array_list.Managed([][][4]u8), -undo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame), -redo_animation_data_stack: std.array_list.Managed([][]fizzy.Animation.Frame), +undo_animation_data_stack: std.array_list.Managed([][]pixi_mod.Animation.Frame), +redo_animation_data_stack: std.array_list.Managed([][]pixi_mod.Animation.Frame), undo_sprite_data_stack: std.array_list.Managed([][2]f32), redo_sprite_data_stack: std.array_list.Managed([][2]f32), @@ -243,8 +244,8 @@ pub fn init(allocator: std.mem.Allocator) History { .undo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator), .redo_layer_data_stack = std.array_list.Managed([][][4]u8).init(allocator), - .undo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator), - .redo_animation_data_stack = std.array_list.Managed([][]fizzy.Animation.Frame).init(allocator), + .undo_animation_data_stack = std.array_list.Managed([][]pixi_mod.Animation.Frame).init(allocator), + .redo_animation_data_stack = std.array_list.Managed([][]pixi_mod.Animation.Frame).init(allocator), .undo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator), .redo_sprite_data_stack = std.array_list.Managed([][2]f32).init(allocator), @@ -252,12 +253,12 @@ pub fn init(allocator: std.mem.Allocator) History { } pub fn append(self: *History, change: Change) !void { - const track_pixels = fizzy.perf.record and std.meta.activeTag(change) == .pixels; + const track_pixels = pixi_mod.perf.record and std.meta.activeTag(change) == .pixels; const pixel_slots: usize = if (track_pixels) switch (change) { .pixels => |p| p.indices.len, else => 0, } else 0; - const t_hist: i128 = if (track_pixels) fizzy.perf.nanoTimestamp() else 0; + const t_hist: i128 = if (track_pixels) pixi_mod.perf.nanoTimestamp() else 0; if (self.redo_stack.items.len > 0) { for (self.redo_stack.items) |*c| { @@ -269,9 +270,9 @@ pub fn append(self: *History, change: Change) !void { if (self.redo_layer_data_stack.items.len > 0) { for (self.redo_layer_data_stack.items) |data| { for (data) |layer| { - fizzy.app.allocator.free(layer); + runtime.allocator().free(layer); } - fizzy.app.allocator.free(data); + runtime.allocator().free(data); } self.redo_layer_data_stack.clearRetainingCapacity(); } @@ -363,13 +364,13 @@ pub fn append(self: *History, change: Change) !void { } if (track_pixels and t_hist != 0) { - fizzy.perf.history_append_pixels_ns +%= @intCast(fizzy.perf.nanoTimestamp() - t_hist); - fizzy.perf.history_append_pixels_calls += 1; - fizzy.perf.history_append_pixels_slots +%= pixel_slots; + pixi_mod.perf.history_append_pixels_ns +%= @intCast(pixi_mod.perf.nanoTimestamp() - t_hist); + pixi_mod.perf.history_append_pixels_calls += 1; + pixi_mod.perf.history_append_pixels_slots +%= pixel_slots; } } -fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { +fn layerMergeUndo(file: *pixi_mod.internal.File, lm: *Change.LayerMerge) !void { const dest_i = for (file.layers.items(.id), 0..) |id, i| { if (id == lm.dest_layer_id) break i; } else return error.InvalidLayerMerge; @@ -377,21 +378,21 @@ fn layerMergeUndo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { var dest = file.layers.get(dest_i); @memcpy(dest.pixels(), lm.dest_pixels_before); dest.mask.deinit(); - dest.mask = try lm.dest_mask_before.clone(fizzy.app.allocator); + dest.mask = try lm.dest_mask_before.clone(runtime.allocator()); dest.invalidate(); file.layers.set(dest_i, dest); const restored = file.deleted_layers.pop() orelse return error.InvalidLayerMerge; - try file.layers.insert(fizzy.app.allocator, lm.source_index, restored); + try file.layers.insert(runtime.allocator(), lm.source_index, restored); file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; file.selected_layer_index = lm.source_index; - fizzy.editor.explorer.pane = .tools; + runtime.state().host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } -fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { +fn layerMergeRedo(file: *pixi_mod.internal.File, lm: *Change.LayerMerge) !void { const src_i = for (file.layers.items(.id), 0..) |id, i| { if (id == lm.source_layer_id) break i; } else return error.InvalidLayerMerge; @@ -419,7 +420,7 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { dest.invalidate(); file.layers.set(dest_i, dest); - try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(src_i)); + try file.deleted_layers.append(runtime.allocator(), file.layers.slice().get(src_i)); file.layers.orderedRemove(src_i); file.editor.layer_composite_dirty = true; @@ -429,13 +430,13 @@ fn layerMergeRedo(file: *fizzy.Internal.File, lm: *Change.LayerMerge) !void { .up => dest_i, .down => dest_i - 1, }; - fizzy.editor.explorer.pane = .tools; + runtime.state().host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); } // Handling cases in this function details how an undo/redo action works, and must be symmetrical. // This means that `change` needs to be modified to contain the active state prior to changing the active state -pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !void { +pub fn undoRedo(self: *History, file: *pixi_mod.internal.File, action: Action) !void { var active_stack = switch (action) { .undo => &self.undo_stack, .redo => &self.redo_stack, @@ -458,8 +459,8 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi // direct `@intCast` to `usize` crashes the safe-mode build with an "integer cast // truncates value" panic every time the user undoes/redoes. `id_extra` only needs // to be a salt that varies between toasts, so truncate via u128 → low bits of usize. - const ts_us: u128 = @intCast(@divTrunc(fizzy.perf.nanoTimestamp(), 1000)); - const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, fizzy.dvui.toastDisplay, 2_000_000); + const ts_us: u128 = @intCast(@divTrunc(pixi_mod.perf.nanoTimestamp(), 1000)); + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), @truncate(ts_us), file.editor.canvas.id, pixi_mod.core.dvui.toastDisplay, 2_000_000); const id = id_mutex.id; const action_text = switch (action) { .undo => "Undo:", @@ -618,14 +619,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi //try file.editor.selected_sprites.append(sprite_index); } - fizzy.editor.explorer.pane = .sprites; + runtime.state().host.setActiveSidebarView(plugin.view_sprites); }, .layers_order => |*layers_order| { file.editor.layer_composite_dirty = true; file.editor.split_composite_dirty = true; // `new_order` holds layer ids (u64 in the on-disk format), not // indices — `layers_order.order` below is `[]u64` so this matches. - var new_order = try fizzy.app.allocator.alloc(u64, layers_order.order.len); + var new_order = try runtime.allocator().alloc(u64, layers_order.order.len); for (0..file.layers.len) |layer_index| { new_order[layer_index] = file.layers.items(.id)[layer_index]; } @@ -655,7 +656,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi } @memcpy(layers_order.order, new_order); - fizzy.app.allocator.free(new_order); + runtime.allocator().free(new_order); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_restore_delete => |*layer_restore_delete| { @@ -664,24 +665,24 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi const a = layer_restore_delete.action; switch (a) { .restore => { - try file.layers.insert(fizzy.app.allocator, layer_restore_delete.index, file.deleted_layers.pop().?); + try file.layers.insert(runtime.allocator(), layer_restore_delete.index, file.deleted_layers.pop().?); layer_restore_delete.action = .delete; }, .delete => { - try file.deleted_layers.append(fizzy.app.allocator, file.layers.slice().get(layer_restore_delete.index)); + try file.deleted_layers.append(runtime.allocator(), file.layers.slice().get(layer_restore_delete.index)); file.layers.orderedRemove(layer_restore_delete.index); layer_restore_delete.action = .restore; }, } - fizzy.editor.explorer.pane = .tools; + runtime.state().host.setActiveSidebarView(plugin.view_tools); file.invalidateActiveLayerTransparencyMaskCache(); }, .layer_name => |*layer_name| { - const name = try fizzy.app.allocator.dupe(u8, file.layers.items(.name)[layer_name.index]); - fizzy.app.allocator.free(file.layers.items(.name)[layer_name.index]); - file.layers.items(.name)[layer_name.index] = try fizzy.app.allocator.dupe(u8, layer_name.name); + const name = try runtime.allocator().dupe(u8, file.layers.items(.name)[layer_name.index]); + runtime.allocator().free(file.layers.items(.name)[layer_name.index]); + file.layers.items(.name)[layer_name.index] = try runtime.allocator().dupe(u8, layer_name.name); layer_name.name = name; - fizzy.editor.explorer.pane = .tools; + runtime.state().host.setActiveSidebarView(plugin.view_tools); }, .layer_settings => |*layer_settings| { const idx = layer_settings.index; @@ -700,21 +701,21 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (visibility_changed) { file.editor.split_composite_dirty = true; } - fizzy.editor.explorer.pane = .tools; + runtime.state().host.setActiveSidebarView(plugin.view_tools); }, .animation_restore_delete => |*animation_restore_delete| { const a = animation_restore_delete.action; switch (a) { .restore => { const animation = file.deleted_animations.pop().?; - try file.animations.insert(fizzy.app.allocator, animation_restore_delete.index, animation); + try file.animations.insert(runtime.allocator(), animation_restore_delete.index, animation); animation_restore_delete.action = .delete; file.selected_animation_index = animation_restore_delete.index; }, .delete => { const animation = file.animations.slice().get(animation_restore_delete.index); file.animations.orderedRemove(animation_restore_delete.index); - try file.deleted_animations.append(fizzy.app.allocator, animation); + try file.deleted_animations.append(runtime.allocator(), animation); animation_restore_delete.action = .restore; if (file.selected_animation_index) |selected_animation_index| { @@ -726,14 +727,14 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi } }, } - fizzy.editor.explorer.pane = .sprites; + runtime.state().host.setActiveSidebarView(plugin.view_sprites); }, .animation_name => |*animation_name| { - const name = try fizzy.app.allocator.dupe(u8, file.animations.items(.name)[animation_name.index]); - fizzy.app.allocator.free(file.animations.items(.name)[animation_name.index]); - file.animations.items(.name)[animation_name.index] = try fizzy.app.allocator.dupe(u8, animation_name.name); + const name = try runtime.allocator().dupe(u8, file.animations.items(.name)[animation_name.index]); + runtime.allocator().free(file.animations.items(.name)[animation_name.index]); + file.animations.items(.name)[animation_name.index] = try runtime.allocator().dupe(u8, animation_name.name); animation_name.name = name; - fizzy.editor.explorer.pane = .sprites; + runtime.state().host.setActiveSidebarView(plugin.view_sprites); }, .animation_settings => {}, .animation_order => |*animation_order| { @@ -771,7 +772,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi const history_frames = &animation_frames.frames; const current_frames = &file.animations.items(.frames)[animation_frames.index]; - std.mem.swap([]fizzy.Animation.Frame, history_frames, current_frames); + std.mem.swap([]pixi_mod.Animation.Frame, history_frames, current_frames); file.selected_animation_index = animation_frames.index; }, @@ -782,7 +783,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi resize.height = file.height(); var layer_data: ?[][][4]u8 = null; - var animation_data: ?[][]fizzy.Animation.Frame = null; + var animation_data: ?[][]pixi_mod.Animation.Frame = null; var sprite_data: ?[][2]f32 = null; switch (action) { @@ -795,9 +796,9 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (self.undo_animation_data_stack.pop()) |ad| { animation_data = ad; - var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len); + var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len); for (0..file.animations.len) |animation_index| { - anim_data[animation_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; + anim_data[animation_index] = runtime.allocator().dupe(pixi_mod.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; } try self.redo_animation_data_stack.append(anim_data); } @@ -805,7 +806,7 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (self.undo_sprite_data_stack.pop()) |sd| { sprite_data = sd; - const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount()); + const new_sprite_data = try runtime.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -820,16 +821,16 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi if (self.redo_animation_data_stack.pop()) |ad| { animation_data = ad; - var anim_data = try fizzy.app.allocator.alloc([]fizzy.Animation.Frame, file.animations.len); + var anim_data = try runtime.allocator().alloc([]pixi_mod.Animation.Frame, file.animations.len); for (0..file.animations.len) |animation_index| { - anim_data[animation_index] = fizzy.app.allocator.dupe(fizzy.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; + anim_data[animation_index] = runtime.allocator().dupe(pixi_mod.Animation.Frame, file.animations.items(.frames)[animation_index]) catch return error.MemoryAllocationFailed; } try self.undo_animation_data_stack.append(anim_data); } if (self.redo_sprite_data_stack.pop()) |sd| { sprite_data = sd; - const new_sprite_data = try fizzy.app.allocator.alloc([2]f32, file.spriteCount()); + const new_sprite_data = try runtime.allocator().alloc([2]f32, file.spriteCount()); for (0..file.spriteCount()) |sprite_index| { new_sprite_data[sprite_index] = file.sprites.items(.origin)[sprite_index]; } @@ -848,11 +849,11 @@ pub fn undoRedo(self: *History, file: *fizzy.Internal.File, action: Action) !voi }) catch return error.ResizeError; if (animation_data) |ad| { - fizzy.app.allocator.free(ad); + runtime.allocator().free(ad); } if (sprite_data) |sd| { - fizzy.app.allocator.free(sd); + runtime.allocator().free(sd); } file.invalidateActiveLayerTransparencyMaskCache(); @@ -944,16 +945,16 @@ pub fn clearRetainingCapacity(self: *History) void { pub fn deinit(self: *History) void { for (self.undo_layer_data_stack.items) |data| { for (data) |layer| { - fizzy.app.allocator.free(layer); + runtime.allocator().free(layer); } - fizzy.app.allocator.free(data); + runtime.allocator().free(data); } for (self.redo_layer_data_stack.items) |data| { for (data) |layer| { - fizzy.app.allocator.free(layer); + runtime.allocator().free(layer); } - fizzy.app.allocator.free(data); + runtime.allocator().free(data); } self.undo_layer_data_stack.deinit(); diff --git a/src/internal/Layer.zig b/src/plugins/pixi/src/internal/Layer.zig similarity index 79% rename from src/internal/Layer.zig rename to src/plugins/pixi/src/internal/Layer.zig index 73816b93..5cd26482 100644 --- a/src/internal/Layer.zig +++ b/src/plugins/pixi/src/internal/Layer.zig @@ -1,7 +1,8 @@ const std = @import("std"); const dvui = @import("dvui"); -const fizzy = @import("../fizzy.zig"); const zip = @import("zip"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); const Layer = @This(); @@ -33,13 +34,13 @@ dirty: bool = false, pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: dvui.Color, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { const num_pixels = width * height; - const p = fizzy.app.allocator.alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed; + const p = runtime.allocator().alloc([4]u8, num_pixels) catch return error.MemoryAllocationFailed; @memset(p, default_color.toRGBA()); return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = .{ .pixelsPMA = .{ .rgba = @ptrCast(p), @@ -49,29 +50,29 @@ pub fn init(id: u64, name: []const u8, width: u32, height: u32, default_color: d .invalidation = invalidation, }, }, - .mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, num_pixels) catch return error.MemoryAllocationFailed, + .mask = std.DynamicBitSet.initEmpty(runtime.allocator(), num_pixels) catch return error.MemoryAllocationFailed, }; } pub fn fromImageFilePath(id: u64, name: []const u8, path: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { - const source = fizzy.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixi_mod.image.fromImageFilePath(name, path, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; } pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { - const source = fizzy.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixi_mod.image.fromImageFileBytes(name, image_bytes, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -79,12 +80,12 @@ pub fn fromImageFileBytes(id: u64, name: []const u8, image_bytes: []const u8, in pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { if (pixel_data.len != width * height) return error.InvalidPixelDataLength; - const source = fizzy.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; + const source = pixi_mod.image.fromPixelsPMA(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -92,24 +93,24 @@ pub fn fromPixelsPMA(id: u64, name: []const u8, pixel_data: []dvui.Color.PMA, wi pub fn fromPixels(id: u64, name: []const u8, pixel_data: []u8, width: u32, height: u32, invalidation: dvui.ImageSource.InvalidationStrategy) !Layer { if (pixel_data.len != width * height) return error.InvalidPixelDataLength; - const source = fizzy.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; + const source = pixi_mod.image.fromPixels(pixel_data, width, height, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), @as(usize, @intCast(width * height))) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; } pub fn fromTexture(id: u64, name: []const u8, texture: dvui.Texture, invalidation: dvui.ImageSource.InvalidationStrategy) Layer { - const source = fizzy.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource; - const mask = std.DynamicBitSet.initEmpty(fizzy.app.allocator, pixelCountForSource(source)) catch return error.MemoryAllocationFailed; + const source = pixi_mod.fs.sourceFromTexture(name, texture, invalidation) catch return error.ErrorCreatingImageSource; + const mask = std.DynamicBitSet.initEmpty(runtime.allocator(), pixelCountForSource(source)) catch return error.MemoryAllocationFailed; return .{ .id = id, - .name = fizzy.app.allocator.dupe(u8, name) catch return error.MemoryAllocationFailed, + .name = runtime.allocator().dupe(u8, name) catch return error.MemoryAllocationFailed, .source = source, .mask = mask, }; @@ -121,53 +122,53 @@ pub fn size(self: Layer) dvui.Size { pub fn deinit(self: *Layer) void { switch (self.source) { - .imageFile => |image| fizzy.app.allocator.free(image.bytes), - .pixels => |p| fizzy.app.allocator.free(p.rgba), - .pixelsPMA => |p| fizzy.app.allocator.free(p.rgba), + .imageFile => |image| runtime.allocator().free(image.bytes), + .pixels => |p| runtime.allocator().free(p.rgba), + .pixelsPMA => |p| runtime.allocator().free(p.rgba), .texture => |t| dvui.textureDestroyLater(t), } - fizzy.app.allocator.free(self.name); + runtime.allocator().free(self.name); self.mask.deinit(); } /// Casts the source pixels into a slice of [4]u8 pub fn pixels(self: *const Layer) [][4]u8 { - return fizzy.image.pixels(self.source); + return pixi_mod.image.pixels(self.source); } /// Caller owns memory that must be freed! pub fn pixelsFromRect(self: *const Layer, allocator: std.mem.Allocator, rect: dvui.Rect) ?[][4]u8 { - return fizzy.image.pixelsFromRect(allocator, self.source, rect); + return pixi_mod.image.pixelsFromRect(allocator, self.source, rect); } /// Casts the source pixels into a slice of bytes pub fn bytes(self: *const Layer) []u8 { - return fizzy.image.bytes(self.source); + return pixi_mod.image.bytes(self.source); } /// Returns the index of the pixel at the given point /// returns null if the point is out of bounds pub fn pixelIndex(self: *Layer, p: dvui.Point) ?usize { - return fizzy.image.pixelIndex(self.source, p); + return pixi_mod.image.pixelIndex(self.source, p); } /// Returns the point at the given index /// returns null if the index is out of bounds pub fn point(self: *Layer, index: usize) ?dvui.Point { - return fizzy.image.point(self.source, index); + return pixi_mod.image.point(self.source, index); } /// Returns the color at the given point /// returns null if the point is out of bounds pub fn pixel(self: *Layer, p: dvui.Point) ?[4]u8 { - return fizzy.image.pixel(self.source, p); + return pixi_mod.image.pixel(self.source, p); } /// Sets the color at the given point /// does not invalidate the layer pub fn setPixel(self: *Layer, p: dvui.Point, color: [4]u8) void { - fizzy.image.setPixel(self.source, p, color); + pixi_mod.image.setPixel(self.source, p, color); } /// Sets the mask at the given point @@ -217,7 +218,7 @@ pub fn setColorFromMask(self: *Layer, color: dvui.Color) void { pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bool) !void { if (!bounds.contains(p)) return; - var queue = std.array_list.Managed(dvui.Point).init(fizzy.app.allocator); + var queue = std.array_list.Managed(dvui.Point).init(runtime.allocator()); defer queue.deinit(); queue.append(p) catch return error.MemoryAllocationFailed; @@ -249,7 +250,7 @@ pub fn floodMaskPoint(layer: *Layer, p: dvui.Point, bounds: dvui.Rect, value: bo } pub fn setPixelIndex(self: *Layer, index: usize, color: [4]u8) void { - fizzy.image.setPixelIndex(self.source, index, color); + pixi_mod.image.setPixelIndex(self.source, index, color); } pub const ShapeOffsetResult = struct { @@ -266,8 +267,8 @@ pub fn invalidate(self: *Layer) void { /// Only used for handling getting the pixels surrounding the origin /// for stroke sizes larger than 1 pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usize) ?ShapeOffsetResult { - const shape = fizzy.editor.tools.stroke_shape; - const s: i32 = @intCast(fizzy.editor.tools.stroke_size); + const shape = runtime.state().tools.stroke_shape; + const s: i32 = @intCast(runtime.state().tools.stroke_size); if (s == 1) { if (current_index != 0) @@ -320,27 +321,17 @@ pub fn getIndexShapeOffset(self: *Layer, origin: dvui.Point, current_index: usiz } /// Porter–Duff "source over" for premultiplied RGBA (`pixelsPMA` byte layout). -/// `top` is composited over `bottom`. -pub fn blendPmaSrcOver(top: [4]u8, bottom: [4]u8) [4]u8 { - const sa: u32 = @intCast(top[3]); - const inv: u32 = 255 - sa; - var out: [4]u8 = undefined; - inline for (0..3) |c| { - const v: u32 = @as(u32, @intCast(top[c])) + @as(u32, @intCast(bottom[c])) * inv / 255; - out[c] = @intCast(@min(255, v)); - } - const a: u32 = sa + @as(u32, @intCast(bottom[3])) * inv / 255; - out[3] = @intCast(@min(255, a)); - return out; -} +/// `top` is composited over `bottom`. The implementation is generic byte math and +/// lives in `core` math; re-exported here for the pixel-art call sites. +pub const blendPmaSrcOver = pixi_mod.math.blendPmaSrcOver; pub fn clearRect(self: *Layer, rect: dvui.Rect) void { - fizzy.image.clearRect(self.source, rect); + pixi_mod.image.clearRect(self.source, rect); self.invalidate(); } pub fn setRect(self: *Layer, rect: dvui.Rect, color: [4]u8) void { - fizzy.image.setRect(self.source, rect, color); + pixi_mod.image.setRect(self.source, rect, color); self.invalidate(); } @@ -416,11 +407,24 @@ pub fn writeSourceToZip( zip_file: ?*anyopaque, resolution: u32, ) !void { - return fizzy.image.writeToZip(layer.source, zip_file, resolution); + const source = layer.source; + const s: dvui.Size = dvui.imageSize(source) catch .{ .w = 0, .h = 0 }; + + const w = @as(c_int, @intFromFloat(s.w)); + const h = @as(c_int, @intFromFloat(s.h)); + + var writer = std.Io.Writer.Allocating.init(runtime.state().host.arena()); + + try pixi_mod.image.ensurePngWriterBuffer(&writer.writer); + try dvui.PNGEncoder.writeWithResolution(&writer.writer, pixi_mod.image.bytes(source), @intCast(w), @intCast(h), resolution); + + if (@as(?*zip.struct_zip_t, @ptrCast(zip_file))) |z| { + _ = zip.zip_entry_write(z, writer.written().ptr, @as(usize, writer.written().len)); + } } pub fn writeSourceToPng(layer: *const Layer, path: []const u8) !void { - return fizzy.fs.writeSourceToPng(layer.source, path); + return pixi_mod.fs.writeSourceToPng(layer.source, path); } pub fn resize(layer: *Layer, new_size: dvui.Size) !void { @@ -429,7 +433,7 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void { var new_layer = Layer.init( layer.id, - fizzy.app.allocator.dupe(u8, layer.name) catch return error.MemoryAllocationFailed, + runtime.allocator().dupe(u8, layer.name) catch return error.MemoryAllocationFailed, @as(u32, @intFromFloat(new_size.w)), @as(u32, @intFromFloat(new_size.h)), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, @@ -457,14 +461,14 @@ pub fn resize(layer: *Layer, new_size: dvui.Size) !void { /// Tighten `src` to the smallest sub-rect of this layer containing every opaque pixel. /// Returns null when `src` is empty, off-layer, or covers only fully-transparent pixels. /// -/// Pure scalar logic lives in `fizzy.algorithms.reduce.reduce` so it can be exercised by +/// Pure scalar logic lives in `pixi_mod.algorithms.reduce.reduce` so it can be exercised by /// unit tests without dvui / fizzy globals — see that module for the contract details. pub fn reduce(layer: *Layer, src: dvui.Rect) ?dvui.Rect { const sz = layer.size(); const layer_w: u32 = @intFromFloat(sz.w); const layer_h: u32 = @intFromFloat(sz.h); - const r = fizzy.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{ + const r = pixi_mod.algorithms.reduce.reduce(layer.pixels(), layer_w, layer_h, .{ .x = @intFromFloat(src.x), .y = @intFromFloat(src.y), .w = @intFromFloat(src.w), diff --git a/src/internal/Palette.zig b/src/plugins/pixi/src/internal/Palette.zig similarity index 81% rename from src/internal/Palette.zig rename to src/plugins/pixi/src/internal/Palette.zig index cefe2c2c..a2bbf77f 100644 --- a/src/internal/Palette.zig +++ b/src/plugins/pixi/src/internal/Palette.zig @@ -1,8 +1,9 @@ const std = @import("std"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const palette_parse = @import("palette_parse.zig"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); pub const Palette = @This(); @@ -19,8 +20,8 @@ pub fn loadFromFile(allocator: std.mem.Allocator, file: []const u8) !Palette { const ext = std.fs.path.extension(file); if (std.mem.eql(u8, ext, ".hex")) { - if (fizzy.fs.read(fizzy.app.allocator, dvui.io, file) catch null) |read| { - defer fizzy.app.allocator.free(read); + if (pixi_mod.fs.read(runtime.allocator(), dvui.io, file) catch null) |read| { + defer runtime.allocator().free(read); return loadFromBytes(allocator, std.fs.path.basename(file), read); } @@ -46,6 +47,6 @@ pub fn loadFromBytes(allocator: std.mem.Allocator, name: []const u8, bytes: []co } pub fn deinit(self: *Palette) void { - fizzy.app.allocator.free(self.name); - fizzy.app.allocator.free(self.colors); + runtime.allocator().free(self.name); + runtime.allocator().free(self.colors); } diff --git a/src/internal/Sprite.zig b/src/plugins/pixi/src/internal/Sprite.zig similarity index 100% rename from src/internal/Sprite.zig rename to src/plugins/pixi/src/internal/Sprite.zig diff --git a/src/internal/grid_layout_validate.zig b/src/plugins/pixi/src/internal/grid_layout_validate.zig similarity index 100% rename from src/internal/grid_layout_validate.zig rename to src/plugins/pixi/src/internal/grid_layout_validate.zig diff --git a/src/internal/layer_order.zig b/src/plugins/pixi/src/internal/layer_order.zig similarity index 100% rename from src/internal/layer_order.zig rename to src/plugins/pixi/src/internal/layer_order.zig diff --git a/src/internal/palette_parse.zig b/src/plugins/pixi/src/internal/palette_parse.zig similarity index 100% rename from src/internal/palette_parse.zig rename to src/plugins/pixi/src/internal/palette_parse.zig diff --git a/src/plugins/pixi/src/keybind_ticks.zig b/src/plugins/pixi/src/keybind_ticks.zig new file mode 100644 index 00000000..ba6ca046 --- /dev/null +++ b/src/plugins/pixi/src/keybind_ticks.zig @@ -0,0 +1,82 @@ +//! Global keybind handlers for pixel-art editing (tool shortcuts, radial menu, export). +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const Tools = pixi_mod.Tools; +const Export = @import("dialogs/Export.zig"); + +pub fn tick() !void { + for (dvui.events()) |e| { + if (e.handled) continue; + + switch (e.evt) { + .key => |ke| { + if (ke.matchBind("quick_tools")) { + const rm = &runtime.state().tools.radial_menu; + switch (ke.action) { + .down => { + const mp = dvui.currentWindow().mouse_pt; + rm.mouse_position = mp; + rm.center = mp; + rm.opened_by_press = false; + rm.suppress_next_pointer_release = false; + rm.outside_click_press_p = null; + rm.visible = true; + }, + .repeat => rm.visible = true, + .up => rm.close(), + } + dvui.refresh(null, @src(), dvui.currentWindow().data().id); + } + + if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { + if (runtime.state().tools.current != .selection or runtime.state().tools.selection_mode == .pixel) { + if (runtime.state().tools.stroke_size < Tools.max_brush_size - 1) + runtime.state().tools.stroke_size += 1; + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); + } + } + + if (ke.matchBind("export") and ke.action == .down) { + var mutex = pixi_mod.core.dvui.dialog(@src(), .{ + .displayFn = Export.dialog, + .callafterFn = Export.callAfter, + .title = "Export...", + .ok_label = "Export", + .cancel_label = "Cancel", + .resizeable = false, + .modal = false, + .header_kind = .info, + .default = .ok, + }); + mutex.mutex.unlock(dvui.io); + } + + if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { + if (runtime.state().tools.current != .selection or runtime.state().tools.selection_mode == .pixel) { + if (runtime.state().tools.stroke_size > 1) + runtime.state().tools.stroke_size -= 1; + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); + } + } + + if (ke.matchBind("pencil") and ke.action == .down) { + runtime.state().tools.set(.pencil); + } + if (ke.matchBind("eraser") and ke.action == .down) { + runtime.state().tools.set(.eraser); + } + if (ke.matchBind("bucket") and ke.action == .down) { + runtime.state().tools.set(.bucket); + } + if (ke.matchBind("pointer") and ke.action == .down) { + runtime.state().tools.set(.pointer); + } + if (ke.matchBind("selection") and ke.action == .down) { + runtime.state().tools.set(.selection); + } + }, + else => {}, + } + } +} diff --git a/src/plugins/pixi/src/pack_project.zig b/src/plugins/pixi/src/pack_project.zig new file mode 100644 index 00000000..c72edc33 --- /dev/null +++ b/src/plugins/pixi/src/pack_project.zig @@ -0,0 +1,236 @@ +//! Async project packing for the pixel-art plugin. Invoked from the plugin vtable; +//! the shell routes `EditorAPI.startPackProject` / `isPackingActive` here. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const PackJob = @import("PackJob.zig"); +const Internal = pixi_mod.internal; + +fn showPackToast(message: []const u8, canvas_id: ?dvui.Id) void { + const anchor = canvas_id orelse blk: { + if (runtime.state().host.activeDoc()) |doc| { + if (runtime.state().docs.fileById(doc.id)) |file| break :blk file.editor.canvas.id; + } + break :blk dvui.currentWindow().data().id; + }; + const id_mutex = dvui.toastAdd(dvui.currentWindow(), @src(), 0, anchor, pixi_mod.core.dvui.toastDisplay, 2_500_000); + const id = id_mutex.id; + const msg_copy = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{message}) catch message; + dvui.dataSetSlice(dvui.currentWindow(), id, "_message", msg_copy); + id_mutex.mutex.unlock(dvui.io); +} + +fn appendOpenPackInputs(st: *State, inputs: *std.ArrayListUnmanaged(PackJob.PackInput)) !void { + const gpa = runtime.allocator(); + const host = st.host; + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const open_file = st.docs.fileById(doc.id) orelse continue; + const snapshot = try PackJob.PackFile.fromOpenFile(gpa, open_file); + try inputs.append(gpa, .{ .open = snapshot }); + } +} + +fn findOpenFileForPackPath(st: *State, path: []const u8) ?*Internal.File { + if (st.docs.fileFromPath(path)) |file| return file; + + const basename = std.fs.path.basename(path); + const gpa = runtime.allocator(); + const host = st.host; + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const file = st.docs.fileById(doc.id) orelse continue; + if (!std.mem.eql(u8, std.fs.path.basename(file.path), basename)) continue; + if (std.mem.eql(u8, file.path, path)) return file; + if (host.folder()) |folder| { + const joined = std.fs.path.join(gpa, &.{ folder, basename }) catch continue; + defer gpa.free(joined); + if (std.mem.eql(u8, file.path, joined)) return file; + } + } + return null; +} + +fn gatherPackInputs( + st: *State, + inputs: *std.ArrayListUnmanaged(PackJob.PackInput), + directory: []const u8, +) !void { + const gpa = runtime.allocator(); + const io = dvui.io; + var dir = try std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }); + defer dir.close(io); + + var iter = dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file) { + const ext = std.fs.path.extension(entry.name); + if (!Internal.File.isFizzyExtension(ext)) continue; + + const abs_path = try std.fs.path.join(gpa, &.{ directory, entry.name }); + defer gpa.free(abs_path); + + if (findOpenFileForPackPath(st, abs_path) != null) continue; + + const owned_path = try gpa.dupe(u8, abs_path); + try inputs.append(gpa, .{ .path = owned_path }); + } else if (entry.kind == .directory) { + const abs_path = try std.fs.path.join(gpa, &.{ directory, entry.name }); + defer gpa.free(abs_path); + try gatherPackInputs(st, inputs, abs_path); + } + } +} + +pub fn start(st: *State) !void { + const gpa = runtime.allocator(); + var inputs: std.ArrayListUnmanaged(PackJob.PackInput) = .empty; + errdefer { + for (inputs.items) |*input| input.deinit(gpa); + inputs.deinit(gpa); + } + + if (comptime builtin.target.cpu.arch == .wasm32) { + try appendOpenPackInputs(st, &inputs); + } else { + const root = st.host.folder() orelse return; + try appendOpenPackInputs(st, &inputs); + try gatherPackInputs(st, &inputs, root); + } + + if (inputs.items.len == 0) { + const msg = if (comptime builtin.target.cpu.arch == .wasm32) + "No open files to pack" + else + "No .fiz or .pixi files to pack"; + showPackToast(msg, null); + return; + } + + var owned_inputs: ?[]PackJob.PackInput = try inputs.toOwnedSlice(gpa); + errdefer if (owned_inputs) |o| { + for (o) |*input| input.deinit(gpa); + gpa.free(o); + }; + + for (st.pack_jobs.items) |old| { + old.cancelled.store(true, .monotonic); + } + + const job = try PackJob.create(gpa, owned_inputs.?); + owned_inputs = null; + errdefer job.destroy(); + + try st.pack_jobs.append(gpa, job); + errdefer _ = st.pack_jobs.pop(); + + if (comptime builtin.target.cpu.arch == .wasm32) { + dvui.refresh(dvui.currentWindow(), @src(), null); + } else { + const thread = try std.Thread.spawn(.{}, PackJob.workerMain, .{job}); + thread.detach(); + } +} + +pub fn isActive(st: *const State) bool { + for (st.pack_jobs.items) |job| { + if (job.cancelled.load(.monotonic)) continue; + if (!job.done.load(.acquire)) return true; + if (!job.result_consumed) return true; + } + return false; +} + +pub fn runWasmWorkers(st: *State) void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + for (st.pack_jobs.items) |job| { + if (job.cancelled.load(.monotonic)) continue; + if (job.done.load(.acquire)) continue; + PackJob.workerMain(job); + return; + } +} + +pub fn tick(st: *State) void { + if (st.pack_jobs.items.len == 0) return; + + const gpa = runtime.allocator(); + var install_index: ?usize = null; + { + var i = st.pack_jobs.items.len; + while (i > 0) { + i -= 1; + const job = st.pack_jobs.items[i]; + if (!job.done.load(.acquire)) continue; + if (job.cancelled.load(.monotonic)) continue; + if (job.currentPhase() == .ready and job.result_atlas != null) { + install_index = i; + break; + } + } + } + + if (install_index) |idx| { + const job = st.pack_jobs.items[idx]; + const new_atlas = job.result_atlas.?; + if (runtime.packer().atlas) |*current_atlas| { + current_atlas.deinitCheckerboardTile(); + for (current_atlas.data.animations) |*anim| gpa.free(anim.name); + gpa.free(current_atlas.data.sprites); + gpa.free(current_atlas.data.animations); + gpa.free(pixi_mod.image.bytes(current_atlas.source)); + + current_atlas.source = new_atlas.source; + current_atlas.data = new_atlas.data; + current_atlas.initCheckerboardTile(); + } else { + runtime.packer().atlas = new_atlas; + runtime.packer().atlas.?.initCheckerboardTile(); + } + runtime.packer().last_packed_at_ns = pixi_mod.perf.nanoTimestamp(); + job.result_consumed = true; + st.host.setActiveSidebarView("pixi_mod.project"); + const toast_canvas: ?dvui.Id = if (st.host.activeDoc()) |doc| + if (st.docs.fileById(doc.id)) |file| file.editor.canvas.id else null + else + null; + showPackToast("Project packed", toast_canvas); + } else blk: { + var i = st.pack_jobs.items.len; + while (i > 0) { + i -= 1; + const job = st.pack_jobs.items[i]; + if (!job.done.load(.acquire)) continue; + if (job.cancelled.load(.monotonic)) continue; + if (job.currentPhase() == .ready and job.result_atlas == null) { + showPackToast("Nothing to pack in the selected files", null); + break :blk; + } + } + } + + var write: usize = 0; + for (st.pack_jobs.items) |job| { + if (!job.done.load(.acquire)) { + st.pack_jobs.items[write] = job; + write += 1; + continue; + } + const phase = job.currentPhase(); + switch (phase) { + .ready, .cancelled => {}, + .failed => { + dvui.log.err("Pack project failed: {any}", .{job.err}); + showPackToast("Pack failed", null); + }, + else => dvui.log.err("Pack job finished in unexpected phase {s}", .{@tagName(phase)}), + } + job.destroy(); + } + st.pack_jobs.shrinkRetainingCapacity(write); +} diff --git a/src/editor/panel/sprites.zig b/src/plugins/pixi/src/panel/sprites.zig similarity index 96% rename from src/editor/panel/sprites.zig rename to src/plugins/pixi/src/panel/sprites.zig index e685370b..8c47d777 100644 --- a/src/editor/panel/sprites.zig +++ b/src/plugins/pixi/src/panel/sprites.zig @@ -1,11 +1,11 @@ const std = @import("std"); const icons = @import("icons"); const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); -const Editor = fizzy.Editor; -const ReflectionLagSample = fizzy.dvui.ReflectionLagSample; -const reflection_surface_cols = fizzy.dvui.reflection_surface_cols; -const wsurf = fizzy.water_surface; +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const ReflectionLagSample = pixi_mod.sprite_render.ReflectionLagSample; +const reflection_surface_cols = pixi_mod.sprite_render.reflection_surface_cols; +const wsurf = pixi_mod.water_surface; const Sprites = @This(); @@ -114,12 +114,12 @@ const SpriteSlot = struct { } }; -/// Cover-flow scrub momentum tuning (sprite-index units). See `fizzy.Fling`. +/// Cover-flow scrub momentum tuning (sprite-index units). See `pixi_mod.Fling`. /// Mouse/trackpad release velocity is measured over a position/time window /// (`releaseWindowed`), not a per-frame EMA — the EMA converged per frame, so a quick /// flick built up too little velocity at 60 Hz (e.g. Safari on a deployed build) even /// though it worked at 120 Hz. The window is wall-clock based, so it's refresh-independent. -const sprite_fling: fizzy.Fling.Tuning = .{ +const sprite_fling: pixi_mod.Fling.Tuning = .{ .decay = 4.0, .min_start = 1.2, .stop = 0.6, @@ -131,7 +131,7 @@ const sprite_fling_window_s: f32 = 0.08; /// Touch scrub: a finger flick is short and bursty, so start coasting at a lower /// speed and tolerate the small gap the browser leaves before `touchend`. Velocity is /// measured over a position/time window (`releaseWindowed`) rather than the last frame. -const sprite_fling_touch: fizzy.Fling.Tuning = .{ +const sprite_fling_touch: pixi_mod.Fling.Tuning = .{ .decay = 4.0, .min_start = 0.6, .stop = 0.6, @@ -186,7 +186,7 @@ moved_since_press: bool = false, /// True when the active scrub began with a touch press (not mouse). drag_was_touch: bool = false, /// Release momentum for the scrub: coasts the flow after a flick, then snaps. -fling: fizzy.Fling = .{}, +fling: pixi_mod.Fling = .{}, /// Set once we've seeded `scroll_pos` from the initial selection. initialized: bool = false, /// Previous "flown" state (see `sideCardsFlown`), so we can fire the fly-out / @@ -209,26 +209,18 @@ prev_scroll_pos: f32 = 0.0, shelf_vel: f32 = 0.0, pub fn draw(self: *Sprites) !void { - if (fizzy.editor.activeFile()) |file| { - const prev_clip = dvui.clip(dvui.parentGet().data().rectScale().r); - defer dvui.clipSet(prev_clip); - - if (dvui.parentGet().data().rect.h < 32.0) { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { + const content_slot = dvui.parentGet().data(); + const parent = content_slot.contentRect(); + if (parent.h < 32.0) { return; } - self.drawAnimationControlsDialog(); + const prev_clip = dvui.clip(content_slot.rectScale().r); + defer dvui.clipSet(prev_clip); - // Since not all panel screens will likely want shadows, which should be reserved for canvases? - // Text editors, consoles, etc would likely want flat panels or to handle shadows themselves. - defer { - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .top, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .bottom, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .left, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(dvui.parentGet().data().rectScale(), .right, .{ .opacity = 0.15 }); - } + self.drawAnimationControlsDialog(); - const parent = dvui.parentGet().data().rect; const parent_height = parent.h; const mode = scrollMode(file); @@ -243,7 +235,7 @@ pub fn draw(self: *Sprites) !void { // the frame playback starts/stops. ---- const playing = file.editor.playing; const flown = sideCardsFlown(playing); - const panel_id = dvui.parentGet().data().id; + const panel_id = content_slot.id; if (flown != self.was_flown) { const cur: f32 = if (dvui.animationGet(panel_id, "play_fly")) |a| a.value() else (if (self.was_flown) 1.0 else 0.0); self.fly_anim_out = flown; @@ -275,7 +267,7 @@ pub fn draw(self: *Sprites) !void { // ---- Animated fit-scale: aim the front sprite at a fraction of the // pane so several neighbours are visible at once. ---- const scale = blk: { - const steps = fizzy.editor.settings.zoom_steps; + const steps = runtime.state().settings.zoom_steps; const sprite_width = src_rect.w; const sprite_height = src_rect.h; const target_width = parent.w * 0.34; @@ -438,12 +430,23 @@ pub fn draw(self: *Sprites) !void { return; } - const perf_sp = fizzy.perf.spritePreviewBegin(); - defer fizzy.perf.spritePreviewEnd(perf_sp); + const perf_sp = pixi_mod.perf.spritePreviewBegin(); + defer pixi_mod.perf.spritePreviewEnd(perf_sp); const center_x = parent.center().x; - // Lift the row a little so the reflection has room below it. - const center_y = parent.center().y - item_h * 0.10; + // Card rects are positioned in the content slot's *content-local* space, where + // y = 0 is the top of the content area (below the tab strip). So the vertical + // center is half the content height, NOT `parent.center().y`: `parent.y` is the + // slot's offset under the tabs, and including it would push the cards down by a + // fixed tab-height that grows as a fraction of the pane as it shrinks (the + // "drifts off the bottom when small" bug). Horizontal centering uses + // `parent.center().x` only because the slot has no left offset (`parent.x ≈ 0`). + // + // Nudge the centerline up by a fraction of the card height so the reflection + // hanging below the baseline doesn't read as bottom-heavy. The nudge scales + // with `item_h`, so it stays proportional across pane sizes (a fixed pixel + // offset would drift the cards as the pane shrank). + const center_y = parent.h / 2.0 - item_h * 0.10; // The waterline: the shared bottom edge every card stands on (the focus // card's full-height bottom). Side cards pin their bottom here too. const baseline_y = center_y + item_h / 2.0; @@ -749,7 +752,7 @@ pub fn draw(self: *Sprites) !void { const tiltness = if (max_depth > 0.0) std.math.clamp(@abs(cd.depth) / max_depth, 0.0, 1.0) else 0.0; const refl_detail = std.math.lerp(1.0, skewed_reflection_detail, tiltness); - _ = fizzy.dvui.sprite(SpriteSlot.src(), .{ + _ = pixi_mod.sprite_render.sprite(SpriteSlot.src(), .{ .source = file.layers.items(.source)[file.selected_layer_index], .file = file, .alpha_source = if (file.checkerboardTileTexture()) |t| dvui.ImageSource{ .texture = t } else null, @@ -803,12 +806,12 @@ pub fn draw(self: *Sprites) !void { /// Side cards lift away during playback, while a drawing tool is active, or when /// `settings.scrolling_cards` is off (focus mode; toggled in settings or the sprites pane). fn sideCardsFlown(playing: bool) bool { - return playing or drawingToolActive() or !fizzy.editor.settings.scrolling_cards; + return playing or drawingToolActive() or !runtime.state().settings.scrolling_cards; } /// Pencil, eraser, and bucket — not pointer (navigate) or selection (marquee). fn drawingToolActive() bool { - return switch (fizzy.editor.tools.current) { + return switch (runtime.state().tools.current) { .pointer, .selection => false, .pencil, .eraser, .bucket => true, }; @@ -1050,7 +1053,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px // Dialogs/subwindows stack above the sprites pane in z-order but share the same // screen rect — don't capture clicks meant for their footer or chrome. - if (fizzy.dvui.canvasPointerInputSuppressed()) { + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) { if (dvui.captured(id)) { for (dvui.events()) |*e| { if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { @@ -1190,7 +1193,7 @@ fn handleInput(self: *Sprites, file: anytype, mode: ScrollMode, count: usize, px } pub fn drawAnimationControlsDialog(_: *Sprites) void { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { const rect = dvui.parentGet().data().rectScale().r; if (dvui.parentGet().data().rect.h < 48.0) { @@ -1237,8 +1240,8 @@ pub fn drawAnimationControlsDialog(_: *Sprites) void { !fly_forced, flown, ) and !fly_forced) { - fizzy.editor.settings.scrolling_cards = !fizzy.editor.settings.scrolling_cards; - fizzy.editor.markSettingsDirty(); + runtime.state().settings.scrolling_cards = !runtime.state().settings.scrolling_cards; + runtime.state().settings.save(runtime.state().host); dvui.refresh(null, @src(), dvui.parentGet().data().id); } } diff --git a/src/plugins/pixi/src/plugin.zig b/src/plugins/pixi/src/plugin.zig new file mode 100644 index 00000000..ac91820e --- /dev/null +++ b/src/plugins/pixi/src/plugin.zig @@ -0,0 +1,696 @@ +//! The pixel-art editor plugin: registration + draw entry points. Its contributions +//! reach the plugin's state through the `Globals` injection. Registered from +//! `Editor.postInit`. +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const internal = @import("../pixi.zig"); +const sdk = internal.sdk; +const runtime = @import("runtime.zig"); +const State = internal.State; +const CanvasData = @import("CanvasData.zig"); +const FileWidget = @import("widgets/FileWidget.zig"); +const ImageWidget = @import("widgets/ImageWidget.zig"); +const PixelArtSettings = @import("Settings.zig"); +const KeybindTicks = @import("keybind_ticks.zig"); +const RadialMenu = @import("radial_menu.zig"); +const Clipboard = @import("clipboard.zig"); +const PackProject = @import("pack_project.zig"); +const TransformOp = @import("transform_op.zig"); +const DocsRegistry = @import("docs_registry.zig"); +const DocBridge = @import("doc_bridge.zig"); +const DocLifecycle = @import("doc_lifecycle.zig"); +const InfobarStatus = @import("infobar_status.zig"); +const GridLayout = @import("dialogs/GridLayout.zig"); +const FlatRasterSaveWarning = @import("dialogs/FlatRasterSaveWarning.zig"); +const NewFile = @import("dialogs/NewFile.zig"); + +const DocHandle = sdk.DocHandle; +const Internal = internal.internal; + +pub const manifest = sdk.PluginManifest{ + .id = "pixi", + .name = "pixi", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + +/// Stable contribution ids (plugin-namespaced) referenced across modules. +pub const view_tools = "internal.tools"; +pub const view_sprites = "internal.sprites"; +pub const view_project = "internal.project"; +pub const bottom_sprites = "internal.sprites_panel"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "pixi", + .display_name = "pixi", +}; + +const vtable: sdk.Plugin.VTable = .{ + .deinit = pluginDeinit, + .initPlugin = pluginInit, + .fileTypePriority = fileTypePriority, + .contributeKeybinds = contributeKeybinds, + .loadDocument = loadDocument, + .loadDocumentFromBytes = loadDocumentFromBytes, + .documentStackSize = documentStackSize, + .documentStackAlign = documentStackAlign, + .documentIdFromBuffer = documentIdFromBuffer, + .deinitDocumentBuffer = deinitDocumentBuffer, + .setDocumentGroupingOnBuffer = setDocumentGroupingOnBuffer, + .createDocument = createDocument, + .isDirty = isDirty, + .saveDocument = saveDocument, + .closeDocument = closeDocument, + .undo = undo, + .redo = redo, + .canUndo = canUndo, + .canRedo = canRedo, + .registerOpenDocument = registerOpenDocument, + .documentPtr = documentPtr, + .documentByPath = documentByPath, + .unregisterDocument = unregisterDocument, + .bindDocumentToPane = bindDocumentToPane, + .documentGrouping = documentGrouping, + .setDocumentGrouping = setDocumentGrouping, + .removeCanvasPane = removeCanvasPane, + .documentPath = documentPath, + .setDocumentPath = setDocumentPath, + .documentHasNativeExtension = documentHasNativeExtension, + .documentHasRecognizedSaveExtension = documentHasRecognizedSaveExtension, + .showsSaveStatusIndicator = showsSaveStatusIndicator, + .isDocumentSaving = isDocumentSaving, + .saveDocumentAsync = saveDocumentAsync, + .timeSinceSaveCompleteNs = timeSinceSaveCompleteNs, + .documentDefaultSaveAsFilename = documentDefaultSaveAsFilename, + .saveDocumentAs = saveDocumentAs, + .resetDocumentSaveUIState = resetDocumentSaveUIState, + .requestNewDocumentDialog = requestNewDocumentDialog, + .drawDocument = drawDocument, + .drawDocumentInfobar = drawDocumentInfobar, + // universal per-frame phases (pixel-art does its raster/canvas work inside them) + .beginFrame = beginFrame, + .prepareFrame = warmupActiveDocumentComposites, + .tickKeybinds = tickKeybinds, + .tickOpenDocuments = tickOpenDocuments, + .tickActiveDocument = tickActiveDocumentPlayback, + .drawOverlay = drawOverlay, + .endFrame = resetDocumentPeekLayers, + .needsContinuousRepaint = isAnyDocumentActivelyDrawing, + // folder lifecycle + save protocol + .onFolderClose = pluginPersistProjectFolder, + .onFolderOpen = pluginReloadProjectFolder, + .saveNeedsConfirmation = shouldConfirmFlatRasterSave, + .requestSaveConfirmation = requestSaveConfirmation, +}; + +/// A `DocHandle` for one of this plugin's open `*Internal.File`s. Resolved by `doc.id` +/// because `docs.files` may reallocate and stale `doc.ptr` values. +fn docFile(doc: DocHandle) *Internal.File { + return runtime.state().docs.fileById(doc.id).?; +} + +/// Priority for opening `ext` (lower wins). pixi owns its native `.fiz`/`.pixi` +/// and flat-image `.png`/`.jpg`/`.jpeg`; native formats win over flat images when +/// some future plugin also claims an image type. +fn fileTypePriority(_: *anyopaque, ext: []const u8) ?u8 { + if (Internal.File.isFizzyExtension(ext)) return 0; + if (Internal.File.isFlatImageExtension(ext)) return 10; + return null; +} + +/// Load `path` into the plugin-owned `*Internal.File` at `out_doc`. Runs on the shell's +/// load worker thread; `File.fromPath` is the pixel-art loader. +fn loadDocument(_: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void { + // Web loads via bytes only (`loadDocumentFromBytes`); the comptime guard keeps the + // disk-reading `File.fromPath` path (Dir.cwd / posix.AT) out of the wasm binary. + if (comptime builtin.target.cpu.arch == .wasm32) return error.Unsupported; + const file = try Internal.File.fromPath(path) orelse return error.InvalidFile; + @as(*Internal.File, @ptrCast(@alignCast(out_doc))).* = file; +} + +/// As `loadDocument`, from in-memory bytes (browser file picker; synchronous). +fn loadDocumentFromBytes(_: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void { + const file = try Internal.File.fromBytes(path, bytes) orelse return error.InvalidFile; + @as(*Internal.File, @ptrCast(@alignCast(out_doc))).* = file; +} + +fn isDirty(_: *anyopaque, doc: DocHandle) bool { + return docFile(doc).dirty(); +} + +/// Persist the document. The shell handles the Save-As / flat-raster / web-download +/// policy before routing here; this just runs the pixel-art async save. +fn saveDocument(_: *anyopaque, doc: DocHandle) anyerror!void { + try docFile(doc).saveAsync(); +} + +/// Release the document's resources. The shell removes it from `open_files` and +/// fixes up the active-tab index; this just frees the pixel-art `File`. +fn closeDocument(_: *anyopaque, doc: DocHandle) void { + docFile(doc).deinit(); +} + +/// Render the open pixel-art document into the workbench-provided content region (the +/// current dvui parent). The workbench owns only the container + tab/split frame and sets +/// `canvas.id` / `workspace_handle` / `center` before routing here; pixi owns the +/// entire region: rulers, the canvas hbox, the transform/edit/sample overlays, the editing +/// widget, and the sample magnifier. The per-pane ruler/overlay/reorder state + draw helpers +/// live on the pixel-art-owned `CanvasData` (keyed by workbench pane `grouping` on `State`). +fn drawDocument(_: *anyopaque, doc: DocHandle) anyerror!void { + const file = docFile(doc); + const chrome = CanvasData.forGrouping(file.editor.grouping); + const container = dvui.parentGet().data(); + + // Grid (column/row) reorder is driven by the rulers and consumed by `FileWidget`; commit + // the pending reorder and clear the per-frame drag indices after the whole document (incl. + // the file widget) has drawn. Registered first so they run last. + defer chrome.columns_drag_index = null; + defer chrome.rows_drag_index = null; + defer chrome.processColumnReorder(file); + defer chrome.processRowReorder(file); + + internal.perf.canvasPaneDrawn(); + + if (runtime.state().settings.show_rulers and !dvui.firstFrame(container.id)) { + defer internal.core.dvui.drawEdgeShadow(container.rectScale(), .top, .{}); + chrome.drawRuler(file, .horizontal); + } + + var canvas_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .both }); + defer canvas_hbox.deinit(); + + if (runtime.state().settings.show_rulers and !dvui.firstFrame(container.id)) { + defer internal.core.dvui.drawEdgeShadow(container.rectScale(), .left, .{}); + chrome.drawRuler(file, .vertical); + } + + chrome.drawTransformDialog(file, container); + chrome.drawEditPill(container); + // Before the file widget so FloatingWidget uses window-scale coords (not canvas zoom). + chrome.drawSampleButton(container); + + const pane_grouping = container.options.id_extra orelse return; + if (@as(u64, @intCast(pane_grouping)) != file.editor.grouping) return; + + var file_widget = FileWidget.init(@src(), .{ + .file = file, + .center = file.editor.center, + }, .{ + .expand = .both, + .background = false, + .color_fill = .transparent, + }); + defer file_widget.deinit(); + file_widget.processEvents(); + + if (dvui.dataGet(null, file.editor.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { + if (file.editor.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { + FileWidget.drawSampleMagnifier(file, data_pt); + } + } +} + +/// Take over a workspace pane to show the pixel-art packed-atlas preview (the "Project" +/// sidebar view's `draw_workspace`). The workbench owns the pane frame and routes here when +/// `view_project` is the active sidebar view. +fn drawProjectView(_: ?*anyopaque, pane: *sdk.WorkbenchPaneView) anyerror!void { + var content_color = dvui.themeGet().color(.window, .fill); + + if (runtime.state().host.appliesNativeWindowOpacity()) { + content_color = if (!runtime.state().host.isMaximized()) + content_color.opacity(runtime.state().host.contentOpacity()) + else + content_color; + } + + const show_packed_atlas = if (comptime builtin.target.cpu.arch == .wasm32) + runtime.packer().atlas != null + else + runtime.state().host.folder() != null and runtime.packer().atlas != null; + + var canvas_vbox = sdk.pane_layout.mainCanvasVbox(content_color, show_packed_atlas, pane.grouping); + defer { + pane.canvas_rect_physical.* = canvas_vbox.data().contentRectScale().r; + dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); + canvas_vbox.deinit(); + } + + if (show_packed_atlas) { + const atlas = &runtime.packer().atlas.?; + var image_widget = ImageWidget.init(@src(), .{ + .source = atlas.source, + .canvas = &atlas.canvas, + .grouping = pane.grouping, + }, .{ + .id_extra = @intCast(pane.grouping), + .expand = .both, + .background = false, + .color_fill = .transparent, + }); + defer image_widget.deinit(); + + image_widget.processEvents(); + + if (dvui.dataGet(null, atlas.canvas.id, "sample_data_point", dvui.Point)) |data_pt| { + if (atlas.canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) { + ImageWidget.drawSampleMagnifier(&atlas.canvas, atlas.source, data_pt); + } + } + } else { + var box = sdk.pane_layout.emptyStateCard(content_color, pane.grouping); + defer box.deinit(); + + const alpha = dvui.alpha(1.0); + dvui.alphaSet(1.0); + defer dvui.alphaSet(alpha); + + const hint: []const u8 = if (comptime builtin.target.cpu.arch == .wasm32) + "Pack open files to see the preview." + else if (runtime.state().host.folder() == null) + "Open a project folder, then pack to see the preview." + else + "Pack the project to see the preview."; + + dvui.labelNoFmt( + @src(), + hint, + .{ .align_x = 0.5 }, + .{ + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.control, .text), + .font = dvui.Font.theme(.body), + }, + ); + } +} + +fn drawDocumentInfobar(state: *anyopaque, doc: DocHandle) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return InfobarStatus.drawDocumentInfobar(st, doc); +} + +fn undo(_: *anyopaque, doc: DocHandle) anyerror!void { + const file = docFile(doc); + try file.history.undoRedo(file, .undo); +} + +fn redo(_: *anyopaque, doc: DocHandle) anyerror!void { + const file = docFile(doc); + try file.history.undoRedo(file, .redo); +} + +fn canUndo(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.canUndo(st, doc); +} + +fn canRedo(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.canRedo(st, doc); +} + +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(@alignCast(runtime.state())); + try host.registerPlugin(&plugin); + try host.registerFileRowFillColor(.{ .color = &fileRowFillColor }); + try host.registerSidebarView(.{ + .id = view_tools, + .owner = &plugin, + .icon = dvui.entypo.pencil, + .title = "Tools", + .draw = drawTools, + }); + try host.registerSidebarView(.{ + .id = view_sprites, + .owner = &plugin, + .icon = dvui.entypo.grid, + .title = "Sprites", + .draw = drawSprites, + }); + try host.registerSidebarView(.{ + .id = view_project, + .owner = &plugin, + .icon = dvui.entypo.box, + .title = "Project", + .draw = drawProject, + .draw_workspace = drawProjectView, + }); + try host.registerBottomView(.{ + .id = bottom_sprites, + .owner = &plugin, + .title = "Sprites", + .draw = drawSpritesPanel, + .persistent = true, + }); + try host.registerSettingsSection(.{ + .id = "internal.settings", + .owner = &plugin, + .title = "pixi", + .draw = PixelArtSettings.draw, + }); + + // Pixel-art's invocable, plugin-specific features. The shell/menus/keybinds trigger these + // by id via `host.runCommand` without naming them. (Generic active-doc editing verbs like + // `transform`/`copy`/`paste` are *not* commands — they are `Plugin.VTable` hooks the shell + // dispatches to the focused document's owner.) + try host.registerCommand(.{ + .id = "internal.gridLayout", + .owner = &plugin, + .title = "Grid Layout…", + .run = gridLayoutCommand, + }); + try host.registerCommand(.{ + .id = "internal.packProject", + .owner = &plugin, + .title = "Pack Project", + .run = packProjectCommand, + .isEnabled = packProjectEnabled, + }); + + // Editing verbs the shell's Edit menu / keybinds dispatch to per active-doc owner + // (`.`). These are pixel-art's answers; another editor registers its own. + try host.registerCommand(.{ .id = "internal.copy", .owner = &plugin, .title = "Copy", .run = pluginCopy }); + try host.registerCommand(.{ .id = "internal.paste", .owner = &plugin, .title = "Paste", .run = pluginPaste }); + try host.registerCommand(.{ .id = "internal.transform", .owner = &plugin, .title = "Transform", .run = pluginTransform }); + try host.registerCommand(.{ .id = "internal.acceptEdit", .owner = &plugin, .title = "Accept Edit", .run = pluginAcceptEdit }); + try host.registerCommand(.{ .id = "internal.cancelEdit", .owner = &plugin, .title = "Cancel Edit", .run = pluginCancelEdit }); + try host.registerCommand(.{ .id = "internal.deleteSelection", .owner = &plugin, .title = "Delete Selection", .run = pluginDeleteSelection }); +} + +/// Stable `*Plugin` for constructing `DocHandle.owner` fields. +pub fn pluginPtr() *sdk.Plugin { + return &plugin; +} + +fn fileRowFillColor(_: ?*anyopaque, color_index: usize) ?dvui.Color { + if (runtime.state().colors.palette) |*palette| { + return palette.getDVUIColor(color_index); + } + return null; +} + +fn drawTools(_: ?*anyopaque) anyerror!void { + try runtime.state().tools_pane.draw(); +} +fn drawSprites(_: ?*anyopaque) anyerror!void { + try runtime.state().sprites_pane.draw(); +} +fn drawProject(_: ?*anyopaque) anyerror!void { + try internal.explorer.project.draw(); +} +fn drawSpritesPanel(_: ?*anyopaque) anyerror!void { + try runtime.state().sprites_panel.draw(); +} + +fn tickKeybinds(_: *anyopaque) anyerror!void { + try KeybindTicks.tick(); +} + +/// Pixel-art's per-frame overlay: the radial tool menu (processes its hold-to-open input, +/// then draws while visible). Wired to the universal `Plugin.drawOverlay` phase. +fn drawOverlay(_: *anyopaque) anyerror!void { + RadialMenu.processHoldOpenInput(); + if (RadialMenu.visible()) try RadialMenu.draw(); +} + +fn pluginCopy(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try Clipboard.copy(st); +} + +fn pluginTransform(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try TransformOp.begin(st); +} + +fn registerOpenDocument(state: *anyopaque, file: *anyopaque) anyerror!*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + const internal_file: *Internal.File = @ptrCast(@alignCast(file)); + const ptr = try DocsRegistry.registerOpenDocument(st, internal_file); + return ptr; +} + +fn documentPtr(state: *anyopaque, id: u64) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return DocsRegistry.documentFromId(st, id); +} + +fn documentByPath(state: *anyopaque, path: []const u8) ?*anyopaque { + const st: *State = @ptrCast(@alignCast(state)); + return DocsRegistry.documentFromPath(st, path); +} + +fn unregisterDocument(state: *anyopaque, id: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + DocsRegistry.unregisterDocument(st, id); +} + +fn bindDocumentToPane(state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { + const st: *State = @ptrCast(@alignCast(state)); + DocBridge.bindDocumentToWorkspace(st, doc, canvas_id, workspace_handle, center); +} + +fn documentGrouping(state: *anyopaque, doc: DocHandle) u64 { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentGrouping(st, doc); +} + +fn setDocumentGrouping(state: *anyopaque, doc: DocHandle, grouping: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + DocBridge.setDocumentGrouping(st, doc, grouping); +} + +fn removeCanvasPane(state: *anyopaque, grouping: u64, allocator: std.mem.Allocator) void { + const st: *State = @ptrCast(@alignCast(state)); + State.removeCanvasPane(st, allocator, grouping); +} + +fn documentPath(state: *anyopaque, doc: DocHandle) []const u8 { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentPath(st, doc); +} + +fn setDocumentPath(state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.setDocumentPath(st, doc, path); +} + +fn documentHasNativeExtension(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentHasNativeExtension(st, doc); +} + +fn documentHasRecognizedSaveExtension(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.documentHasRecognizedSaveExtension(st, doc); +} + +fn showsSaveStatusIndicator(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.showsSaveStatusIndicator(st, doc); +} + +fn isDocumentSaving(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.isDocumentSaving(st, doc); +} + +fn shouldConfirmFlatRasterSave(state: *anyopaque, doc: DocHandle) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.shouldConfirmFlatRasterSave(st, doc); +} + +fn saveDocumentAsync(state: *anyopaque, doc: DocHandle) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.saveDocumentAsync(st, doc); +} + +fn timeSinceSaveCompleteNs(state: *anyopaque, doc: DocHandle) ?i128 { + const st: *State = @ptrCast(@alignCast(state)); + return DocBridge.timeSinceSaveCompleteNs(st, doc); +} + +fn pluginDeinit(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.deinitPlugin(st); +} + +fn pluginInit(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.initPlugin(st); +} + +fn documentStackSize(state: *anyopaque) usize { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.sizeOfDocument(st); +} + +fn documentStackAlign(state: *anyopaque) usize { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.alignOfDocument(st); +} + +fn documentIdFromBuffer(state: *anyopaque, doc: *anyopaque) u64 { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.documentIdFromBuffer(st, doc); +} + +fn deinitDocumentBuffer(state: *anyopaque, doc: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.deinitDocumentBuffer(st, doc); +} + +fn setDocumentGroupingOnBuffer(state: *anyopaque, doc: *anyopaque, grouping: u64) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.setDocumentGroupingOnBuffer(st, doc, grouping); +} + +fn createDocument(state: *anyopaque, path: []const u8, grid: sdk.EditorAPI.NewDocGrid, out_doc: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.createDocument(st, path, grid, out_doc); +} + +fn documentDefaultSaveAsFilename(state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.documentDefaultSaveAsFilename(st, doc, allocator); +} + +fn saveDocumentAs(state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.saveDocumentAs(st, doc, path, window); +} + +fn resetDocumentSaveUIState(state: *anyopaque, doc: DocHandle) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.resetDocumentSaveUIState(st, doc); +} + +fn requestNewDocumentDialog(_: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void { + NewFile.request(parent_path, id_extra); +} + +/// Command body for `internal.gridLayout` — opens the grid-layout dialog for the active doc. +fn gridLayoutCommand(_: *anyopaque) anyerror!void { + const doc = runtime.state().host.activeDoc() orelse return; + GridLayout.request(doc.id); +} + +fn requestSaveConfirmation(_: *anyopaque, doc: DocHandle, mode: sdk.Plugin.SaveConfirmMode, from_save_all_quit: bool) void { + FlatRasterSaveWarning.request(doc.id, mode, from_save_all_quit); +} + +fn beginFrame(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + // Advance the per-frame render clock used as a composite-cache invalidation key. + internal.render.frame_index +%= 1; + // Sweep any in-flight atlas-pack jobs. The shell no longer orchestrates packing — the + // plugin drives its own background work from this universal per-frame phase. + PackProject.tick(st); + if (comptime @import("builtin").target.cpu.arch == .wasm32) PackProject.runWasmWorkers(st); +} + +/// Command body for `internal.packProject`. +fn packProjectCommand(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try PackProject.start(st); +} + +/// `internal.packProject` is enabled only when no pack is already in flight. +fn packProjectEnabled(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + return !PackProject.isActive(st); +} + +fn tickOpenDocuments(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.tickOpenDocuments(st); +} + +fn tickActiveDocumentPlayback(state: *anyopaque, timer_host_id: dvui.Id) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.tickActiveDocumentPlayback(st, timer_host_id); +} + +fn resetDocumentPeekLayers(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.resetDocumentPeekLayers(st); +} + +fn warmupActiveDocumentComposites(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocLifecycle.warmupActiveDocumentComposites(st); +} + +fn isAnyDocumentActivelyDrawing(state: *anyopaque) bool { + const st: *State = @ptrCast(@alignCast(state)); + return DocLifecycle.isAnyDocumentActivelyDrawing(st); +} + +// Editing-verb command bodies (registered in `register`). `anyerror!void` to match `Command.run`. +fn pluginAcceptEdit(state: *anyopaque) anyerror!void { + DocLifecycle.acceptEdit(@ptrCast(@alignCast(state))); +} + +fn pluginCancelEdit(state: *anyopaque) anyerror!void { + DocLifecycle.cancelEdit(@ptrCast(@alignCast(state))); +} + +fn pluginDeleteSelection(state: *anyopaque) anyerror!void { + DocLifecycle.deleteSelection(@ptrCast(@alignCast(state))); +} + +fn pluginPersistProjectFolder(state: *anyopaque) void { + const st: *State = @ptrCast(@alignCast(state)); + DocsRegistry.persistProjectFolder(st); +} + +fn pluginReloadProjectFolder(state: *anyopaque, allocator: std.mem.Allocator) void { + const st: *State = @ptrCast(@alignCast(state)); + DocsRegistry.reloadProjectFolder(st, allocator); +} + +fn pluginPaste(state: *anyopaque) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + try Clipboard.paste(st); +} + +/// Pixel-art editing + tool keybinds. +/// binds in `Keybinds.register`; this fills in the pixel-art half. Platform: see +/// `Keybinds.register` for why `host.isMacOS()` (not `builtin`) is used. +fn contributeKeybinds(state: *anyopaque, win: *dvui.Window) anyerror!void { + const st: *State = @ptrCast(@alignCast(state)); + if (st.host.isMacOS()) { + try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .command = true }); + try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .command = true, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .command = true, .shift = true }); + try win.keybinds.putNoClobber(win.gpa, "zoom", .{ .command = true }); + try win.keybinds.putNoClobber(win.gpa, "sample", .{ .control = true }); + try win.keybinds.putNoClobber(win.gpa, "transform", .{ .command = true, .key = .t }); + try win.keybinds.putNoClobber(win.gpa, "grid_layout", .{ .command = true, .key = .g }); + try win.keybinds.putNoClobber(win.gpa, "export", .{ .command = true, .key = .p }); + try win.keybinds.putNoClobber(win.gpa, "delete_selection_contents", .{ .key = .backspace }); + } else { + try win.keybinds.putNoClobber(win.gpa, "new_file", .{ .key = .n, .control = true }); + try win.keybinds.putNoClobber(win.gpa, "undo", .{ .key = .z, .control = true, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "redo", .{ .key = .z, .control = true, .shift = true }); + try win.keybinds.putNoClobber(win.gpa, "zoom", .{ .control = true }); + try win.keybinds.putNoClobber(win.gpa, "sample", .{ .alt = true }); + try win.keybinds.putNoClobber(win.gpa, "transform", .{ .control = true, .key = .t }); + try win.keybinds.putNoClobber(win.gpa, "grid_layout", .{ .control = true, .key = .g }); + try win.keybinds.putNoClobber(win.gpa, "export", .{ .control = true, .key = .p }); + try win.keybinds.putNoClobber(win.gpa, "delete_selection_contents", .{ .key = .delete }); + } + + try win.keybinds.putNoClobber(win.gpa, "increase_stroke_size", .{ .key = .right_bracket }); + try win.keybinds.putNoClobber(win.gpa, "decrease_stroke_size", .{ .key = .left_bracket }); + try win.keybinds.putNoClobber(win.gpa, "quick_tools", .{ .key = .space }); + + try win.keybinds.putNoClobber(win.gpa, "pencil", .{ .key = .d, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "eraser", .{ .key = .e, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "bucket", .{ .key = .b, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "selection", .{ .key = .s, .command = false, .control = false, .alt = false, .shift = false }); + try win.keybinds.putNoClobber(win.gpa, "pointer", .{ .key = .escape }); +} diff --git a/src/plugins/pixi/src/radial_menu.zig b/src/plugins/pixi/src/radial_menu.zig new file mode 100644 index 00000000..a277989f --- /dev/null +++ b/src/plugins/pixi/src/radial_menu.zig @@ -0,0 +1,238 @@ +//! Radial tool menu overlay — opened via Space / hold on empty workspace. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const Tools = pixi_mod.Tools; + +pub fn visible() bool { + return runtime.state().tools.radial_menu.visible; +} + +pub fn processHoldOpenInput() void { + const rm = &runtime.state().tools.radial_menu; + if (!rm.visible or !rm.opened_by_press) { + rm.outside_click_press_p = null; + return; + } + + const dismiss_move_threshold: f32 = dvui.Dragging.threshold; + + for (dvui.events()) |*e| { + if (e.evt != .mouse) continue; + const me = e.evt.mouse; + rm.mouse_position = me.p; + + const primary = me.button.pointer() or me.button.touch(); + if (!primary) continue; + + switch (me.action) { + .press => { + if (!rm.containsPhysical(me.p)) { + rm.outside_click_press_p = me.p; + } else { + rm.outside_click_press_p = null; + } + }, + .motion => { + if (rm.outside_click_press_p) |press_p| { + if (me.p.diff(press_p).length() > dismiss_move_threshold) { + rm.outside_click_press_p = null; + } + } + }, + .release => { + if (rm.suppress_next_pointer_release) { + rm.suppress_next_pointer_release = false; + rm.outside_click_press_p = null; + continue; + } + if (rm.outside_click_press_p) |press_p| { + const moved = me.p.diff(press_p).length() > dismiss_move_threshold; + if (!moved and !rm.containsPhysical(me.p) and !rm.containsPhysical(press_p)) { + rm.close(); + } + rm.outside_click_press_p = null; + } + }, + else => {}, + } + } +} + +pub fn draw() !void { + var fw: dvui.FloatingWidget = undefined; + fw.init(@src(), .{}, .{ + .rect = .cast(dvui.windowRect()), + }); + defer fw.deinit(); + + const menu_color = dvui.themeGet().color(.content, .fill).lighten(4.0); + const center = fw.data().rectScale().pointFromPhysical(runtime.state().tools.radial_menu.center); + const tool_count: usize = std.meta.fields(Tools.Tool).len; + const radius: f32 = 50.0; + const width: f32 = radius * 2.0; + const height: f32 = radius * 2.0; + const step: f32 = (2.0 * std.math.pi) / @as(f32, @floatFromInt(tool_count)); + var angle: f32 = 180.0; + + var outer_anim = dvui.animate(@src(), .{ .duration = 400_000, .kind = .horizontal, .easing = dvui.easing.outBack }, .{}); + const temp_radius: f32 = 3.0 * radius * (outer_anim.val orelse 1.0); + var outer_rect = dvui.Rect.fromPoint(center); + outer_rect.w = temp_radius; + outer_rect.h = temp_radius; + outer_rect.x -= outer_rect.w / 2.0; + outer_rect.y -= outer_rect.h / 2.0; + + var box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .rect = outer_rect, + .expand = .none, + .background = true, + .corner_radius = dvui.Rect.all(100000), + .box_shadow = .{ + .color = .black, + .offset = .{ .x = -4.0, .y = 4.0 }, + .fade = 8.0, + .alpha = 0.35, + }, + .color_fill = menu_color.opacity(0.75), + .border = dvui.Rect.all(0.0), + }); + box.deinit(); + outer_anim.deinit(); + + const ui_atlas = runtime.state().host.uiAtlas(); + + for (0..tool_count) |i| { + var anim = dvui.animate(@src(), .{ .duration = 100_000 + 50_000 * @as(i32, @intCast(i)), .kind = .alpha, .easing = dvui.easing.linear }, .{ + .id_extra = i, + }); + defer anim.deinit(); + + if (anim.val) |val| { + angle += ((1 - val) * 100.0) * 0.015; + } + + var color = dvui.themeGet().color(.control, .fill_hover); + if (runtime.state().colors.file_tree_palette) |*palette| { + color = palette.getDVUIColor(i); + } + + const x: f32 = std.math.round(width / 2.0 + radius * std.math.cos(angle) - width / 2.0); + const y: f32 = std.math.round(height / 2.0 + radius * std.math.sin(angle) - height / 2.0); + const new_center = center.plus(.{ .x = x, .y = y }); + var rect = dvui.Rect.fromPoint(new_center); + rect.w = 40.0; + rect.h = 40.0; + rect.x -= rect.w / 2.0; + rect.y -= rect.h / 2.0; + + const tool = @as(Tools.Tool, @enumFromInt(i)); + var button: dvui.ButtonWidget = undefined; + button.init(@src(), .{}, .{ + .rect = rect, + .id_extra = i, + .corner_radius = dvui.Rect.all(1000.0), + .color_fill = if (tool == runtime.state().tools.current) dvui.themeGet().color(.content, .fill) else .transparent, + .box_shadow = if (tool == runtime.state().tools.current) .{ + .color = .black, + .offset = .{ .x = -2.5, .y = 2.5 }, + .fade = 4.0, + .alpha = 0.25, + .corner_radius = dvui.Rect.all(1000), + } else null, + .padding = .all(0), + .margin = .all(0), + }); + + runtime.state().tools.drawTooltip(tool, button.data().rectScale().r, i) catch {}; + + const selection_sprite = switch (runtime.state().tools.selection_mode) { + .box => ui_atlas.sprites[pixi_mod.atlas.sprites.box_selection_default], + .pixel => ui_atlas.sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .color => ui_atlas.sprites[pixi_mod.atlas.sprites.color_selection_default], + }; + + const sprite = switch (tool) { + .pointer => ui_atlas.sprites[pixi_mod.atlas.sprites.cursor_default], + .pencil => ui_atlas.sprites[pixi_mod.atlas.sprites.pencil_default], + .eraser => ui_atlas.sprites[pixi_mod.atlas.sprites.eraser_default], + .bucket => ui_atlas.sprites[pixi_mod.atlas.sprites.bucket_default], + .selection => selection_sprite, + }; + + const size: dvui.Size = dvui.imageSize(ui_atlas.source) catch .{ .w = 1, .h = 1 }; + const atlas_w = if (size.w > 0) size.w else 1; + const atlas_h = if (size.h > 0) size.h else 1; + const uv = dvui.Rect{ + .x = @as(f32, @floatFromInt(sprite.source[0])) / atlas_w, + .y = @as(f32, @floatFromInt(sprite.source[1])) / atlas_h, + .w = @as(f32, @floatFromInt(sprite.source[2])) / atlas_w, + .h = @as(f32, @floatFromInt(sprite.source[3])) / atlas_h, + }; + + button.processEvents(); + button.drawBackground(); + + var rs = button.data().contentRectScale(); + const sw = @as(f32, @floatFromInt(sprite.source[2])) * rs.s; + const sh = @as(f32, @floatFromInt(sprite.source[3])) * rs.s; + rs.r.x += (rs.r.w - sw) / 2.0; + rs.r.y += (rs.r.h - sh) / 2.0; + rs.r.w = sw; + rs.r.h = sh; + + dvui.renderImage(ui_atlas.source, rs, .{ + .uv = uv, + .fade = 0.0, + }) catch { + std.log.err("Failed to render image", .{}); + }; + angle += step; + + if (button.hovered()) { + runtime.state().tools.set(tool); + } + if (button.clicked()) { + runtime.state().tools.set(tool); + runtime.state().tools.radial_menu.close(); + } + + button.deinit(); + } + + var anim = dvui.animate(@src(), .{ .duration = 100_000, .kind = .alpha, .easing = dvui.easing.linear }, .{ + .id_extra = tool_count + 1, + }); + defer anim.deinit(); + + var rect = dvui.Rect.fromPoint(center); + rect.w = 40.0; + rect.h = 40.0; + rect.x -= rect.w / 2.0; + rect.y -= rect.h / 2.0; + + if (runtime.state().host.activeDoc()) |doc| { + if (runtime.state().docs.fileById(doc.id)) |file| { + if (dvui.buttonIcon(@src(), "Play", if (file.editor.playing) icons.tvg.entypo.pause else icons.tvg.entypo.play, .{}, .{}, .{ + .expand = .none, + .corner_radius = dvui.Rect.all(1000), + .box_shadow = .{ + .color = .black, + .offset = .{ .x = -2.5, .y = 2.5 }, + .fade = 4.0, + .alpha = 0.25, + .corner_radius = dvui.Rect.all(1000), + }, + .color_fill = dvui.themeGet().color(.control, .fill_hover), + .rect = rect, + })) { + file.editor.playing = !file.editor.playing; + if (runtime.state().tools.radial_menu.opened_by_press) { + runtime.state().tools.radial_menu.close(); + } + } + } + } +} diff --git a/src/gfx/render.zig b/src/plugins/pixi/src/render.zig similarity index 92% rename from src/gfx/render.zig rename to src/plugins/pixi/src/render.zig index 3631ee62..4ce6bfe9 100644 --- a/src/gfx/render.zig +++ b/src/plugins/pixi/src/render.zig @@ -1,14 +1,15 @@ const std = @import("std"); const builtin = @import("builtin"); -const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); -const perf = fizzy.perf; +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const perf = pixi_mod.perf; /// Monotonic frame counter, incremented once per frame from Editor.tick. pub var frame_index: u64 = 0; pub const RenderFileOptions = struct { - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, rs: dvui.RectScale, color_mod: dvui.Color = .white, fade: f32 = 0.0, @@ -60,7 +61,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { uploadSubRectAndSyncCache( source_key, &tex, - fizzy.image.bytes(source).ptr, + pixi_mod.image.bytes(source).ptr, @intFromFloat(dirty.x), @intFromFloat(dirty.y), @intFromFloat(dirty.w), @@ -84,7 +85,7 @@ fn flushPendingLayerTextureUploads(init_opts: RenderFileOptions) void { uploadSubRectAndSyncCache( temp_key, &tex, - fizzy.image.bytes(temp_source).ptr, + pixi_mod.image.bytes(temp_source).ptr, @intFromFloat(dirty.x), @intFromFloat(dirty.y), @intFromFloat(dirty.w), @@ -112,7 +113,7 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde if (init_opts.file.editor.isolate_layer) { if (init_opts.file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.editor.explorer.tools.layersHovered()) { + } else if (!runtime.state().tools_pane.layersHovered()) { min_layer_index = init_opts.file.selected_layer_index; } } @@ -122,11 +123,11 @@ fn layerViewStateForRender(init_opts: RenderFileOptions) struct { min_layer_inde } /// Non-null while layer list DnD preview is active (`File.editor.layer_drag_preview_*`); maps list position → storage index. -fn layerOrderBufForDragPreview(file: *fizzy.Internal.File, buf: []usize) ?[]const usize { +fn layerOrderBufForDragPreview(file: *pixi_mod.internal.File, buf: []usize) ?[]const usize { const r = file.editor.layer_drag_preview_removed orelse return null; const ins = file.editor.layer_drag_preview_insert_before orelse return null; if (file.layers.len == 0 or file.layers.len > buf.len) return null; - fizzy.Internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]); + pixi_mod.internal.File.layerOrderAfterMove(file.layers.len, r, ins, buf[0..file.layers.len]); return buf[0..file.layers.len]; } @@ -288,22 +289,22 @@ pub fn renderLayersMagnifierSample(init_opts: RenderFileOptions) !void { const vs = layerViewStateForRender(init_opts); - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(init_opts.rs.r, dvui.Rect.Physical.all(0)); - var triangles = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade }); - defer triangles.deinit(fizzy.app.allocator); + var triangles = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + defer triangles.deinit(runtime.allocator()); triangles.uvFromRectuv(init_opts.rs.r, init_opts.uv); var dimmed_triangles: ?dvui.Triangles = null; defer { - if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator); + if (dimmed_triangles) |*dt| dt.deinit(runtime.allocator()); } if (vs.needs_dimmed) { - var dt = try triangles.dupe(fizzy.app.allocator); + var dt = try triangles.dupe(runtime.allocator()); dt.color(.gray); dimmed_triangles = dt; } @@ -370,7 +371,7 @@ fn splitCompositeEligible( /// Pixel size of the flattened layer stack — prefers the first layer (`canvasPixelSize`) so the /// composite matches bitmap data even when `columns × column_width` / `rows × row_height` disagree /// (slice/grid previews use the canvas as the locked image rect). -fn layerCompositeExtent(file: *fizzy.Internal.File) struct { w: u32, h: u32 } { +fn layerCompositeExtent(file: *pixi_mod.internal.File) struct { w: u32, h: u32 } { const c = file.canvasPixelSize(); if (c.w > 0 and c.h > 0) return .{ .w = c.w, .h = c.h }; const w = file.width(); @@ -389,7 +390,7 @@ pub fn compositeTargetPixelFormat() dvui.enums.TexturePixelFormat { /// Rebuilds the full-canvas flattened layer texture (all layers included). /// Used when NOT actively drawing. -pub fn syncLayerComposite(file: *fizzy.Internal.File) !void { +pub fn syncLayerComposite(file: *pixi_mod.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -441,7 +442,7 @@ pub fn syncLayerComposite(file: *fizzy.Internal.File) !void { /// The "below" target flattens layers visually below (higher index), and /// the "above" target flattens layers visually above (lower index). /// Only rebuilt when the split layer changes or a structural change occurs. -fn syncSplitComposite(file: *fizzy.Internal.File) !void { +fn syncSplitComposite(file: *pixi_mod.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -526,7 +527,7 @@ fn syncSplitComposite(file: *fizzy.Internal.File) !void { /// Pre-builds split-composite GPU targets and touches temp/selection textures so the first /// stroke does not pay allocation + flatten cost. Safe to call once after open or when /// selecting a drawing tool; no-op if composites are already current. -pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void { +pub fn warmupDrawingComposites(file: *pixi_mod.internal.File) !void { const w0 = perf.nanoTimestamp(); try syncSplitComposite(file); _ = file.editor.temporary_layer.source.getTexture() catch null; @@ -539,7 +540,7 @@ pub fn warmupDrawingComposites(file: *fizzy.Internal.File) !void { /// from high index (visually bottom) to low index (visually top). An optional /// `skip_index` excludes a single layer. fn renderLayersIntoTarget( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, target: dvui.Texture.Target, min_index: usize, max_index: usize, @@ -563,12 +564,12 @@ fn renderLayersIntoTarget( defer dvui.clipSet(prev_clip); dvui.clipSet(image_rect); - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0 }); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); var order_buf: [1024]usize = undefined; @@ -596,7 +597,7 @@ fn renderLayersIntoTarget( /// sprite panel then draws each card (front and reflection) as a single textured /// pass sampling this, instead of replaying the whole stack as several /// overlapping alpha-blended fills per card. Rebuilt at most once per frame. -pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void { +pub fn syncPreviewComposite(file: *pixi_mod.internal.File) !void { const ce = layerCompositeExtent(file); const w = ce.w; const h = ce.h; @@ -658,32 +659,32 @@ pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void { // 1) Opaque content-fill base — the transparency backdrop, matching the card. { - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = dvui.themeGet().color(.content, .fill), .fade = 0 }); + defer tris.deinit(runtime.allocator()); dvui.renderTriangles(tris, null) catch {}; } // 2) Checkerboard tile — one tile per sprite cell (uv repeats columns × rows). if (file.checkerboardTileTexture()) |checker| { - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); const tint = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = tint, .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = tint, .fade = 0 }); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = @floatFromInt(file.columns), .h = @floatFromInt(file.rows) }); dvui.renderTriangles(tris, checker) catch {}; } // 3) Flattened layers, then selection + temp overlays — sampled 1:1. - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(image_rect, dvui.Rect.Physical.all(0)); - var tris = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = .white, .fade = 0 }); - defer tris.deinit(fizzy.app.allocator); + var tris = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = .white, .fade = 0 }); + defer tris.deinit(runtime.allocator()); tris.uvFromRectuv(image_rect, .{ .x = 0, .y = 0, .w = 1, .h = 1 }); if (file.editor.layer_composite_target) |ct| { @@ -700,7 +701,7 @@ pub fn syncPreviewComposite(file: *fizzy.Internal.File) !void { /// Returns the baked cover-flow preview composite texture for single-pass card /// drawing, or null when the fast path isn't eligible (peek / isolate / dimming / /// active drawing / transform). Callers fall back to the multi-pass stack. -pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture { +pub fn spritePreviewComposite(file: *pixi_mod.internal.File) ?dvui.Texture { if (file.peek_layer_index != null) return null; if (file.editor.isolate_layer) return null; if (file.editor.transform != null) return null; @@ -712,7 +713,7 @@ pub fn spritePreviewComposite(file: *fizzy.Internal.File) ?dvui.Texture { return dvui.Texture.fromTargetTemp(t) catch null; } -pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void { +pub fn destroyLayerCompositeResources(file: *pixi_mod.internal.File) void { if (file.editor.layer_composite_target) |t| { t.destroyLater(); file.editor.layer_composite_target = null; @@ -728,7 +729,7 @@ pub fn destroyLayerCompositeResources(file: *fizzy.Internal.File) void { destroySplitCompositeResources(file); } -pub fn destroySplitCompositeResources(file: *fizzy.Internal.File) void { +pub fn destroySplitCompositeResources(file: *pixi_mod.internal.File) void { if (file.editor.split_composite_below) |t| { t.destroyLater(); file.editor.split_composite_below = null; @@ -766,35 +767,35 @@ pub fn renderLayers(init_opts: RenderFileOptions) !void { var triangles = if (init_opts.quad) |q| blk: { // Skewed quad: build a subdivided mesh so the texture follows the // perspective instead of being mapped onto an axis-aligned rect. - var qpath: dvui.Path.Builder = .init(fizzy.app.allocator); + var qpath: dvui.Path.Builder = .init(runtime.allocator()); defer qpath.deinit(); qpath.addPoint(q[0]); qpath.addPoint(q[1]); qpath.addPoint(q[2]); qpath.addPoint(q[3]); - break :blk try fizzy.dvui.pathToSubdividedQuad(qpath.build(), fizzy.app.allocator, .{ + break :blk try pixi_mod.sprite_render.pathToSubdividedQuad(qpath.build(), runtime.allocator(), .{ .subdivisions = init_opts.quad_subdivisions, .uv = init_opts.uv, .color_mod = init_opts.color_mod, }); } else blk: { - var path: dvui.Path.Builder = .init(fizzy.app.allocator); + var path: dvui.Path.Builder = .init(runtime.allocator()); defer path.deinit(); path.addRect(content_rs.r, init_opts.corner_radius.scale(content_rs.s, dvui.Rect.Physical)); - var t = try path.build().fillConvexTriangles(fizzy.app.allocator, .{ .color = init_opts.color_mod, .fade = init_opts.fade }); + var t = try path.build().fillConvexTriangles(runtime.allocator(), .{ .color = init_opts.color_mod, .fade = init_opts.fade }); t.uvFromRectuv(content_rs.r, init_opts.uv); break :blk t; }; - defer triangles.deinit(fizzy.app.allocator); + defer triangles.deinit(runtime.allocator()); var dimmed_triangles: ?dvui.Triangles = null; defer { - if (dimmed_triangles) |*dt| dt.deinit(fizzy.app.allocator); + if (dimmed_triangles) |*dt| dt.deinit(runtime.allocator()); } if (needs_dimmed) { - var dt = try triangles.dupe(fizzy.app.allocator); + var dt = try triangles.dupe(runtime.allocator()); dt.color(.gray); dimmed_triangles = dt; } diff --git a/src/plugins/pixi/src/runtime.zig b/src/plugins/pixi/src/runtime.zig new file mode 100644 index 00000000..47f28cef --- /dev/null +++ b/src/plugins/pixi/src/runtime.zig @@ -0,0 +1,35 @@ +//! Runtime accessors — backed by `sdk.runtime` and shell-owned state. +const std = @import("std"); +const sdk = @import("sdk"); +const State = @import("State.zig"); +const Packer = @import("Packer.zig"); + +var shell_state: ?*State = null; + +/// Static embed: App creates state and calls this before `postInit`. +pub fn adoptShellState(st: *State) void { + shell_state = st; +} + +pub fn allocator() std.mem.Allocator { + return sdk.allocator(); +} + +pub fn host() *sdk.Host { + return sdk.host(); +} + +pub fn state() *State { + if (shell_state) |s| return s; + if (sdk.injectedState(State)) |s| return s; + const pl = sdk.host().pluginById("pixi") orelse @panic("pixi plugin not registered"); + return @ptrCast(@alignCast(pl.state)); +} + +pub fn packer() *Packer { + return state().packer orelse @panic("pixi packer not wired"); +} + +pub fn setPacker(p: *Packer) void { + if (shell_state) |s| s.packer = p; +} diff --git a/src/plugins/pixi/src/sprite_render.zig b/src/plugins/pixi/src/sprite_render.zig new file mode 100644 index 00000000..b59a639f --- /dev/null +++ b/src/plugins/pixi/src/sprite_render.zig @@ -0,0 +1,694 @@ +//! Sprite/atlas rendering library for the pixel-art plugin. +//! +//! Heavy rendering on top of `core.Sprite` rects: layer compositing, file previews, +//! reflections, and water-surface meshes. Shell/workbench UI icons use +//! `pixi_mod.core_sprite.draw` from core instead of this module. +const std = @import("std"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); + +pub const SpriteInitOptions = struct { + source: dvui.ImageSource, + file: ?*pixi_mod.internal.File = null, + alpha_source: ?dvui.ImageSource = null, + sprite: pixi_mod.core_sprite, + scale: f32 = 1.0, + depth: f32 = 0.0, // -1.0 is front, 1.0 is back + reflection: bool = false, + overlap: f32 = 0.0, + /// Overall opacity in [0, 1]; 1.0 is fully opaque. Used to fade cards out + /// toward the background the further they sit from the focus. + opacity: f32 = 1.0, + /// Vertical shift (logical px, positive = down) applied to the reflection + /// only. Lets the reflection slide away from the card — e.g. as a card flies + /// up out of view, its reflection sinks down, like peeling off a waterline. + reflection_offset: f32 = 0.0, + /// Depth-lagged reflection grid (logical px); rows shear while scrolling and ripple on settle. + reflection_lag: ?ReflectionLagSample = null, + /// Reflection mesh density multiplier in (0, 1]. 1.0 = full per-zoom density; + /// lower values coarsen the (O(n²)) mesh. Callers pass <1 for distant/skewed + /// cards so only the head-on focus cards pay for a fine, high-res reflection. + reflection_detail: f32 = 1.0, +}; + +/// Columns the reflection mesh samples across a card's width (waterline strip). +/// Matches `water_surface.cols_per_slot` (+1) so finer ripples render per card. +pub const reflection_surface_cols = pixi_mod.water_surface.reflection_surface_cols; + +/// Reflection-only waterline sample across the card width (logical px). `cols_dx` +/// is horizontal refraction from surface slope; `cols_dy` is vertical height at +/// the seam (positive = down). The card itself stays flat — only the reflection +/// mesh pins its top edge and propagates ripples downward. +pub const ReflectionLagSample = struct { + cols_dx: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, + cols_dy: [reflection_surface_cols]f32 = .{0} ** reflection_surface_cols, +}; + +pub fn sprite(src: std.builtin.SourceLocation, init_opts: SpriteInitOptions, opts: dvui.Options) dvui.WidgetData { + const source_size: dvui.Size = dvui.imageSize(init_opts.source) catch .{ .w = 0, .h = 0 }; + + const overlap: f32 = 1.0 - init_opts.overlap; + + const uv = dvui.Rect{ + .x = @as(f32, @floatFromInt(init_opts.sprite.source[0])) / source_size.w, + .y = @as(f32, @floatFromInt(init_opts.sprite.source[1])) / source_size.h, + .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) / source_size.w, + .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) / source_size.h, + }; + + const options = (dvui.Options{ .name = "sprite" }).override(opts); + + var size = dvui.Size{}; + if (options.min_size_content) |msc| { + // user gave us a min size, use it + size = msc; + } else { + // user didn't give us one, use natural size + size = .{ .w = @as(f32, @floatFromInt(init_opts.sprite.source[2])) * init_opts.scale * overlap, .h = @as(f32, @floatFromInt(init_opts.sprite.source[3])) * init_opts.scale * overlap }; + } + + var wd = dvui.WidgetData.init(src, .{}, options.override(.{ .min_size_content = size })); + wd.register(); + + const cr = wd.contentRect(); + const ms = wd.options.min_size_contentGet(); + + var too_big = false; + if (ms.w > cr.w or ms.h > cr.h) { + too_big = true; + } + + var e = wd.options.expandGet(); + const g = wd.options.gravityGet(); + var rect = dvui.placeIn(cr, ms, e, g); + + if (too_big and e != .ratio) { + if (ms.w > cr.w and !e.isHorizontal()) { + rect.w = ms.w; + rect.x -= g.x * (ms.w - cr.w); + } + + if (ms.h > cr.h and !e.isVertical()) { + rect.h = ms.h; + rect.y -= g.y * (ms.h - cr.h); + } + } + + // rect is the content rect, so expand to the whole rect + wd.rect = rect.outset(wd.options.paddingGet()).outset(wd.options.borderGet()).outset(wd.options.marginGet()); + + var renderBackground: ?dvui.Color = if (wd.options.backgroundGet()) wd.options.color(.fill) else null; + + if (wd.options.rotationGet() == 0.0) { + wd.borderAndBackground(.{}); + renderBackground = null; + } else { + if (wd.options.borderGet().nonZero()) { + dvui.log.debug("image {x} can't render border while rotated\n", .{wd.id}); + } + } + + var path: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + defer path.deinit(); + + var top_left = wd.contentRectScale().r.topLeft(); + var top_right = wd.contentRectScale().r.topRight(); + var bottom_right = wd.contentRectScale().r.bottomRight(); + var bottom_left = wd.contentRectScale().r.bottomLeft(); + + if (init_opts.depth > 0) { + top_left = top_left.plus(bottom_right.diff(top_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); + bottom_left = bottom_left.plus(top_right.diff(bottom_left).normalize().scale(init_opts.depth * wd.contentRectScale().r.w * -1.0, dvui.Point.Physical)); + } else { + top_right = top_right.plus(bottom_right.diff(top_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); + bottom_right = bottom_right.plus(top_right.diff(bottom_right).normalize().scale(init_opts.depth * wd.contentRectScale().r.w, dvui.Point.Physical)); + } + + const lag_active = init_opts.reflection_lag != null; + const reflection_lag_phys: ?ReflectionLagSample = if (lag_active) reflectionLagSamplePhysical( + init_opts.reflection_lag.?, + wd.contentRectScale().s, + ) else null; + + path.addPoint(top_left); + path.addPoint(top_right); + path.addPoint(bottom_right); + path.addPoint(bottom_left); + + // Distance fade toward transparent: `fade_white` tints textured draws by the + // card opacity, and `op` scales the alpha of solid fills. No-ops at op == 1. + const op = std.math.clamp(init_opts.opacity, 0.0, 1.0); + const fade_white = dvui.Color.white.opacity(op); + + // Cover-flow fast path: when a file's layer stack is fully flattenable, the + // checker + layers + selection + temp are baked into one texture once per + // frame, so each card (front and reflection) is a single textured pass + // instead of several overlapping alpha-blended fills. Null → multi-pass path. + const preview_tex: ?dvui.Texture = if (init_opts.file) |f| pixi_mod.render.spritePreviewComposite(f) else null; + + if (init_opts.reflection) { + var path2: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + defer path2.deinit(); + + // Direct vertical mirror: reflect each (already skewed) top corner straight + // down through its bottom corner, so the reflection is a true flip of the + // card — same width and skew at every height, sharing the bottom edge — + // rather than a trapezoid that flares outward. pathToSubdividedQuad reads + // these as (tl, tr, br, bl); the far edge (tl, tr) samples the sprite top + // and the near edge (br, bl) the sprite bottom, giving the mirrored uv. + // `refl_off` slides the whole reflection down independently of the card. + const refl_off = dvui.Point.Physical{ .x = 0.0, .y = init_opts.reflection_offset * wd.contentRectScale().s }; + path2.addPoint(bottom_left.plus(bottom_left.diff(top_left)).plus(refl_off)); + path2.addPoint(bottom_right.plus(bottom_right.diff(top_right)).plus(refl_off)); + path2.addPoint(bottom_right.plus(refl_off)); + path2.addPoint(bottom_left.plus(refl_off)); + + const preview_extent = @min(wd.contentRectScale().r.w, wd.contentRectScale().r.h); + // Subdivide in proportion to on-screen size so the *physical* ripple density + // stays constant across zoom — a big (zoomed-in) card gets many more verts, + // rendering the fine field detail instead of undersampling it into coarse + // waves. (The field already carries dense ripples at `cols_per_slot`.) + const base_subdivisions_f = std.math.clamp(preview_extent / 13.0, 14.0, 44.0); + // The mesh is O(subdivisions²) and is rebuilt + rendered per layer for every + // card. Only the head-on focus cards need the fine, high-res ripple; skewed + // shelf cards pass a low `reflection_detail` so they fall to the coarse floor + // and stay cheap, which is what keeps the shelf affordable on slower GPUs. + const detail = std.math.clamp(init_opts.reflection_detail, 0.0, 1.0); + const subdivisions_f = @max(6.0, base_subdivisions_f * detail); + const subdivisions: usize = @intFromFloat(subdivisions_f); + + if (init_opts.alpha_source) |alpha_source| preview: { + const reflection_path = path2.build(); + + const reflection_lag = reflection_lag_phys orelse ReflectionLagSample{}; + const displacement_max = wd.contentRectScale().r.h * 0.52; + const refl_lag = if (lag_active) reflection_lag else null; + + if (preview_tex) |ptex| { + // Single textured pass: checker + layers + selection + temp are + // pre-flattened into the preview composite, so the reflection is one + // draw instead of replaying the whole stack per card. + var refl = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ + .subdivisions = subdivisions, + .uv = uv, + .vertical_fade = true, + .color_mod = fade_white, + .reflection_lag = refl_lag, + .waterline_propagate = true, + .displacement_max = displacement_max, + }) catch unreachable; + defer refl.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(refl, ptex) catch { + dvui.log.err("Failed to render reflection preview composite", .{}); + }; + break :preview; + } + + // Build two meshes from the same path so vertex positions match (shared + // ripple) but UVs differ: bg uses the full quad for checkerboard alpha, + // layers use the sprite atlas rect. + var reflection_triangles_bg = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ + .subdivisions = subdivisions, + .color_mod = dvui.themeGet().color(.content, .fill).lighten(4.0).opacity(op), + .vertical_fade = true, + .reflection_lag = refl_lag, + .waterline_propagate = true, + .displacement_max = displacement_max, + }) catch unreachable; + defer reflection_triangles_bg.deinit(dvui.currentWindow().arena()); + + var reflection_triangles_layers = pathToSubdividedQuad(reflection_path, dvui.currentWindow().arena(), .{ + .subdivisions = subdivisions, + .uv = uv, + .vertical_fade = true, + .color_mod = fade_white, + .reflection_lag = refl_lag, + .waterline_propagate = true, + .displacement_max = displacement_max, + }) catch unreachable; + defer reflection_triangles_layers.deinit(dvui.currentWindow().arena()); + + var reflection_triangles_layers_dimmed = reflection_triangles_layers.dupe(dvui.currentWindow().arena()) catch unreachable; + defer reflection_triangles_layers_dimmed.deinit(dvui.currentWindow().arena()); + reflection_triangles_layers_dimmed.color(.gray); + + dvui.renderTriangles(reflection_triangles_bg, alpha_source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + + if (init_opts.file) |file| { + const preview_opts = pixi_mod.render.RenderFileOptions{ + .file = file, + .rs = .{ + .r = wd.contentRectScale().r, + .s = wd.contentRectScale().s, + }, + .uv = uv, + .corner_radius = .all(0), + }; + pixi_mod.render.renderReflectionLayerStack(preview_opts, reflection_triangles_layers, reflection_triangles_layers_dimmed) catch |err| { + dvui.log.err("Failed to render reflection layer stack: {any}", .{err}); + }; + + dvui.renderTriangles(reflection_triangles_layers, file.editor.selection_layer.source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + + // Match renderLayers: use cached GPU texture when the canvas has already uploaded this frame. + // Avoids getTexture() on .pixelsPMA sources (would upload when invalidation is .always). + if (file.editor.temp_layer_has_content or file.editor.temp_gpu_dirty_rect != null) { + const temp_src = file.editor.temporary_layer.source; + const temp_key = temp_src.hash(); + if (dvui.textureGetCached(temp_key)) |tex| { + dvui.renderTriangles(reflection_triangles_layers, tex) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } else { + dvui.renderTriangles(reflection_triangles_layers, temp_src.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + } + } else { + dvui.renderTriangles(reflection_triangles_layers, init_opts.source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + } + } + + // The preview composite already bakes the content-fill base + checkerboard, + // so skip the separate base/checker passes when it's in use. + if (preview_tex == null) { + if (init_opts.alpha_source) |alpha_source| { + if (init_opts.depth != 0.0) { + // Skew the opaque base along with the art so no axis-aligned sliver + // of fill colour pokes out past the receding edge. + var base_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .color_mod = dvui.themeGet().color(.content, .fill).opacity(op), + }) catch unreachable; + defer base_triangles.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(base_triangles, null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } else { + wd.contentRectScale().r.fill(.all(0), .{ .color = dvui.themeGet().color(.content, .fill).opacity(op), .fade = 1.5 }); + } + + const alpha_triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .color_mod = dvui.themeGet().color(.content, .fill).lighten(6.0).opacity(0.5).opacity(op), + }) catch unreachable; + dvui.renderTriangles(alpha_triangles, alpha_source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + } + + if (preview_tex) |ptex| { + // Front card: one textured pass from the baked preview composite. Skewed + // cards build a subdivided quad so the art tilts like a record on a shelf; + // head-on cards use the plain quad. + const front_path = if (init_opts.depth != 0.0) blk: { + var q: dvui.Path.Builder = .init(dvui.currentWindow().arena()); + q.addPoint(top_left); + q.addPoint(top_right); + q.addPoint(bottom_right); + q.addPoint(bottom_left); + break :blk q.build(); + } else path.build(); + var tris = pathToSubdividedQuad(front_path, dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .uv = uv, + .color_mod = fade_white, + }) catch unreachable; + defer tris.deinit(dvui.currentWindow().arena()); + dvui.renderTriangles(tris, ptex) catch { + dvui.log.err("Failed to render sprite preview composite", .{}); + }; + } else if (init_opts.file) |file| { + pixi_mod.render.renderLayers(.{ + .file = file, + .rs = .{ + .r = wd.contentRectScale().r, + .s = wd.contentRectScale().s, + }, + .uv = uv, + .corner_radius = .all(0), + .color_mod = fade_white, + // When skewed, render the layer stack into the same quad as the + // background so the art tilts like a record on a shelf. + .quad = if (init_opts.depth != 0.0) .{ top_left, top_right, bottom_right, bottom_left } else null, + }) catch { + dvui.log.err("Failed to render layers", .{}); + }; + } else { + const triangles = pathToSubdividedQuad(path.build(), dvui.currentWindow().arena(), .{ + .subdivisions = 8, + .uv = uv, + .color_mod = fade_white, + }) catch unreachable; + + dvui.renderTriangles(triangles, init_opts.source.getTexture() catch null) catch { + dvui.log.err("Failed to render triangles", .{}); + }; + } + + path.build().stroke(.{ .color = opts.color_border orelse .transparent, .thickness = 1.0, .closed = true }); + + wd.minSizeSetAndRefresh(); + wd.minSizeReportToParent(); + + return wd; +} + +pub const PathToSubdividedQuadOptions = struct { + subdivisions: usize = 4, + uv: ?dvui.Rect = null, + vertical_fade: bool = false, + color_mod: dvui.Color = .white, + reflection_lag: ?ReflectionLagSample = null, + /// When true, reflection meshes refract ripples deeper below the seam. + waterline_propagate: bool = true, + /// Cap vertex offset (physical px) so ripples stay inside the reflection. + displacement_max: f32 = 0.0, +}; + +fn reflectionLagSamplePhysical(sample: ReflectionLagSample, scale: f32) ReflectionLagSample { + var out = sample; + for (&out.cols_dx) |*c| c.* *= scale; + for (&out.cols_dy) |*c| c.* *= scale; + return out; +} + +/// Linear interpolation across the column strip by horizontal fraction `t_x`. +/// Per-row reflection factors, hoisted out of the per-vertex loop. The two `pow` +/// calls (depth lag + seam pin) depend only on the row (`t_y`), so computing them +/// once per row instead of per vertex removes thousands of `pow` calls per frame. +const ReflectionRow = struct { + low_submerge: bool, + lag: f32, + lag_mix: f32, // already × 0.55 + submerge_scale: f32, // lerp(1, 1.25, submerge) + dx_pin: f32, +}; + +fn reflectionRowFactors(t_y: f32) ReflectionRow { + const submerge = 1.0 - std.math.clamp(t_y, 0, 1); + const seam_t = std.math.clamp(t_y, 0, 1); + return .{ + .low_submerge = submerge <= 0.001, + .lag = std.math.pow(f32, submerge, 1.55) * 0.74, + .lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1) * 0.55, + .submerge_scale = std.math.lerp(1.0, 1.25, submerge), + .dx_pin = 1.0 - std.math.pow(f32, seam_t, 4.5), + }; +} + +/// Horizontal refraction for one vertex using precomputed row factors. Equivalent +/// to `reflectionMeshDisplacement(.x)`, just with the row-constant work hoisted. +fn reflectionRowDx(t_x: f32, dx_seam: f32, row: ReflectionRow, sample: ReflectionLagSample) f32 { + // `dx_seam` (the column's refraction at the seam) is supplied precomputed — it + // depends only on t_x, so the caller resolves it once per column. Only the + // depth-lagged sample, which shifts t_x by the row's phase lag, needs an interp. + const t_lag = if (row.low_submerge) + t_x + else + std.math.clamp(t_x - (if (dx_seam >= 0) row.lag else -row.lag), 0, 1); + const dx_lag = if (row.low_submerge) dx_seam else interpolateReflectionCols(&sample.cols_dx, t_lag); + return std.math.lerp(dx_seam, dx_lag, row.lag_mix) * row.submerge_scale * row.dx_pin; +} + +fn interpolateReflectionCols(cols: []const f32, t_x: f32) f32 { + if (cols.len == 0) return 0; + if (cols.len == 1) return cols[0]; + const f = std.math.clamp(t_x, 0, 1) * @as(f32, @floatFromInt(cols.len - 1)); + const idx0: usize = @intFromFloat(@floor(f)); + const idx1 = @min(idx0 + 1, cols.len - 1); + const t = f - @as(f32, @floatFromInt(idx0)); + return std.math.lerp(cols[idx0], cols[idx1], t); +} + +fn clampDisplacement(d: dvui.Point.Physical, max_mag: f32) dvui.Point.Physical { + if (max_mag <= 0.0001) return d; + const mag = @sqrt(d.x * d.x + d.y * d.y); + if (mag <= max_mag) return d; + const s = max_mag / mag; + return .{ .x = d.x * s, .y = d.y * s }; +} + +/// Depth into the reflection body (0 at the waterline seam, 1 at the far edge). +fn reflectionSubmergeDepth(t_y: f32) f32 { + return 1.0 - std.math.clamp(t_y, 0, 1); +} + +/// Expanding ripple: larger displacement toward the reflection bottom. Rises +/// quickly just below the seam (so the effect is still strong in the upper region +/// that stays on-screen when zoomed in and the reflection's bottom is clipped), +/// then keeps growing toward the far edge for the full zoomed-out slosh. +fn reflectionDepthAmplitude(submerge: f32) f32 { + const d = std.math.clamp(submerge, 0, 1); + return 1.0 + d * (1.8 + 1.4 * d); +} + +/// Phase lag vs depth — deeper rows follow the same wave, slower and larger. +fn reflectionDepthLag(submerge: f32) f32 { + const d = std.math.clamp(submerge, 0, 1); + return std.math.pow(f32, d, 1.55) * 0.74; +} + +/// Sample the surface field with increasing horizontal phase lag at depth. +fn reflectionLaggedTx(t_x: f32, cols_dx: []const f32, submerge: f32) f32 { + if (submerge <= 0.001) return t_x; + const lag = reflectionDepthLag(submerge); + const slope = interpolateReflectionCols(cols_dx, t_x); + const dir: f32 = if (slope >= 0) 1 else -1; + return std.math.clamp(t_x - dir * lag, 0, 1); +} + +/// Reflection mesh: seam pinned at the waterline; the body carries horizontal +/// refraction ripples that phase-lag with depth. cols_dy is not applied. +fn reflectionMeshDisplacement(t_x: f32, t_y: f32, sample: ReflectionLagSample) dvui.Point.Physical { + const submerge = reflectionSubmergeDepth(t_y); + const t_lag = reflectionLaggedTx(t_x, &sample.cols_dx, submerge); + const lag_mix = std.math.clamp(submerge * submerge * 0.9, 0, 1); + + const seam_t = std.math.clamp(t_y, 0, 1); + // Peak refraction just under the card base (not mid-body / far edge); seam + // corners stay pinned so the base width still matches the card. + const dx_pin = std.math.pow(f32, seam_t, 1.4) * (1.0 - std.math.pow(f32, seam_t, 12.0)); + const dx_seam = interpolateReflectionCols(&sample.cols_dx, t_x); + const dx_lag = interpolateReflectionCols(&sample.cols_dx, t_lag); + const dx = std.math.lerp(dx_seam, dx_lag, lag_mix * 0.55) * std.math.lerp(1.0, 1.25, submerge) * dx_pin; + + return .{ .x = dx, .y = 0 }; +} + +fn waterlineMeshDisplacement( + t_x: f32, + t_y: f32, + sample: ReflectionLagSample, + propagate: bool, +) dvui.Point.Physical { + if (propagate) return reflectionMeshDisplacement(t_x, t_y, sample); + const s = std.math.clamp(t_y, 0, 1); + const strength = s * (0.1 + 0.9 * s); + return .{ + .x = interpolateReflectionCols(&sample.cols_dx, t_x) * strength, + .y = 0, + }; +} + +fn reflectionCombinedDisplacement(t_x: f32, t_y: f32, options: PathToSubdividedQuadOptions) dvui.Point.Physical { + var d: dvui.Point.Physical = .{ .x = 0, .y = 0 }; + if (options.reflection_lag) |sample| { + d = d.plus(waterlineMeshDisplacement(t_x, t_y, sample, options.waterline_propagate)); + } + return clampDisplacement(d, options.displacement_max); +} + +pub fn pathToSubdividedQuad(path: dvui.Path, allocator: std.mem.Allocator, options: PathToSubdividedQuadOptions) std.mem.Allocator.Error!dvui.Triangles { + if (path.points.len != 4) { + return .empty; + } + + const subdivs = options.subdivisions; + const vtx_count = (subdivs + 1) * (subdivs + 1); + const idx_count = 2 * subdivs * subdivs * 3; + + var builder = try dvui.Triangles.Builder.init(allocator, vtx_count, idx_count); + errdefer comptime unreachable; + + // Four quad corners in order: tl, tr, br, bl + const tl = path.points[0]; + const tr = path.points[1]; + const br = path.points[2]; + const bl = path.points[3]; + + // Use given UV or default to (0,0,1,1) + const base_uv = options.uv orelse dvui.Rect{ .x = 0, .y = 0, .w = 1, .h = 1 }; + + { + // The seam refraction for a reflection mesh depends only on the column + // (t_x), so precompute it once per column and reuse it down every row + // instead of re-interpolating cols_dx per vertex. Guarded by the buffer + // size; non-reflection meshes and any unusually fine mesh fall back to the + // inline interp below (`seam_cache` stays false). + var dx_seam_col: [64]f32 = undefined; + const seam_cache = options.reflection_lag != null and options.waterline_propagate and subdivs + 1 <= dx_seam_col.len; + if (seam_cache) { + const sample = options.reflection_lag.?; + var x: usize = 0; + while (x <= subdivs) : (x += 1) { + const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); + dx_seam_col[x] = interpolateReflectionCols(&sample.cols_dx, t_x); + } + } + + var y: usize = 0; + while (y <= subdivs) : (y += 1) { // vertical + const t_y = @as(f32, @floatFromInt(y)) / @as(f32, @floatFromInt(subdivs)); + // Interpolate between tl/bl for left and tr/br for right + const left = dvui.Point.Physical{ + .x = tl.x + (bl.x - tl.x) * t_y, + .y = tl.y + (bl.y - tl.y) * t_y, + }; + const right = dvui.Point.Physical{ + .x = tr.x + (br.x - tr.x) * t_y, + .y = tr.y + (br.y - tr.y) * t_y, + }; + // Keep each row monotonic in x so a steep ripple pinches instead of + // folding back over itself. Overlapping triangles double-blend the + // semi-transparent reflection, which reads as a too-bright seam where + // the verts cross (most visible on the fly-in splash). + const row_increasing = right.x >= left.x; + // Hoist the per-row (pow-heavy) refraction factors out of the x-loop. + const refl_row: ?ReflectionRow = if (options.reflection_lag != null and options.waterline_propagate) + reflectionRowFactors(t_y) + else + null; + // Vertex tint only depends on the row (vertical fade), so resolve the + // colour and its PMA conversion once per row, not per vertex. + var row_col: dvui.Color = options.color_mod; + if (options.vertical_fade) row_col = row_col.opacity(0.5 * t_y); + const row_col_pma = dvui.Color.PMA.fromColor(row_col); + var prev_x: f32 = 0; + var x: usize = 0; + while (x <= subdivs) : (x += 1) { // horizontal + const t_x = @as(f32, @floatFromInt(x)) / @as(f32, @floatFromInt(subdivs)); + var pos = dvui.Point.Physical{ + .x = left.x + (right.x - left.x) * t_x, + .y = left.y + (right.y - left.y) * t_x, + }; + if (options.reflection_lag) |sample| { + if (refl_row) |row| { + const dx_seam = if (seam_cache) dx_seam_col[x] else interpolateReflectionCols(&sample.cols_dx, t_x); + var dx = reflectionRowDx(t_x, dx_seam, row, sample); + // The reflection offset is purely horizontal (dy = 0), so the + // magnitude clamp is just |dx| — no Point/​sqrt needed. + const dmax = options.displacement_max; + if (dmax > 0.0001 and @abs(dx) > dmax) dx = std.math.sign(dx) * dmax; + pos.x += dx; + } else { + pos = pos.plus(reflectionCombinedDisplacement(t_x, t_y, options)); + } + if (x > 0) { + if (row_increasing) { + pos.x = @max(pos.x, prev_x); + } else { + pos.x = @min(pos.x, prev_x); + } + } + prev_x = pos.x; + } + + const uv = .{ + base_uv.x + base_uv.w * t_x, + base_uv.y + base_uv.h * t_y, + }; + + builder.appendVertex(.{ + .pos = pos, + .col = row_col_pma, + .uv = uv, + }); + } + } + } + + // Generate indices for quads in row-major order + for (0..subdivs) |j| { + for (0..subdivs) |i| { + const row_stride = subdivs + 1; + const idx0 = j * row_stride + i; + const idx1 = idx0 + 1; + const idx2 = idx0 + row_stride; + const idx3 = idx2 + 1; + // 0---1 + // | / | + // 2---3 + // first triangle (idx0, idx2, idx1) + builder.appendTriangles(&.{ + @intCast(idx0), + @intCast(idx2), + @intCast(idx1), + }); + // second triangle (idx1, idx2, idx3) + builder.appendTriangles(&.{ + @intCast(idx1), + @intCast(idx2), + @intCast(idx3), + }); + } + } + + return builder.build(); +} + +pub fn renderSprite(source: dvui.ImageSource, s: pixi_mod.core_sprite, data_point: dvui.Point, scale: f32, opts: dvui.RenderTextureOptions) !void { + const atlas_size = dvui.imageSize(source) catch { + std.log.err("Failed to get atlas size", .{}); + return; + }; + + var opt = opts; + + const uv = dvui.Rect{ + .x = (@as(f32, @floatFromInt(s.source[0])) / atlas_size.w), + .y = (@as(f32, @floatFromInt(s.source[1])) / atlas_size.h), + .w = (@as(f32, @floatFromInt(s.source[2])) / atlas_size.w), + .h = (@as(f32, @floatFromInt(s.source[3])) / atlas_size.h), + }; + + opt.uv = uv; + + const origin = dvui.Point{ + .x = @as(f32, @floatFromInt(s.origin[0])) * 1 / scale, + .y = @as(f32, @floatFromInt(s.origin[1])) * 1 / scale, + }; + + const position = data_point.diff(origin); + + const box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .rect = .{ + .x = position.x, + .y = position.y, + .w = @as(f32, @floatFromInt(s.source[2])) * scale, + .h = @as(f32, @floatFromInt(s.source[3])) * scale, + }, + .border = dvui.Rect.all(0), + .corner_radius = .{ .x = 0, .y = 0 }, + .padding = .{ .x = 0, .y = 0 }, + .margin = .{ .x = 0, .y = 0 }, + .background = false, + .color_fill = dvui.themeGet().color(.err, .fill), + }); + defer box.deinit(); + + const rs = box.data().rectScale(); + + try dvui.renderImage(source, rs, opt); +} diff --git a/src/plugins/pixi/src/transform_op.zig b/src/plugins/pixi/src/transform_op.zig new file mode 100644 index 00000000..193c6d94 --- /dev/null +++ b/src/plugins/pixi/src/transform_op.zig @@ -0,0 +1,123 @@ +//! Begin a transform on the active document (selection → transform handles). +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); +const State = pixi_mod.State; +const Internal = pixi_mod.internal; + +fn activeFile(st: *State) ?*Internal.File { + const doc = st.host.activeDoc() orelse return null; + return st.docs.fileById(doc.id); +} + +pub fn begin(st: *State) !void { + const file = activeFile(st) orelse return; + if (file.editor.transform) |*t| { + t.cancel(); + } + + var selected_layer = file.layers.get(file.selected_layer_index); + + switch (st.tools.current) { + .selection => { + file.editor.transform_layer.clear(); + var pixel_iterator = file.editor.selection_layer.mask.iterator(.{ .kind = .set, .direction = .forward }); + while (pixel_iterator.next()) |pixel_index| { + @memcpy(&file.editor.transform_layer.pixels()[pixel_index], &selected_layer.pixels()[pixel_index]); + selected_layer.pixels()[pixel_index] = .{ 0, 0, 0, 0 }; + file.editor.transform_layer.mask.set(pixel_index); + } + selected_layer.invalidate(); + }, + else => { + file.editor.transform_layer.clear(); + + if (file.editor.selected_sprites.count() > 0) { + var sprite_iterator = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); + + while (sprite_iterator.next()) |index| { + const source_rect = file.spriteRect(index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + source_rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + source_rect, + .{ .transparent = true, .mask = true }, + ); + selected_layer.clearRect(source_rect); + } + } + } else { + if (file.editor.canvas.hovered) { + if (file.spriteIndex(file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt))) |sprite_index| { + const rect = file.spriteRect(sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + rect, + .{ .transparent = true, .mask = true }, + ); + selected_layer.clearRect(rect); + } + } + } else if (file.selected_animation_index) |animation_index| { + const animation = file.animations.get(animation_index); + if (file.selected_animation_frame_index < animation.frames.len) { + const source_rect = file.spriteRect(animation.frames[file.selected_animation_frame_index].sprite_index); + if (selected_layer.pixelsFromRect( + dvui.currentWindow().arena(), + source_rect, + )) |source_pixels| { + file.editor.transform_layer.blit( + source_pixels, + source_rect, + .{ .transparent = true, .mask = true }, + ); + selected_layer.clearRect(source_rect); + } + } + } + } + }, + } + + const source_rect = dvui.Rect.fromSize(file.editor.transform_layer.size()); + if (file.editor.transform_layer.reduce(source_rect)) |reduced_data_rect| { + defer file.editor.selection_layer.clearMask(); + const gpa = runtime.allocator(); + file.editor.transform = .{ + .target_texture = dvui.textureCreateTarget(.{ .width = file.width(), .height = file.height(), .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + dvui.log.err("Failed to create target texture", .{}); + return; + }, + .file_id = file.id, + .layer_id = selected_layer.id, + .data_points = .{ + reduced_data_rect.topLeft(), + reduced_data_rect.topRight(), + reduced_data_rect.bottomRight(), + reduced_data_rect.bottomLeft(), + reduced_data_rect.center(), + reduced_data_rect.center(), + }, + .source = pixi_mod.image.fromPixelsPMA( + @ptrCast(file.editor.transform_layer.pixelsFromRect(gpa, reduced_data_rect)), + @intFromFloat(reduced_data_rect.w), + @intFromFloat(reduced_data_rect.h), + .ptr, + ) catch return error.MemoryAllocationFailed, + }; + + for (file.editor.transform.?.data_points[0..4]) |*point| { + const d = point.diff(file.editor.transform.?.point(.pivot).*); + if (d.length() > file.editor.transform.?.radius) { + file.editor.transform.?.radius = d.length() + 4; + } + } + } +} diff --git a/src/plugins/pixi/src/web_file_io.zig b/src/plugins/pixi/src/web_file_io.zig new file mode 100644 index 00000000..2e009273 --- /dev/null +++ b/src/plugins/pixi/src/web_file_io.zig @@ -0,0 +1,30 @@ +//! Browser download helpers for the wasm build (no shell `fizzy` dependency). +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const pixi_mod = @import("../pixi.zig"); +const runtime = @import("runtime.zig"); + +fn downloadNameWithExtension(allocator: std.mem.Allocator, filename: []const u8, ext: []const u8) ![]const u8 { + if (std.ascii.eqlIgnoreCase(std.fs.path.extension(filename), ext)) { + return try allocator.dupe(u8, filename); + } + const base = std.fs.path.basename(filename); + const stem: []const u8 = if (std.mem.lastIndexOf(u8, base, ".")) |i| base[0..i] else base; + if (stem.len == 0) { + return try std.fmt.allocPrint(allocator, "download{s}", .{ext}); + } + return try std.fmt.allocPrint(allocator, "{s}{s}", .{ stem, ext }); +} + +pub fn downloadBytes(filename: []const u8, data: []const u8) !void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + try dvui.backend.downloadData(filename, data); +} + +pub fn downloadBytesWithExtension(filename: []const u8, ext: []const u8, data: []const u8) !void { + if (comptime builtin.target.cpu.arch != .wasm32) return; + const name = try downloadNameWithExtension(runtime.allocator(), filename, ext); + defer runtime.allocator().free(name); + try downloadBytes(name, data); +} diff --git a/src/plugins/pixi/src/widgets/CanvasBridge.zig b/src/plugins/pixi/src/widgets/CanvasBridge.zig new file mode 100644 index 00000000..3218788a --- /dev/null +++ b/src/plugins/pixi/src/widgets/CanvasBridge.zig @@ -0,0 +1,24 @@ +//! Bridges the decoupled `CanvasWidget` back to editor/app globals. The canvas takes the +//! pan/zoom scheme as config and input-suppression as a hook so it stays a reusable +//! viewport; these helpers supply the pixel-art editor's wiring at the install sites. +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); +const CanvasWidget = pixi_mod.core.dvui.CanvasWidget; + +/// Map the user's resolved pan/zoom preference onto the canvas's own scheme enum. +pub fn scheme() CanvasWidget.PanZoomScheme { + return switch (pixi_mod.Settings.resolvedPanZoomScheme(&runtime.state().settings, runtime.state().host)) { + .mouse => .mouse, + .trackpad => .trackpad, + }; +} + +/// Suppression hook for a main-scope canvas (the document editing surface, image previews). +pub fn mainSuppressed(_: ?*anyopaque) bool { + return pixi_mod.core.dvui.canvasPointerInputSuppressed(); +} + +/// Suppression hook for a dialog-scope canvas (embedded previews like Grid Layout). +pub fn dialogSuppressed(_: ?*anyopaque) bool { + return pixi_mod.core.dvui.dialogCanvasPointerInputSuppressed(); +} diff --git a/src/editor/widgets/FileWidget.zig b/src/plugins/pixi/src/widgets/FileWidget.zig similarity index 94% rename from src/editor/widgets/FileWidget.zig rename to src/plugins/pixi/src/widgets/FileWidget.zig index f9a6989c..88637f46 100644 --- a/src/editor/widgets/FileWidget.zig +++ b/src/plugins/pixi/src/widgets/FileWidget.zig @@ -1,9 +1,7 @@ const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); const builtin = @import("builtin"); -const sdl3 = @import("backend").c; const Options = dvui.Options; const Rect = dvui.Rect; @@ -16,8 +14,39 @@ const ScrollContainerWidget = dvui.ScrollContainerWidget; const ScaleWidget = dvui.ScaleWidget; pub const FileWidget = @This(); -const CanvasWidget = @import("CanvasWidget.zig"); +const CanvasWidget = pixi_mod.core.dvui.CanvasWidget; +const CanvasBridge = @import("CanvasBridge.zig"); +const CanvasData = @import("../CanvasData.zig"); +const DocLifecycle = @import("../doc_lifecycle.zig"); const icons = @import("icons"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); + +// ---- Canvas hooks: pixel-art reactions to off-artboard viewport gestures. The canvas is +// otherwise a generic viewport; these supply the editor's behavior at install time. ---- + +/// Off-artboard tap (no move, no hold) → clear the current selection. +fn onEmptyTap(_: ?*anyopaque) void { + DocLifecycle.cancelEdit(runtime.state()); +} + +/// Off-artboard hold past the hold-menu duration → open the radial tool menu at the press +/// point. The canvas releases its own capture afterward so the menu buttons can be hovered. +fn onEmptyHold(_: ?*anyopaque, press_p: dvui.Point.Physical) void { + const rm = &runtime.state().tools.radial_menu; + rm.mouse_position = press_p; + rm.center = press_p; + rm.visible = true; + rm.opened_by_press = true; + rm.suppress_next_pointer_release = true; + rm.outside_click_press_p = null; +} + +/// A modified (ctrl/cmd or shift) off-artboard press is the sprite-selection marquee's +/// while the pointer tool is active — yield it instead of starting a viewport pan. +fn yieldModifiedEmptyPress(_: ?*anyopaque) bool { + return runtime.state().tools.current == .pointer; +} init_options: InitOptions, options: Options, @@ -47,7 +76,7 @@ const SpriteReorderMode = enum { }; pub const InitOptions = struct { - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, center: bool = false, }; @@ -78,6 +107,13 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Optio .h = @floatFromInt(init_opts.file.height()), }, .center = init_opts.center, + .pan_zoom_scheme = CanvasBridge.scheme(), + .hooks = .{ + .onEmptyTap = onEmptyTap, + .onEmptyHold = onEmptyHold, + .yieldModifiedEmptyPress = yieldModifiedEmptyPress, + .pointerInputSuppressed = CanvasBridge.mainSuppressed, + }, }, opts); return fw; @@ -205,7 +241,7 @@ pub fn processSample(self: *FileWidget) void { /// Set `file.peek_layer_index` to the visible layer with an opaque pixel at `point`, mirroring /// `sampleColorAtPoint`'s selection rule (bottommost match wins). Called every frame while the /// sample key is held so other layers dim like during layer-list hover. -pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void { +pub fn peekLayerAtPoint(file: *pixi_mod.internal.File, point: dvui.Point) void { if (file.editor.isolate_layer) return; var layer_index: usize = file.layers.len; @@ -225,7 +261,7 @@ pub fn peekLayerAtPoint(file: *fizzy.Internal.File, point: dvui.Point) void { /// Walk visible layers for an opaque pixel at `point`. Optionally selects the hit layer, /// sets the primary color (`apply_primary`), and/or adjusts the active tool (`change_tool`). pub fn sampleColorAtPoint( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, point: dvui.Point, change_layer: bool, apply_primary: bool, @@ -237,7 +273,7 @@ pub fn sampleColorAtPoint( if (file.editor.isolate_layer) { if (file.peek_layer_index) |peek_layer_index| { min_layer_index = peek_layer_index; - } else if (!fizzy.editor.explorer.tools.layersHovered()) { + } else if (!runtime.state().tools_pane.layersHovered()) { min_layer_index = file.selected_layer_index; } } @@ -257,7 +293,7 @@ pub fn sampleColorAtPoint( // Sample acts as a focused layer-pick: narrow multi-selection to just this layer // so the ctrl modifier (also the layer-list multi-select toggle) doesn't accumulate. file.editor.selected_layer_indices.clearRetainingCapacity(); - file.editor.selected_layer_indices.append(fizzy.app.allocator, layer_index) catch {}; + file.editor.selected_layer_indices.append(runtime.allocator(), layer_index) catch {}; file.editor.layer_selection_anchor = layer_index; } } @@ -271,27 +307,27 @@ pub fn sampleColorAtPoint( if (off_canvas) { // Sampling the empty margin outside the artboard isn't an erase — drop back // to the pointer tool so the click reads as "leave drawing mode". - if (fizzy.editor.tools.current != .pointer) { - fizzy.editor.tools.set(.pointer); + if (runtime.state().tools.current != .pointer) { + runtime.state().tools.set(.pointer); } } else if (color[3] == 0) { - if (fizzy.editor.tools.current != .eraser) { - fizzy.editor.tools.set(.eraser); + if (runtime.state().tools.current != .eraser) { + runtime.state().tools.set(.eraser); } } else { - fizzy.editor.colors.primary = color; - if (switch (fizzy.editor.tools.current) { + runtime.state().colors.primary = color; + if (switch (runtime.state().tools.current) { .pencil, .bucket => false, else => true, }) - fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool); + runtime.state().tools.set(runtime.state().tools.previous_drawing_tool); } } else if (apply_primary and color[3] > 0) { - fizzy.editor.colors.primary = color; + runtime.state().colors.primary = color; } } -fn sample(self: *FileWidget, file: *fizzy.Internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void { +fn sample(self: *FileWidget, file: *pixi_mod.internal.File, point: dvui.Point, screen_p: dvui.Point.Physical, change_layer: bool, change_tool: bool) void { if (!file.editor.canvas.samplePointerInViewport(screen_p)) { self.sample_data_point = null; return; @@ -313,7 +349,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { switch (e.evt) { .mouse => |me| { - if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (fizzy.editor.tools.current != .pointer and self.sample_data_point == null)) { + if ((me.button.pointer() and me.action == .press and !me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) or (runtime.state().tools.current != .pointer and self.sample_data_point == null)) { if (file.spriteIndex(self.init_options.file.editor.canvas.dataFromScreenPoint(me.p))) |sprite_index| { var found: bool = false; for (file.animations.items(.frames), 0..) |frames, anim_index| { @@ -342,7 +378,7 @@ pub fn processAnimationSelection(self: *FileWidget) void { } pub fn processCellReorder(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; if (self.drag_data_point != null) return; @@ -408,12 +444,12 @@ pub fn processCellReorder(self: *FileWidget) void { if (self.removed_sprite_indices) |removed_sprite_indices| { if (self.insert_before_sprite_indices) |insert_before_sprite_indices| { - fizzy.app.allocator.free(insert_before_sprite_indices); + runtime.allocator().free(insert_before_sprite_indices); self.insert_before_sprite_indices = null; } // This will actually trigger the drag/drop - var insert_before_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch { + var insert_before_sprite_indices = runtime.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { dvui.log.err("Failed to allocate insert before sprite indices", .{}); return; }; @@ -438,11 +474,11 @@ pub fn processCellReorder(self: *FileWidget) void { file.history.append(.{ .reorder_cell = .{ - .removed_sprite_indices = fizzy.app.allocator.dupe(usize, removed_sprite_indices) catch { + .removed_sprite_indices = runtime.allocator().dupe(usize, removed_sprite_indices) catch { dvui.log.err("Failed to duplicate removed sprite indices", .{}); return; }, - .insert_before_sprite_indices = fizzy.app.allocator.dupe(usize, insert_before_sprite_indices) catch { + .insert_before_sprite_indices = runtime.allocator().dupe(usize, insert_before_sprite_indices) catch { dvui.log.err("Failed to duplicate insert before sprite indices", .{}); return; }, @@ -466,7 +502,7 @@ pub fn processCellReorder(self: *FileWidget) void { dvui.cursorSet(.hand); defer e.handle(@src(), file.editor.canvas.scroll_container.data()); if (self.removed_sprite_indices == null and file.editor.selected_sprites.count() > 0) { - var removed_sprite_indices = fizzy.app.allocator.alloc(usize, file.editor.selected_sprites.count()) catch { + var removed_sprite_indices = runtime.allocator().alloc(usize, file.editor.selected_sprites.count()) catch { dvui.log.err("Failed to allocate removed sprite indices", .{}); return; }; @@ -493,7 +529,7 @@ pub fn processCellReorder(self: *FileWidget) void { /// /// Supports add/remove, drag selection, etc. pub fn processSpriteSelection(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -568,9 +604,7 @@ pub fn processSpriteSelection(self: *FileWidget) void { file.editor.primary_sprite_index = sprite_index; } } else if (!file.editor.canvas.hovered) { - fizzy.editor.cancel() catch { - dvui.log.err("Failed to cancel", .{}); - }; + DocLifecycle.cancelEdit(runtime.state()); } } @@ -641,18 +675,29 @@ const BubblePanShared = struct { tool_not_pointer: bool, }; +/// The pixel-art per-pane `CanvasData` for the pane drawing this file, or null if none is +/// allocated yet. Holds the column/row reorder drag state this widget reads while previewing. +fn canvasData(self: *FileWidget) ?*CanvasData { + return runtime.state().canvas_by_grouping.get(self.init_options.file.editor.grouping); +} + +/// True while a column or row is mid-drag in this pane's rulers. +fn columnRowReorderActive(self: *FileWidget) bool { + const cd = self.canvasData() orelse return false; + return cd.columns_drag_index != null or cd.rows_drag_index != null; +} + /// Same read-only state as `drawSpriteBubbles` uses for `BubblePanShared` (no animation side effects). fn bubblePanSharedForGrid(self: *FileWidget) ?BubblePanShared { if (self.init_options.file.editor.transform != null) return null; if (self.resize_data_point != null) return null; - if (self.init_options.file.editor.workspace.columns_drag_index != null) return null; - if (self.init_options.file.editor.workspace.rows_drag_index != null) return null; + if (self.columnRowReorderActive()) return null; if (self.removed_sprite_indices != null) return null; if (!(self.active() or self.hovered())) return null; const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id; const cw = dvui.currentWindow(); - const tool_not_pointer = fizzy.editor.tools.current != .pointer; + const tool_not_pointer = runtime.state().tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); const sample_active = self.sample_data_point != null; @@ -824,10 +869,10 @@ pub fn drawSpriteBubbles(self: *FileWidget) void { const animation_id = self.init_options.file.editor.canvas.scroll_container.data().id; const cw = dvui.currentWindow(); const drag_sprite_selection = dvui.dragName("sprite_selection_drag"); - const tool_not_pointer = fizzy.editor.tools.current != .pointer; + const tool_not_pointer = runtime.state().tools.current != .pointer; const mod_shift = cw.modifiers.matchBind("shift"); const mod_ctrl_cmd = cw.modifiers.matchBind("ctrl/cmd"); - const radial_visible = fizzy.editor.tools.radial_menu.visible; + const radial_visible = runtime.state().tools.radial_menu.visible; const sample_active = self.sample_data_point != null; const canvas_gesturing = self.init_options.file.editor.canvas.trackpadPinching() or self.init_options.file.editor.canvas.gestureActive(); @@ -1043,7 +1088,7 @@ fn bubbleSpriteDataRect(col_in_row: usize, base_y: f32, col_w: f32, row_h: f32) /// When `accs` is null and `shadow_only` is false, only UI elements are drawn. fn drawSpriteBubbleForRow( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, sprite_index: usize, sprite_rect: dvui.Rect, accs: ?*BubbleAccs, @@ -1080,7 +1125,7 @@ fn drawSpriteBubbleForRow( if (animation_index) |ai| { const id = file.animations.get(ai).id; - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { color = palette.getDVUIColor(@intCast(id)); } if (file.selected_animation_index == ai) { @@ -1386,7 +1431,7 @@ pub fn drawSpriteBubble( var add_rem_message: ?[]const u8 = null; var border_color = dvui.themeGet().color(.control, .fill_hover); - if (fizzy.editor.colors.file_tree_palette) |*palette| { + if (runtime.state().colors.file_tree_palette) |*palette| { if (self.init_options.file.selected_animation_index) |index| { border_color = palette.getDVUIColor(@intCast(self.init_options.file.animations.get(index).id)); add_rem_message = std.fmt.allocPrint(dvui.currentWindow().arena(), "{s}", .{self.init_options.file.animations.get(index).name}) catch { @@ -1489,7 +1534,7 @@ pub fn drawSpriteBubble( var anim = self.init_options.file.animations.get(anim_index); - var frames = std.array_list.Managed(fizzy.Animation.Frame).init(fizzy.app.allocator); + var frames = std.array_list.Managed(pixi_mod.Animation.Frame).init(runtime.allocator()); frames.appendSlice(anim.frames) catch { dvui.log.err("Failed to append frames", .{}); return false; @@ -1566,7 +1611,7 @@ pub fn drawSpriteBubble( self.init_options.file.history.append(.{ .animation_frames = .{ .index = anim_index, - .frames = fizzy.app.allocator.dupe(fizzy.Animation.Frame, anim.frames) catch { + .frames = runtime.allocator().dupe(pixi_mod.Animation.Frame, anim.frames) catch { dvui.log.err("Failed to dupe frames", .{}); return false; }, @@ -1575,7 +1620,7 @@ pub fn drawSpriteBubble( dvui.log.err("Failed to append history", .{}); }; - fizzy.app.allocator.free(anim.frames); + runtime.allocator().free(anim.frames); anim.frames = frames.toOwnedSlice() catch { dvui.log.err("Failed to free frames", .{}); return false; @@ -1588,12 +1633,12 @@ pub fn drawSpriteBubble( self.init_options.file.selected_animation_index = anim_index; self.init_options.file.collapseAnimationSelectionToPrimary(); self.init_options.file.editor.animations_scroll_to_index = anim_index; - fizzy.editor.explorer.sprites.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; - fizzy.editor.explorer.pane = .sprites; + runtime.state().sprites_pane.edit_anim_id = self.init_options.file.animations.items(.id)[anim_index]; + runtime.state().host.setActiveSidebarView(@import("../plugin.zig").view_sprites); var anim = self.init_options.file.animations.get(anim_index); if (anim.frames.len == 0) { - anim.appendFrame(fizzy.app.allocator, .{ .sprite_index = sprite_index, .ms = temp_ms }) catch { + anim.appendFrame(runtime.allocator(), .{ .sprite_index = sprite_index, .ms = temp_ms }) catch { dvui.log.err("Failed to append frame", .{}); return false; }; @@ -1727,7 +1772,7 @@ pub fn drawSpriteBubble( /// Draw the highlight colored selection box for each selected sprite. pub fn drawSpriteSelection(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -1857,8 +1902,8 @@ fn strokePolylineDashedPhysical( } fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { - if (fizzy.editor.tools.current != .selection) return; - if (fizzy.editor.tools.selection_mode != .box) return; + if (runtime.state().tools.current != .selection) return; + if (runtime.state().tools.selection_mode != .box) return; const start = self.drag_data_point orelse return; if (dvui.dragging(dvui.currentWindow().mouse_pt, "stroke_drag") == null) return; @@ -1903,8 +1948,8 @@ fn drawBoxSelectionMarqueeOutline(self: *FileWidget) void { /// Preview for rectangular selection while dragging (box mode). fn applySelectionBoxPreview( - file: *fizzy.Internal.File, - active_layer: *const fizzy.Internal.Layer, + file: *pixi_mod.internal.File, + active_layer: *const pixi_mod.internal.Layer, start: dvui.Point, end: dvui.Point, mod: dvui.enums.Mod, @@ -1947,7 +1992,7 @@ fn applySelectionBoxPreview( /// This selection is pixel-based, and includes shift/ctrl/cmd modifiers to support add/remove. /// The selection uses the same logic as the stroke tool to brush the selection over existing pixels. pub fn processSelection(self: *FileWidget) void { - if (switch (fizzy.editor.tools.current) { + if (switch (runtime.state().tools.current) { .selection, => false, else => true, @@ -1970,7 +2015,7 @@ pub fn processSelection(self: *FileWidget) void { // Pixel mode: draw the committed selection before handling events (brush preview layers on top). // Box mode: skip — the mask is updated on mouse release in the same frame as this paint; drawing // here would use stale data until the next frame. Box repaints from the current mask after events. - if (fizzy.editor.tools.selection_mode == .pixel or fizzy.editor.tools.selection_mode == .color) { + if (runtime.state().tools.selection_mode == .pixel or runtime.state().tools.selection_mode == .color) { @memset(file.editor.temporary_layer.pixels(), .{ 0, 0, 0, 0 }); file.editor.temporary_layer.clearMask(); @@ -1990,21 +2035,21 @@ pub fn processSelection(self: *FileWidget) void { switch (e.evt) { .key => |ke| { var update: bool = false; - if (fizzy.editor.tools.selection_mode == .pixel) { + if (runtime.state().tools.selection_mode == .pixel) { if (ke.matchBind("increase_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.stroke_size < fizzy.Editor.Tools.max_brush_size - 1) - fizzy.editor.tools.stroke_size += 1; + if (runtime.state().tools.stroke_size < pixi_mod.Tools.max_brush_size - 1) + runtime.state().tools.stroke_size += 1; - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } if (ke.matchBind("decrease_stroke_size") and (ke.action == .down or ke.action == .repeat)) { - if (fizzy.editor.tools.stroke_size > 1) - fizzy.editor.tools.stroke_size -= 1; + if (runtime.state().tools.stroke_size > 1) + runtime.state().tools.stroke_size -= 1; - fizzy.editor.tools.setStrokeSize(fizzy.editor.tools.stroke_size); + runtime.state().tools.setStrokeSize(runtime.state().tools.stroke_size); e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); update = true; } @@ -2027,7 +2072,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); @@ -2045,8 +2090,8 @@ pub fn processSelection(self: *FileWidget) void { const current_point = self.init_options.file.editor.canvas.dataFromScreenPoint(me.p); if (me.action == .position) { - const box_mode = fizzy.editor.tools.selection_mode == .box; - const color_mode = fizzy.editor.tools.selection_mode == .color; + const box_mode = runtime.state().tools.selection_mode == .box; + const color_mode = runtime.state().tools.selection_mode == .color; const is_drag = dvui.dragging(me.p, "stroke_drag") != null; const box_drag = box_mode and is_drag and self.drag_data_point != null; @@ -2097,7 +2142,7 @@ pub fn processSelection(self: *FileWidget) void { .temporary, .{ .mask_only = true, - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); @@ -2128,7 +2173,7 @@ pub fn processSelection(self: *FileWidget) void { if (!widget_active) continue; e.handle(@src(), self.init_options.file.editor.canvas.scroll_container.data()); - if (fizzy.editor.tools.selection_mode == .color) { + if (runtime.state().tools.selection_mode == .color) { // Only clear the mask if we don't have ctrl/cmd pressed if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); @@ -2146,14 +2191,14 @@ pub fn processSelection(self: *FileWidget) void { if (!me.mod.matchBind("ctrl/cmd") and !me.mod.matchBind("shift")) file.editor.selection_layer.clearMask(); - if (fizzy.editor.tools.selection_mode == .box) { + if (runtime.state().tools.selection_mode == .box) { self.drag_data_point = current_point; } else { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); @@ -2166,23 +2211,23 @@ pub fn processSelection(self: *FileWidget) void { dvui.captureMouse(null, e.num); dvui.dragEnd(); - if (fizzy.editor.tools.selection_mode == .box) { + if (runtime.state().tools.selection_mode == .box) { if (self.drag_data_point) |start| { file.selectRectBetweenPoints( start, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); } - } else if (fizzy.editor.tools.selection_mode != .color) { + } else if (runtime.state().tools.selection_mode != .color) { file.selectPoint( current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); } @@ -2214,14 +2259,14 @@ pub fn processSelection(self: *FileWidget) void { }); } - if (fizzy.editor.tools.selection_mode == .pixel) { + if (runtime.state().tools.selection_mode == .pixel) { if (self.drag_data_point) |previous_point| { file.selectLine( previous_point, current_point, .{ .value = !me.mod.matchBind("shift"), - .stroke_size = fizzy.editor.tools.stroke_size, + .stroke_size = runtime.state().tools.stroke_size, }, ); } @@ -2236,7 +2281,7 @@ pub fn processSelection(self: *FileWidget) void { } } - if (fizzy.editor.tools.selection_mode == .box) { + if (runtime.state().tools.selection_mode == .box) { const mouse_pt = dvui.currentWindow().mouse_pt; const is_drag = dvui.dragging(mouse_pt, "stroke_drag") != null; if (!(is_drag and self.drag_data_point != null)) { @@ -2258,7 +2303,7 @@ pub fn processSelection(self: *FileWidget) void { fn processStrokeDragSegment( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, previous_point: dvui.Point, current_point: dvui.Point, screen_pt: dvui.Point.Physical, @@ -2319,7 +2364,7 @@ fn processStrokeDragSegment( .stroke_size = stroke_size, }, ); - fizzy.perf.draw_event_count += 1; + pixi_mod.perf.draw_event_count += 1; } else |err| { dvui.log.err("strokeUndoExpandToCoverRect failed: {}", .{err}); } @@ -2334,7 +2379,7 @@ fn processStrokeDragSegment( { if (self.sample_data_point == null or color[3] == 0) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2355,12 +2400,12 @@ fn processStrokeDragSegment( /// Supports using shift to draw a line between two points, and increasing/decreasing stroke size pub fn processStroke(self: *FileWidget) void { const file = self.init_options.file; - const stroke_size = fizzy.editor.tools.stroke_size; + const stroke_size = runtime.state().tools.stroke_size; const widget_active = self.active(); if (self.cell_reorder_point != null) return; - if (switch (fizzy.editor.tools.current) { + if (switch (runtime.state().tools.current) { .pencil, .eraser, => false, @@ -2369,8 +2414,8 @@ pub fn processStroke(self: *FileWidget) void { if (self.sample_key_down or self.right_mouse_down) return; - const color: [4]u8 = switch (fizzy.editor.tools.current) { - .pencil => fizzy.editor.colors.primary, + const color: [4]u8 = switch (runtime.state().tools.current) { + .pencil => runtime.state().colors.primary, .eraser => [_]u8{ 0, 0, 0, 0 }, else => unreachable, }; @@ -2515,7 +2560,7 @@ pub fn processStroke(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; file.drawPoint( current_point, .temporary, @@ -2541,10 +2586,10 @@ pub fn processStroke(self: *FileWidget) void { /// Supports using ctrl/cmd to replace all existing pixels of the same color with the new color, /// or without modifiers to flood fill the layer with the new color. pub fn processFill(self: *FileWidget) void { - if (fizzy.editor.tools.current != .bucket) return; + if (runtime.state().tools.current != .bucket) return; if (self.sample_key_down) return; const file = self.init_options.file; - const color = fizzy.editor.colors.primary; + const color = runtime.state().colors.primary; const widget_active = self.active(); // Skip the cursor-follow temp preview on touch: the finger occludes the pixel and @@ -2554,7 +2599,7 @@ pub fn processFill(self: *FileWidget) void { self.sample_data_point == null) { clearTempPreview(&file.editor); - const temp_color = if (fizzy.editor.tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; + const temp_color = if (runtime.state().tools.current != .eraser) color else [_]u8{ 255, 255, 255, 255 }; const fill_preview_pt = self.init_options.file.editor.canvas.dataFromScreenPoint(dvui.currentWindow().mouse_pt); file.drawPoint( fill_preview_pt, @@ -2627,7 +2672,7 @@ pub fn processTransform(self: *FileWidget) void { triangles.rotate(.{ .x = transform.point(.pivot).x, .y = transform.point(.pivot).y }, transform.rotation); for (transform.data_points[0..6], 0..) |*data_point, point_index| { - const transform_point = @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index)); + const transform_point = @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index)); const screen_point = if (point_index < 4) file.editor.canvas.screenFromDataPoint(.{ .x = triangles.vertexes[point_index].pos.x, .y = triangles.vertexes[point_index].pos.y }) else file.editor.canvas.screenFromDataPoint(data_point.*); var screen_rect = dvui.Rect.Physical.fromPoint(screen_point); @@ -2644,7 +2689,7 @@ pub fn processTransform(self: *FileWidget) void { if (screen_rect.contains(dvui.currentWindow().mouse_pt)) { dvui.cursorSet(.hand); } else if (transform.active_point) |active_point| { - if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) { + if (active_point == @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index))) { dvui.cursorSet(.hand); } } @@ -2739,7 +2784,7 @@ pub fn processTransform(self: *FileWidget) void { new_point.y = @round(new_point.y); // Now we have to un-rotate the vertex and set the original location - new_point = fizzy.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation); + new_point = pixi_mod.math.rotate(new_point, transform.point(.pivot).*, -transform.rotation); const opposite_index: usize = switch (point_index) { 0 => 2, @@ -2790,8 +2835,8 @@ pub fn processTransform(self: *FileWidget) void { const opposite_point = &transform.data_points[opposite_index]; - var rotation_direction: dvui.Point = fizzy.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0); - var rotation_perp: dvui.Point = fizzy.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0); + var rotation_direction: dvui.Point = pixi_mod.math.rotate(dvui.Point{ .x = 1, .y = 0 }, transform.point(.pivot).*, 0); + var rotation_perp: dvui.Point = pixi_mod.math.rotate(dvui.Point{ .x = 0, .y = 1 }, transform.point(.pivot).*, 0); // Calculate the difference between the adjacent points and the new point @@ -2845,7 +2890,7 @@ pub fn processTransform(self: *FileWidget) void { transform.rotation = std.math.degreesToRadians(@round(std.math.radiansToDegrees(transform.start_rotation + (angle - drag_angle)))); if (me.mod.matchBind("ctrl/cmd")) { // Lock rotation to cardinal directions - const direction = fizzy.math.Direction.fromRadians(transform.rotation); + const direction = pixi_mod.math.Direction.fromRadians(transform.rotation); transform.rotation = switch (direction) { .n => std.math.pi / 2.0, .ne => std.math.pi / 4.0, @@ -2983,7 +3028,7 @@ pub fn drawTransform(self: *FileWidget) void { } var centroid = transform.centroid(); - centroid = fizzy.math.rotate(centroid, transform.point(.pivot).*, transform.rotation); + centroid = pixi_mod.math.rotate(centroid, transform.point(.pivot).*, transform.rotation); // Full-sprite center guides (magenta). When ortho cell dimensions are shown, centering is // indicated on those dimension lines (blue) instead — avoids overlapping magenta guides. @@ -3111,7 +3156,7 @@ pub fn drawTransform(self: *FileWidget) void { // Dimensions and angle labels { - const dim_font = dvui.Font.theme(.mono).larger(-2); + const dim_font = dvui.Font.theme(.mono); if (show_ortho_dims) { const ns = dvui.currentWindow().natural_scale; @@ -3254,7 +3299,7 @@ pub fn drawTransform(self: *FileWidget) void { const bottom_left_v = triangles.vertexes[3].pos; const bottom_right_v = triangles.vertexes[2].pos; - const offset_v = fizzy.math.rotate( + const offset_v = pixi_mod.math.rotate( dvui.Point{ .x = label_off_screen, .y = 0 }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3266,7 +3311,7 @@ pub fn drawTransform(self: *FileWidget) void { const simple_v = std.fmt.allocPrint(arena, "{d}", .{@as(i32, @intFromFloat(@round(inner_h_f)))}) catch "—"; renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v)); - const offset_h = fizzy.math.rotate( + const offset_h = pixi_mod.math.rotate( dvui.Point{ .x = 0, .y = -label_off_screen }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3283,7 +3328,7 @@ pub fn drawTransform(self: *FileWidget) void { const bottom_left = triangles.vertexes[3].pos; const bottom_right = triangles.vertexes[2].pos; - const offset_v = fizzy.math.rotate( + const offset_v = pixi_mod.math.rotate( dvui.Point{ .x = label_off_screen, .y = 0 }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3299,7 +3344,7 @@ pub fn drawTransform(self: *FileWidget) void { ) catch "—"; renderTransformDimLabel(dim_font, simple_v, center_v.plus(off_v)); - const offset_h = fizzy.math.rotate( + const offset_h = pixi_mod.math.rotate( dvui.Point{ .x = 0, .y = -label_off_screen }, .{ .x = 0, .y = 0 }, transform.rotation, @@ -3399,7 +3444,7 @@ pub fn drawTransform(self: *FileWidget) void { var color = dvui.themeGet().color(.window, .text); if (transform.active_point) |active_point| { - if (active_point == @as(fizzy.Editor.Transform.TransformPoint, @enumFromInt(point_index))) { + if (active_point == @as(pixi_mod.Transform.TransformPoint, @enumFromInt(point_index))) { color = dvui.themeGet().color(.highlight, .fill); } } else if (screen_rect.contains(dvui.currentWindow().mouse_pt)) { @@ -3509,7 +3554,7 @@ fn doubleStrokeDimensionTickColor(points: []const dvui.Point.Physical, thickness /// axis-aligned quad (4 vertices, 2 triangles) submitted via one `renderTriangles`. fn drawBatchedGridLines( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, columns: usize, rows: usize, grid_color: dvui.Color, @@ -3635,7 +3680,7 @@ fn appendLineQuad(builder: *dvui.Triangles.Builder, tl: dvui.Point.Physical, br: } /// Viewport in data space + row/column index range for culling (matches bubble / grid logic). -fn fileCanvasVisibleGridParams(file: *fizzy.Internal.File) ?struct { +fn fileCanvasVisibleGridParams(file: *pixi_mod.internal.File) ?struct { visible_data: dvui.Rect, row_h: f32, col_w: f32, @@ -3722,7 +3767,7 @@ fn appendHorizontalGridRunsForRow( /// Batches grid lines for the resize-shrink overlay (original layer_rect shown in error tint). fn drawBatchedResizeOverlayGrid( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, columns: usize, layer_rect: dvui.Rect, grid_thickness: f32, @@ -3809,8 +3854,8 @@ fn checkerboardVertexColor( } /// Animation color for transparency tint; matches bubble arc palette lookup order (selected animation first, else first containing animation). -fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) ?dvui.Color { - if (fizzy.editor.colors.file_tree_palette) |*palette| { +fn spriteAnimationPaletteColor(file: *pixi_mod.internal.File, sprite_index: usize) ?dvui.Color { + if (runtime.state().colors.file_tree_palette) |*palette| { var animation_index: ?usize = null; if (file.selected_animation_index) |selected_animation_index| { @@ -3842,8 +3887,8 @@ fn spriteAnimationPaletteColor(file: *fizzy.Internal.File, sprite_index: usize) } fn checkerboardCellCornerColor( - effect: fizzy.Editor.Settings.TransparencyEffect, - file: *fizzy.Internal.File, + effect: pixi_mod.Settings.TransparencyEffect, + file: *pixi_mod.internal.File, sprite_index: usize, c_tl: dvui.Color, c_tr: dvui.Color, @@ -3884,10 +3929,10 @@ fn checkerboardGridPalette() struct { tone: dvui.Color, c_tl: dvui.Color, c_tr: } /// Same tint as the batched checkerboard for the cell under `sprite_index` (center UV), for bubbles etc. -fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: usize) dvui.Color { +fn checkerboardTintAtSpriteCellCenter(file: *pixi_mod.internal.File, sprite_index: usize) dvui.Color { const pal = checkerboardGridPalette(); const tone = pal.tone; - switch (fizzy.editor.settings.transparency_effect) { + switch (runtime.state().settings.transparency_effect) { .none => return tone, .rainbow => { const mu_mv = dvui.dataGet(null, file.editor.canvas.id, "checkerboard_mouse_uv", dvui.Point) orelse dvui.Point{ .x = 0.5, .y = 0.5 }; @@ -3906,11 +3951,11 @@ fn checkerboardTintAtSpriteCellCenter(file: *fizzy.Internal.File, sprite_index: /// Checkerboard behind layers: one batched quad per visible cell (UV 0..1 per cell — vertex colors /// vary per cell for rainbow / animation effects, which is why this isn't a single wrapped quad). -fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void { +fn drawCheckerboardCellsBatched(file: *pixi_mod.internal.File) void { const n = file.spriteCount(); if (n == 0) return; - const te = fizzy.editor.settings.transparency_effect; + const te = runtime.state().settings.transparency_effect; const pal = checkerboardGridPalette(); const tone = pal.tone; const rs = file.editor.canvas.screen_rect_scale; @@ -4009,7 +4054,7 @@ fn drawCheckerboardCellsBatched(file: *fizzy.Internal.File) void { } pub fn active(self: *FileWidget) bool { - if (fizzy.editor.activeFile()) |file| { + if (runtime.state().docs.activeFile(runtime.state().host)) |file| { if (file.id == self.init_options.file.id) { return true; } @@ -4018,9 +4063,9 @@ pub fn active(self: *FileWidget) bool { } pub fn drawCursor(self: *FileWidget) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) return; - if (fizzy.editor.tools.current == .pointer and self.sample_data_point == null) return; - if (fizzy.editor.tools.radial_menu.visible) return; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return; + if (runtime.state().tools.current == .pointer and self.sample_data_point == null) return; + if (runtime.state().tools.radial_menu.visible) return; if (self.init_options.file.editor.transform != null) return; if (self.init_options.file.editor.canvas.gestureActive()) return; if (self.init_options.file.editor.canvas.trackpadPinching()) return; @@ -4059,20 +4104,20 @@ pub fn drawCursor(self: *FileWidget) void { const data_point = self.init_options.file.editor.canvas.dataFromScreenPoint(mouse_point); - const selection_sprite = switch (fizzy.editor.tools.selection_mode) { - .box => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.box_selection_default], - .pixel => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pixel_selection_default], - .color => if (subtract) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_rem_default] else if (add) fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_add_default] else fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.color_selection_default], + const selection_sprite = switch (runtime.state().tools.selection_mode) { + .box => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.box_selection_default], + .pixel => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pixel_selection_default], + .color => if (subtract) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_rem_default] else if (add) runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_add_default] else runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.color_selection_default], }; - if (switch (fizzy.editor.tools.current) { - .pencil => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.pencil_default], - .eraser => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.eraser_default], - .bucket => fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.bucket_default], + if (switch (runtime.state().tools.current) { + .pencil => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.pencil_default], + .eraser => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.eraser_default], + .bucket => runtime.state().host.uiAtlas().sprites[pixi_mod.atlas.sprites.bucket_default], .selection => selection_sprite, else => null, }) |sprite| { - const atlas_size = dvui.imageSize(fizzy.editor.atlas.source) catch { + const atlas_size = dvui.imageSize(runtime.state().host.uiAtlas().source) catch { dvui.log.err("Failed to get atlas size", .{}); return; }; @@ -4110,7 +4155,7 @@ pub fn drawCursor(self: *FileWidget) void { const rs = box.data().rectScale(); - dvui.renderImage(fizzy.editor.atlas.source, rs, .{ + dvui.renderImage(runtime.state().host.uiAtlas().source, rs, .{ .uv = uv, }) catch { dvui.log.err("Failed to render cursor image", .{}); @@ -4161,7 +4206,7 @@ fn mapDataRectToPhysicalStrip(sr: dvui.Rect, parent_data: dvui.Rect, parent_phys /// Draw the checkerboard alpha pattern into `dest_phys`. Uses wrap=.repeat on the tile texture so /// the entire region is one quad with UV scaled so each `cw × ch` of data space spans one tile. fn drawSampleMagnifierCheckerboardTiles( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, region_data: dvui.Rect, dest_phys: dvui.Rect.Physical, scale: f32, @@ -4188,7 +4233,7 @@ fn drawSampleMagnifierCheckerboardTiles( /// Build checkerboard + layers into an offscreen target. Layer composites are synced on the screen /// target first so `renderLayers` does not rebind this target via `syncLayerComposite`. fn drawSampleMagnifierCompositeBuild( - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, region_data: dvui.Rect, content_rs: dvui.RectScale, file_w: f32, @@ -4200,18 +4245,18 @@ fn drawSampleMagnifierCompositeBuild( const h: u32 = @intFromFloat(@max(@ceil(content_rs.r.h), 1)); const layer_region = region_data.intersect(dvui.Rect{ .x = 0, .y = 0, .w = file_w, .h = file_h }); - const layer_opts_base = fizzy.render.RenderFileOptions{ + const layer_opts_base = pixi_mod.render.RenderFileOptions{ .file = file, .rs = content_rs, .allow_peek = false, }; // Refresh cached layer composites on the screen target (not the magnifier target). - fizzy.render.ensureLayerCompositesForPreview(layer_opts_base) catch { + pixi_mod.render.ensureLayerCompositesForPreview(layer_opts_base) catch { dvui.log.err("Failed to sync layer composites for magnifier", .{}); }; - const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = fizzy.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { + const target = dvui.textureCreateTarget(.{ .width = w, .height = h, .format = pixi_mod.render.compositeTargetPixelFormat(), .interpolation = .nearest }) catch { dvui.log.err("Failed to create magnifier composite target", .{}); return null; }; @@ -4237,7 +4282,7 @@ fn drawSampleMagnifierCompositeBuild( .w = layer_region.w / file_w, .h = layer_region.h / file_h, }; - fizzy.render.renderLayersMagnifierSample(.{ + pixi_mod.render.renderLayersMagnifierSample(.{ .file = file, .rs = .{ .r = layer_phys, .s = 1.0 }, .uv = uv_rect, @@ -4338,9 +4383,9 @@ fn drawSampleMagnifierPresent( } }, .{ .thickness = 2, .color = .black }); } -pub fn drawSampleMagnifier(file: *fizzy.Internal.File, data_point: dvui.Point) void { +pub fn drawSampleMagnifier(file: *pixi_mod.internal.File, data_point: dvui.Point) void { const canvas = &file.editor.canvas; - if (fizzy.dvui.canvasPointerInputSuppressed()) return; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return; if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; _ = dvui.cursorSet(.hidden); @@ -4438,8 +4483,8 @@ pub fn updateActiveLayerMask(self: *FileWidget) void { } pub fn drawLayers(self: *FileWidget) void { - const perf_t0 = fizzy.perf.drawLayersBegin(); - defer fizzy.perf.drawLayersEnd(perf_t0); + const perf_t0 = pixi_mod.perf.drawLayersBegin(); + defer pixi_mod.perf.drawLayersEnd(perf_t0); var file = self.init_options.file; var columns: usize = file.columns; @@ -4509,11 +4554,11 @@ pub fn drawLayers(self: *FileWidget) void { if (self.removed_sprite_indices != null) { self.drawCellReorderPreview(); return; - } else if (file.editor.workspace.columns_drag_index != null or file.editor.workspace.rows_drag_index != null) { + } else if (self.columnRowReorderActive()) { self.drawColumnRowReorderPreview(); return; } else { - fizzy.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = .{ .r = self.init_options.file.editor.canvas.rect, @@ -4565,14 +4610,14 @@ pub fn drawLayers(self: *FileWidget) void { } // Draw the selection box for the selected sprites - if (fizzy.editor.tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { + if (runtime.state().tools.current == .pointer and file.editor.transform == null and self.resize_data_point == null) { var iter = file.editor.selected_sprites.iterator(.{ .kind = .set, .direction = .forward }); while (iter.next()) |i| { const sprite_rect = file.spriteRect(i); const sprite_rect_physical = self.init_options.file.editor.canvas.screenFromDataRect(sprite_rect); // Draw the origins when in the sprites pane - if (fizzy.editor.explorer.pane == .sprites) { + if (runtime.state().host.isActiveSidebarView(@import("../plugin.zig").view_sprites)) { const origin: dvui.Point = .{ .x = sprite_rect.topLeft().x + file.sprites.get(i).origin[0], .y = sprite_rect.topLeft().y + file.sprites.get(i).origin[1] }; const horizontal_line_start: dvui.Point = .{ .x = sprite_rect.topLeft().x, .y = origin.y }; @@ -4605,7 +4650,7 @@ const ReorderAxis = enum { columns, rows }; /// Checkerboard alpha over each cell of the floating column/row, matching `drawCheckerboardCellsBatched` tint/UVs at half opacity. fn drawCheckerboardReorderFloatingStrip( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, removed_data_rect: dvui.Rect, strip_phys: dvui.Rect.Physical, axis: ReorderAxis, @@ -4635,7 +4680,7 @@ fn drawCheckerboardReorderFloatingStrip( const c_tr = pal.c_tr; const c_bl = pal.c_bl; const c_br = pal.c_br; - const te = fizzy.editor.settings.transparency_effect; + const te = runtime.state().settings.transparency_effect; const cols_f = @max(@as(f32, @floatFromInt(file.columns)), 1.0); const rows_f = @max(@as(f32, @floatFromInt(file.rows)), 1.0); @@ -4709,17 +4754,17 @@ fn drawCanvasCheckerboardBackground(self: *FileWidget) void { fn drawColumnRowReorderPreview(self: *FileWidget) void { const file = self.init_options.file; - const workspace = file.editor.workspace; - if (workspace.columns_drag_index == null and workspace.rows_drag_index == null) return; + const cd = self.canvasData() orelse return; + if (cd.columns_drag_index == null and cd.rows_drag_index == null) return; - const axis: ReorderAxis = if (workspace.columns_drag_index != null) .columns else .rows; + const axis: ReorderAxis = if (cd.columns_drag_index != null) .columns else .rows; const target_index = switch (axis) { - .columns => workspace.columns_target_index, - .rows => workspace.rows_target_index, + .columns => cd.columns_target_index, + .rows => cd.rows_target_index, }; const removed_index = switch (axis) { - .columns => workspace.columns_drag_index, - .rows => workspace.rows_drag_index, + .columns => cd.columns_drag_index, + .rows => cd.rows_drag_index, } orelse return; self.drawReorderPreviewForAxis(file, axis, target_index, removed_index); @@ -4727,7 +4772,7 @@ fn drawColumnRowReorderPreview(self: *FileWidget) void { fn renderLayersInDataRect( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, data_rect: dvui.Rect, screen_rect_override: ?dvui.Rect.Physical, ) void { @@ -4735,7 +4780,7 @@ fn renderLayersInDataRect( const w = @as(f32, @floatFromInt(file.width())); const h = @as(f32, @floatFromInt(file.height())); const r = screen_rect_override orelse file.editor.canvas.screenFromDataRect(data_rect); - fizzy.render.renderLayers(.{ + pixi_mod.render.renderLayers(.{ .file = file, .rs = .{ .r = r, .s = scale }, .uv = .{ @@ -4749,7 +4794,7 @@ fn renderLayersInDataRect( fn reorderSegmentRects( axis: ReorderAxis, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, target_index: usize, removed_index: usize, target_rect: dvui.Rect, @@ -4823,7 +4868,7 @@ fn reorderSegmentRects( fn drawReorderPreviewForAxis( self: *FileWidget, - file: *fizzy.Internal.File, + file: *pixi_mod.internal.File, axis: ReorderAxis, target_index: ?usize, removed_index: usize, @@ -4973,10 +5018,10 @@ fn drawReorderPreviewForAxis( }); { - fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{ + pixi_mod.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .right else .top, .{ .opacity = 0.5, }); - fizzy.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{ + pixi_mod.core.dvui.drawEdgeShadow(.{ .r = file.editor.canvas.screenFromDataRect(animated_target_box_rect), .s = scale }, if (axis == .columns) .left else .bottom, .{ .opacity = 0.5, }); } @@ -5234,22 +5279,22 @@ pub fn drawCellReorderPreview(self: *FileWidget) void { if (left_index) |left_index_value| { if (!temp_selected_sprite.isSet(left_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .left, .{ .opacity = 0.35 }); } } if (right_index) |right_index_value| { if (!temp_selected_sprite.isSet(right_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .right, .{ .opacity = 0.35 }); } } if (top_index) |top_index_value| { if (!temp_selected_sprite.isSet(top_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .top, .{ .opacity = 0.35 }); } } if (bottom_index) |bottom_index_value| { if (!temp_selected_sprite.isSet(bottom_index_value)) { - fizzy.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 }); + pixi_mod.core.dvui.drawEdgeShadow(image_rect_scale, .bottom, .{ .opacity = 0.35 }); } } } @@ -5429,7 +5474,7 @@ fn autoPanForResize(self: *FileWidget, mouse_pt: dvui.Point.Physical) void { } pub fn processResize(self: *FileWidget) void { - if (fizzy.editor.tools.current != .pointer) return; + if (runtime.state().tools.current != .pointer) return; if (self.init_options.file.editor.transform != null) return; if (self.sample_data_point != null) return; @@ -5629,7 +5674,7 @@ pub fn processResize(self: *FileWidget) void { pub fn processEvents(self: *FileWidget) void { const transform = self.init_options.file.editor.transform != null; - const reorder = self.init_options.file.editor.workspace.columns_drag_index != null or self.init_options.file.editor.workspace.rows_drag_index != null or self.removed_sprite_indices != null; + const reorder = self.columnRowReorderActive() or self.removed_sprite_indices != null; // Try to ensure that selected animation frame index is valid if (self.init_options.file.selected_animation_index) |ai| { @@ -5711,7 +5756,7 @@ pub fn processEvents(self: *FileWidget) void { const canvas_ptr = &self.init_options.file.editor.canvas; const mouse_pt = dvui.currentWindow().mouse_pt; - canvas_ptr.hovered = !fizzy.dvui.canvasPointerInputSuppressed() and + canvas_ptr.hovered = !pixi_mod.core.dvui.canvasPointerInputSuppressed() and canvas_ptr.pointerOverDrawable(mouse_pt); // Cursor-leave: when hover transitions true → false, the last brush/fill preview @@ -5753,18 +5798,18 @@ pub fn processEvents(self: *FileWidget) void { // current single touch will become one — otherwise the bucket/pencil hover preview would // flash on the pinned finger as the user starts a pan gesture. if (self.hovered() and !self.init_options.file.editor.canvas.gestureActive()) { - const pe_t0 = fizzy.perf.processEventsBegin(); - defer fizzy.perf.processEventsEnd(pe_t0); + const pe_t0 = pixi_mod.perf.processEventsBegin(); + defer pixi_mod.perf.processEventsEnd(pe_t0); resetTempLayerPreview(&self.init_options.file.editor); { - const mask_t0 = fizzy.perf.updateMaskBegin(); - defer fizzy.perf.updateMaskEnd(mask_t0); + const mask_t0 = pixi_mod.perf.updateMaskBegin(); + defer pixi_mod.perf.updateMaskEnd(mask_t0); self.updateActiveLayerMask(); } - if (fizzy.editor.tools.current == .selection) { + if (runtime.state().tools.current == .selection) { if (dvui.timerDoneOrNone(self.init_options.file.editor.canvas.scroll_container.data().id)) { self.init_options.file.editor.checkerboard.toggleAll(); @@ -5773,14 +5818,14 @@ pub fn processEvents(self: *FileWidget) void { } if (self.init_options.file.editor.transform == null) { - const tool_t0 = fizzy.perf.toolProcessBegin(); - switch (fizzy.editor.tools.current) { + const tool_t0 = pixi_mod.perf.toolProcessBegin(); + switch (runtime.state().tools.current) { .bucket => self.processFill(), .pencil, .eraser => self.processStroke(), .selection => self.processSelection(), else => {}, } - fizzy.perf.toolProcessEnd(tool_t0); + pixi_mod.perf.toolProcessEnd(tool_t0); } } else if (self.hovered() and self.init_options.file.editor.canvas.gestureActive()) { // A 2-finger gesture (or its pending evaluation) just took over. Make sure any @@ -5835,10 +5880,10 @@ pub fn processEvents(self: *FileWidget) void { } // Draw shadows for the scroll container - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{}); - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .top, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .bottom, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .left, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.file.editor.canvas.scroll_container.data().rectScale(), .right, .{}); self.drawTransform(); self.processSample(); @@ -5857,7 +5902,7 @@ pub fn deinit(self: *FileWidget) void { } pub fn hovered(self: *FileWidget) bool { - if (fizzy.dvui.canvasPointerInputSuppressed()) return false; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return false; return self.init_options.file.editor.canvas.hovered; } @@ -5911,7 +5956,7 @@ fn tempBrushRect(point: dvui.Point, stroke_size: usize, img_w: u32, img_h: u32) } /// Data-space rect of the on-screen canvas, outset by brush size so edge stamps are not clipped. -fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal.File, stroke_size: usize) dvui.Rect { +fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const pixi_mod.internal.File, stroke_size: usize) dvui.Rect { const vis = canvas.dataFromScreenRect(canvas.rect); const m: f32 = @floatFromInt(stroke_size); const inflated = vis.outsetAll(m); @@ -5920,7 +5965,7 @@ fn tempStrokePreviewClipRect(canvas: *CanvasWidget, file: *const fizzy.Internal. return dvui.Rect.intersect(inflated, .{ .x = 0, .y = 0, .w = iw, .h = ih }); } -fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Rect) void { +fn expandTempGpuDirtyRect(editor: *pixi_mod.internal.File.EditorData, rect: dvui.Rect) void { if (editor.temp_gpu_dirty_rect) |existing| { editor.temp_gpu_dirty_rect = existing.unionWith(rect); } else { @@ -5934,10 +5979,10 @@ fn expandTempGpuDirtyRect(editor: *fizzy.Internal.File.EditorData, rect: dvui.Re /// Clears the pixels covered by the current temp preview dirty rect, then /// resets the tracking state. Used before redrawing the brush preview at a /// new position. -fn clearTempPreview(editor: *fizzy.Internal.File.EditorData) void { +fn clearTempPreview(editor: *pixi_mod.internal.File.EditorData) void { if (editor.temp_preview_dirty_rect) |dirty| { if (dirty.w > 0 and dirty.h > 0) { - fizzy.image.clearRect(editor.temporary_layer.source, dirty); + pixi_mod.image.clearRect(editor.temporary_layer.source, dirty); expandTempGpuDirtyRect(editor, dirty); } } @@ -5945,10 +5990,10 @@ fn clearTempPreview(editor: *fizzy.Internal.File.EditorData) void { } /// Clears the temporary brush preview layer and marks GPU/composite dirty. -fn resetTempLayerPreview(editor: *fizzy.Internal.File.EditorData) void { +fn resetTempLayerPreview(editor: *pixi_mod.internal.File.EditorData) void { if (editor.temp_preview_dirty_rect) |dirty| { if (dirty.w > 0 and dirty.h > 0) { - fizzy.image.clearRect(editor.temporary_layer.source, dirty); + pixi_mod.image.clearRect(editor.temporary_layer.source, dirty); expandTempGpuDirtyRect(editor, dirty); } editor.temp_preview_dirty_rect = null; diff --git a/src/editor/widgets/ImageWidget.zig b/src/plugins/pixi/src/widgets/ImageWidget.zig similarity index 92% rename from src/editor/widgets/ImageWidget.zig rename to src/plugins/pixi/src/widgets/ImageWidget.zig index 12e8ed47..9448eb42 100644 --- a/src/editor/widgets/ImageWidget.zig +++ b/src/plugins/pixi/src/widgets/ImageWidget.zig @@ -1,5 +1,6 @@ pub const ImageWidget = @This(); -const CanvasWidget = @import("CanvasWidget.zig"); +const CanvasWidget = pixi_mod.core.dvui.CanvasWidget; +const CanvasBridge = @import("CanvasBridge.zig"); init_options: InitOptions, options: Options, @@ -35,6 +36,8 @@ pub fn init(src: std.builtin.SourceLocation, init_opts: InitOptions, opts: Optio .w = size.w, .h = size.h, }, + .pan_zoom_scheme = CanvasBridge.scheme(), + .hooks = .{ .pointerInputSuppressed = CanvasBridge.mainSuppressed }, }, opts); return iw; @@ -141,27 +144,27 @@ fn sample(self: *ImageWidget, point: dvui.Point, screen_p: dvui.Point.Physical) var color: [4]u8 = .{ 0, 0, 0, 0 }; - if (fizzy.image.pixelIndex(self.init_options.source, point)) |index| { - const c = fizzy.image.pixels(self.init_options.source)[index]; + if (pixi_mod.image.pixelIndex(self.init_options.source, point)) |index| { + const c = pixi_mod.image.pixels(self.init_options.source)[index]; if (c[3] > 0) { color = c; } } - fizzy.editor.colors.primary = color; + runtime.state().colors.primary = color; self.sample_data_point = point; if (color[3] == 0) { - if (fizzy.editor.tools.current != .eraser) { - fizzy.editor.tools.set(.eraser); + if (runtime.state().tools.current != .eraser) { + runtime.state().tools.set(.eraser); } } else { - fizzy.editor.tools.set(fizzy.editor.tools.previous_drawing_tool); + runtime.state().tools.set(runtime.state().tools.previous_drawing_tool); } } pub fn drawCursor(self: *ImageWidget) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) return; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return; for (dvui.events()) |*e| { if (!self.init_options.canvas.scroll_container.matchEvent(e)) { continue; @@ -204,7 +207,7 @@ pub fn drawSample(self: *ImageWidget) void { } pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data_point: dvui.Point) void { - if (fizzy.dvui.canvasPointerInputSuppressed()) return; + if (pixi_mod.core.dvui.canvasPointerInputSuppressed()) return; if (!canvas.samplePointerInViewport(dvui.currentWindow().mouse_pt)) return; _ = dvui.cursorSet(.hidden); @@ -265,7 +268,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data }); defer fw.deinit(); - const size = fizzy.image.size(source); + const size = pixi_mod.image.size(source); const uv_rect = dvui.Rect{ .x = (data_point.x - sample_region_size / 2) / size.w, .y = (data_point.y - sample_region_size / 2) / size.h, @@ -316,7 +319,7 @@ pub fn drawSampleMagnifier(canvas: *CanvasWidget, source: dvui.ImageSource, data } fn packedAtlasCheckerboardTexture() ?dvui.Texture { - if (fizzy.packer.atlas) |atlas| return atlas.checkerboard_tile; + if (runtime.packer().atlas) |atlas| return atlas.checkerboard_tile; return null; } @@ -382,7 +385,7 @@ pub fn drawImage(self: *ImageWidget) void { // by `syncTransformCachesFromWidgets` before `updateTouchGesture` runs. The mismatch // is the visible "image moves at a different rate than the alpha layer" jitter on the // packed-atlas preview during pinch zoom. Mirror FileWidget.drawLayers, which renders - // its layer textures via `fizzy.render.renderLayers` against the cached `canvas.rect` + // its layer textures via `pixi_mod.render.renderLayers` against the cached `canvas.rect` // for the same reason. dvui.renderImage(self.init_options.source, .{ .r = self.init_options.canvas.rect, @@ -431,10 +434,10 @@ pub fn processEvents(self: *ImageWidget) void { self.drawImage(); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{}); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 }); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{}); - fizzy.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .top, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .bottom, .{ .opacity = 0.15 }); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .left, .{}); + pixi_mod.core.dvui.drawEdgeShadow(self.init_options.canvas.scroll_container.data().rectScale(), .right, .{ .opacity = 0.15 }); self.drawCursor(); self.drawSample(); @@ -466,8 +469,9 @@ const ScaleWidget = dvui.ScaleWidget; const std = @import("std"); const math = std.math; const dvui = @import("dvui"); -const fizzy = @import("../../fizzy.zig"); const builtin = @import("builtin"); +const pixi_mod = @import("../../pixi.zig"); +const runtime = @import("../runtime.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/plugins/pixi/static/integration.zig b/src/plugins/pixi/static/integration.zig new file mode 100644 index 00000000..c88440b7 --- /dev/null +++ b/src/plugins/pixi/static/integration.zig @@ -0,0 +1,134 @@ +//! Pixi plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +//! +//! The vendored `zstbi`/`msf_gif` modules are built via the reusable `fizzy.plugin.addCModule` +//! helper (same one a third-party C plugin uses, and the one pixi's own standalone `build.zig` +//! calls) — so the build *logic* lives in one place. `zip` keeps its purpose-built +//! `src/deps/zip/build.zig` (a distinct "C into the consumer + wasm libc shim" pattern). +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); +const fizzy_plugin = @import("../../../../plugin_sdk.zig"); +const zip_mod = @import("../src/deps/zip/build.zig"); + +pub const id = "pixi"; +pub const installDylib = helpers.installDylib; + +const deps_root = "src/plugins/pixi/src/deps"; + +pub const ZipPackage = zip_mod.Package; +pub fn zipPackage(b: *std.Build) ZipPackage { + return zip_mod.package(b, .{}); +} +pub fn linkZipNative(exe: *std.Build.Step.Compile) void { + zip_mod.link(exe); +} +pub fn linkZipWasm(exe: *std.Build.Step.Compile) void { + zip_mod.linkWasm(exe); +} + +pub fn addZstbiModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + web: bool, +) *std.Build.Module { + const web_cflags = [_][]const u8{ "-DSTBI_NO_STDLIB=1", "-DSTBI_NO_SIMD=1" }; + const c_sources = if (web) &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/stbi/zstbi.c"), .flags = &web_cflags }, + .{ .file = b.path(deps_root ++ "/stbi/fizzy_stbi_libc.c"), .flags = &web_cflags }, + } else &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/stbi/zstbi.c") }, + }; + return fizzy_plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path(deps_root ++ "/stbi/zstbi.zig"), + .c_sources = c_sources, + .link_libc = !web, + .single_threaded = web, + }); +} + +pub fn addMsfGifModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + web: bool, +) *std.Build.Module { + const web_cflags = [_][]const u8{"-I" ++ deps_root ++ "/msf_gif/wasm_shim"}; + const c_sources = if (web) &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/msf_gif/fizzy_msf_gif_wasm.c"), .flags = &web_cflags }, + } else &[_]fizzy_plugin.CSourceFile{ + .{ .file = b.path(deps_root ++ "/msf_gif/msf_gif.c") }, + }; + return fizzy_plugin.addCModule(b, .{ + .target = target, + .optimize = optimize, + .root_source_file = b.path(deps_root ++ "/msf_gif/msf_gif.zig"), + .c_sources = c_sources, + .link_libc = !web, + .single_threaded = web, + }); +} + +const module_path = "src/plugins/pixi/pixi.zig"; +const dylib_path = "src/plugins/pixi/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, + assets: *std.Build.Module, + zip: *std.Build.Module, + zstbi: *std.Build.Module, + msf_gif: *std.Build.Module, + icons: ?*std.Build.Module = null, + backend: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); + module.addImport("assets", imports.assets); + module.addImport("zip", imports.zip); + module.addImport("zstbi", imports.zstbi); + module.addImport("msf_gif", imports.msf_gif); + if (imports.icons) |icons| module.addImport("icons", icons); + if (imports.backend) |backend| module.addImport("backend", backend); +} + +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + }, consumer); + applyImports(mod, imports); + return mod; +} + +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/shared/build/helpers.zig b/src/plugins/shared/build/helpers.zig new file mode 100644 index 00000000..3551f235 --- /dev/null +++ b/src/plugins/shared/build/helpers.zig @@ -0,0 +1,93 @@ +//! Fizzy-internal build helpers for the static-embed + bundled-dylib graph of built-in +//! plugins. These always run from the fizzy build root, so every path is a single +//! fizzy-relative `b.path(...)` — there is no plugin-package root to disambiguate. +//! Third-party plugins never touch this; they use `fizzy.plugin.create` / `.install`. +const std = @import("std"); + +/// C-ABI entry symbols the host looks up. Kept in sync with `plugin_sdk.dylib_exports` +/// (the third-party path); duplicated here to avoid a deep relative import. +pub const dylib_exports = [_][]const u8{ + "fizzy_plugin_abi_fingerprint", + "fizzy_plugin_sdk_version", + "fizzy_plugin_min_sdk_version", + "fizzy_plugin_version", + "fizzy_plugin_id", + "fizzy_plugin_register", + "fizzy_plugin_set_dvui_context", + "fizzy_plugin_set_render_bridge", + "fizzy_plugin_set_globals", +}; + +pub const StaticModuleOptions = struct { + import_name: []const u8, + root_source_file: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + options_name: ?[]const u8 = null, + options: ?*std.Build.Step.Options = null, +}; + +pub fn addStaticModule( + b: *std.Build, + opts: StaticModuleOptions, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = opts.target.result.cpu.arch != .wasm32, + .single_threaded = opts.target.result.cpu.arch == .wasm32, + }); + if (opts.options_name) |name| { + if (opts.options) |o| mod.addOptions(name, o); + } + consumer.addImport(opts.import_name, mod); + return mod; +} + +pub const DylibOptions = struct { + name: []const u8, + root_source_file: std.Build.LazyPath, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + options_name: ?[]const u8 = null, + options: ?*std.Build.Step.Options = null, +}; + +pub fn addDylib( + b: *std.Build, + opts: DylibOptions, +) *std.Build.Step.Compile { + const dylib_module = b.createModule(.{ + .target = opts.target, + .optimize = opts.optimize, + .root_source_file = opts.root_source_file, + .link_libc = true, + }); + if (opts.options_name) |name| { + if (opts.options) |o| dylib_module.addOptions(name, o); + } + const lib = b.addLibrary(.{ + .name = opts.name, + .linkage = .dynamic, + .root_module = dylib_module, + }); + lib.linker_allow_shlib_undefined = true; + lib.root_module.export_symbol_names = &dylib_exports; + return lib; +} + +pub fn installDylib(b: *std.Build, lib: *std.Build.Step.Compile, name: []const u8) void { + const ext: []const u8 = switch (lib.rootModuleTarget().os.tag) { + .windows => "dll", + .macos => "dylib", + else => "so", + }; + const dest = b.fmt("{s}.{s}", .{ name, ext }); + const install_step = b.addInstallArtifact(lib, .{ + .dest_dir = .{ .override = .prefix }, + .dest_sub_path = dest, + }); + b.getInstallStep().dependOn(&install_step.step); +} diff --git a/src/plugins/workbench/build.zig b/src/plugins/workbench/build.zig new file mode 100644 index 00000000..fe3d88a5 --- /dev/null +++ b/src/plugins/workbench/build.zig @@ -0,0 +1,34 @@ +//! Standalone build for the workbench plugin — the canonical third-party shape. +//! `cd src/plugins/workbench && zig build` produces `workbench.`. The +//! `-Dworkbench-file-tree` option feeds a `workbench_opts` module the plugin imports; +//! attaching a build-options module to a `fizzy.plugin.create` lib is exactly how any +//! third-party plugin would expose compile-time flags. See docs/PLUGINS.md. +const std = @import("std"); +const fizzy = @import("fizzy"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const file_tree = b.option( + bool, + "workbench-file-tree", + "Register the Files sidebar view at compile time", + ) orelse true; + const workbench_opts = b.addOptions(); + workbench_opts.addOption(bool, "file_tree", file_tree); + + const lib = fizzy.plugin.create(b, .{ + .name = "workbench", + .target = target, + .optimize = optimize, + .root_source_file = b.path("root.zig"), + }); + lib.root_module.addOptions("workbench_opts", workbench_opts); + + if (b.lazyDependency("icons", .{ .target = target, .optimize = optimize })) |dep| { + lib.root_module.addImport("icons", dep.module("icons")); + } + + fizzy.plugin.install(b, lib, .{}); +} diff --git a/src/plugins/workbench/build.zig.zon b/src/plugins/workbench/build.zig.zon new file mode 100644 index 00000000..9e850490 --- /dev/null +++ b/src/plugins/workbench/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .workbench, + .version = "0.0.0", + .minimum_zig_version = "0.16.0", + .paths = .{ + "build.zig", + "build.zig.zon", + "root.zig", + "workbench.zig", + "src", + "static", + }, + .fingerprint = 0xc23e2206858de248, + .dependencies = .{ + .fizzy = .{ + .path = "../../..", + }, + .icons = .{ + .url = "https://github.com/foxnne/zig-lib-icons/archive/db034786a1286ab28dc35aba534c098aa4f1a3aa.tar.gz", + .hash = "icons-0.0.0-iJxA-VvGMwAgiKSXRe_Y0O7RpasdtEJhBfVx8IGGEBl_", + .lazy = true, + }, + }, +} diff --git a/src/plugins/workbench/root.zig b/src/plugins/workbench/root.zig new file mode 100644 index 00000000..21bedd2b --- /dev/null +++ b/src/plugins/workbench/root.zig @@ -0,0 +1,7 @@ +//! Dylib entry for the workbench plugin — canonical shape: one `exportEntry` wired to +//! `src/plugin.zig` (see `src/plugins/root.zig`). +const sdk = @import("sdk"); + +comptime { + sdk.dylib.exportEntry(@import("src/plugin.zig")); +} diff --git a/src/plugins/workbench/src/FileLoadJob.zig b/src/plugins/workbench/src/FileLoadJob.zig new file mode 100644 index 00000000..eee291d7 --- /dev/null +++ b/src/plugins/workbench/src/FileLoadJob.zig @@ -0,0 +1,147 @@ +//! Background file-load job. Owns a worker thread that runs the owning plugin's loader +//! (`owner.loadDocument`) off the main thread so large files don't stall the editor. The +//! main thread polls `done` each frame via `Editor.processLoadingJobs`; once true, the +//! result is moved into `editor.open_files`. +//! +//! Cancellation is best-effort: the plugin loader is monolithic, so we can only observe +//! cancellation AFTER it returns. The worker checks the flag, frees the loaded file if +//! cancelled, and exits. +//! +//! Ownership / threading model: +//! - `path` is owned by the job, freed in `destroy()`. +//! - `doc_buf` is written by the worker, read by the main thread only after `done.load(.acquire)`. +//! - `phase` / `cancelled` are written by either side, read by either side. +//! - The job pointer itself is owned by `Editor.loading_jobs`. Worker holds a borrowed pointer +//! but only writes through atomic fields + the worker-only `doc_buf`/`err` fields. + +const std = @import("std"); +const wb = @import("../workbench.zig"); +const dvui = wb.dvui; +const perf = wb.perf; +const sdk = wb.sdk; + +const FileLoadJob = @This(); + +pub const Phase = enum(u8) { + queued = 0, + reading = 1, + ready = 2, + failed = 3, + cancelled = 4, +}; + +allocator: std.mem.Allocator, + +/// Absolute path. Owned by this job. +path: []u8, + +/// Plugin that owns this file's extension (resolved on the main thread before spawn). +owner: *sdk.Plugin, + +/// Workspace grouping the file should land in once loaded. +target_grouping: u64, + +window: *dvui.Window, +started_at_ns: i128, + +phase: std.atomic.Value(u8) = .init(@intFromEnum(Phase.queued)), +progress_num: std.atomic.Value(u32) = .init(0), +progress_den: std.atomic.Value(u32) = .init(0), +cancelled: std.atomic.Value(bool) = .init(false), +done: std.atomic.Value(bool) = .init(false), + +/// Plugin-document staging buffer (size/align from `owner.documentStackSize/Align`). +doc_slab: []u8, +doc_buf: []u8, + +err: ?anyerror = null, + +pub fn create(allocator: std.mem.Allocator, path: []const u8, owner: *sdk.Plugin, target_grouping: u64) !*FileLoadJob { + const path_copy = try allocator.dupe(u8, path); + errdefer allocator.free(path_copy); + + const staging = try owner.allocDocumentBuffer(allocator); + errdefer allocator.free(staging.backing); + + const job = try allocator.create(FileLoadJob); + errdefer allocator.destroy(job); + + job.* = .{ + .allocator = allocator, + .path = path_copy, + .owner = owner, + .target_grouping = target_grouping, + .window = dvui.currentWindow(), + .started_at_ns = perf.nanoTimestamp(), + .doc_slab = staging.backing, + .doc_buf = staging.buf, + }; + return job; +} + +pub fn destroy(job: *FileLoadJob) void { + const a = job.allocator; + a.free(job.path); + a.free(job.doc_slab); + a.destroy(job); +} + +pub fn workerMain(job: *FileLoadJob) void { + defer { + job.done.store(true, .release); + dvui.refresh(job.window, @src(), null); + } + + if (job.cancelled.load(.monotonic)) { + job.phase.store(@intFromEnum(Phase.cancelled), .release); + return; + } + + job.phase.store(@intFromEnum(Phase.reading), .release); + + const handled = job.owner.loadDocument(job.path, job.doc_buf.ptr) catch |e| { + job.err = e; + job.phase.store(@intFromEnum(Phase.failed), .release); + return; + }; + if (!handled) { + job.err = error.InvalidFile; + job.phase.store(@intFromEnum(Phase.failed), .release); + return; + } + + if (job.cancelled.load(.monotonic)) { + job.owner.deinitDocumentBuffer(job.doc_buf.ptr); + job.phase.store(@intFromEnum(Phase.cancelled), .release); + return; + } + + job.phase.store(@intFromEnum(Phase.ready), .release); +} + +pub fn elapsedExceeds(job: *const FileLoadJob, threshold_ms: i64) bool { + const elapsed_ns = perf.nanoTimestamp() - job.started_at_ns; + return @divTrunc(elapsed_ns, std.time.ns_per_ms) >= threshold_ms; +} + +pub fn currentPhase(job: *const FileLoadJob) Phase { + const raw = job.phase.load(.acquire); + return switch (raw) { + 0 => .queued, + 1 => .reading, + 2 => .ready, + 3 => .failed, + 4 => .cancelled, + else => .queued, + }; +} + +pub fn phaseLabel(phase: Phase) []const u8 { + return switch (phase) { + .queued => "Queued", + .reading => "Reading", + .ready => "Done", + .failed => "Failed", + .cancelled => "Cancelled", + }; +} diff --git a/src/plugins/workbench/src/Workbench.zig b/src/plugins/workbench/src/Workbench.zig new file mode 100644 index 00000000..ba813ed2 --- /dev/null +++ b/src/plugins/workbench/src/Workbench.zig @@ -0,0 +1,230 @@ +//! The Workbench is the file-management home of the editor. This plugin owns the +//! file tree (`files.zig`), the open/load flow (`FileLoadJob.zig`), and the +//! workspace/tabs/splits system (`Workspace.zig`). It exposes its capabilities to +//! other plugins through the `workbench-api` Host service (`Workbench.Api`) so they +//! never reach into the editor globals. +//! +//! Per-branch decorations let any plugin draw a right-justified icon on a file row +//! (e.g. the built-in "unsaved" dot). Decorators run inside the row's hbox after +//! the label, so an expanding label pushes them to the right edge. +const std = @import("std"); +const dvui = @import("dvui"); +const icons = @import("icons"); +const files = @import("files.zig"); +const Workspace = @import("Workspace.zig"); +const runtime = @import("runtime.zig"); +const workbench_layout = @import("workbench_layout.zig"); +const sdk = @import("sdk"); + +pub const Api = sdk.services.workbench.Api; +pub const BranchDecorator = Api.BranchDecorator; + +pub const Workbench = @This(); + +allocator: std.mem.Allocator, +decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, + +/// Workspaces keyed by tab-grouping id (owned here, not on the shell Editor). +workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, +open_workspace_grouping: u64 = 0, +grouping_id_counter: u64 = 0, +tab_drag_from_tree_path: ?[]u8 = null, +file_tree_data_id: ?dvui.Id = null, + +/// The `workbench-api` service instance handed to plugins. Its `ctx` must be the +/// editor's FINAL heap address, so it's filled in by `initService` from +/// `Editor.postInit` (after `Editor.init`'s by-value result is copied to the heap), +/// not during `init` where `&editor.*` would point at a stack temporary. +api: Api = undefined, + +pub fn init(allocator: std.mem.Allocator) Workbench { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *Workbench) void { + self.decorators.deinit(self.allocator); +} + +pub fn initDefaultWorkspace(self: *Workbench) !void { + self.workspaces = .empty; + try self.workspaces.put(self.allocator, 0, Workspace.init(0)); +} + +pub fn deinitWorkspaces(self: *Workbench) void { + for (self.workspaces.values()) |*workspace| workspace.deinit(); + self.workspaces.deinit(self.allocator); +} + +pub fn currentGroupingID(self: *Workbench) u64 { + return self.open_workspace_grouping; +} + +pub fn newGroupingID(self: *Workbench) u64 { + self.grouping_id_counter += 1; + return self.grouping_id_counter; +} + +pub fn clearFileTreeTabDragDropState(self: *Workbench) void { + if (self.tab_drag_from_tree_path) |p| { + self.allocator.free(p); + self.tab_drag_from_tree_path = null; + } +} + +pub fn clearFileTreeDataId(self: *Workbench) void { + self.file_tree_data_id = null; +} + +/// Explorer peek/collapse hides the workspace subtree; clear latched center flags. +pub fn clearAllWorkspaceCenter(self: *Workbench) void { + for (self.workspaces.values()) |*ws| { + ws.center = false; + } +} + +/// When the open doc at `closed_index` closes, pick another tab in the same workspace. +pub fn adjustOpenFileIndexAfterClose( + self: *Workbench, + grouping: u64, + closed_index: usize, + replacement_index: ?usize, +) void { + const workspace = self.workspaces.getPtr(grouping) orelse return; + if (workspace.open_file_index == closed_index) { + if (replacement_index) |idx| workspace.open_file_index = idx; + } +} + +pub fn rebuildWorkspaces(self: *Workbench) !void { + return workbench_layout.rebuildWorkspaces(self); +} + +pub fn drawWorkspaces(self: *Workbench, panel: workbench_layout.PanelPanedState, index: usize) !dvui.App.Result { + return workbench_layout.drawWorkspaces(self, panel, index); +} + +pub fn activeDoc(self: *Workbench) ?sdk.DocHandle { + if (self.workspaces.get(self.open_workspace_grouping)) |workspace| { + return runtime.host().docByIndex(workspace.open_file_index); + } + return null; +} + +pub fn setActiveDocIndex(self: *Workbench, index: usize) void { + const doc = runtime.host().docByIndex(index) orelse return; + const grouping = doc.owner.documentGrouping(doc); + if (self.workspaces.getPtr(grouping)) |workspace| { + self.open_workspace_grouping = grouping; + workspace.open_file_index = index; + } +} + +pub fn activeWorkspaceCanvasRectPhysical(self: *Workbench) ?dvui.Rect.Physical { + const workspace = self.workspaces.getPtr(self.open_workspace_grouping) orelse return null; + return workspace.canvas_rect_physical; +} + +/// Build the `workbench-api` service. `host_ctx` is the shell `*Host`. +pub fn initService(self: *Workbench, host_ctx: *sdk.Host) void { + self.api = .{ .ctx = host_ctx, .vtable = &service_vtable }; +} + +/// Register the decorations the shell ships with. Called once after the editor is +/// constructed. (Plugins register their own via `registerBranchDecorator`.) +pub fn registerBuiltins(self: *Workbench) !void { + try self.registerBranchDecorator(.{ .draw = &drawUnsavedDot }); +} + +pub fn registerBranchDecorator(self: *Workbench, decorator: BranchDecorator) !void { + try self.decorators.append(self.allocator, decorator); +} + +/// Called by the file explorer for each file row (inside the row's hbox). +pub fn drawBranchDecorations(self: *Workbench, path: []const u8, id_extra: usize) void { + for (self.decorators.items) |decorator| decorator.draw(decorator.ctx, path, id_extra); +} + +/// Built-in: a dot on rows whose file is open with unsaved changes. Mirrors the +/// tab dirty indicator (`Workspace.zig` ~:528) so the two stay visually consistent. +fn drawUnsavedDot(_: ?*anyopaque, path: []const u8, id_extra: usize) void { + const doc = runtime.host().docFromPath(path) orelse return; + if (doc.owner.showsSaveStatusIndicator(doc)) return; + if (!doc.owner.isDirty(doc)) return; + dvui.icon(@src(), "explorer_dirty", icons.tvg.lucide.@"circle-small", .{ + .stroke_color = dvui.themeGet().color(.window, .text), + }, .{ + .gravity_x = 1.0, + .gravity_y = 0.5, + .padding = dvui.Rect.all(2), + .id_extra = id_extra, + }); +} + +// ============================================================================ +// workbench-api — the formal Host service (layout defined in sdk/services/workbench.zig) +// ============================================================================ + +const service_vtable: Api.VTable = .{ + .open = svcOpen, + .currentGrouping = svcCurrentGrouping, + .newGrouping = svcNewGrouping, + .close = svcClose, + .save = svcSave, + .isOpen = svcIsOpen, + .openCount = svcOpenCount, + .openPathAt = svcOpenPathAt, + .createFile = svcCreateFile, + .createDir = svcCreateDir, + .rename = svcRename, + .delete = svcDelete, + .move = svcMove, + .registerBranchDecorator = svcRegisterBranchDecorator, +}; + +inline fn hostOf(ctx: *anyopaque) *sdk.Host { + return @ptrCast(@alignCast(ctx)); +} + +fn svcOpen(ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool { + return hostOf(ctx).openFilePath(path, grouping); +} +fn svcCurrentGrouping(_: *anyopaque) u64 { + return runtime.workbench().currentGroupingID(); +} +fn svcNewGrouping(_: *anyopaque) u64 { + return runtime.workbench().newGroupingID(); +} +fn svcClose(ctx: *anyopaque, id: u64) anyerror!void { + return hostOf(ctx).closeDocById(id); +} +fn svcSave(ctx: *anyopaque) anyerror!void { + return hostOf(ctx).save(); +} +fn svcIsOpen(ctx: *anyopaque, path: []const u8) bool { + return hostOf(ctx).docFromPath(path) != null; +} +fn svcOpenCount(ctx: *anyopaque) usize { + return hostOf(ctx).openDocCount(); +} +fn svcOpenPathAt(ctx: *anyopaque, index: usize) ?[]const u8 { + const doc = hostOf(ctx).docByIndex(index) orelse return null; + return doc.owner.documentPath(doc); +} +fn svcCreateFile(_: *anyopaque, path: []const u8) anyerror!void { + return files.createFilePath(path); +} +fn svcCreateDir(_: *anyopaque, path: []const u8) anyerror!void { + return files.createDirPath(path); +} +fn svcRename(_: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void { + return files.renamePath(path, new_path, kind); +} +fn svcDelete(_: *anyopaque, path: []const u8) void { + files.deletePath(path); +} +fn svcMove(_: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool { + return files.moveOnePath(path, target_dir, dvui.currentWindow().arena()); +} +fn svcRegisterBranchDecorator(_: *anyopaque, decorator: BranchDecorator) anyerror!void { + return runtime.workbench().registerBranchDecorator(decorator); +} diff --git a/src/plugins/workbench/src/Workspace.zig b/src/plugins/workbench/src/Workspace.zig new file mode 100644 index 00000000..96f19304 --- /dev/null +++ b/src/plugins/workbench/src/Workspace.zig @@ -0,0 +1,1054 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const wb = @import("../workbench.zig"); +const dvui = wb.dvui; +const wdvui = wb.wdvui; +const sdk = wb.sdk; +const runtime = @import("runtime.zig"); +const icons = @import("icons"); + +/// Workspaces are drawn recursively inside of the explorer paned widget +/// second pane, and contains drag/drop enabled tabs. Tabs can freely be dragged to +/// panes or other tab bars. +/// Workspaces can potentially draw open files, the project logo, or the project pane +/// containing the packed atlas. +pub const Workspace = @This(); + +open_file_index: usize = 0, +grouping: u64 = 0, +center: bool = false, + +tabs_drag_index: ?usize = null, +tabs_removed_index: ?usize = null, +tabs_insert_before_index: ?usize = null, + +/// Physical-pixel content rect of this workspace's canvas vbox, captured each frame during +/// `drawCanvas` (or a sidebar view's `draw_workspace` takeover, e.g. pixel art's Project view). +/// `null` until the workspace has rendered at least once. Used +/// by the editor-level load/save toast overlays to center cards over the area the user is +/// actually looking at (rather than the OS window rect). +canvas_rect_physical: ?dvui.Rect.Physical = null, + +pub fn init(grouping: u64) Workspace { + return .{ .grouping = grouping }; +} + +/// Release any plugin-owned per-pane canvas chrome. Called when a pane is removed +/// (`Editor.rebuildWorkspaces`) and for each pane at editor shutdown. +pub fn deinit(self: *Workspace) void { + for (runtime.host().plugins.items) |plugin| { + plugin.removeCanvasPane(self.grouping, runtime.allocator()); + } +} + +const handle_size = 10; +const handle_dist = 60; + +const opacity = 60; + +const color_0 = wb.math.Color.initBytes(0, 0, 0, 0); +const color_1 = wb.math.Color.initBytes(230, 175, 137, opacity); +const color_2 = wb.math.Color.initBytes(216, 145, 115, opacity); +const color_3 = wb.math.Color.initBytes(41, 23, 41, opacity); +const color_4 = wb.math.Color.initBytes(194, 109, 92, opacity); +const color_5 = wb.math.Color.initBytes(180, 89, 76, opacity); + +const logo_colors: [12]wb.math.Color = [_]wb.math.Color{ + color_1, color_1, color_1, + color_2, color_2, color_3, + color_4, color_3, color_0, + color_3, color_0, color_0, +}; + +var dragging: bool = false; + +pub fn draw(self: *Workspace) !dvui.App.Result { + // Canvas Area + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .gravity_y = 0.0, + .id_extra = @intCast(self.grouping), + }); + defer vbox.deinit(); + + // Set the active workspace grouping when the user clicks on the workspace rect + for (dvui.events()) |*e| { + if (!vbox.matchEvent(e)) { + continue; + } + + if (e.evt == .mouse) { + if (e.evt.mouse.action == .press or (e.evt.mouse.action == .position and e.evt.mouse.mod.matchBind("ctrl/cmd"))) { + runtime.workbench().open_workspace_grouping = self.grouping; + } + } + } + + // A sidebar view may optionally take over this workspace pane's content region (e.g. pixel + // art's "Project" view renders the packed atlas here instead of document tabs+canvas). The + // workbench owns only the pane frame; it hands the active view the opaque workspace handle. + const active = runtime.host().activeSidebarView(); + if (active != null and active.?.draw_workspace != null) { + var pane_view: sdk.WorkbenchPaneView = .{ + .grouping = self.grouping, + .canvas_rect_physical = &self.canvas_rect_physical, + }; + try active.?.draw_workspace.?(active.?.ctx, &pane_view); + } else { + self.drawTabs(); + try self.drawCanvas(); + } + + return .ok; +} + +/// Same `@src()` for every call so DVUI sees one stable id when switching between `drawCanvas` and +/// a plugin's `draw_workspace` takeover (avoids first-frame min-size / layout flash). Use `grouping` +/// so multi-workspace panes stay distinct. Delegates to `sdk.pane_layout` for a single definition. +pub fn workspaceMainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { + return sdk.pane_layout.mainCanvasVbox(content_color, background, grouping); +} + +/// Rounded “card” behind the project empty state and the homepage. Delegates to `sdk.pane_layout`. +pub fn workspaceEmptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { + return sdk.pane_layout.emptyStateCard(content_color, grouping); +} + +fn drawTabs(self: *Workspace) void { + if (runtime.host().openDocCount() == 0) return; + + // Handle dragging of tabs between workspace reorderables (tab bars) + defer self.processTabsDrag(); + + { + var tabs_anim = dvui.animate(@src(), .{ .duration = 500_000, .kind = .vertical, .easing = dvui.easing.outBack }, .{}); + defer tabs_anim.deinit(); + + var tabs_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer tabs_box.deinit(); + + var scroll_area = dvui.scrollArea(@src(), .{ .horizontal = .auto, .horizontal_bar = .hide, .vertical_bar = .hide }, .{ + .expand = .none, + .background = false, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .border = dvui.Rect.all(0), + .corner_radius = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer scroll_area.deinit(); + + { + var tabs = dvui.reorder(@src(), .{ .drag_name = "tab_drag" }, .{ + .expand = .none, + .background = false, + }); + defer tabs.deinit(); + + var tabs_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = @intCast(self.grouping), + }); + defer tabs_hbox.deinit(); + + const files_len = runtime.host().openDocCount(); + + // Find the neighbouring tabs (within this workspace grouping) of the active tab. + var prev_same_group_index: ?usize = null; + var next_same_group_index: ?usize = null; + + const active_in_this_group = blk: { + if (runtime.workbench().open_workspace_grouping != self.grouping) break :blk false; + if (self.open_file_index >= files_len) break :blk false; + const active_doc = runtime.host().docByIndex(self.open_file_index) orelse break :blk false; + if (active_doc.owner.documentGrouping(active_doc) != self.grouping) break :blk false; + break :blk true; + }; + + if (active_in_this_group) { + const active_index = self.open_file_index; + + var j: usize = active_index; + while (j > 0) { + j -= 1; + const tab_doc = runtime.host().docByIndex(j) orelse continue; + if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) { + prev_same_group_index = j; + break; + } + } + + j = active_index + 1; + while (j < files_len) : (j += 1) { + const tab_doc = runtime.host().docByIndex(j) orelse continue; + if (tab_doc.owner.documentGrouping(tab_doc) == self.grouping) { + next_same_group_index = j; + break; + } + } + } + + for (0..files_len) |i| { + const doc = runtime.host().docByIndex(i) orelse continue; + const is_fizzy_file = doc.owner.documentHasNativeExtension(doc); + + if (doc.owner.documentGrouping(doc) != self.grouping) continue; + + var reorderable = tabs.reorderable(@src(), .{}, .{ + .expand = .vertical, + .id_extra = i, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .border = .all(0), + }); + defer reorderable.deinit(); + + const selected = self.open_file_index == i and runtime.workbench().open_workspace_grouping == self.grouping; + + var hbox: dvui.BoxWidget = undefined; + hbox.init(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .border = dvui.Rect.all(0), + .color_fill = if (selected) .transparent else dvui.themeGet().color(.window, .fill).opacity(runtime.host().contentOpacity()), + .background = true, + .id_extra = i, + .padding = .{ .x = 2, .y = 2, .w = 2, .h = 0 }, + .margin = dvui.Rect.all(0), + }); + + defer hbox.deinit(); + + const tab_hovered = wdvui.hovered(hbox.data()); + + if (reorderable.floating()) { + self.tabs_drag_index = i; + hbox.data().options.color_fill = dvui.themeGet().color(.control, .fill); + } + hbox.drawBackground(); + + if (!selected and active_in_this_group and tabs.drag_point == null) { + // Draw edge shadow between the active tab and its neighbours within this grouping. + if (prev_same_group_index) |prev_index| { + if (i == prev_index) { + // This tab is directly to the left of the active tab. + wdvui.drawEdgeShadow(hbox.data().rectScale(), .right, .{}); + } + } + + if (next_same_group_index) |next_index| { + if (i == next_index) { + // This tab is directly to the right of the active tab. + wdvui.drawEdgeShadow(hbox.data().rectScale(), .left, .{}); + } + } + } + + if (reorderable.removed()) { + self.tabs_removed_index = i; + } else if (reorderable.insertBefore()) { + self.tabs_insert_before_index = i; + } + + if (is_fizzy_file) { + const ui_atlas = runtime.host().uiAtlas(); + const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default]; + const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; + _ = wb.Sprite.draw(logo_sprite, @src(), ui_atlas.source, 2.0, .{ + .gravity_y = 0.5, + .padding = dvui.Rect.all(4), + }); + } else { + dvui.icon(@src(), "file_icon", icons.tvg.lucide.file, .{ + .stroke_color = if (is_fizzy_file) .transparent else dvui.themeGet().color(.control, .text), + }, .{ + .gravity_y = 0.5, + .padding = dvui.Rect.all(4), + }); + } + + dvui.label(@src(), "{s}", .{std.fs.path.basename(doc.owner.documentPath(doc))}, .{ + .color_text = if (selected) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), + .padding = dvui.Rect.all(4), + .gravity_y = 0.5, + }); + + const close_inner = wdvui.windowHeaderCloseInnerSide(); + + const status_close_box = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .gravity_y = 0.5, + .margin = dvui.Rect.all(0), + .padding = wdvui.tab_status_inset, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + }); + defer status_close_box.deinit(); + + // Saving has priority over hover/close/dirty indicators: the user wants visible + // confirmation that the save is in flight, and the slot's size matches the close + // button so the layout doesn't shift when saving starts/ends. `editor.saving` + // can be written by a background save worker (`saveZip`), so we read it with an + // atomic load — the write side uses an atomic store in matching `save*` paths. + const save_flash_elapsed = doc.owner.timeSinceSaveCompleteNs(doc); + const save_in_check_phase = if (save_flash_elapsed) |elapsed| + wdvui.bubbleSpinnerSaveInCheckPhase(elapsed) + else + false; + const save_blocks_tab_close = doc.owner.isDocumentSaving(doc) or + (doc.owner.showsSaveStatusIndicator(doc) and !save_in_check_phase); + + if (save_blocks_tab_close) { + wdvui.bubbleSpinner(@src(), .{ + .id_extra = i *% 16 + 5, + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.window, .text), + }, .{ + .complete_elapsed_ns = save_flash_elapsed, + }); + } else if (save_in_check_phase and !tab_hovered) { + wdvui.bubbleSpinner(@src(), .{ + .id_extra = i *% 16 + 5, + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, + .color_text = dvui.themeGet().color(.window, .text), + }, .{ + .complete_elapsed_ns = save_flash_elapsed, + }); + } else { + var tab_close_button: dvui.ButtonWidget = undefined; + tab_close_button.init(@src(), .{ .draw_focus = false }, wdvui.tabCloseButtonOptions(.{ + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, + .id_extra = i *% 16 + 1, + })); + defer tab_close_button.deinit(); + + tab_close_button.processEvents(); + + const dirty = doc.owner.isDirty(doc); + const show_close_visible = tab_hovered or (selected and !dirty); + const err_accent = dvui.themeGet().color(.err, .fill); + const close_hovered = tab_close_button.hovered(); + + if (show_close_visible and (tab_hovered or close_hovered)) { + const rs = tab_close_button.data().borderRectScale(); + rs.r.fill(dvui.Rect.Physical.all(rs.r.h * 0.5), .{ + .color = err_accent, + }); + } + + if (dirty and !show_close_visible) { + dvui.icon(@src(), "dirty_icon", icons.tvg.lucide.@"circle-small", .{ + .stroke_color = dvui.themeGet().color(.window, .text), + }, .{ + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, + .id_extra = i *% 16 + 0, + }); + } else { + const icon_color = if (!show_close_visible) + dvui.Color.transparent + else if (tab_hovered or close_hovered) + dvui.Color.white + else + dvui.themeGet().color(.window, .text); + dvui.icon(@src(), "close", icons.tvg.lucide.x, .{ + .stroke_color = icon_color, + .fill_color = icon_color, + }, .{ + .expand = .none, + .min_size_content = .{ .w = close_inner, .h = close_inner }, + .gravity_x = 0.5, + .gravity_y = 0.5, + .id_extra = i *% 16 + 2, + .background = false, + .border = dvui.Rect.all(0), + .box_shadow = null, + .ninepatch_fill = &dvui.Ninepatch.none, + .ninepatch_hover = &dvui.Ninepatch.none, + .ninepatch_press = &dvui.Ninepatch.none, + }); + } + + if (tab_close_button.clicked()) { + runtime.host().closeDocById(doc.id) catch |err| { + dvui.log.err("closeFile: {d} failed: {s}", .{ i, @errorName(err) }); + }; + break; + } + } + + if (selected and !reorderable.floating()) { + wdvui.drawTabActiveIndicator( + reorderable.data().borderRectScale(), + dvui.themeGet().color(.window, .text), + ); + } + + loop: for (dvui.events()) |*e| { + if (!hbox.matchEvent(e)) { + continue; + } + + switch (e.evt) { + .mouse => |me| { + if (me.action == .press and me.button.pointer()) { + runtime.host().setActiveDocIndex(i); + dvui.refresh(null, @src(), hbox.data().id); + + e.handle(@src(), hbox.data()); + dvui.captureMouse(hbox.data(), e.num); + dvui.dragPreStart(me.p, .{ .size = reorderable.data().rectScale().r.size(), .offset = reorderable.data().rectScale().r.topLeft().diff(me.p) }); + } else if (me.action == .release and me.button.pointer()) { + dvui.captureMouse(null, e.num); + dvui.dragEnd(); + } else if (me.action == .motion) { + if (dvui.captured(hbox.data().id)) { + e.handle(@src(), hbox.data()); + if (dvui.dragging(me.p, null)) |_| { + reorderable.reorder.dragStart(reorderable.data().id.asUsize(), me.p, 0); // reorder grabs capture + break :loop; + } + } + } + }, + + else => {}, + } + } + } + if (tabs.finalSlot()) { + self.tabs_insert_before_index = runtime.host().openDocCount(); + } + } + } +} + +pub fn processTabsDrag(self: *Workspace) void { + if (self.tabs_insert_before_index) |insert_before| { + if (self.tabs_removed_index) |removed| { // Dragging from this workspace + + if (removed > runtime.host().openDocCount()) return; + if (removed > insert_before) { + runtime.host().swapDocs(removed, insert_before); + runtime.host().setActiveDocIndex(insert_before); + } else { + if (insert_before > 0) { + runtime.host().swapDocs(removed, insert_before - 1); + runtime.host().setActiveDocIndex(insert_before - 1); + } else { + runtime.host().swapDocs(removed, insert_before); + runtime.host().setActiveDocIndex(insert_before); + } + } + + self.tabs_removed_index = null; + self.tabs_insert_before_index = null; + } else { // Dragging from another workspace + for (runtime.workbench().workspaces.values()) |*workspace| { + if (workspace.tabs_removed_index) |removed| { + if (removed > insert_before) { + runtime.host().swapDocs(removed, insert_before); + if (runtime.host().docByIndex(insert_before)) |d| { + d.owner.setDocumentGrouping(d, self.grouping); + } + runtime.host().setActiveDocIndex(insert_before); + } else { + if (insert_before > 0) { + runtime.host().swapDocs(removed, insert_before - 1); + if (runtime.host().docByIndex(insert_before - 1)) |d| { + d.owner.setDocumentGrouping(d, self.grouping); + } + runtime.host().setActiveDocIndex(insert_before - 1); + } else { + runtime.host().swapDocs(removed, insert_before); + if (runtime.host().docByIndex(insert_before)) |d| { + d.owner.setDocumentGrouping(d, self.grouping); + } + runtime.host().setActiveDocIndex(insert_before); + } + } + + self.tabs_removed_index = null; + self.tabs_insert_before_index = null; + + workspace.tabs_removed_index = null; + workspace.tabs_insert_before_index = null; + } + } + } + } +} + +/// Repoint `open_file_index` on workspaces that were showing the dragged tab as active. +fn repointWorkspacesAfterTabDrag(tab_bar_workspace: ?*Workspace, drag_index: usize) void { + const dragged_doc = runtime.host().docByIndex(drag_index) orelse return; + if (tab_bar_workspace) |workspace| { + if (workspace.open_file_index == runtime.host().docIndex(dragged_doc.id)) { + var i: usize = 0; + while (i < runtime.host().openDocCount()) : (i += 1) { + const doc = runtime.host().docByIndex(i).?; + if (doc.owner.documentGrouping(doc) == workspace.grouping and doc.id != dragged_doc.id) { + workspace.open_file_index = i; + break; + } + } + } + } else { + for (runtime.workbench().workspaces.values()) |*w| { + if (w.open_file_index == drag_index) { + var i: usize = 0; + while (i < runtime.host().openDocCount()) : (i += 1) { + const doc = runtime.host().docByIndex(i).?; + if (doc.owner.documentGrouping(doc) == w.grouping and doc.id != dragged_doc.id) { + w.open_file_index = i; + break; + } + } + } + } + } +} + +const WorkspaceTabDragSrc = union(enum) { + tab_bar: struct { ws: *Workspace, index: usize }, + tree_open: usize, + tree_closed: []const u8, + none, + + fn resolve() WorkspaceTabDragSrc { + for (runtime.workbench().workspaces.values()) |*w| { + if (w.tabs_drag_index) |i| return .{ .tab_bar = .{ .ws = w, .index = i } }; + } + if (runtime.workbench().tab_drag_from_tree_path) |p| { + var i: usize = 0; + while (i < runtime.host().openDocCount()) : (i += 1) { + const doc = runtime.host().docByIndex(i).?; + if (doc.owner.documentByPath(p) != null) { + return .{ .tree_open = i }; + } + } + return .{ .tree_closed = p }; + } + return .none; + } +}; + +/// Responsible for handling the cross-widget drag of tabs between multiple workspaces or between tabs and workspaces. +/// Also handles the same `tab_drag` from the Files tree (see `files.zig` + DVUI reorder_tree cross-widget pattern). +pub fn processTabDrag(self: *Workspace, data: *dvui.WidgetData) void { + if (!dvui.dragName("tab_drag")) { + runtime.workbench().clearFileTreeTabDragDropState(); + return; + } + + const drag_src = WorkspaceTabDragSrc.resolve(); + switch (drag_src) { + .none => return, + else => {}, + } + + events_loop: for (dvui.events()) |*e| { + if (!dvui.eventMatch(e, .{ .id = data.id, .r = data.rectScale().r, .drag_name = "tab_drag" })) continue; + + switch (drag_src) { + .none => unreachable, + .tab_bar => |tb| { + const workspace = tb.ws; + const drag_index = tb.index; + + var right_side = data.rectScale().r; + right_side.w /= 2; + right_side.x += right_side.w; + + if (right_side.contains(e.evt.mouse.p) and runtime.workbench().workspaces.keys()[runtime.workbench().workspaces.keys().len - 1] == self.grouping) { + if (e.evt == .mouse and e.evt.mouse.action == .position) { + right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + defer workspace.tabs_drag_index = null; + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + runtime.workbench().clearFileTreeTabDragDropState(); + + repointWorkspacesAfterTabDrag(workspace, drag_index); + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; + const new_g = runtime.workbench().newGroupingID(); + dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g); + runtime.workbench().open_workspace_grouping = new_g; + } + } else if (data.rectScale().r.contains(e.evt.mouse.p)) { + if (e.evt == .mouse and e.evt.mouse.action == .position) { + data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + defer workspace.tabs_drag_index = null; + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + runtime.workbench().clearFileTreeTabDragDropState(); + + repointWorkspacesAfterTabDrag(workspace, drag_index); + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; + dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping); + runtime.workbench().open_workspace_grouping = self.grouping; + self.open_file_index = runtime.host().docIndex(dragged_doc.id) orelse 0; + } + } + }, + .tree_open => |drag_index| { + var right_side = data.rectScale().r; + right_side.w /= 2; + right_side.x += right_side.w; + + if (right_side.contains(e.evt.mouse.p) and runtime.workbench().workspaces.keys()[runtime.workbench().workspaces.keys().len - 1] == self.grouping) { + if (e.evt == .mouse and e.evt.mouse.action == .position) { + right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + runtime.workbench().clearFileTreeTabDragDropState(); + + repointWorkspacesAfterTabDrag(null, drag_index); + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; + const new_g = runtime.workbench().newGroupingID(); + dragged_doc.owner.setDocumentGrouping(dragged_doc, new_g); + runtime.workbench().open_workspace_grouping = new_g; + } + } else if (data.rectScale().r.contains(e.evt.mouse.p)) { + if (e.evt == .mouse and e.evt.mouse.action == .position) { + data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + runtime.workbench().clearFileTreeTabDragDropState(); + + repointWorkspacesAfterTabDrag(null, drag_index); + const dragged_doc = runtime.host().docByIndex(drag_index) orelse continue; + dragged_doc.owner.setDocumentGrouping(dragged_doc, self.grouping); + runtime.workbench().open_workspace_grouping = self.grouping; + self.open_file_index = runtime.host().docIndex(dragged_doc.id) orelse 0; + } + } + }, + .tree_closed => |path| { + var right_side = data.rectScale().r; + right_side.w /= 2; + right_side.x += right_side.w; + + if (right_side.contains(e.evt.mouse.p) and runtime.workbench().workspaces.keys()[runtime.workbench().workspaces.keys().len - 1] == self.grouping) { + if (e.evt == .mouse and e.evt.mouse.action == .position) { + right_side.fill(dvui.Rect.Physical.all(right_side.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + const new_g = runtime.workbench().newGroupingID(); + const maybe_idx = runtime.host().openOrFocusFileAtGrouping(path, new_g) catch { + runtime.workbench().clearFileTreeTabDragDropState(); + continue :events_loop; + }; + if (maybe_idx) |idx| { + // File was already open and moved between groupings — repoint the + // workspaces that were showing it, and focus the new pane now. + repointWorkspacesAfterTabDrag(null, idx); + runtime.workbench().open_workspace_grouping = new_g; + } + // Else: async load — leave `open_workspace_grouping` alone. Switching + // to the not-yet-extant workspace would make `activeFile()` null and + // collapse the bottom panel mid-load; `processLoadingJobs` will focus + // the new pane once the worker lands the file, matching the + // "Open to the side" menu action. + runtime.workbench().clearFileTreeTabDragDropState(); + } + } else if (data.rectScale().r.contains(e.evt.mouse.p)) { + if (e.evt == .mouse and e.evt.mouse.action == .position) { + data.rectScale().r.fill(dvui.Rect.Physical.all(data.rectScale().r.w / 8), .{ + .color = dvui.themeGet().color(.highlight, .fill).opacity(0.5), + }); + } + + if (e.evt == .mouse and e.evt.mouse.action == .release and e.evt.mouse.button.pointer()) { + e.handle(@src(), data); + dvui.dragEnd(); + dvui.refresh(null, @src(), data.id); + const maybe_idx = runtime.host().openOrFocusFileAtGrouping(path, self.grouping) catch { + runtime.workbench().clearFileTreeTabDragDropState(); + continue :events_loop; + }; + if (maybe_idx) |idx| { + repointWorkspacesAfterTabDrag(null, idx); + self.open_file_index = idx; + } + // Else: async load into this workspace's existing grouping. The + // worker's `processLoadingJobs` focus handler will set the active + // file once it lands. + runtime.workbench().clearFileTreeTabDragDropState(); + } + } + }, + } + } +} + +pub fn drawCanvas(self: *Workspace) !void { + var content_color = dvui.themeGet().color(.window, .fill); + + switch (builtin.os.tag) { + .macos => { + content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color; + }, + .windows => { + content_color = if (!runtime.host().isMaximized()) content_color.opacity(runtime.host().contentOpacity()) else content_color; + }, + else => {}, + } + + const has_files = runtime.host().openDocCount() > 0; + + var canvas_vbox = workspaceMainCanvasVbox(content_color, has_files, self.grouping); + defer { + self.canvas_rect_physical = canvas_vbox.data().contentRectScale().r; + dvui.toastsShow(canvas_vbox.data().id, canvas_vbox.data().contentRectScale().r.toNatural()); + canvas_vbox.deinit(); + } + defer self.processTabDrag(canvas_vbox.data()); + + if (has_files) { + if (self.open_file_index >= runtime.host().openDocCount()) { + self.open_file_index = runtime.host().openDocCount() - 1; + } + + if (runtime.host().docByIndex(self.open_file_index)) |doc| { + doc.owner.bindDocumentToPane(doc, canvas_vbox.data().id, self, self.center); + _ = try doc.owner.drawDocument(doc); + } + } else { + var box = workspaceEmptyStateCard(content_color, self.grouping); + defer box.deinit(); + + // Make sure alpha is 1 before we draw the homepage, as the logo hover animation breaks if alpha is not 1 + const alpha = dvui.alpha(1.0); + dvui.alphaSet(1.0); + defer dvui.alphaSet(alpha); + + try self.drawHomePage(canvas_vbox); + } +} + +pub fn drawHomePage(_: *Workspace, canvas_vbox: *dvui.BoxWidget) !void { + const logo_pixel_size = 32; + const logo_width = 3; + const logo_height = 5; + + const logo_vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .none, + .gravity_x = 0.5, + .gravity_y = 0.5, + .background = false, + .padding = dvui.Rect.all(10), + }); + defer logo_vbox.deinit(); + + { // Logo + + const vbox2 = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .none, + .gravity_x = 0.5, + .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size * logo_height }, + .padding = dvui.Rect.all(20), + }); + defer vbox2.deinit(); + + for (0..4) |i| { + const hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .min_size_content = .{ .w = logo_pixel_size * logo_width, .h = logo_pixel_size }, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + .id_extra = i, + }); + defer hbox.deinit(); + + for (0..3) |j| { + const index = i * logo_width + j; + var fizzy_color = logo_colors[index]; + + if (fizzy_color.value[3] < 1.0 and fizzy_color.value[3] > 0.0) { + const theme_bg = dvui.themeGet().color(.window, .fill); + fizzy_color = fizzy_color.lerp(wb.math.Color.initBytes(theme_bg.r, theme_bg.g, theme_bg.b, 255), fizzy_color.value[3]); + fizzy_color.value[3] = 1.0; + } + + const color = fizzy_color.bytes(); + + const pixel = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .none, + .min_size_content = .{ .w = logo_pixel_size, .h = logo_pixel_size }, + .id_extra = index, + .background = false, + .color_fill = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + }); + + const rect = pixel.data().rect.outset(.{ .x = 0, .y = 0 }); + const rs = pixel.data().rectScale(); + pixel.deinit(); + + if (fizzy_color.value[3] <= 0.0) continue; + + try drawBubble(rect, rs, color, index); + } + } + } + + var vbox = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .none, + .gravity_x = 0.5, + }); + + { + var button: dvui.ButtonWidget = undefined; + button.init(@src(), .{ .draw_focus = true }, .{ + .gravity_x = 0.5, + .expand = .horizontal, + .padding = dvui.Rect.all(2), + .color_fill = .transparent, + .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), + .color_fill_press = dvui.themeGet().color(.window, .fill_press), + }); + defer button.deinit(); + + button.processEvents(); + button.drawBackground(); + + wdvui.labelWithKeybind( + "New File", + dvui.currentWindow().keybinds.get("new_file") orelse .{}, + true, + .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, + .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, + ); + + if (button.clicked()) { + runtime.host().requestNewDocument(null, 0); + } + } + { + var button: dvui.ButtonWidget = undefined; + button.init(@src(), .{ .draw_focus = true }, .{ + .gravity_x = 0.5, + .expand = .horizontal, + .padding = dvui.Rect.all(2), + .color_fill = .transparent, + .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), + .color_fill_press = dvui.themeGet().color(.window, .fill_press), + }); + defer button.deinit(); + + button.processEvents(); + button.drawBackground(); + + wdvui.labelWithKeybind( + "Open Folder", + dvui.currentWindow().keybinds.get("open_folder") orelse .{}, + true, + .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, + .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, + ); + + if (button.clicked()) { + runtime.host().showOpenFolderDialog(setProjectFolderCallback, null); + } + } + + { + var button: dvui.ButtonWidget = undefined; + button.init(@src(), .{ .draw_focus = true }, .{ + .gravity_x = 0.5, + .expand = .horizontal, + .padding = dvui.Rect.all(2), + .color_fill = .transparent, + .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), + .color_fill_press = dvui.themeGet().color(.window, .fill_press), + }); + defer button.deinit(); + + button.processEvents(); + button.drawBackground(); + + wdvui.labelWithKeybind( + "Open Files", + dvui.currentWindow().keybinds.get("open_files") orelse .{}, + true, + .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0 }, + .{ .padding = dvui.Rect.all(4), .expand = .horizontal, .gravity_x = 1.0, .font = dvui.Font.theme(.heading) }, + ); + + if (button.clicked()) { + runtime.host().showOpenFileDialog(openFilesCallback, &.{ + .{ .name = "Image Files", .pattern = "fizzy;png;jpg;jpeg" }, + }, "", null); + } + } + vbox.deinit(); + + const spacer = dvui.spacer(@src(), .{ .expand = .horizontal, .min_size_content = .{ .h = 30 } }); + + { + var recents_box = dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .none, + .gravity_x = 0.5, + .max_size_content = .{ .h = (canvas_vbox.data().rect.h - spacer.rect.y) / 3.0, .w = canvas_vbox.data().rect.w / 2.0 }, + }); + defer recents_box.deinit(); + + var scroll_area = dvui.scrollArea(@src(), .{}, .{ + .expand = .both, + .color_border = dvui.themeGet().color(.control, .fill), + .corner_radius = dvui.Rect.all(8), + .color_fill = .transparent, + }); + defer scroll_area.deinit(); + + var i: usize = runtime.host().recentFolderCount(); + while (i > 0) : (i -= 1) { + var anim = dvui.animate(@src(), .{ + .kind = .horizontal, + .duration = 150_000 + 150_000 * @as(i32, @intCast(i)), + .easing = dvui.easing.outBack, + }, .{ + .id_extra = i, + .expand = .horizontal, + }); + defer anim.deinit(); + + const folder = runtime.host().recentFolderAt(i - 1) orelse continue; + if (dvui.button(@src(), folder, .{ + .draw_focus = false, + }, .{ + .expand = .horizontal, + .font = dvui.Font.theme(.mono), + .id_extra = i, + .margin = dvui.Rect.all(1), + .padding = dvui.Rect.all(2), + .color_fill = .transparent, + .color_fill_hover = dvui.themeGet().color(.window, .fill_hover), + .color_fill_press = dvui.themeGet().color(.window, .fill_press), + .color_text = dvui.themeGet().color(.control, .text).opacity(0.5), + })) { + try runtime.host().setProjectFolder(folder); + } + } + } +} + +pub fn drawBubble(rect: dvui.Rect, rs: dvui.RectScale, color: [4]u8, _: usize) !void { + var bubble_h: f32 = rect.h; + for (dvui.events()) |evt| { + switch (evt.evt) { + .mouse => |me| { + const dx = @abs(me.p.x - (rs.r.x + rs.r.w * 0.5)) / rs.s; + const dy = @abs(me.p.y - (rs.r.y - rs.r.h * 0.5)) / rs.s; + const distance = @sqrt(dx * dx + dy * dy); + const max_distance: f32 = rect.h * 2.0; + + var t = distance / max_distance; + if (t > 1.0) t = 1.0; + if (t < 0.0) t = 0.0; + bubble_h = @ceil(rect.h - rect.h * t); + }, + else => {}, + } + } + + // Derive the pill's physical rect directly from the base's physical rect + // (no dvui.box layout round-trip). This guarantees identical left/right + // edges between base and pill at any scale or splitter ratio. + const base_phys = rs.r.outsetAll(1); + const bubble_h_phys = @ceil(bubble_h * rs.s); + const bubble_phys = dvui.Rect.Physical{ + .x = base_phys.x, + .y = rs.r.y - bubble_h_phys, + .w = base_phys.w, + .h = bubble_h_phys, + }; + + var path = dvui.Path.Builder.init(dvui.currentWindow().lifo()); + defer path.deinit(); + + path.addRect(base_phys, dvui.Rect.Physical.all(0)); + + if (bubble_phys.h > 0) { + const rad_x = rs.r.w / 2.0; + const rad_y = rs.r.h / 2.0; + const r = bubble_phys; + const tl = dvui.Point.Physical{ .x = r.x + rad_x, .y = r.y + rad_x }; + const bl = dvui.Point.Physical{ .x = r.x, .y = r.y + r.h }; + const br = dvui.Point.Physical{ .x = r.x + r.w, .y = r.y + r.h }; + const tr = dvui.Point.Physical{ .x = r.x + r.w - rad_y, .y = r.y + rad_y }; + path.addArc(tl, rad_x, dvui.math.pi * 1.5, dvui.math.pi, true); + path.addArc(bl, 0, dvui.math.pi, dvui.math.pi * 0.5, true); + path.addArc(br, 0, dvui.math.pi * 0.5, 0, true); + path.addArc(tr, rad_y, dvui.math.pi * 2.0, dvui.math.pi * 1.5, false); + } + + path.build().fillConvex(.{ .color = .{ .r = color[0], .g = color[1], .b = color[2], .a = color[3] }, .fade = 1.0 }); +} + +// This should never be able to return more than one folder +pub fn setProjectFolderCallback(folder: ?[][:0]const u8) void { + if (folder) |f| { + runtime.host().setProjectFolder(f[0]) catch { + dvui.log.err("Failed to set project folder: {s}", .{f[0]}); + }; + } +} + +pub fn openFilesCallback(files: ?[][:0]const u8) void { + if (files) |f| { + for (f) |file| { + _ = runtime.host().openFilePath(file, runtime.workbench().open_workspace_grouping) catch { + dvui.log.err("Failed to open file: {s}", .{file}); + }; + } + } +} diff --git a/src/editor/explorer/files.zig b/src/plugins/workbench/src/files.zig similarity index 79% rename from src/editor/explorer/files.zig rename to src/plugins/workbench/src/files.zig index 18b2f379..05741780 100644 --- a/src/editor/explorer/files.zig +++ b/src/plugins/workbench/src/files.zig @@ -1,21 +1,18 @@ const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); -const dvui = @import("dvui"); -const Editor = fizzy.Editor; const builtin = @import("builtin"); - +const wb = @import("../workbench.zig"); +const runtime = @import("runtime.zig"); +const dvui = wb.dvui; +const wdvui = wb.wdvui; const icons = @import("icons"); -const nfd = @import("nfd"); -const zstbi = @import("zstbi"); - pub var tree_removed_path: ?[]const u8 = null; pub var selected_id: ?usize = null; pub var edit_id: ?usize = null; /// Multi-selection for the file tree. Maps `id_extra` (hash of absolute path) to the heap-owned /// absolute path string. The primary `selected_id` is always a key here when set. Paths are -/// allocated from `fizzy.app.allocator` so they outlive the dvui arena used during draw. +/// allocated from `runtime.allocator()` so they outlive the dvui arena used during draw. pub var selected_paths: std.AutoArrayHashMapUnmanaged(usize, []u8) = .empty; pub var selection_anchor: ?usize = null; @@ -31,10 +28,8 @@ var pending_file_shift_range: ?struct { clicked_path: []const u8, } = null; -/// Set from New File dialog when creating on disk; tree uses this to expand parents, focus rename, and set `new_file_close_rect`. +/// Set from New File dialog when creating on disk; tree uses this to expand parents, focus rename, and set the dialog close-rect override. pub var new_file_path: ?[]const u8 = null; -/// When set, the dialog animates into this rect (explorer row) then closes. -pub var new_file_close_rect: ?dvui.Rect.Physical = null; const open_message = if (builtin.os.tag == .macos) "Reveal in Finder" else "Reveal in File Browser"; @@ -65,7 +60,7 @@ pub fn draw() !void { } // `tab_drag` matches workspace tab strips so file rows can drop on the canvas like tabs (DVUI reorder_tree cross-widget pattern). - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = "tab_drag" }, .{ .background = false, .expand = .both }); + var tree = wdvui.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = "tab_drag" }, .{ .background = false, .expand = .both }); defer tree.deinit(); // Same as tools pane header: first frame after open (or after Files wasn't drawn last frame) @@ -81,10 +76,10 @@ pub fn draw() !void { // Safe as long as `selected_paths` isn't mutated between now and `tree.deinit`. tree.selected_branch_ids = selectionBranchIdsForMultiDrag(dvui.currentWindow().arena()) catch selected_paths.keys(); - if (fizzy.editor.folder) |path| { + if (runtime.host().folder()) |path| { try drawFiles(path, tree); } else { - fizzy.editor.file_tree_data_id = null; + runtime.workbench().file_tree_data_id = null; dvui.labelNoFmt( @src(), "Open a project folder to begin.", @@ -94,17 +89,17 @@ pub fn draw() !void { if (dvui.button(@src(), "Open Folder", .{ .draw_focus = false }, .{ .expand = .horizontal, .style = .highlight })) { if (try dvui.dialogNativeFolderSelect(dvui.currentWindow().arena(), .{ .title = "Open Project Folder" })) |folder| { - try fizzy.editor.setProjectFolder(folder); + try runtime.host().setProjectFolder(folder); } } } } fn drawWeb() !void { - var tree = fizzy.dvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both }); + var tree = wdvui.TreeWidget.tree(@src(), .{}, .{ .background = false, .expand = .both }); defer tree.deinit(); - const viewport_w = fizzy.editor.explorer.scroll_info.viewport.w; + const viewport_w = runtime.host().explorerViewportWidth(); const wrap_w: f32 = if (viewport_w > 0) viewport_w else 200; { @@ -131,7 +126,7 @@ fn drawWeb() !void { .style = .highlight, .min_size_content = .{ .w = 110, .h = 0 }, })) { - fizzy.backend.showOpenFileDialog( + runtime.host().showOpenFileDialog( struct { fn cb(_: ?[][:0]const u8) void {} }.cb, @@ -142,11 +137,12 @@ fn drawWeb() !void { } } -pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { +pub fn drawFiles(path: []const u8, tree: *wdvui.TreeWidget) !void { const unique_id = dvui.parentGet().extendId(@src(), 0); - fizzy.editor.file_tree_data_id = unique_id; + runtime.workbench().file_tree_data_id = unique_id; - var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal }); + // Right margin keeps the entry clear of the overlay scrollbar that draws over the pane's right edge. + var filter_hbox = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .margin = .{ .w = 10 } }); dvui.icon( @src(), "FilterIcon", @@ -253,7 +249,7 @@ pub fn drawFiles(path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { } /// Context menu for the project root directory: close project, reveal on disk, new file / folder. -fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u8, tree: *fizzy.dvui.TreeWidget) !void { +fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u8, tree: *wdvui.TreeWidget) !void { var fw2 = dvui.floatingMenu(@src(), .{ .from = dvui.Rect.Natural.fromPoint(point) }, .{ .box_shadow = .{ .color = .black, .offset = .{ .x = 0, .y = 0 }, @@ -268,11 +264,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "Close", .{}, .{ .expand = .horizontal, })) != null) { - if (fizzy.editor.folder) |f| { - fizzy.editor.ignore.deinit(fizzy.app.allocator); - fizzy.app.allocator.free(f); - fizzy.editor.folder = null; - } + runtime.host().closeProjectFolder(); fw2.close(); } @@ -280,7 +272,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u _ = dvui.separator(@src(), .{ .expand = .horizontal }); if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) { - fizzy.editor.openInFileBrowser(project_path) catch { + runtime.host().openInFileBrowser(project_path) catch { dvui.log.err("Failed to open file browser", .{}); }; @@ -290,20 +282,7 @@ fn showRootProjectContextMenu(point: dvui.Point.Natural, project_path: []const u if ((dvui.menuItemLabel(@src(), "New File...", .{}, .{ .expand = .horizontal })) != null) { defer fw2.close(); - const parent_owned = try dvui.currentWindow().arena().dupe(u8, project_path); - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.NewFile.dialog, - .callafterFn = fizzy.Editor.Dialogs.NewFile.callAfter, - .title = "New File...", - .ok_label = "Create", - .cancel_label = "Cancel", - .resizeable = false, - .header_kind = .info, - .default = .ok, - .id_extra = root_branch_id.asUsize(), - }); - dvui.dataSetSlice(null, mutex.id, "_parent_path", parent_owned); - mutex.mutex.unlock(dvui.io); + runtime.host().requestNewDocument(project_path, root_branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -408,33 +387,30 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind } if (!std.mem.eql(u8, label, te.getText()) and te.getText().len > 0 and valid_path) { - switch (kind) { - .directory => { - std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ label, te.getText() }); - - for (fizzy.editor.open_files.values()) |*file| { - if (std.mem.containsAtLeast(u8, file.path, 1, full_path)) { - const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(file.path)) catch "Failed to duplicate path"; - fizzy.app.allocator.free(file.path); - file.path = try std.fs.path.join(fizzy.app.allocator, &.{ new_path, file_name }); - } - } - }, - .file => { - std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ label, te.getText() }); - - if (fizzy.editor.getFileFromPath(full_path)) |file| { - fizzy.app.allocator.free(file.path); - file.path = fizzy.app.allocator.dupe(u8, new_path) catch { - dvui.log.err("Failed to duplicate path: {s}", .{new_path}); - return error.FailedToDuplicatePath; - }; - } - }, - else => {}, - } + try renamePath(full_path, new_path, kind); } } + } else if (kind == .file) { + // File row: label expands and pushes plugin-registered decorations + // (e.g. the unsaved dot) to the right edge of the row. + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .background = false, + .padding = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .id_extra = id_extra, + }); + defer row.deinit(); + dvui.label(@src(), "{s}", .{label}, .{ + .color_text = color, + .padding = padding, + .margin = dvui.Rect.all(0), + .id_extra = id_extra, + .font = font, + .expand = .horizontal, + .gravity_y = 0.5, + }); + runtime.workbench().drawBranchDecorations(full_path, id_extra); } else { dvui.label(@src(), "{s}", .{label}, .{ .color_text = color, @@ -448,7 +424,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind } } -pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidget, unique_id: dvui.Id, outer_filter_text: []const u8) !void { +pub fn recurseFiles(root_directory: []const u8, outer_tree: *wdvui.TreeWidget, unique_id: dvui.Id, outer_filter_text: []const u8) !void { var color_i: usize = 0; var id_extra: usize = 0; @@ -456,7 +432,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg errdefer pending_file_shift_range = null; const recursor = struct { - fn search(directory: []const u8, tree: *fizzy.dvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*fizzy.dvui.TreeWidget.Branch) !void { + fn search(directory: []const u8, tree: *wdvui.TreeWidget, inner_unique_id: dvui.Id, inner_id_extra: *usize, color_id: *usize, filter_text: []const u8, parent_branch: ?*wdvui.TreeWidget.Branch) !void { const io = dvui.io; var dir = std.Io.Dir.cwd().openDir(io, directory, .{ .access_sub_paths = true, .iterate = true }) catch return; defer dir.close(io); @@ -484,8 +460,8 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg &.{ directory, entry.name }, ); - if (fizzy.editor.folder) |proj_root| { - if (fizzy.editor.ignore.isIgnored(proj_root, abs_path, entry.name, entry.kind)) { + if (runtime.host().folder()) |proj_root| { + if (runtime.host().isPathIgnored(proj_root, abs_path, entry.name, entry.kind)) { continue; } } @@ -500,11 +476,11 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg } inner_id_extra.* = dvui.Id.update(tree.data().id, abs_path).asUsize(); - try visible_file_rows_order.append(fizzy.app.allocator, .{ .id = inner_id_extra.*, .path = abs_path }); + try visible_file_rows_order.append(runtime.allocator(), .{ .id = inner_id_extra.*, .path = abs_path }); var color = dvui.themeGet().color(.control, .fill); - if (fizzy.editor.colors.palette) |*palette| { - color = palette.getDVUIColor(color_id.*); + if (runtime.host().fileRowFillColor(color_id.*)) |tint| { + color = tint; } const padding = dvui.Rect.all(2); @@ -517,7 +493,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg var expanded = false; const expanded_indent: f32 = 14.0; - if (fizzy.editor.explorer.open_branches.get(branch_id) != null) { + if (runtime.host().explorerBranchIsOpen(branch_id)) { expanded = true; } @@ -555,7 +531,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg selected_id = inner_id_extra.*; var close_rect = branch.button.data().borderRectScale().r; close_rect.h = @max(10.0, close_rect.h); - new_file_close_rect = close_rect; + wdvui.dialog_close_rect_override = close_rect; new_file_path = null; } } @@ -597,13 +573,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg dvui.dataSetSlice(null, inner_unique_id, "removed_path", abs_path); if (entry.kind == .file and tree.id_branch == inner_id_extra.*) { - if (fizzy.editor.tab_drag_from_tree_path) |old| { + if (runtime.workbench().tab_drag_from_tree_path) |old| { if (!std.mem.eql(u8, old, abs_path)) { - fizzy.app.allocator.free(old); - fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; + runtime.allocator().free(old); + runtime.workbench().tab_drag_from_tree_path = runtime.allocator().dupe(u8, abs_path) catch null; } } else { - fizzy.editor.tab_drag_from_tree_path = fizzy.app.allocator.dupe(u8, abs_path) catch null; + runtime.workbench().tab_drag_from_tree_path = runtime.allocator().dupe(u8, abs_path) catch null; } } } @@ -616,7 +592,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg if (branch.dropInto() and entry.kind == .directory) { try applyFileMove(inner_unique_id, tree, abs_path); // Expand the folder so the dropped item is visible - fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {}; + runtime.host().setExplorerBranchOpen(branch_id, true); } { // Add right click context menu for item options @@ -652,7 +628,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg break :blk &[_][]const u8{}; }; for (to_open) |p| { - _ = fizzy.editor.openFilePath(p, fizzy.editor.currentGroupingID()) catch |e| { + _ = runtime.host().openFilePath(p, runtime.workbench().currentGroupingID()) catch |e| { dvui.log.err("Failed to open file: {any} ({s})", .{ e, p }); }; } @@ -672,13 +648,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg var have_grouping = false; for (to_open) |p| { if (!have_grouping) { - side_grouping = if (fizzy.editor.open_files.count() == 0) - fizzy.editor.currentGroupingID() + side_grouping = if (runtime.host().openDocCount() == 0) + runtime.workbench().currentGroupingID() else - fizzy.editor.newGroupingID(); + runtime.workbench().newGroupingID(); have_grouping = true; } - _ = fizzy.editor.openFilePath(p, side_grouping) catch { + _ = runtime.host().openFilePath(p, side_grouping) catch { dvui.log.err("Failed to open file: {s}", .{p}); }; } @@ -690,7 +666,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg } if ((dvui.menuItemLabel(@src(), open_message, .{}, .{ .expand = .horizontal })) != null) { - fizzy.editor.openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch { + runtime.host().openInFileBrowser(if (entry.kind == .file) std.fs.path.dirname(abs_path) orelse abs_path else abs_path) catch { dvui.log.err("Failed to open file browser", .{}); }; @@ -701,22 +677,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg defer fw2.close(); const parent_dir: []const u8 = if (entry.kind == .directory) abs_path else directory; - const parent_owned = try dvui.currentWindow().arena().dupe(u8, parent_dir); - // Create a generic dialog that contains typical okay and cancel buttons and header - // The displayFn will be called during the drawing of the dialog, prior to ok and cancel buttons - var mutex = fizzy.dvui.dialog(@src(), .{ - .displayFn = fizzy.Editor.Dialogs.NewFile.dialog, - .callafterFn = fizzy.Editor.Dialogs.NewFile.callAfter, - .title = "New File...", - .ok_label = "Create", - .cancel_label = "Cancel", - .resizeable = false, - .header_kind = .info, - .default = .ok, - .id_extra = branch_id.asUsize(), - }); - dvui.dataSetSlice(null, mutex.id, "_parent_path", parent_owned); - mutex.mutex.unlock(dvui.io); + runtime.host().requestNewDocument(parent_dir, branch_id.asUsize()); } if ((dvui.menuItemLabel(@src(), "New Folder...", .{}, .{ .expand = .horizontal })) != null) { @@ -753,13 +714,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg dvui.log.err("Failed to collect selection paths: {any}", .{err}); break :blk &[_][]const u8{}; }; - for (top) |del_path| { - if (pathIsDirAbsolute(del_path)) { - std.Io.Dir.deleteDirAbsolute(dvui.io, del_path) catch dvui.log.err("Failed to delete folder: {s}", .{del_path}); - } else { - std.Io.Dir.deleteFileAbsolute(dvui.io, del_path) catch dvui.log.err("Failed to delete file: {s}", .{del_path}); - } - } + for (top) |del_path| deletePath(del_path); } } } @@ -783,9 +738,14 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg const file_icon_color: dvui.Color = if (ext == .fizzy) .transparent else icon_color; if (ext == .fizzy) { - _ = fizzy.dvui.sprite( + const ui_atlas = runtime.host().uiAtlas(); + const ui_sprite = ui_atlas.sprites[wb.atlas.sprites.logo_default]; + const logo_sprite = wb.Sprite{ .origin = ui_sprite.origin, .source = ui_sprite.source }; + _ = wb.Sprite.draw( + logo_sprite, @src(), - .{ .source = fizzy.editor.atlas.source, .sprite = fizzy.editor.atlas.data.sprites[fizzy.atlas.sprites.logo_default], .scale = 2.0 }, + ui_atlas.source, + 2.0, .{ .gravity_y = 0.5, .margin = padding, .padding = padding, .background = false }, ); } else { @@ -804,24 +764,17 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg editableLabel( inner_id_extra.*, - if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", fizzy.editor.folder.?, abs_path) catch entry.name else entry.name, - if (fizzy.editor.getFileFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), + if (filter_text.len > 0) std.fs.path.relativePosix(dvui.currentWindow().arena(), ".", runtime.host().folder().?, abs_path) catch entry.name else entry.name, + if (runtime.host().docFromPath(abs_path) != null) dvui.themeGet().color(.window, .text) else dvui.themeGet().color(.control, .text), entry.kind, abs_path, ) catch { dvui.log.err("Failed to draw editable label", .{}); }; - if (fizzy.editor.getFileFromPath(abs_path)) |file| { - // Save spinner takes priority over the dirty dot: while a file is - // mid-save it's no longer "dirty waiting to be saved", it's "saving - // right now", and the user needs that distinction at a glance when - // multiple files are flushing in parallel. `isSaving` reads via an - // atomic load so the background `saveZip` worker can flip the flag - // safely from another thread. - const save_flash_elapsed = file.timeSinceSaveComplete(); - if (file.showsSaveStatusIndicator()) { - fizzy.dvui.bubbleSpinner(@src(), .{ + if (runtime.host().docFromPath(abs_path)) |doc| { + if (doc.owner.showsSaveStatusIndicator(doc)) { + wdvui.bubbleSpinner(@src(), .{ .id_extra = inner_id_extra.* +% 4001, .expand = .none, .min_size_content = .{ .w = 14, .h = 14 }, @@ -829,35 +782,18 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg .gravity_y = 0.5, .color_text = dvui.themeGet().color(.window, .text), }, .{ - .complete_elapsed_ns = save_flash_elapsed, + .complete_elapsed_ns = doc.owner.timeSinceSaveCompleteNs(doc), }); - } else if (file.dirty()) { - _ = dvui.icon( - @src(), - "DirtyIcon", - icons.tvg.lucide.@"circle-small", - .{ .stroke_color = dvui.themeGet().color(.window, .text) }, - .{ - .expand = .none, - .gravity_x = 1.0, - .gravity_y = 0.5, - }, - ); } } if (branch.button.clicked()) { const mode = detectClickMode(branch.button.data().borderRectScale().r); applyFileClick(inner_id_extra.*, abs_path, mode); - if (mode == .replace) { - switch (ext) { - .fizzy, .png, .jpg => { - _ = fizzy.editor.openFilePath(abs_path, fizzy.editor.currentGroupingID()) catch |err| { - dvui.log.err("{any}: {s}", .{ err, abs_path }); - }; - }, - else => {}, - } + if (mode == .replace and openablePath(abs_path)) { + _ = runtime.host().openFilePath(abs_path, runtime.workbench().currentGroupingID()) catch |err| { + dvui.log.err("{any}: {s}", .{ err, abs_path }); + }; } } }, @@ -922,9 +858,7 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg // .alpha = 0.15 * t, // }, })) { - fizzy.editor.explorer.open_branches.put(branch_id, {}) catch { - dvui.log.debug("Failed to track branch state!", .{}); - }; + runtime.host().setExplorerBranchOpen(branch_id, true); try search( abs_path, tree, @@ -935,13 +869,13 @@ pub fn recurseFiles(root_directory: []const u8, outer_tree: *fizzy.dvui.TreeWidg branch, ); } else { - if (fizzy.editor.explorer.open_branches.contains(branch_id)) { - _ = fizzy.editor.explorer.open_branches.remove(branch_id); + if (runtime.host().explorerBranchIsOpen(branch_id)) { + runtime.host().setExplorerBranchOpen(branch_id, false); } } // Keep open_branches in sync so hover-expand and drop-into expand persist next frame if (branch.expanded) { - fizzy.editor.explorer.open_branches.put(branch_id, {}) catch {}; + runtime.host().setExplorerBranchOpen(branch_id, true); } color_id.* = color_id.* + 1; }, @@ -964,33 +898,33 @@ pub fn isFileSelected(id: usize) bool { fn selectionFreeAll() void { var it = selected_paths.iterator(); - while (it.next()) |e| fizzy.app.allocator.free(e.value_ptr.*); + while (it.next()) |e| runtime.allocator().free(e.value_ptr.*); selected_paths.clearRetainingCapacity(); } fn selectionPut(id: usize, path: []const u8) void { if (selected_paths.getPtr(id)) |existing| { if (std.mem.eql(u8, existing.*, path)) return; - fizzy.app.allocator.free(existing.*); - existing.* = fizzy.app.allocator.dupe(u8, path) catch return; + runtime.allocator().free(existing.*); + existing.* = runtime.allocator().dupe(u8, path) catch return; return; } - const copy = fizzy.app.allocator.dupe(u8, path) catch return; - selected_paths.put(fizzy.app.allocator, id, copy) catch { - fizzy.app.allocator.free(copy); + const copy = runtime.allocator().dupe(u8, path) catch return; + selected_paths.put(runtime.allocator(), id, copy) catch { + runtime.allocator().free(copy); }; } fn selectionRemove(id: usize) bool { if (selected_paths.fetchSwapRemove(id)) |kv| { - fizzy.app.allocator.free(kv.value); + runtime.allocator().free(kv.value); return true; } return false; } /// Apply a modifier-aware click to the file-tree selection. Indexed by id_extra (path hash). -fn applyFileClick(id: usize, path: []const u8, mode: fizzy.dvui.TreeSelection.ClickMode) void { +fn applyFileClick(id: usize, path: []const u8, mode: wdvui.TreeSelection.ClickMode) void { switch (mode) { .replace => { selectionFreeAll(); @@ -1054,14 +988,14 @@ fn applyFileShiftRange(clicked_id: usize, clicked_path: []const u8, anchor_id: u /// Derive the click mode from the most recent pointer release event that falls within `rect`. /// Used after `branch.button.clicked()` so we can honor ctrl/cmd/shift without intercepting the /// button's own event handling. -fn detectClickMode(rect: dvui.Rect.Physical) fizzy.dvui.TreeSelection.ClickMode { - var mode: fizzy.dvui.TreeSelection.ClickMode = .replace; +fn detectClickMode(rect: dvui.Rect.Physical) wdvui.TreeSelection.ClickMode { + var mode: wdvui.TreeSelection.ClickMode = .replace; for (dvui.events()) |*e| { if (e.evt != .mouse) continue; const me = e.evt.mouse; if (me.action != .release or !me.button.pointer()) continue; if (!rect.contains(me.p)) continue; - mode = fizzy.dvui.TreeSelection.clickModeFromMod(me.mod); + mode = wdvui.TreeSelection.clickModeFromMod(me.mod); } return mode; } @@ -1109,13 +1043,10 @@ fn pathIsDirAbsolute(abs: []const u8) bool { return true; } -/// Same file kinds as primary-click open in the tree (not directories). +/// True when some registered plugin claims this file extension (not directories). fn openablePath(abs_path: []const u8) bool { if (pathIsDirAbsolute(abs_path)) return false; - return switch (extension(abs_path)) { - .fizzy, .png, .jpg => true, - else => false, - }; + return runtime.host().pluginForExtension(std.fs.path.extension(abs_path)) != null; } fn appendOpenableFilesInTree(arena: std.mem.Allocator, root_abs: []const u8, out: *std.ArrayListUnmanaged([]const u8)) !void { @@ -1185,7 +1116,7 @@ fn selectionBranchIdsForMultiDrag(arena: std.mem.Allocator) ![]const usize { /// Move the drag source (and, for a multi-drag, every other selected path) into `target_dir`. /// Renames files/folders on disk and rewrites open-file paths in-place. Clears the drag's /// stashed `removed_path` when complete. -fn applyFileMove(unique_id: dvui.Id, tree: *fizzy.dvui.TreeWidget, target_dir: []const u8) !void { +fn applyFileMove(unique_id: dvui.Id, tree: *wdvui.TreeWidget, target_dir: []const u8) !void { const arena = dvui.currentWindow().arena(); // The primary (floating) row's path is stashed here by the branch that reports `floating()`. @@ -1235,7 +1166,7 @@ fn applyFileMove(unique_id: dvui.Id, tree: *fizzy.dvui.TreeWidget, target_dir: [ dvui.dataRemove(null, unique_id, "removed_path"); } -fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.Allocator) !bool { +pub fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.Allocator) !bool { const base = std.fs.path.basename(source_path); const new_path = try std.fs.path.join(arena, &.{ target_dir, base }); if (std.mem.eql(u8, source_path, new_path)) return false; @@ -1245,9 +1176,8 @@ fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.A return false; }; - if (fizzy.editor.getFileFromPath(source_path)) |file| { - fizzy.app.allocator.free(file.path); - file.path = fizzy.app.allocator.dupe(u8, new_path) catch { + if (runtime.host().docFromPath(source_path)) |doc| { + doc.owner.setDocumentPath(doc, new_path) catch { dvui.log.err("Failed to duplicate path: {s}", .{new_path}); return error.FailedToDuplicatePath; }; @@ -1255,6 +1185,67 @@ fn moveOnePath(source_path: []const u8, target_dir: []const u8, arena: std.mem.A return true; } +// ---- workbench-api file-tree operations ------------------------------------- +// The functions below are the disk-mutating primitives behind both the explorer's +// inline actions (rename/delete above) and the `workbench-api` Host service. They +// keep any matching open document's `path` field in sync so tabs don't dangle. + +/// Rename `full_path` to `new_path`. A directory rename rewrites the `path` of +/// every open document beneath it; a file rename rewrites that document. Logs and +/// continues on a filesystem failure (matches the explorer's inline behavior). +pub fn renamePath(full_path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void { + switch (kind) { + .directory => { + std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename folder: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); + + var di: usize = 0; + while (di < runtime.host().openDocCount()) : (di += 1) { + const doc = runtime.host().docByIndex(di) orelse continue; + const path = doc.owner.documentPath(doc); + if (std.mem.containsAtLeast(u8, path, 1, full_path)) { + const file_name = dvui.currentWindow().arena().dupe(u8, std.fs.path.basename(path)) catch "Failed to duplicate path"; + const new_full = try std.fs.path.join(runtime.allocator(), &.{ new_path, file_name }); + doc.owner.setDocumentPath(doc, new_full) catch { + dvui.log.err("Failed to update open document path", .{}); + }; + } + } + }, + .file => { + std.Io.Dir.renameAbsolute(full_path, new_path, dvui.io) catch dvui.log.err("Failed to rename file: {s} to {s}", .{ std.fs.path.basename(full_path), std.fs.path.basename(new_path) }); + + if (runtime.host().docFromPath(full_path)) |doc| { + doc.owner.setDocumentPath(doc, new_path) catch { + dvui.log.err("Failed to duplicate path: {s}", .{new_path}); + return error.FailedToDuplicatePath; + }; + } + }, + else => {}, + } +} + +/// Delete `path` from disk (a directory must be empty — mirrors the explorer's +/// inline Delete). Logs and continues on failure. +pub fn deletePath(path: []const u8) void { + if (pathIsDirAbsolute(path)) { + std.Io.Dir.deleteDirAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete folder: {s}", .{path}); + } else { + std.Io.Dir.deleteFileAbsolute(dvui.io, path) catch dvui.log.err("Failed to delete file: {s}", .{path}); + } +} + +/// Create an empty file at absolute `path`. +pub fn createFilePath(path: []const u8) !void { + var handle = try std.Io.Dir.createFileAbsolute(dvui.io, path, .{}); + handle.close(dvui.io); +} + +/// Create a directory at absolute `path` (parents must already exist). +pub fn createDirPath(path: []const u8) !void { + try std.Io.Dir.createDirAbsolute(dvui.io, path, .default_dir); +} + /// Remove stale selections whose underlying file no longer exists (e.g. moved by a multi-drag). pub fn pruneMissingSelections() void { var i: usize = 0; @@ -1266,7 +1257,7 @@ pub fn pruneMissingSelections() void { continue; }; if (selected_id == removed.key) selected_id = null; - fizzy.app.allocator.free(removed.value); + runtime.allocator().free(removed.value); continue; }; i += 1; diff --git a/src/plugins/workbench/src/plugin.zig b/src/plugins/workbench/src/plugin.zig new file mode 100644 index 00000000..f4d6d64c --- /dev/null +++ b/src/plugins/workbench/src/plugin.zig @@ -0,0 +1,78 @@ +//! The workbench plugin: file management. Registered from `Editor.postInit`. +const std = @import("std"); +const dvui = @import("dvui"); +const internal = @import("../workbench.zig"); +const runtime = @import("runtime.zig"); +const sdk = internal.sdk; +const files = @import("files.zig"); + +const workbench_opts = @import("workbench_opts"); + +pub const manifest = sdk.PluginManifest{ + .id = "workbench", + .name = "Workbench", + .version = .{ .major = 0, .minor = 1, .patch = 0 }, +}; + +/// Stable contribution ids (plugin-namespaced) referenced across modules. +pub const view_files = "workbench.files"; +pub const center_workspaces = "workbench.workspaces"; + +var plugin: sdk.Plugin = .{ + .state = undefined, + .vtable = &vtable, + .id = "workbench", + .display_name = "Workbench", +}; + +const vtable: sdk.Plugin.VTable = .{ + .contributeKeybinds = contributeKeybinds, +}; + +/// When false at compile time (`-Dworkbench-file-tree=false`), the Files sidebar is not registered. +pub const has_file_tree = workbench_opts.file_tree; + +pub fn register(host: *sdk.Host) !void { + plugin.state = @ptrCast(runtime.workbench()); + try host.registerPlugin(&plugin); + if (comptime has_file_tree) { + try host.registerSidebarView(.{ + .id = view_files, + .owner = &plugin, + .icon = dvui.entypo.folder, + .title = "Files", + .draw = drawFiles, + }); + } + try host.registerCenterProvider(.{ + .id = center_workspaces, + .owner = &plugin, + .draw = drawCenter, + }); +} + +fn drawFiles(_: ?*anyopaque) anyerror!void { + try files.draw(); +} + +fn drawCenter(_: ?*anyopaque) anyerror!dvui.App.Result { + return runtime.host().drawWorkspaces(0); +} + +/// File-management keybinds (open / save). The shell registers its own +/// global/region binds in `Keybinds.register`; this fills in the file half. +fn contributeKeybinds(_: *anyopaque, win: *dvui.Window) anyerror!void { + if (internal.platform.isMacOS()) { + try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .command = true }); + try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .command = true }); + try win.keybinds.putNoClobber(win.gpa, "save", .{ .command = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_as", .{ .command = true, .shift = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_all", .{ .command = true, .alt = true, .key = .s }); + } else { + try win.keybinds.putNoClobber(win.gpa, "open_folder", .{ .key = .f, .control = true }); + try win.keybinds.putNoClobber(win.gpa, "open_files", .{ .key = .o, .control = true }); + try win.keybinds.putNoClobber(win.gpa, "save", .{ .control = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_as", .{ .control = true, .shift = true, .key = .s }); + try win.keybinds.putNoClobber(win.gpa, "save_all", .{ .control = true, .alt = true, .key = .s }); + } +} diff --git a/src/plugins/workbench/src/runtime.zig b/src/plugins/workbench/src/runtime.zig new file mode 100644 index 00000000..19db7734 --- /dev/null +++ b/src/plugins/workbench/src/runtime.zig @@ -0,0 +1,25 @@ +//! Runtime accessors — backed by `sdk.runtime` and shell-injected workbench pointer. +const std = @import("std"); +const sdk = @import("sdk"); +const Workbench = @import("Workbench.zig"); + +var shell_workbench: ?*Workbench = null; + +/// Static embed: App calls this before `postInit`. +pub fn setWorkbench(w: *Workbench) void { + shell_workbench = w; +} + +pub fn allocator() std.mem.Allocator { + return sdk.allocator(); +} + +pub fn host() *sdk.Host { + return sdk.host(); +} + +pub fn workbench() *Workbench { + if (shell_workbench) |w| return w; + if (sdk.injectedState(Workbench)) |w| return w; + @panic("workbench pointer not wired"); +} diff --git a/src/plugins/workbench/src/workbench_layout.zig b/src/plugins/workbench/src/workbench_layout.zig new file mode 100644 index 00000000..b92afd6d --- /dev/null +++ b/src/plugins/workbench/src/workbench_layout.zig @@ -0,0 +1,137 @@ +//! Workspace map maintenance + recursive split drawing. +const std = @import("std"); +const dvui = @import("dvui"); +const wb_mod = @import("../workbench.zig"); +const runtime = @import("runtime.zig"); +const Workbench = @import("Workbench.zig"); +const Workspace = @import("Workspace.zig"); + +const handle_size = 10; +const handle_dist = 60; + +pub fn rebuildWorkspaces(wb: *Workbench) !void { + const host = runtime.host(); + + var i: usize = 0; + while (i < host.openDocCount()) : (i += 1) { + const doc = host.docByIndex(i) orelse continue; + const grouping = doc.owner.documentGrouping(doc); + if (!wb.workspaces.contains(grouping)) { + var workspace: Workspace = .init(grouping); + var j: usize = 0; + while (j < host.openDocCount()) : (j += 1) { + const d = host.docByIndex(j) orelse continue; + if (d.owner.documentGrouping(d) == grouping) { + workspace.open_file_index = host.docIndex(d.id) orelse 0; + } + } + try wb.workspaces.put(runtime.allocator(), grouping, workspace); + } + } + + for (wb.workspaces.values()) |*workspace| { + if (wb.workspaces.count() == 1) break; + + var contains = false; + var k: usize = 0; + while (k < host.openDocCount()) : (k += 1) { + const doc = host.docByIndex(k) orelse continue; + if (doc.owner.documentGrouping(doc) == workspace.grouping) { + contains = true; + break; + } + } + + if (!contains) { + if (wb.open_workspace_grouping == workspace.grouping) { + for (wb.workspaces.values()) |*w| { + if (w.grouping != workspace.grouping) { + wb.open_workspace_grouping = w.grouping; + break; + } + } + } + workspace.deinit(); + _ = wb.workspaces.orderedRemove(workspace.grouping); + break; + } + } + + for (wb.workspaces.values()) |*workspace| { + if (host.docByIndex(workspace.open_file_index)) |doc| { + if (doc.owner.documentGrouping(doc) == workspace.grouping) continue; + } + var idx: usize = host.openDocCount(); + while (idx > 0) { + idx -= 1; + if (host.docByIndex(idx)) |d| { + if (d.owner.documentGrouping(d) == workspace.grouping) { + workspace.open_file_index = idx; + break; + } + } + } + } +} + +pub const PanelPanedState = struct { + dragging: bool, + animating: bool, + split_ratio: *f32, +}; + +pub fn drawWorkspaces(wb: *Workbench, panel: PanelPanedState, index: usize) !dvui.App.Result { + if (index >= wb.workspaces.count()) return .ok; + + var s = wb_mod.wdvui.paned(@src(), .{ + .direction = .horizontal, + .collapsed_size = if (index == wb.workspaces.count() - 1) std.math.floatMax(f32) else 0, + .handle_size = handle_size, + .handle_dynamic = .{ .handle_size_max = handle_size, .distance_max = handle_dist }, + }, .{ + .expand = .both, + .background = false, + }); + defer s.deinit(); + + const dragging = panel.dragging or s.dragging; + + if (!dragging) { + const should_center = (s.animating and s.split_ratio.* < 1.0) or + (panel.animating and panel.split_ratio.* < 1.0); + if (index + 1 < wb.workspaces.count()) { + wb.workspaces.values()[index + 1].center = should_center; + } else if (wb.workspaces.count() == 1) { + wb.workspaces.values()[index].center = should_center; + } + } + + if (s.collapsing and s.split_ratio.* < 0.5) { + s.animateSplit(1.0, dvui.easing.outBack); + } + + if (!s.dragging and !s.animating and !s.collapsing and !s.collapsed_state) { + if (index == wb.workspaces.count() - 1) { + if (s.split_ratio.* != 1.0) { + s.animateSplit(1.0, dvui.easing.outBack); + } + } else { + if (dvui.firstFrame(s.wd.id)) { + s.split_ratio.* = 1.0; + s.animateSplit(0.5, dvui.easing.outBack); + } + } + } + + if (s.showFirst()) { + const result = try wb.workspaces.values()[index].draw(); + if (result != .ok) return result; + } + + if (s.showSecond()) { + const result = try drawWorkspaces(wb, panel, index + 1); + if (result != .ok) return result; + } + + return .ok; +} diff --git a/src/plugins/workbench/static/integration.zig b/src/plugins/workbench/static/integration.zig new file mode 100644 index 00000000..7397ff86 --- /dev/null +++ b/src/plugins/workbench/static/integration.zig @@ -0,0 +1,67 @@ +//! Workbench plugin — fizzy-internal static-embed + bundled-dylib module graph. +//! Runs only from the fizzy build root, so paths are single fizzy-relative literals. +const std = @import("std"); +const helpers = @import("../../shared/build/helpers.zig"); + +pub const id = "workbench"; +pub const installDylib = helpers.installDylib; + +const module_path = "src/plugins/workbench/workbench.zig"; +const dylib_path = "src/plugins/workbench/root.zig"; + +pub const ModuleImports = struct { + dvui: *std.Build.Module, + core: *std.Build.Module, + sdk: *std.Build.Module, + proxy_bridge: ?*std.Build.Module = null, + icons: ?*std.Build.Module = null, + backend: ?*std.Build.Module = null, +}; + +fn applyImports(module: *std.Build.Module, imports: ModuleImports) void { + module.addImport("dvui", imports.dvui); + module.addImport("core", imports.core); + module.addImport("sdk", imports.sdk); + if (imports.proxy_bridge) |proxy_bridge| module.addImport("proxy_bridge", proxy_bridge); + if (imports.icons) |icons| module.addImport("icons", icons); + if (imports.backend) |backend| module.addImport("backend", backend); +} + +pub fn addStaticModule( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + workbench_opts: *std.Build.Step.Options, + consumer: *std.Build.Module, +) *std.Build.Module { + const mod = helpers.addStaticModule(b, .{ + .import_name = id, + .root_source_file = b.path(module_path), + .target = target, + .optimize = optimize, + .options_name = "workbench_opts", + .options = workbench_opts, + }, consumer); + applyImports(mod, imports); + return mod; +} + +pub fn addDylib( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.builtin.OptimizeMode, + imports: ModuleImports, + workbench_opts: *std.Build.Step.Options, +) *std.Build.Step.Compile { + const lib = helpers.addDylib(b, .{ + .name = id, + .root_source_file = b.path(dylib_path), + .target = target, + .optimize = optimize, + .options_name = "workbench_opts", + .options = workbench_opts, + }); + applyImports(lib.root_module, imports); + return lib; +} diff --git a/src/plugins/workbench/workbench.zig b/src/plugins/workbench/workbench.zig new file mode 100644 index 00000000..96310f63 --- /dev/null +++ b/src/plugins/workbench/workbench.zig @@ -0,0 +1,30 @@ +//! Workbench plugin root module **and** intra-plugin import hub. +//! +//! - The shell resolves `@import("workbench")` to this file when compiled into the app (static +//! embed) and reaches its public surface here. +//! - Files under `src/` import it as `../workbench.zig` for shared deps + types — the +//! conventional `.zig` namespace. +//! +//! Must sit at the plugin root: a Zig module cannot import files above its root file's +//! directory. The build-side static-embed glue lives in `static/`. +const std = @import("std"); + +pub const sdk = @import("sdk"); +pub const core = @import("core"); +pub const dvui = @import("dvui"); + +pub const math = core.math; +pub const atlas = core.atlas; +pub const platform = core.platform; +pub const perf = core.perf; +pub const Sprite = core.Sprite; + +/// Shell's custom dvui widgets/helpers (TreeWidget, paned, labelWithKeybind, …). +pub const wdvui = core.dvui; + +pub const plugin = @import("src/plugin.zig"); +pub const runtime = @import("src/runtime.zig"); +pub const files = @import("src/files.zig"); +pub const Workspace = @import("src/Workspace.zig"); +pub const Workbench = @import("src/Workbench.zig"); +pub const FileLoadJob = @import("src/FileLoadJob.zig"); diff --git a/src/sdk/DocHandle.zig b/src/sdk/DocHandle.zig new file mode 100644 index 00000000..5af748ab --- /dev/null +++ b/src/sdk/DocHandle.zig @@ -0,0 +1,14 @@ +//! An opaque handle to an open document. The shell stores these per tab/workspace +//! and never inspects `ptr` — it only routes operations to `owner` (the plugin +//! that opened the document and knows how to render/save/undo it). For pixel art +//! `ptr` is a `*pixelart.internal.File`; a text plugin would point it at its own type. +const Plugin = @import("Plugin.zig"); + +pub const DocHandle = @This(); + +/// Plugin-owned, opaque document state. +ptr: *anyopaque, +/// The plugin that owns this document. +owner: *Plugin, +/// Shell-assigned stable identifier for tabs/workspaces. +id: u64, diff --git a/src/sdk/EditorAPI.zig b/src/sdk/EditorAPI.zig new file mode 100644 index 00000000..c35f2027 --- /dev/null +++ b/src/sdk/EditorAPI.zig @@ -0,0 +1,393 @@ +//! The shell-provided read/utility surface a plugin reaches through the `Host`. +//! +//! The shell installs one of these on the `Host` during startup (`Host.installShell`); +//! plugins call the convenience forwarders on `Host` (e.g. `host.arena()`), which +//! dispatch through this vtable. It exposes only the genuinely shared shell state a +//! plugin still needs — the per-frame arena, the open project folder, the few shell- +//! owned settings plugins read, and the dirty-mark hook — without leaking the concrete +//! `Editor` type across the SDK boundary. +const std = @import("std"); +const dvui = @import("dvui"); +const DocHandle = @import("DocHandle.zig"); + +const EditorAPI = @This(); + +/// Sub-rect within the shell UI spritesheet. Layout matches `core.Sprite`. +pub const UiSprite = struct { + origin: [2]f32 = .{ 0.0, 0.0 }, + source: [4]u32, +}; + +/// Read-only view of the shell's UI icon atlas (source texture + sprite table). +pub const UiAtlasView = struct { + source: dvui.ImageSource, + sprites: []const UiSprite, +}; + +/// A name/extension-pattern pair for a native save dialog. Layout matches the backend's +/// `DialogFileFilter` (which mirrors `SDL_DialogFileFilter`), so the shell forwards a slice +/// of these straight to the backend without a copy. `pattern` is a `;`-separated extension +/// list, e.g. `"png;jpg;jpeg"`. +pub const SaveDialogFilter = extern struct { + name: [*:0]const u8, + pattern: [*:0]const u8, +}; + +/// Invoked when a native save dialog resolves: the chosen paths, or null if cancelled. +pub const SaveDialogCallback = *const fn (?[][:0]const u8) void; + +/// Invoked when a native open-file/folder dialog resolves. +pub const OpenPathsCallback = *const fn (?[][:0]const u8) void; + +/// Grid dimensions for `createDocument`. +pub const NewDocGrid = struct { + columns: u32 = 1, + rows: u32 = 1, + column_width: u32, + row_height: u32, +}; + +/// Web save-dialog kind (wasm only; native ignores). +pub const WebSaveKind = enum { save, save_as }; + +ctx: *anyopaque, +vtable: *const VTable, + +pub const VTable = struct { + /// The shell's per-frame arena allocator (reset every frame; do not free). + arena: *const fn (ctx: *anyopaque) std.mem.Allocator, + /// The open project root folder, or null when none is open. + folder: *const fn (ctx: *anyopaque) ?[]const u8, + /// The user palettes folder (config), or null on platforms without one (web). + paletteFolder: *const fn (ctx: *anyopaque) ?[]const u8, + /// Mark shell settings dirty so the debounced autosave persists them. + markSettingsDirty: *const fn (ctx: *anyopaque) void, + /// Shell-owned content-area opacity (also drives the shell's own panes); plugins + /// read it to match the shell chrome. + contentOpacity: *const fn (ctx: *anyopaque) f32, + /// Whether the OS window is currently maximized (always false on web). + isMaximized: *const fn (ctx: *anyopaque) bool, + /// Runtime macOS detection (uses `navigator.platform` on web, `os.tag` on native). + isMacOS: *const fn (ctx: *anyopaque) bool, + /// True on native macOS/Windows where unfocused window chrome dims content opacity. + appliesNativeWindowOpacity: *const fn (ctx: *anyopaque) bool, + /// The explorer pane's content rect (shell layout); plugins drawn inside the explorer + /// read it to size their content. Zero rect when no shell is installed. + explorerRect: *const fn (ctx: *anyopaque) dvui.Rect, + /// The explorer scroll area's virtual content size (shell layout). Zero size when no + /// shell is installed. + explorerVirtualSize: *const fn (ctx: *anyopaque) dvui.Size, + /// Run the platform's native "save file" dialog (native: OS dialog; web: download + /// picker). `cb` is invoked when it resolves. No-op when no shell is installed. + showSaveDialog: *const fn ( + ctx: *anyopaque, + cb: SaveDialogCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, + ) void, + /// Shell-owned UI icon spritesheet (cursors, tool icons, logo). Stable for the + /// editor lifetime; plugins read `.source` / `.sprites` but never mutate it. + uiAtlas: *const fn (ctx: *anyopaque) UiAtlasView, + /// The actively focused open document, or null when none. + activeDoc: *const fn (ctx: *anyopaque) ?DocHandle, + /// Open document by ordered index (tab order), or null when out of range. + docByIndex: *const fn (ctx: *anyopaque, index: usize) ?DocHandle, + /// Open document by stable id, or null when not open. + docById: *const fn (ctx: *anyopaque, id: u64) ?DocHandle, + /// Ordered index of document `id`, or null when not open. + docIndex: *const fn (ctx: *anyopaque, id: u64) ?usize, + /// Number of open documents. + openDocCount: *const fn (ctx: *anyopaque) usize, + /// Focus the document at `index` (updates workspace tab selection). + setActiveDocIndex: *const fn (ctx: *anyopaque, index: usize) void, + /// Swap the open documents at indices `a` and `b` (used by tab drag-reorder). The shell + /// owns the open-document collection; this is the only mutation of its order plugins do. + swapDocs: *const fn (ctx: *anyopaque, a: usize, b: usize) void, + /// Allocate the next shell document id (monotonic). + allocDocId: *const fn (ctx: *anyopaque) u64, + + /// Explorer scroll viewport width (0 when unavailable). + explorerViewportWidth: *const fn (ctx: *anyopaque) f32, + /// Lookup an open document by absolute path. + docFromPath: *const fn (ctx: *anyopaque, path: []const u8) ?DocHandle, + /// Open `path` in `grouping` (async load when needed). Returns true when a new load started. + openFilePath: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool, + /// Focus an open doc or queue load; returns index when already open, null when loading. + openOrFocusFileAtGrouping: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!?usize, + /// Close document `id` (may prompt when dirty). + closeDocById: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + /// Open/switch the project root folder. + setProjectFolder: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + /// Close the current project folder (no-op when none open). + closeProjectFolder: *const fn (ctx: *anyopaque) void, + /// Recent project folders (most recent last). + recentFolderCount: *const fn (ctx: *anyopaque) usize, + recentFolderAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8, + /// Reveal `path` in the OS file browser. + openInFileBrowser: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + /// True when `abs_path` is ignored by `.fizignore`/`.gitignore` at `project_root`. + isPathIgnored: *const fn ( + ctx: *anyopaque, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, + ) bool, + /// Explorer tree branch expanded state. + explorerBranchIsOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id) bool, + setExplorerBranchOpen: *const fn (ctx: *anyopaque, branch_id: dvui.Id, open: bool) void, + /// Draw workspace panes (center region); `index` is the root pane (usually 0). + drawWorkspaces: *const fn (ctx: *anyopaque, index: usize) anyerror!dvui.App.Result, + /// Native open-folder dialog (no-op on web). + showOpenFolderDialog: *const fn (ctx: *anyopaque, cb: OpenPathsCallback, default_folder: ?[]const u8) void, + /// Native open-file dialog (web: file picker). + showOpenFileDialog: *const fn ( + ctx: *anyopaque, + cb: OpenPathsCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, + ) void, + + save: *const fn (ctx: *anyopaque) anyerror!void, + requestPrepareFrame: *const fn (ctx: *anyopaque) void, + /// Wake the app event loop for another frame. Safe from worker threads (PTY readers, etc.). + refresh: *const fn (ctx: *anyopaque) void, + + // ---- new document ---- + /// Heap-owned unique basename like `untitled-1`; caller frees with the app allocator. + allocUntitledPath: *const fn (ctx: *anyopaque) anyerror![]u8, + /// Create and open a new document at `path` (path ownership transfers to the shell). + createDocument: *const fn (ctx: *anyopaque, path: []const u8, grid: NewDocGrid) anyerror!DocHandle, + /// Hint the files tree to scroll/highlight a path just created (e.g. New File dialog). + setExplorerNewFilePath: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + + // ---- save / quit flow ---- + requestSaveAs: *const fn (ctx: *anyopaque) void, + requestWebSave: *const fn (ctx: *anyopaque, kind: WebSaveKind) void, + cancelPendingSaveDialog: *const fn (ctx: *anyopaque) void, + setPendingCloseDocId: *const fn (ctx: *anyopaque, id: u64) void, + queueCloseAfterSave: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + trackQuitSaveInFlight: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + resumeSaveAllQuit: *const fn (ctx: *anyopaque) void, + abortSaveAllQuit: *const fn (ctx: *anyopaque) void, +}; + +pub fn arena(self: EditorAPI) std.mem.Allocator { + return self.vtable.arena(self.ctx); +} + +pub fn folder(self: EditorAPI) ?[]const u8 { + return self.vtable.folder(self.ctx); +} + +pub fn paletteFolder(self: EditorAPI) ?[]const u8 { + return self.vtable.paletteFolder(self.ctx); +} + +pub fn markSettingsDirty(self: EditorAPI) void { + self.vtable.markSettingsDirty(self.ctx); +} + +pub fn contentOpacity(self: EditorAPI) f32 { + return self.vtable.contentOpacity(self.ctx); +} + +pub fn isMaximized(self: EditorAPI) bool { + return self.vtable.isMaximized(self.ctx); +} + +pub fn isMacOS(self: EditorAPI) bool { + return self.vtable.isMacOS(self.ctx); +} + +pub fn appliesNativeWindowOpacity(self: EditorAPI) bool { + return self.vtable.appliesNativeWindowOpacity(self.ctx); +} + +pub fn explorerRect(self: EditorAPI) dvui.Rect { + return self.vtable.explorerRect(self.ctx); +} + +pub fn explorerVirtualSize(self: EditorAPI) dvui.Size { + return self.vtable.explorerVirtualSize(self.ctx); +} + +pub fn showSaveDialog( + self: EditorAPI, + cb: SaveDialogCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + self.vtable.showSaveDialog(self.ctx, cb, filters, default_filename, default_folder); +} + +pub fn uiAtlas(self: EditorAPI) UiAtlasView { + return self.vtable.uiAtlas(self.ctx); +} + +pub fn activeDoc(self: EditorAPI) ?DocHandle { + return self.vtable.activeDoc(self.ctx); +} + +pub fn docByIndex(self: EditorAPI, index: usize) ?DocHandle { + return self.vtable.docByIndex(self.ctx, index); +} + +pub fn docById(self: EditorAPI, id: u64) ?DocHandle { + return self.vtable.docById(self.ctx, id); +} + +pub fn docIndex(self: EditorAPI, id: u64) ?usize { + return self.vtable.docIndex(self.ctx, id); +} + +pub fn openDocCount(self: EditorAPI) usize { + return self.vtable.openDocCount(self.ctx); +} + +pub fn setActiveDocIndex(self: EditorAPI, index: usize) void { + self.vtable.setActiveDocIndex(self.ctx, index); +} + +pub fn swapDocs(self: EditorAPI, a: usize, b: usize) void { + self.vtable.swapDocs(self.ctx, a, b); +} + +pub fn allocDocId(self: EditorAPI) u64 { + return self.vtable.allocDocId(self.ctx); +} + +pub fn explorerViewportWidth(self: EditorAPI) f32 { + return self.vtable.explorerViewportWidth(self.ctx); +} + +pub fn docFromPath(self: EditorAPI, path: []const u8) ?DocHandle { + return self.vtable.docFromPath(self.ctx, path); +} + +pub fn openFilePath(self: EditorAPI, path: []const u8, grouping: u64) !bool { + return self.vtable.openFilePath(self.ctx, path, grouping); +} + +pub fn openOrFocusFileAtGrouping(self: EditorAPI, path: []const u8, grouping: u64) !?usize { + return self.vtable.openOrFocusFileAtGrouping(self.ctx, path, grouping); +} + +pub fn closeDocById(self: EditorAPI, id: u64) !void { + return self.vtable.closeDocById(self.ctx, id); +} + +pub fn setProjectFolder(self: EditorAPI, path: []const u8) !void { + return self.vtable.setProjectFolder(self.ctx, path); +} + +pub fn closeProjectFolder(self: EditorAPI) void { + self.vtable.closeProjectFolder(self.ctx); +} + +pub fn recentFolderCount(self: EditorAPI) usize { + return self.vtable.recentFolderCount(self.ctx); +} + +pub fn recentFolderAt(self: EditorAPI, index: usize) ?[]const u8 { + return self.vtable.recentFolderAt(self.ctx, index); +} + +pub fn openInFileBrowser(self: EditorAPI, path: []const u8) !void { + return self.vtable.openInFileBrowser(self.ctx, path); +} + +pub fn isPathIgnored( + self: EditorAPI, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, +) bool { + return self.vtable.isPathIgnored(self.ctx, project_root, abs_path, name, kind); +} + +pub fn explorerBranchIsOpen(self: EditorAPI, branch_id: dvui.Id) bool { + return self.vtable.explorerBranchIsOpen(self.ctx, branch_id); +} + +pub fn setExplorerBranchOpen(self: EditorAPI, branch_id: dvui.Id, open: bool) void { + self.vtable.setExplorerBranchOpen(self.ctx, branch_id, open); +} + +pub fn drawWorkspaces(self: EditorAPI, index: usize) !dvui.App.Result { + return self.vtable.drawWorkspaces(self.ctx, index); +} + +pub fn showOpenFolderDialog(self: EditorAPI, cb: OpenPathsCallback, default_folder: ?[]const u8) void { + self.vtable.showOpenFolderDialog(self.ctx, cb, default_folder); +} + +pub fn showOpenFileDialog( + self: EditorAPI, + cb: OpenPathsCallback, + filters: []const SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + self.vtable.showOpenFileDialog(self.ctx, cb, filters, default_filename, default_folder); +} + +pub fn save(self: EditorAPI) !void { + return self.vtable.save(self.ctx); +} + +pub fn requestPrepareFrame(self: EditorAPI) void { + self.vtable.requestPrepareFrame(self.ctx); +} + +pub fn refresh(self: EditorAPI) void { + self.vtable.refresh(self.ctx); +} + +pub fn allocUntitledPath(self: EditorAPI) ![]u8 { + return self.vtable.allocUntitledPath(self.ctx); +} + +pub fn createDocument(self: EditorAPI, path: []const u8, grid: NewDocGrid) !DocHandle { + return self.vtable.createDocument(self.ctx, path, grid); +} + +pub fn setExplorerNewFilePath(self: EditorAPI, path: []const u8) !void { + return self.vtable.setExplorerNewFilePath(self.ctx, path); +} + +pub fn requestSaveAs(self: EditorAPI) void { + self.vtable.requestSaveAs(self.ctx); +} + +pub fn requestWebSave(self: EditorAPI, kind: WebSaveKind) void { + self.vtable.requestWebSave(self.ctx, kind); +} + +pub fn cancelPendingSaveDialog(self: EditorAPI) void { + self.vtable.cancelPendingSaveDialog(self.ctx); +} + +pub fn setPendingCloseDocId(self: EditorAPI, id: u64) void { + self.vtable.setPendingCloseDocId(self.ctx, id); +} + +pub fn queueCloseAfterSave(self: EditorAPI, id: u64) !void { + return self.vtable.queueCloseAfterSave(self.ctx, id); +} + +pub fn trackQuitSaveInFlight(self: EditorAPI, id: u64) !void { + return self.vtable.trackQuitSaveInFlight(self.ctx, id); +} + +pub fn resumeSaveAllQuit(self: EditorAPI) void { + self.vtable.resumeSaveAllQuit(self.ctx); +} + +pub fn abortSaveAllQuit(self: EditorAPI) void { + self.vtable.abortSaveAllQuit(self.ctx); +} diff --git a/src/sdk/Host.zig b/src/sdk/Host.zig new file mode 100644 index 00000000..64ac080a --- /dev/null +++ b/src/sdk/Host.zig @@ -0,0 +1,648 @@ +//! The services the shell exposes to plugins, and the registries it owns. Plugins +//! receive a `*Host` instead of reaching into editor globals; it holds the plugin +//! registry, the shell region registries, and a service locator. The Host is +//! embedded in `Editor`. +const std = @import("std"); +const dvui = @import("dvui"); +const Plugin = @import("Plugin.zig"); +const regions = @import("regions.zig"); +const EditorAPI = @import("EditorAPI.zig"); +const DocHandle = @import("DocHandle.zig"); +const WorkbenchPaneView = @import("WorkbenchPane.zig").WorkbenchPaneView; + +pub const Host = @This(); + +pub const SidebarView = regions.SidebarView; +pub const BottomView = regions.BottomView; +pub const CenterProvider = regions.CenterProvider; +pub const MenuContribution = regions.MenuContribution; +pub const MenuSectionContribution = regions.MenuSectionContribution; +pub const SettingsSection = regions.SettingsSection; +pub const Command = regions.Command; + +/// Per-plugin opaque settings blobs: plugin id -> serialized JSON. The Host owns the +/// key + value strings; the shell persists them verbatim under "plugins" in +/// settings.json and never interprets them. +pub const PluginSettings = std.StringArrayHashMapUnmanaged([]const u8); + +/// Optional tint for a workbench file-tree row background. `color_index` is the row's +/// stable index during the current tree draw (workbench increments per file). Return +/// null to defer to the next resolver or the theme default. +pub const FileRowFillColor = struct { + ctx: ?*anyopaque = null, + color: *const fn (ctx: ?*anyopaque, color_index: usize) ?dvui.Color, +}; + +allocator: std.mem.Allocator, + +/// All registered plugins (statically compiled in, or loaded from a runtime dylib). +plugins: std.ArrayListUnmanaged(*Plugin) = .empty, + +/// Service locator for inter-plugin APIs: name -> opaque service vtable. E.g. the +/// workbench plugin registers "workbench" so editor plugins can place tabs and +/// draw per-branch explorer decorations without a compile-time dependency on it. +services: std.StringHashMapUnmanaged(*anyopaque) = .empty, + +/// The shell's read/utility surface (arena, folder, shared settings, dirty mark), +/// installed by the shell during startup. Null until installed (headless/test). +shell_api: ?EditorAPI = null, + +/// Opaque per-plugin settings store (see `PluginSettings`). +plugin_settings: PluginSettings = .empty, + +/// File-tree row fill tints (workbench asks the Host; editor plugins register). +file_row_fill_colors: std.ArrayListUnmanaged(FileRowFillColor) = .empty, + +// ---- shell region registries ----------------------------------------------- +// The shell iterates these instead of hardcoded enums/switches. Items keep their +// registration order, which is the order they appear in the UI. + +/// Left-region (explorer) views, one per sidebar icon. +sidebar_views: std.ArrayListUnmanaged(SidebarView) = .empty, +/// Bottom-panel views (shown as a tab strip). +bottom_views: std.ArrayListUnmanaged(BottomView) = .empty, +/// Center ("main window") providers; the active one draws the whole center. +center_providers: std.ArrayListUnmanaged(CenterProvider) = .empty, +/// Menubar contributions (non-macOS in-app menu bar). +menus: std.ArrayListUnmanaged(MenuContribution) = .empty, +/// Nested items contributed into an open parent menu (e.g. View > Example). +menu_sections: std.ArrayListUnmanaged(MenuSectionContribution) = .empty, +/// Settings sections (Settings view renders each under its title, grouped by owner). +settings_sections: std.ArrayListUnmanaged(SettingsSection) = .empty, +/// Plugin-contributed commands, invoked by id (menus, keybinds, palette) — see `Command`. +commands: std.ArrayListUnmanaged(Command) = .empty, + +/// Active selection by contribution id (null = use the first registered). +active_sidebar_view: ?[]const u8 = null, +active_bottom_view: ?[]const u8 = null, +active_center: ?[]const u8 = null, + +pub fn init(allocator: std.mem.Allocator) Host { + return .{ .allocator = allocator }; +} + +pub fn deinit(self: *Host) void { + self.plugins.deinit(self.allocator); + self.services.deinit(self.allocator); + self.sidebar_views.deinit(self.allocator); + self.bottom_views.deinit(self.allocator); + self.center_providers.deinit(self.allocator); + self.menus.deinit(self.allocator); + self.menu_sections.deinit(self.allocator); + self.settings_sections.deinit(self.allocator); + self.commands.deinit(self.allocator); + self.file_row_fill_colors.deinit(self.allocator); + { + var it = self.plugin_settings.iterator(); + while (it.next()) |e| { + self.allocator.free(e.key_ptr.*); + self.allocator.free(e.value_ptr.*); + } + self.plugin_settings.deinit(self.allocator); + } +} + +// ---- shell services (installed by the shell during startup) ---------------- + +/// Install the shell's read/utility surface. Called once during startup. +pub fn installShell(self: *Host, api: EditorAPI) void { + self.shell_api = api; +} + +/// Per-frame arena allocator (reset every frame; do not free). Asserts the shell is installed. +pub fn arena(self: *Host) std.mem.Allocator { + return self.shell_api.?.arena(); +} + +/// Open project root folder, or null when none is open. +pub fn folder(self: *Host) ?[]const u8 { + return if (self.shell_api) |a| a.folder() else null; +} + +/// User palettes folder (config), or null on platforms without one. +pub fn paletteFolder(self: *Host) ?[]const u8 { + return if (self.shell_api) |a| a.paletteFolder() else null; +} + +/// Mark shell settings dirty so the debounced autosave persists them. +pub fn markSettingsDirty(self: *Host) void { + if (self.shell_api) |a| a.markSettingsDirty(); +} + +/// Shell-owned content-area opacity (matches the shell chrome). 1.0 if no shell installed. +pub fn contentOpacity(self: *Host) f32 { + return if (self.shell_api) |a| a.contentOpacity() else 1.0; +} + +/// Whether the OS window is currently maximized. False if no shell installed (headless/web). +pub fn isMaximized(self: *Host) bool { + return if (self.shell_api) |a| a.isMaximized() else false; +} + +pub fn isMacOS(self: *Host) bool { + return if (self.shell_api) |a| a.isMacOS() else false; +} + +pub fn appliesNativeWindowOpacity(self: *Host) bool { + return if (self.shell_api) |a| a.appliesNativeWindowOpacity() else false; +} + +/// The explorer pane's content rect (shell layout). Zero rect if no shell installed. +pub fn explorerRect(self: *Host) dvui.Rect { + return if (self.shell_api) |a| a.explorerRect() else .{}; +} + +/// The explorer scroll area's virtual content size (shell layout). Zero size if no shell installed. +pub fn explorerVirtualSize(self: *Host) dvui.Size { + return if (self.shell_api) |a| a.explorerVirtualSize() else .{}; +} + +/// Run the platform's native "save file" dialog. No-op if no shell installed (headless/test). +pub fn showSaveDialog( + self: *Host, + cb: EditorAPI.SaveDialogCallback, + filters: []const EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + if (self.shell_api) |a| a.showSaveDialog(cb, filters, default_filename, default_folder); +} + +/// Shell-owned UI icon spritesheet. Asserts the shell is installed. +pub fn uiAtlas(self: *Host) EditorAPI.UiAtlasView { + return self.shell_api.?.uiAtlas(); +} + +/// The actively focused open document, or null when none. +pub fn activeDoc(self: *Host) ?DocHandle { + return if (self.shell_api) |a| a.activeDoc() else null; +} + +pub fn docByIndex(self: *Host, index: usize) ?DocHandle { + return if (self.shell_api) |a| a.docByIndex(index) else null; +} + +pub fn docById(self: *Host, id: u64) ?DocHandle { + return if (self.shell_api) |a| a.docById(id) else null; +} + +pub fn docIndex(self: *Host, id: u64) ?usize { + return if (self.shell_api) |a| a.docIndex(id) else null; +} + +pub fn openDocCount(self: *Host) usize { + return if (self.shell_api) |a| a.openDocCount() else 0; +} + +pub fn setActiveDocIndex(self: *Host, index: usize) void { + if (self.shell_api) |a| a.setActiveDocIndex(index); +} + +pub fn swapDocs(self: *Host, a_index: usize, b_index: usize) void { + if (self.shell_api) |a| a.swapDocs(a_index, b_index); +} + +pub fn allocDocId(self: *Host) u64 { + return if (self.shell_api) |a| a.allocDocId() else 0; +} + +pub fn explorerViewportWidth(self: *Host) f32 { + return if (self.shell_api) |a| a.explorerViewportWidth() else 0; +} + +pub fn docFromPath(self: *Host, path: []const u8) ?DocHandle { + return if (self.shell_api) |a| a.docFromPath(path) else null; +} + +pub fn openFilePath(self: *Host, path: []const u8, grouping: u64) !bool { + return if (self.shell_api) |a| try a.openFilePath(path, grouping) else false; +} + +pub fn openOrFocusFileAtGrouping(self: *Host, path: []const u8, grouping: u64) !?usize { + return if (self.shell_api) |a| try a.openOrFocusFileAtGrouping(path, grouping) else null; +} + +pub fn closeDocById(self: *Host, id: u64) !void { + if (self.shell_api) |a| return a.closeDocById(id); +} + +pub fn setProjectFolder(self: *Host, path: []const u8) !void { + return if (self.shell_api) |a| try a.setProjectFolder(path) else error.ShellNotInstalled; +} + +pub fn closeProjectFolder(self: *Host) void { + if (self.shell_api) |a| a.closeProjectFolder(); +} + +pub fn recentFolderCount(self: *Host) usize { + return if (self.shell_api) |a| a.recentFolderCount() else 0; +} + +pub fn recentFolderAt(self: *Host, index: usize) ?[]const u8 { + return if (self.shell_api) |a| a.recentFolderAt(index) else null; +} + +pub fn openInFileBrowser(self: *Host, path: []const u8) !void { + return if (self.shell_api) |a| try a.openInFileBrowser(path) else error.ShellNotInstalled; +} + +pub fn isPathIgnored( + self: *Host, + project_root: []const u8, + abs_path: []const u8, + name: []const u8, + kind: std.Io.File.Kind, +) bool { + return if (self.shell_api) |a| a.isPathIgnored(project_root, abs_path, name, kind) else false; +} + +pub fn explorerBranchIsOpen(self: *Host, branch_id: dvui.Id) bool { + return if (self.shell_api) |a| a.explorerBranchIsOpen(branch_id) else false; +} + +pub fn setExplorerBranchOpen(self: *Host, branch_id: dvui.Id, open: bool) void { + if (self.shell_api) |a| a.setExplorerBranchOpen(branch_id, open); +} + +pub fn drawWorkspaces(self: *Host, index: usize) !dvui.App.Result { + return if (self.shell_api) |a| try a.drawWorkspaces(index) else .ok; +} + +pub fn showOpenFolderDialog(self: *Host, cb: EditorAPI.OpenPathsCallback, default_folder: ?[]const u8) void { + if (self.shell_api) |a| a.showOpenFolderDialog(cb, default_folder); +} + +pub fn showOpenFileDialog( + self: *Host, + cb: EditorAPI.OpenPathsCallback, + filters: []const EditorAPI.SaveDialogFilter, + default_filename: []const u8, + default_folder: ?[]const u8, +) void { + if (self.shell_api) |a| a.showOpenFileDialog(cb, filters, default_filename, default_folder); +} + +pub fn save(self: *Host) !void { + if (self.shell_api) |a| return a.save(); +} + +pub fn requestPrepareFrame(self: *Host) void { + if (self.shell_api) |a| a.requestPrepareFrame(); +} + +pub fn refresh(self: *Host) void { + if (self.shell_api) |a| a.refresh(); +} + +pub fn allocUntitledPath(self: *Host) ![]u8 { + return if (self.shell_api) |a| try a.allocUntitledPath() else error.ShellNotInstalled; +} + +pub fn createDocument(self: *Host, path: []const u8, grid: EditorAPI.NewDocGrid) !DocHandle { + return if (self.shell_api) |a| try a.createDocument(path, grid) else error.ShellNotInstalled; +} + +pub fn setExplorerNewFilePath(self: *Host, path: []const u8) !void { + return if (self.shell_api) |a| try a.setExplorerNewFilePath(path) else error.ShellNotInstalled; +} + +pub fn requestSaveAs(self: *Host) void { + if (self.shell_api) |a| a.requestSaveAs(); +} + +pub fn requestWebSave(self: *Host, kind: EditorAPI.WebSaveKind) void { + if (self.shell_api) |a| a.requestWebSave(kind); +} + +pub fn cancelPendingSaveDialog(self: *Host) void { + if (self.shell_api) |a| a.cancelPendingSaveDialog(); +} + +pub fn setPendingCloseDocId(self: *Host, id: u64) void { + if (self.shell_api) |a| a.setPendingCloseDocId(id); +} + +pub fn queueCloseAfterSave(self: *Host, id: u64) !void { + if (self.shell_api) |a| return a.queueCloseAfterSave(id); +} + +pub fn trackQuitSaveInFlight(self: *Host, id: u64) !void { + if (self.shell_api) |a| return a.trackQuitSaveInFlight(id); +} + +pub fn resumeSaveAllQuit(self: *Host) void { + if (self.shell_api) |a| a.resumeSaveAllQuit(); +} + +pub fn abortSaveAllQuit(self: *Host) void { + if (self.shell_api) |a| a.abortSaveAllQuit(); +} + +// ---- per-plugin settings store --------------------------------------------- + +/// The stored settings blob for `id` (serialized JSON), or null if none. The returned +/// slice is owned by the Host and valid until the next `storePluginSettings` for `id`. +pub fn loadPluginSettings(self: *Host, id: []const u8) ?[]const u8 { + return self.plugin_settings.get(id); +} + +/// Store `json` as `id`'s settings blob (replacing any previous), and mark the shell +/// settings dirty so it persists. The Host copies both `id` and `json`. +pub fn storePluginSettings(self: *Host, id: []const u8, json: []const u8) !void { + const dup = try self.allocator.dupe(u8, json); + errdefer self.allocator.free(dup); + if (self.plugin_settings.getPtr(id)) |slot| { + self.allocator.free(slot.*); + slot.* = dup; + } else { + const key = try self.allocator.dupe(u8, id); + try self.plugin_settings.put(self.allocator, key, dup); + } + self.markSettingsDirty(); +} + +/// Register a plugin under its self-declared `id`. The `id` is the single source of truth +/// for routing (`pluginById`, `pluginForExtension`); a folder name or dylib path is not. +/// Rejects a second plugin claiming an already-registered `id` so routing can never become +/// ambiguous — the dylib loader turns this into a failed load the user is told about +/// (built-in ids always win, since they register first). +pub fn registerPlugin(self: *Host, plugin: *Plugin) !void { + if (self.pluginById(plugin.id) != null) return error.DuplicatePluginId; + try self.plugins.append(self.allocator, plugin); +} + +/// Lookup a registered plugin by stable id (`"pixi"`, `"workbench"`, …). +pub fn pluginById(self: *Host, id: []const u8) ?*Plugin { + for (self.plugins.items) |plugin| { + if (std.mem.eql(u8, plugin.id, id)) return plugin; + } + return null; +} + +/// First registered plugin that implements `createDocument` (for shell New File flows). +pub fn pluginWithCreateDocument(self: *Host) ?*Plugin { + for (self.plugins.items) |plugin| { + if (plugin.vtable.createDocument != null) return plugin; + } + return null; +} + +pub fn registerFileRowFillColor(self: *Host, resolver: FileRowFillColor) !void { + try self.file_row_fill_colors.append(self.allocator, resolver); +} + +/// First non-null tint from registered resolvers, or null for the workbench theme default. +pub fn fileRowFillColor(self: *Host, color_index: usize) ?dvui.Color { + for (self.file_row_fill_colors.items) |resolver| { + if (resolver.color(resolver.ctx, color_index)) |color| return color; + } + return null; +} + +pub fn registerService(self: *Host, name: []const u8, service: *anyopaque) !void { + try self.services.put(self.allocator, name, service); +} + +pub fn getService(self: *Host, name: []const u8) ?*anyopaque { + return self.services.get(name); +} + +/// Typed service lookup. `Service` must declare `service_name` and match the registered layout. +pub fn getServiceTyped(self: *Host, comptime Service: type) ?*Service { + const ptr = self.getService(Service.service_name) orelse return null; + return @ptrCast(@alignCast(ptr)); +} + +// ---- region registration (called from a plugin's register / postInit) ------- + +pub fn registerSidebarView(self: *Host, view: SidebarView) !void { + try self.sidebar_views.append(self.allocator, view); + if (self.active_sidebar_view == null) self.active_sidebar_view = view.id; +} + +pub fn registerBottomView(self: *Host, view: BottomView) !void { + try self.bottom_views.append(self.allocator, view); + if (self.active_bottom_view == null) self.active_bottom_view = view.id; +} + +/// Move a bottom-panel tab from `from_index` to `to_index`. +pub fn reorderBottomView(self: *Host, from_index: usize, to_index: usize) void { + if (from_index >= self.bottom_views.items.len or to_index >= self.bottom_views.items.len) return; + if (from_index == to_index) return; + const item = self.bottom_views.items[from_index]; + _ = self.bottom_views.orderedRemove(from_index); + self.bottom_views.insert(self.allocator, to_index, item) catch return; +} + +pub fn setSidebarViewHidden(self: *Host, id: []const u8, hidden: bool) void { + for (self.sidebar_views.items) |*view| { + if (std.mem.eql(u8, view.id, id)) { + view.hidden = hidden; + return; + } + } +} + +/// Fluent sugar — same fields as `SidebarView`, without a new ABI type. +pub fn registerSidebar( + self: *Host, + spec: struct { + id: []const u8, + title: []const u8, + icon: []const u8, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, + owner: ?*Plugin = null, + hidden: bool = false, + draw_workspace: ?*const fn (ctx: ?*anyopaque, pane: *WorkbenchPaneView) anyerror!void = null, + }, +) !void { + try self.registerSidebarView(.{ + .id = spec.id, + .title = spec.title, + .icon = spec.icon, + .draw = spec.draw, + .owner = spec.owner, + .hidden = spec.hidden, + .draw_workspace = spec.draw_workspace, + }); +} + +pub fn registerBottom( + self: *Host, + spec: struct { + id: []const u8, + title: []const u8, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, + owner: ?*Plugin = null, + persistent: bool = false, + }, +) !void { + try self.registerBottomView(.{ + .id = spec.id, + .title = spec.title, + .draw = spec.draw, + .owner = spec.owner, + .persistent = spec.persistent, + }); +} + +pub fn registerCenter( + self: *Host, + spec: struct { + id: []const u8, + draw: *const fn (ctx: ?*anyopaque) anyerror!dvui.App.Result, + owner: ?*Plugin = null, + }, +) !void { + try self.registerCenterProvider(.{ + .id = spec.id, + .draw = spec.draw, + .owner = spec.owner, + }); +} + +pub fn registerCenterProvider(self: *Host, provider: CenterProvider) !void { + try self.center_providers.append(self.allocator, provider); + if (self.active_center == null) self.active_center = provider.id; +} + +pub fn registerMenu(self: *Host, menu: MenuContribution) !void { + try self.menus.append(self.allocator, menu); +} + +pub fn registerMenuSection(self: *Host, section: MenuSectionContribution) !void { + try self.menu_sections.append(self.allocator, section); +} + +pub fn registerSettingsSection(self: *Host, section: SettingsSection) !void { + try self.settings_sections.append(self.allocator, section); +} + +// ---- commands -------------------------------------------------------------- + +/// Register a plugin command. Ids should be plugin-namespaced (`"pixelart.packProject"`). +pub fn registerCommand(self: *Host, cmd: Command) !void { + try self.commands.append(self.allocator, cmd); +} + +/// The registered command with `id`, or null. +pub fn command(self: *Host, id: []const u8) ?*Command { + for (self.commands.items) |*c| { + if (std.mem.eql(u8, c.id, id)) return c; + } + return null; +} + +/// Whether `id` is registered and currently enabled (absent `isEnabled` = enabled). +/// Unknown ids are treated as disabled. +pub fn commandEnabled(self: *Host, id: []const u8) bool { + const c = self.command(id) orelse return false; + const owner = c.owner orelse return true; + return if (c.isEnabled) |f| f(owner.state) else true; +} + +/// Run the command `id` (no-op when unknown). The owner's opaque `state` is passed to `run`. +pub fn runCommand(self: *Host, id: []const u8) !void { + const c = self.command(id) orelse return; + const owner = c.owner orelse return; + try c.run(owner.state); +} + +// ---- active selection ------------------------------------------------------ + +pub fn setActiveSidebarView(self: *Host, id: []const u8) void { + self.active_sidebar_view = id; +} + +pub fn isActiveSidebarView(self: *Host, id: []const u8) bool { + const active = self.active_sidebar_view orelse return false; + return std.mem.eql(u8, active, id); +} + +/// The currently active sidebar view, or the first visible registered view as fallback. +pub fn activeSidebarView(self: *Host) ?*SidebarView { + if (self.active_sidebar_view) |id| { + for (self.sidebar_views.items) |*v| { + if (std.mem.eql(u8, v.id, id)) return v; + } + } + return self.firstVisibleSidebarView(); +} + +pub fn firstVisibleSidebarView(self: *Host) ?*SidebarView { + for (self.sidebar_views.items) |*v| { + if (!v.hidden) return v; + } + return null; +} + +pub fn hasPersistentBottomView(self: *Host) bool { + for (self.bottom_views.items) |*v| { + if (v.persistent) return true; + } + return false; +} + +pub fn setActiveBottomView(self: *Host, id: []const u8) void { + self.active_bottom_view = id; +} + +pub fn isActiveBottomView(self: *Host, id: []const u8) bool { + const active = self.active_bottom_view orelse return false; + return std.mem.eql(u8, active, id); +} + +pub fn activeBottomView(self: *Host) ?*BottomView { + if (self.active_bottom_view) |id| { + for (self.bottom_views.items) |*v| { + if (std.mem.eql(u8, v.id, id)) return v; + } + } + if (self.bottom_views.items.len > 0) return &self.bottom_views.items[0]; + return null; +} + +pub fn setActiveCenter(self: *Host, id: []const u8) void { + self.active_center = id; +} + +pub fn activeCenter(self: *Host) ?*CenterProvider { + if (self.active_center) |id| { + for (self.center_providers.items) |*p| { + if (std.mem.eql(u8, p.id, id)) return p; + } + } + if (self.center_providers.items.len > 0) return &self.center_providers.items[0]; + return null; +} + +/// The registered plugin with the highest priority (lowest numeric value) for `ext`, +/// or null if none claims it. Specialized plugins claim known types at low values; +/// the code plugin claims every extension at `Plugin.file_type_fallback_priority`. +pub fn pluginForExtension(self: *Host, ext: []const u8) ?*Plugin { + var best: ?*Plugin = null; + var best_priority: u8 = 255; + for (self.plugins.items) |plugin| { + if (plugin.fileTypePriority(ext)) |p| { + if (best == null or p < best_priority) { + best = plugin; + best_priority = p; + } + } + } + return best; +} + +/// Open a "new document" dialog. `parent_path` (when set) targets an on-disk folder; `id_extra` +/// disambiguates launches from distinct explorer rows. Dispatches to the first plugin that +/// provides a new-document dialog. +/// TODO: with more than one editor plugin, present a typed "New > " chooser instead of +/// picking the first provider. +pub fn requestNewDocument(self: *Host, parent_path: ?[]const u8, id_extra: usize) void { + for (self.plugins.items) |plugin| { + if (plugin.vtable.requestNewDocumentDialog) |f| { + f(plugin.state, parent_path, id_extra); + return; + } + } +} diff --git a/src/sdk/Plugin.zig b/src/sdk/Plugin.zig new file mode 100644 index 00000000..528938df --- /dev/null +++ b/src/sdk/Plugin.zig @@ -0,0 +1,468 @@ +//! A feature module that plugs into the editor shell. Today plugins are compiled +//! in and registered statically; the same vtable shape is what a prebuilt plugin +//! dylib will expose at runtime. All hooks are optional function pointers taking +//! the plugin's own opaque `state`, so a plugin implements only what it needs +//! (e.g. the workbench plugin has no `drawDocument`; an editor plugin does). +//! +//! Cross-boundary types may be normal Zig types (not strict C-ABI): host and +//! plugins are pinned to the same SDK build, so layouts match. Only the dlopen +//! entry symbols need `callconv(.c)`. +const std = @import("std"); +const dvui = @import("dvui"); +const DocHandle = @import("DocHandle.zig"); +const EditorAPI = @import("EditorAPI.zig"); + +pub const Plugin = @This(); + +/// Priority for a plugin that opens any file as plain text when no specialized plugin +/// claims the extension. Must be higher (numerically larger) than every specialized +/// claim so `Host.pluginForExtension` only picks it as a fallback. +pub const file_type_fallback_priority: u8 = 100; + +/// Opaque, plugin-owned state passed back to every vtable call. +state: *anyopaque, +vtable: *const VTable, + +/// Stable, unique identifier (snake_case), e.g. "pixelart", "workbench". +id: []const u8, +/// User-facing name shown in UI. +display_name: []const u8, + +/// Mode for an owner's pre-save confirmation (`requestSaveConfirmation`). `editor_save` is a +/// plain in-place save; `save_and_close` is part of a close/quit flow and resumes the shell +/// close walk once the save settles. +pub const SaveConfirmMode = enum { editor_save, save_and_close }; + +// Every field below is an optional fn pointer, so the type system requires *nothing*. But to +// function as an **editor** (open / draw / save files) a plugin must implement the document +// cluster — `fileTypePriority`, the load+staging hooks (`documentStackSize`/`documentStackAlign`/ +// `loadDocument`/`documentIdFromBuffer`/`registerOpenDocument`/`deinitDocumentBuffer`), +// `drawDocument`, `saveDocument`, `isDirty`, and `documentPtr`. Everything else is genuinely +// optional. Each hook's doc comment tags how the shell invokes it: +// [broadcast] — the shell calls it for every plugin at a fixed point each frame +// [active-doc] — the shell calls `doc.owner.hook(doc)` only for the focused document +// [requested] — only fires after the plugin asks for it via a `host.*` call +// A plugin that is *not* an editor (the workbench file tree) implements none of the document +// hooks; it contributes panes + a center provider instead. +pub const VTable = struct { + /// Tear down `state`. Called when the plugin is unregistered / app shuts down. + deinit: ?*const fn (state: *anyopaque) void = null, + /// One-time plugin setup (e.g. background worker threads). + initPlugin: ?*const fn (state: *anyopaque) anyerror!void = null, + + /// Priority for opening files with extension `ext` (including the dot, e.g. + /// ".fiz", or `""` when the basename has no extension); lower value wins. + /// `null` = this plugin does not handle `ext`. A plugin may claim many extensions. + /// A text editor may return `file_type_fallback_priority` for every `ext` so it + /// opens anything no other plugin claims. + fileTypePriority: ?*const fn (state: *anyopaque, ext: []const u8) ?u8 = null, + + // ---- document lifecycle (operates on the plugin's own type via DocHandle) ---- + /// Load the document at `path`, constructing the plugin's own document value in + /// place at `out_doc`. The shell owns the typed buffer behind `out_doc` (for pixel + /// art a `*Internal.File`); the SDK stays type-agnostic. Runs on the shell's load + /// worker thread, so it must only touch the host allocator + the given buffer. + loadDocument: ?*const fn (state: *anyopaque, path: []const u8, out_doc: *anyopaque) anyerror!void = null, + /// `loadDocument`, but from in-memory bytes (browser file picker). `path` is used + /// for extension detection + display name. Synchronous (web has no load worker). + loadDocumentFromBytes: ?*const fn (state: *anyopaque, path: []const u8, bytes: []const u8, out_doc: *anyopaque) anyerror!void = null, + /// Size of the plugin's document type for stack/heap staging buffers (`loadDocument`, etc.). + documentStackSize: ?*const fn (state: *anyopaque) usize = null, + documentStackAlign: ?*const fn (state: *anyopaque) usize = null, + documentIdFromBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque) u64 = null, + deinitDocumentBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque) void = null, + setDocumentGroupingOnBuffer: ?*const fn (state: *anyopaque, doc: *anyopaque, grouping: u64) void = null, + createDocument: ?*const fn (state: *anyopaque, path: []const u8, grid: EditorAPI.NewDocGrid, out_doc: *anyopaque) anyerror!void = null, + saveDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + closeDocument: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, + isDirty: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + undo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + redo: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + canUndo: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + canRedo: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + + /// Register a loaded/created document in the plugin's open-doc map. `file` points at + /// the plugin's document type (for pixel art, `*Internal.File` on the caller's stack). + /// Returns the stable registry pointer for `DocHandle.ptr`. + registerOpenDocument: ?*const fn (state: *anyopaque, file: *anyopaque) anyerror!*anyopaque = null, + /// Resolve a document id to the plugin's registry pointer, or null when not open. + documentPtr: ?*const fn (state: *anyopaque, id: u64) ?*anyopaque = null, + /// Lookup an open document by absolute path. + documentByPath: ?*const fn (state: *anyopaque, path: []const u8) ?*anyopaque = null, + /// Drop the registry entry after `closeDocument` has torn down resources. + unregisterDocument: ?*const fn (state: *anyopaque, id: u64) void = null, + + /// Bind a document to a workbench pane before `drawDocument` (canvas id, workspace handle, center flag). + bindDocumentToPane: ?*const fn (state: *anyopaque, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void = null, + documentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle) u64 = null, + setDocumentGrouping: ?*const fn (state: *anyopaque, doc: DocHandle, grouping: u64) void = null, + removeCanvasPane: ?*const fn (state: *anyopaque, grouping: u64, allocator: std.mem.Allocator) void = null, + documentPath: ?*const fn (state: *anyopaque, doc: DocHandle) []const u8 = null, + setDocumentPath: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8) anyerror!void = null, + documentHasNativeExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + /// True when `saveDocument` can write the document without Save As (e.g. `.fiz` or flat image). + documentHasRecognizedSaveExtension: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + showsSaveStatusIndicator: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + isDocumentSaving: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + saveDocumentAsync: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + timeSinceSaveCompleteNs: ?*const fn (state: *anyopaque, doc: DocHandle) ?i128 = null, + documentDefaultSaveAsFilename: ?*const fn (state: *anyopaque, doc: DocHandle, allocator: std.mem.Allocator) anyerror![]const u8 = null, + saveDocumentAs: ?*const fn (state: *anyopaque, doc: DocHandle, path: []const u8, window: *dvui.Window) anyerror!void = null, + resetDocumentSaveUIState: ?*const fn (state: *anyopaque, doc: DocHandle) void = null, + /// Open the owner's "new document" dialog. Not doc-scoped — the host dispatches to a plugin + /// that provides one (see `Host.requestNewDocument`). `parent_path` (when set) creates the + /// document on disk in that folder; `id_extra` disambiguates per-explorer-row launches. + /// TODO: with more than one editor plugin this becomes a typed "New > " chooser. + requestNewDocumentDialog: ?*const fn (state: *anyopaque, parent_path: ?[]const u8, id_extra: usize) void = null, + + // ---- render hooks (the plugin draws its own dvui UI into the host window) ---- + // Sidebar/explorer panes and bottom-panel tabs are NOT vtable hooks — plugins + // contribute them as named, owned views via `Host.registerSidebarView` / + // `Host.registerBottomView`, which the shell renders as tab strips when more than + // one is registered. Only per-document rendering routes through the vtable below. + /// Draw an open document (center/workspace region), dispatched via `DocHandle.owner`. + drawDocument: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + /// Draw active-document status into the shell infobar (dimensions, cursor, etc.). + drawDocumentInfobar: ?*const fn (state: *anyopaque, doc: DocHandle) anyerror!void = null, + + // ---- shell contributions ---- + contributeMenu: ?*const fn (state: *anyopaque) anyerror!void = null, + contributeKeybinds: ?*const fn (state: *anyopaque, win: *dvui.Window) anyerror!void = null, + + // ---- per-frame shell phases (the shell calls these for every plugin each frame, in + // this order). A plugin does its own per-frame work (caches, playback, overlays) + // inside these generic phases; none carry domain meaning. ---- + /// [broadcast] Top of frame, before workspace rebuild / any document drawing. Advance the + /// frame clock / invalidate per-frame caches. + beginFrame: ?*const fn (state: *anyopaque) void = null, + /// [requested] A one-shot pre-draw pass: runs after layout but before document draw, and + /// **only on a frame where the plugin asked for it** via `host.requestPrepareFrame()` (not + /// every frame). Use to warm expensive render data for the upcoming draw. A plugin that + /// never calls `requestPrepareFrame` never sees this. + prepareFrame: ?*const fn (state: *anyopaque) void = null, + /// [broadcast] Process the plugin's own per-frame keyboard shortcuts (distinct from + /// `contributeKeybinds`, which registers them once). Runs before the shell's global keybinds. + tickKeybinds: ?*const fn (state: *anyopaque) anyerror!void = null, + /// [broadcast] Advance the plugin's open documents; return true to request a follow-up + /// animation frame (e.g. an in-progress save-status fade). + tickOpenDocuments: ?*const fn (state: *anyopaque) bool = null, + /// [broadcast] Advance time-based state for the active document (animation playback, a + /// blinking cursor, …). `timer_host_id` is the active document container's widget id, to + /// anchor any dvui timer/animation the plugin schedules. + tickActiveDocument: ?*const fn (state: *anyopaque, timer_host_id: dvui.Id) void = null, + /// [broadcast] Draw a plugin-owned floating overlay (tool menu, HUD) on top of the frame, + /// after the center region is drawn. + drawOverlay: ?*const fn (state: *anyopaque) anyerror!void = null, + /// [broadcast] End of the center draw — reset per-frame scratch state held across the draw + /// (symmetric counterpart to `beginFrame`). + endFrame: ?*const fn (state: *anyopaque) void = null, + /// [broadcast] True while the plugin needs the shell to keep repainting continuously (an + /// active stroke, a running animation, a background job) rather than idling until input. + needsContinuousRepaint: ?*const fn (state: *anyopaque) bool = null, + + // ---- folder lifecycle ---- + /// [broadcast] Fired just before the open root folder changes or closes — a plugin can + /// persist any state it keyed to that folder (open tabs, view state, …). + onFolderClose: ?*const fn (state: *anyopaque) void = null, + /// [broadcast] Fired after a new root folder has opened (read it via `host.folder()`) — a + /// plugin can load state it keyed to that folder. + onFolderOpen: ?*const fn (state: *anyopaque, allocator: std.mem.Allocator) void = null, + + // ---- save protocol ---- + /// [active-doc] True when the owner wants a confirmation before `saveDocument` (e.g. a save + /// that would flatten lossy data, change encoding, or overwrite an on-disk change). When + /// true the shell calls `requestSaveConfirmation` instead of saving directly. + saveNeedsConfirmation: ?*const fn (state: *anyopaque, doc: DocHandle) bool = null, + /// [active-doc] Open the owner's pre-save confirmation dialog for `doc` (only called when + /// `saveNeedsConfirmation(doc)` is true). The dialog drives the save through the shell + /// save/close API. `from_save_all_quit` marks requests issued during the quit walk. + requestSaveConfirmation: ?*const fn (state: *anyopaque, doc: DocHandle, mode: SaveConfirmMode, from_save_all_quit: bool) void = null, + + // NOTE: editing actions (copy / paste / transform / accept-edit / cancel-edit / + // delete-selection) are deliberately NOT hooks here. They are user-invoked and their meaning + // varies per editor, so a plugin registers them as `Command`s (e.g. `"pixelart.copy"`) and + // the shell dispatches its Edit-menu / keybinds to `"."`. See the + // commands section in docs/PLUGINS.md. +}; + +pub fn commandId(comptime plugin_id: []const u8, comptime action: []const u8) [:0]const u8 { + return plugin_id ++ "." ++ action; +} + +/// Comptime check that a vtable implements the document cluster required for an editor plugin. +pub fn assertEditorVTable(comptime vt: VTable) void { + comptime { + if (vt.loadDocument == null) @compileError("Editor vtable missing required hook: loadDocument"); + if (vt.documentStackSize == null) @compileError("Editor vtable missing required hook: documentStackSize"); + if (vt.documentStackAlign == null) @compileError("Editor vtable missing required hook: documentStackAlign"); + if (vt.registerOpenDocument == null) @compileError("Editor vtable missing required hook: registerOpenDocument"); + if (vt.drawDocument == null) @compileError("Editor vtable missing required hook: drawDocument"); + if (vt.documentPtr == null) @compileError("Editor vtable missing required hook: documentPtr"); + if (vt.isDirty == null) @compileError("Editor vtable missing required hook: isDirty"); + if (vt.saveDocument == null) @compileError("Editor vtable missing required hook: saveDocument"); + if (vt.closeDocument == null) @compileError("Editor vtable missing required hook: closeDocument"); + } +} + +/// Comptime check that a vtable does not implement document hooks (menu-only / utility profile). +pub fn assertUtilityVTable(comptime vt: VTable) void { + comptime { + if (vt.loadDocument != null) @compileError("Utility vtable must not implement document hook: loadDocument"); + if (vt.drawDocument != null) @compileError("Utility vtable must not implement document hook: drawDocument"); + if (vt.registerOpenDocument != null) @compileError("Utility vtable must not implement document hook: registerOpenDocument"); + if (vt.createDocument != null) @compileError("Utility vtable must not implement document hook: createDocument"); + } +} + +// Thin wrappers so callers don't repeat the optional-vtable dance. + +pub fn fileTypePriority(self: Plugin, ext: []const u8) ?u8 { + return if (self.vtable.fileTypePriority) |f| f(self.state, ext) else null; +} + +pub fn contributeKeybinds(self: Plugin, win: *dvui.Window) !void { + if (self.vtable.contributeKeybinds) |f| try f(self.state, win); +} + +pub fn tickKeybinds(self: Plugin) !void { + if (self.vtable.tickKeybinds) |f| try f(self.state); +} + +pub fn drawOverlay(self: Plugin) !void { + if (self.vtable.drawOverlay) |f| try f(self.state); +} + +pub fn registerOpenDocument(self: Plugin, file: *anyopaque) !*anyopaque { + return if (self.vtable.registerOpenDocument) |f| try f(self.state, file) else error.Unsupported; +} + +pub fn documentPtr(self: Plugin, id: u64) ?*anyopaque { + return if (self.vtable.documentPtr) |f| f(self.state, id) else null; +} + +pub fn documentByPath(self: Plugin, path: []const u8) ?*anyopaque { + return if (self.vtable.documentByPath) |f| f(self.state, path) else null; +} + +pub fn unregisterDocument(self: Plugin, id: u64) void { + if (self.vtable.unregisterDocument) |f| f(self.state, id); +} + +pub fn onFolderClose(self: Plugin) void { + if (self.vtable.onFolderClose) |f| f(self.state); +} + +pub fn onFolderOpen(self: Plugin, allocator: std.mem.Allocator) void { + if (self.vtable.onFolderOpen) |f| f(self.state, allocator); +} + +pub fn bindDocumentToPane(self: Plugin, doc: DocHandle, canvas_id: dvui.Id, workspace_handle: *anyopaque, center: bool) void { + if (self.vtable.bindDocumentToPane) |f| f(self.state, doc, canvas_id, workspace_handle, center); +} + +pub fn documentGrouping(self: Plugin, doc: DocHandle) u64 { + return if (self.vtable.documentGrouping) |f| f(self.state, doc) else 0; +} + +pub fn setDocumentGrouping(self: Plugin, doc: DocHandle, grouping: u64) void { + if (self.vtable.setDocumentGrouping) |f| f(self.state, doc, grouping); +} + +pub fn removeCanvasPane(self: Plugin, grouping: u64, allocator: std.mem.Allocator) void { + if (self.vtable.removeCanvasPane) |f| f(self.state, grouping, allocator); +} + +pub fn documentPath(self: Plugin, doc: DocHandle) []const u8 { + return if (self.vtable.documentPath) |f| f(self.state, doc) else ""; +} + +pub fn setDocumentPath(self: Plugin, doc: DocHandle, path: []const u8) !void { + if (self.vtable.setDocumentPath) |f| try f(self.state, doc, path); +} + +pub fn documentHasNativeExtension(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.documentHasNativeExtension) |f| f(self.state, doc) else false; +} + +pub fn documentHasRecognizedSaveExtension(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.documentHasRecognizedSaveExtension) |f| f(self.state, doc) else false; +} + +pub fn showsSaveStatusIndicator(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.showsSaveStatusIndicator) |f| f(self.state, doc) else false; +} + +pub fn isDocumentSaving(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.isDocumentSaving) |f| f(self.state, doc) else false; +} + +pub fn saveNeedsConfirmation(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.saveNeedsConfirmation) |f| f(self.state, doc) else false; +} + +pub fn saveDocumentAsync(self: Plugin, doc: DocHandle) !void { + if (self.vtable.saveDocumentAsync) |f| try f(self.state, doc); +} + +pub fn timeSinceSaveCompleteNs(self: Plugin, doc: DocHandle) ?i128 { + return if (self.vtable.timeSinceSaveCompleteNs) |f| f(self.state, doc) else null; +} + +// ---- document lifecycle wrappers (operate on a DocHandle this plugin owns) ---- + +/// Load `path` into the shell-owned buffer at `out_doc`. Returns whether the plugin +/// handled it; `false` means this plugin exposes no loader (the shell should treat the +/// open as failed). See the `loadDocument` vtable field for the threading contract. +pub fn loadDocument(self: Plugin, path: []const u8, out_doc: *anyopaque) !bool { + if (self.vtable.loadDocument) |f| { + try f(self.state, path, out_doc); + return true; + } + return false; +} + +/// `loadDocument`, but from in-memory `bytes` (browser file picker). +pub fn loadDocumentFromBytes(self: Plugin, path: []const u8, bytes: []const u8, out_doc: *anyopaque) !bool { + if (self.vtable.loadDocumentFromBytes) |f| { + try f(self.state, path, bytes, out_doc); + return true; + } + return false; +} + +pub fn isDirty(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.isDirty) |f| f(self.state, doc) else false; +} + +pub fn saveDocument(self: Plugin, doc: DocHandle) !void { + if (self.vtable.saveDocument) |f| try f(self.state, doc); +} + +/// Tear down an open document. Returns whether the plugin handled it, so the shell +/// can fall back to its own teardown when no plugin claims the document. +pub fn closeDocument(self: Plugin, doc: DocHandle) bool { + if (self.vtable.closeDocument) |f| { + f(self.state, doc); + return true; + } + return false; +} + +pub fn undo(self: Plugin, doc: DocHandle) !void { + if (self.vtable.undo) |f| try f(self.state, doc); +} + +pub fn redo(self: Plugin, doc: DocHandle) !void { + if (self.vtable.redo) |f| try f(self.state, doc); +} + +pub fn canUndo(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.canUndo) |f| f(self.state, doc) else false; +} + +pub fn canRedo(self: Plugin, doc: DocHandle) bool { + return if (self.vtable.canRedo) |f| f(self.state, doc) else false; +} + +// ---- render hook wrappers ---- + +/// Draw an open document into the current dvui parent (the workbench sets up the +/// container, then routes here). Returns whether the plugin drew anything. +pub fn drawDocument(self: Plugin, doc: DocHandle) !bool { + if (self.vtable.drawDocument) |f| { + try f(self.state, doc); + return true; + } + return false; +} + +pub fn drawDocumentInfobar(self: Plugin, doc: DocHandle) !void { + if (self.vtable.drawDocumentInfobar) |f| try f(self.state, doc); +} + +pub fn deinit(self: Plugin) void { + if (self.vtable.deinit) |f| f(self.state); +} + +pub fn initPlugin(self: Plugin) !void { + if (self.vtable.initPlugin) |f| try f(self.state); +} + +pub fn documentStackSize(self: Plugin) usize { + return if (self.vtable.documentStackSize) |f| f(self.state) else 0; +} + +pub fn documentStackAlign(self: Plugin) usize { + return if (self.vtable.documentStackAlign) |f| f(self.state) else 1; +} + +pub fn documentIdFromBuffer(self: Plugin, doc: *anyopaque) u64 { + return if (self.vtable.documentIdFromBuffer) |f| f(self.state, doc) else 0; +} + +pub fn deinitDocumentBuffer(self: Plugin, doc: *anyopaque) void { + if (self.vtable.deinitDocumentBuffer) |f| f(self.state, doc); +} + +pub fn setDocumentGroupingOnBuffer(self: Plugin, doc: *anyopaque, grouping: u64) void { + if (self.vtable.setDocumentGroupingOnBuffer) |f| f(self.state, doc, grouping); +} + +pub fn createDocument(self: Plugin, path: []const u8, grid: EditorAPI.NewDocGrid, out_doc: *anyopaque) !void { + if (self.vtable.createDocument) |f| try f(self.state, path, grid, out_doc) else return error.Unsupported; +} + +pub fn documentDefaultSaveAsFilename(self: Plugin, doc: DocHandle, allocator: std.mem.Allocator) ![]const u8 { + return if (self.vtable.documentDefaultSaveAsFilename) |f| try f(self.state, doc, allocator) else error.Unsupported; +} + +pub fn saveDocumentAs(self: Plugin, doc: DocHandle, path: []const u8, window: *dvui.Window) !void { + if (self.vtable.saveDocumentAs) |f| try f(self.state, doc, path, window) else return error.Unsupported; +} + +pub fn resetDocumentSaveUIState(self: Plugin, doc: DocHandle) void { + if (self.vtable.resetDocumentSaveUIState) |f| f(self.state, doc); +} + +pub fn requestSaveConfirmation(self: Plugin, doc: DocHandle, mode: SaveConfirmMode, from_save_all_quit: bool) void { + if (self.vtable.requestSaveConfirmation) |f| f(self.state, doc, mode, from_save_all_quit); +} + +pub fn requestNewDocumentDialog(self: Plugin, parent_path: ?[]const u8, id_extra: usize) void { + if (self.vtable.requestNewDocumentDialog) |f| f(self.state, parent_path, id_extra); +} + +pub fn beginFrame(self: Plugin) void { + if (self.vtable.beginFrame) |f| f(self.state); +} + +pub fn prepareFrame(self: Plugin) void { + if (self.vtable.prepareFrame) |f| f(self.state); +} + +pub fn endFrame(self: Plugin) void { + if (self.vtable.endFrame) |f| f(self.state); +} + +pub fn tickOpenDocuments(self: Plugin) bool { + return if (self.vtable.tickOpenDocuments) |f| f(self.state) else false; +} + +pub fn tickActiveDocument(self: Plugin, timer_host_id: dvui.Id) void { + if (self.vtable.tickActiveDocument) |f| f(self.state, timer_host_id); +} + +pub fn needsContinuousRepaint(self: Plugin) bool { + return if (self.vtable.needsContinuousRepaint) |f| f(self.state) else false; +} + +/// Allocate a buffer suitable for staging `loadDocument` / `createDocument`. Caller frees `backing`. +pub fn allocDocumentBuffer(self: Plugin, allocator: std.mem.Allocator) !struct { backing: []u8, buf: []u8 } { + const size = self.documentStackSize(); + const align_req = self.documentStackAlign(); + if (size == 0 or align_req == 0) return error.Unsupported; + const pad = align_req - 1; + const backing = try allocator.alloc(u8, size + pad); + const offset = std.mem.alignForward(usize, @intFromPtr(backing.ptr), align_req) - @intFromPtr(backing.ptr); + return .{ .backing = backing, .buf = backing[offset..][0..size] }; +} diff --git a/src/sdk/WorkbenchPane.zig b/src/sdk/WorkbenchPane.zig new file mode 100644 index 00000000..bb3cf5a9 --- /dev/null +++ b/src/sdk/WorkbenchPane.zig @@ -0,0 +1,10 @@ +//! Opaque workbench pane handle passed to a sidebar view's `draw_workspace` hook. +//! Plugins use this instead of casting back to the workbench's internal `Workspace` type. +const dvui = @import("dvui"); + +pub const WorkbenchPaneView = struct { + grouping: u64, + /// Workbench-owned slot; the plugin writes the physical content rect each frame so + /// shell toasts can center over the pane the user is looking at. + canvas_rect_physical: *?dvui.Rect.Physical, +}; diff --git a/src/sdk/document.zig b/src/sdk/document.zig new file mode 100644 index 00000000..9ceaf15a --- /dev/null +++ b/src/sdk/document.zig @@ -0,0 +1,47 @@ +//! Document staging helpers for plugin authors. +//! +//! Use these from `loadDocument` / `loadDocumentFromBytes` vtable hooks when your document +//! type is constructed from a path or bytes into a shell-owned staging buffer. +const std = @import("std"); + +const Plugin = @import("Plugin.zig"); + +/// Shell-allocated staging memory for one document load/create. +pub const StagingBuffer = struct { + backing: []u8, + buf: []u8, + + pub fn deinit(self: StagingBuffer, allocator: std.mem.Allocator) void { + allocator.free(self.backing); + } +}; + +pub fn allocStaging(plugin: *Plugin, allocator: std.mem.Allocator) !StagingBuffer { + const staging = try plugin.allocDocumentBuffer(allocator); + return .{ .backing = staging.backing, .buf = staging.buf }; +} + +pub fn loadPathInto(comptime Doc: type, path: []const u8, out: *Doc) !void { + out.* = try Doc.fromPath(path); +} + +pub fn loadBytesInto(comptime Doc: type, path: []const u8, bytes: []const u8, out: *Doc) !void { + out.* = try Doc.fromBytes(path, bytes); +} + +/// Load `path` into the plugin staging buffer at `staging.buf.ptr`. +pub fn loadIntoStaging(plugin: *Plugin, path: []const u8, staging: StagingBuffer) !void { + const handled = try plugin.loadDocument(path, staging.buf.ptr); + if (!handled) return error.Unsupported; +} + +/// Load in-memory bytes into the plugin staging buffer at `staging.buf.ptr`. +pub fn loadBytesIntoStaging( + plugin: *Plugin, + path: []const u8, + bytes: []const u8, + staging: StagingBuffer, +) !void { + const handled = try plugin.loadDocumentFromBytes(path, bytes, staging.buf.ptr); + if (!handled) return error.Unsupported; +} diff --git a/src/sdk/dvui_context.zig b/src/sdk/dvui_context.zig new file mode 100644 index 00000000..f13ad8f9 --- /dev/null +++ b/src/sdk/dvui_context.zig @@ -0,0 +1,44 @@ +//! Wire a loaded plugin dylib's dvui globals to the host's live state. +//! +//! Host and plugin each compile their own `dvui` copy; before plugin draw/tick the host +//! calls the plugin's `fizzy_plugin_set_dvui_context` export (see `dylib.zig`). +const dvui = @import("dvui"); + +/// C ABI setter type shared by host loader and plugin dylib export. +pub const SetContextFn = *const fn ( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) callconv(.c) void; + +/// Set this compilation unit's dvui globals from host-owned pointers. +pub fn inject( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, +) void { + if (window) |w| dvui.current_window = w; + if (io) |i| { + const io_ptr: *@TypeOf(dvui.io) = @ptrCast(@alignCast(i)); + dvui.io = io_ptr.*; + } + if (comptime dvui.useFreeType) { + if (ft2lib) |ft| { + const ft_ptr: *@TypeOf(dvui.ft2lib) = @ptrCast(@alignCast(ft)); + dvui.ft2lib = ft_ptr.*; + } + } + if (debug) |d| dvui.debug = d.*; +} + +/// Push the host exe's current dvui state into a loaded plugin image. +pub fn syncHostIntoPlugin(setter: SetContextFn) void { + setter( + dvui.current_window, + @ptrCast(&dvui.io), + if (comptime dvui.useFreeType) @ptrCast(&dvui.ft2lib) else null, + &dvui.debug, + ); +} diff --git a/src/sdk/dylib.zig b/src/sdk/dylib.zig new file mode 100644 index 00000000..34087a03 --- /dev/null +++ b/src/sdk/dylib.zig @@ -0,0 +1,260 @@ +//! Runtime dynamic-library contract for Fizzy plugins. +//! +//! Host and plugin each compile their own copy of `dvui` + `sdk` + `core`; the host injects +//! its live dvui context into the plugin image (see `dvui_context.zig`). Cross-boundary +//! vtables use normal Zig layouts pinned to the same Fizzy/SDK build. Only the `dlopen` entry +//! symbols below use C calling convention. +//! +//! **Compatibility:** a structural `abi_fingerprint` is the hard memory-safety gate; human- +//! readable `sdk_version` (see `version.zig`) tells authors when to rebuild. See +//! `docs/PLUGINS.md` § Compatibility. +const std = @import("std"); +const dvui = @import("dvui"); +const proxy_bridge = @import("proxy_bridge"); +const fingerprint = @import("fingerprint.zig"); +const dvui_context = @import("dvui_context.zig"); +const runtime = @import("runtime.zig"); +const version = @import("version.zig"); +const manifest_mod = @import("manifest.zig"); + +const Host = @import("Host.zig"); +const Plugin = @import("Plugin.zig"); +const DocHandle = @import("DocHandle.zig"); +const EditorAPI = @import("EditorAPI.zig"); +const regions = @import("regions.zig"); +const workbench_service = @import("services/workbench.zig"); + +pub const PluginManifest = manifest_mod.PluginManifest; + +/// C ABI — host loader injects host-owned pointers into the plugin image before `register`. +/// +/// `gpa` is always the app allocator. `arg_b`/`arg_c` are two generic injection slots whose +/// meaning is defined by the receiving plugin's `set_globals` (they are *not* fixed roles). +/// The conventions in this tree: +/// - third-party (`exportEntry`): `arg_b` = the `*Host`, `arg_c` = unused (a plugin owns its state) +/// - workbench / code: `arg_b` = `*Host`, `arg_c` = the plugin's own state +/// - pixi: `arg_b` = the plugin's `*State`, `arg_c` = `*Packer` (historical; takes no host here) +pub const SetGlobalsFn = *const fn ( + gpa: ?*const anyopaque, + arg_b: ?*anyopaque, + arg_c: ?*anyopaque, +) callconv(.c) void; + +/// C ABI — host loader pushes its render bridge into the plugin's proxy backend. +pub const SetRenderBridgeFn = *const fn (?*const proxy_bridge.RenderBridge) callconv(.c) void; + +/// C ABI — `fizzy_plugin_register`. +pub const RegisterFn = *const fn (?*Host) callconv(.c) u32; + +/// C ABI — `fizzy_plugin_abi_fingerprint`; the loader rejects any value != `abi_fingerprint`. +pub const GetAbiFingerprintFn = *const fn () callconv(.c) u64; + +pub const VersionTriplet = extern struct { + major: u32, + minor: u32, + patch: u32, +}; + +/// C ABI — returns SDK version this plugin was built against. +pub const GetSdkVersionFn = *const fn () callconv(.c) VersionTriplet; + +/// C ABI — returns the plugin's declared minimum host SDK version. +pub const GetMinSdkVersionFn = *const fn () callconv(.c) VersionTriplet; + +/// C ABI — returns the plugin's own release version. +pub const GetPluginVersionFn = *const fn () callconv(.c) VersionTriplet; + +/// C ABI — returns the plugin's stable id (NUL-terminated). +pub const GetPluginIdFn = *const fn () callconv(.c) [*:0]const u8; + +/// dvui data/handle types that cross the boundary by value or through the render bridge. +const dvui_boundary_types = .{ + dvui.Window, + dvui.Debug, + dvui.Vertex, + dvui.Vertex.Index, + dvui.Texture, + dvui.TextureTarget, + dvui.Rect.Physical, + dvui.Id, +}; + +/// SDK types whose full structure is part of the contract. +const sdk_boundary_types = .{ + Host, + Plugin, + Plugin.VTable, + DocHandle, + EditorAPI, + EditorAPI.VTable, + regions.SidebarView, + regions.BottomView, + regions.CenterProvider, + regions.MenuContribution, + regions.MenuSectionContribution, + regions.SettingsSection, + regions.Command, + Host.FileRowFillColor, + proxy_bridge.RenderBridge, + workbench_service.Api, + workbench_service.Api.VTable, + VersionTriplet, +}; + +const entry_symbol_types = .{ + RegisterFn, + SetGlobalsFn, + SetRenderBridgeFn, + GetAbiFingerprintFn, + GetSdkVersionFn, + GetMinSdkVersionFn, + GetPluginVersionFn, + GetPluginIdFn, + dvui_context.SetContextFn, +}; + +pub const abi_fingerprint: u64 = blk: { + @setEvalBranchQuota(1_000_000); + var h = fingerprint.seed; + h = fingerprint.hashAll(h, dvui_boundary_types, 0); + h = fingerprint.hashAll(h, sdk_boundary_types, 6); + h = fingerprint.hashAll(h, entry_symbol_types, 3); + break :blk h; +}; + +pub const symbol_register: [:0]const u8 = "fizzy_plugin_register"; +pub const symbol_set_dvui_context: [:0]const u8 = "fizzy_plugin_set_dvui_context"; +pub const symbol_set_render_bridge: [:0]const u8 = "fizzy_plugin_set_render_bridge"; +pub const symbol_set_globals: [:0]const u8 = "fizzy_plugin_set_globals"; +pub const symbol_abi_fingerprint: [:0]const u8 = "fizzy_plugin_abi_fingerprint"; +pub const symbol_sdk_version: [:0]const u8 = "fizzy_plugin_sdk_version"; +pub const symbol_min_sdk_version: [:0]const u8 = "fizzy_plugin_min_sdk_version"; +pub const symbol_plugin_version: [:0]const u8 = "fizzy_plugin_version"; +pub const symbol_plugin_id: [:0]const u8 = "fizzy_plugin_id"; + +pub const RegisterStatus = enum(u32) { + ok = 0, + err_register = 1, + err_null_host = 2, + err_abi_mismatch = 3, + err_sdk_version = 4, +}; + +pub fn fingerprintMatches(plugin_fp: u64) bool { + return plugin_fp == abi_fingerprint; +} + +pub fn tripletFromSemver(v: std.SemanticVersion) VersionTriplet { + return .{ + .major = @intCast(v.major), + .minor = @intCast(v.minor), + .patch = @intCast(v.patch), + }; +} + +pub fn semverFromTriplet(t: VersionTriplet) std.SemanticVersion { + return .{ .major = t.major, .minor = t.minor, .patch = t.patch }; +} + +/// Emit version/id C exports for a built-in dylib that does not use `exportEntry`. +pub fn exportManifestSymbols(comptime manifest: PluginManifest) void { + const IdEntry = struct { + const id_z = manifest.id ++ "\x00"; + fn pluginId() callconv(.c) [*:0]const u8 { + return id_z; + } + }; + const ManifestEntry = struct { + fn sdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(version.sdk_version); + } + fn minSdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.min_sdk_version); + } + fn pluginVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.version); + } + }; + @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id }); + @export(&ManifestEntry.sdkVersion, .{ .name = symbol_sdk_version }); + @export(&ManifestEntry.minSdkVersion, .{ .name = symbol_min_sdk_version }); + @export(&ManifestEntry.pluginVersion, .{ .name = symbol_plugin_version }); +} + +/// Emit the C entry symbols every plugin dylib must export, wired to the plugin's +/// own `register` and `manifest`. +/// +/// `plugin_mod` must expose: +/// - `pub fn register(*Host) !void` +/// - `pub const manifest: PluginManifest` +pub fn exportEntry(comptime plugin_mod: type) void { + comptime { + if (@hasDecl(plugin_mod, "manifest") == false) { + @compileError("plugin module must declare `pub const manifest: sdk.PluginManifest`"); + } + } + const manifest = plugin_mod.manifest; + const IdEntry = struct { + const id_z = manifest.id ++ "\x00"; + fn pluginId() callconv(.c) [*:0]const u8 { + return id_z; + } + }; + + const Entry = struct { + fn abiFingerprint() callconv(.c) u64 { + return abi_fingerprint; + } + fn sdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(version.sdk_version); + } + fn minSdkVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.min_sdk_version); + } + fn pluginVersion() callconv(.c) VersionTriplet { + return tripletFromSemver(manifest.version); + } + fn register(host: ?*Host) callconv(.c) u32 { + if (host == null) return @intFromEnum(RegisterStatus.err_null_host); + if (!version.sdkVersionSatisfies(version.sdk_version, manifest.min_sdk_version)) { + return @intFromEnum(RegisterStatus.err_sdk_version); + } + plugin_mod.register(host.?) catch return @intFromEnum(RegisterStatus.err_register); + return @intFromEnum(RegisterStatus.ok); + } + fn setDvuiContext( + window: ?*dvui.Window, + io: ?*anyopaque, + ft2lib: ?*anyopaque, + debug: ?*dvui.Debug, + ) callconv(.c) void { + dvui_context.inject(window, io, ft2lib, debug); + } + fn setRenderBridge(bridge: ?*const proxy_bridge.RenderBridge) callconv(.c) void { + proxy_bridge.setBridge(bridge); + } + fn setGlobals(gpa: ?*const anyopaque, host: ?*anyopaque, state: ?*anyopaque) callconv(.c) void { + runtime.installRuntime( + if (gpa) |p| @ptrCast(@alignCast(p)) else null, + if (host) |p| @ptrCast(@alignCast(p)) else null, + state, + ); + } + }; + @export(&Entry.abiFingerprint, .{ .name = symbol_abi_fingerprint }); + @export(&Entry.sdkVersion, .{ .name = symbol_sdk_version }); + @export(&Entry.minSdkVersion, .{ .name = symbol_min_sdk_version }); + @export(&Entry.pluginVersion, .{ .name = symbol_plugin_version }); + @export(&IdEntry.pluginId, .{ .name = symbol_plugin_id }); + @export(&Entry.register, .{ .name = symbol_register }); + @export(&Entry.setDvuiContext, .{ .name = symbol_set_dvui_context }); + @export(&Entry.setRenderBridge, .{ .name = symbol_set_render_bridge }); + @export(&Entry.setGlobals, .{ .name = symbol_set_globals }); +} + +test "abi fingerprint is non-zero and self-consistent" { + try std.testing.expect(abi_fingerprint != fingerprint.seed); + try std.testing.expect(abi_fingerprint != 0); + try std.testing.expect(fingerprintMatches(abi_fingerprint)); + try std.testing.expect(!fingerprintMatches(abi_fingerprint +% 1)); +} diff --git a/src/sdk/fingerprint.zig b/src/sdk/fingerprint.zig new file mode 100644 index 00000000..429c8259 --- /dev/null +++ b/src/sdk/fingerprint.zig @@ -0,0 +1,150 @@ +//! Compile-time structural fingerprint of the plugin ABI boundary. +//! +//! Host and plugin each compile their own copy of the SDK + dvui types, then each +//! computes this fingerprint from those types. The loader rejects any plugin whose +//! fingerprint differs from the host's, so an incompatible layout — a changed vtable +//! hook signature, a reordered struct field, a different dvui struct size — is caught +//! at load time instead of corrupting memory at runtime. This replaces a hand-bumped +//! `abi_version` integer: there is nothing to remember to bump. +//! +//! **Name-free by design.** The hash folds in only `@sizeOf`, `@alignOf`, field +//! names/offsets, enum tag layout, and function-pointer *signatures* (parameter and +//! return types, recursively). It deliberately never hashes `@typeName`, because the +//! host links `dvui_sdl3` while a plugin links `dvui_proxy`; those carry different +//! module-qualified type names for structurally identical types, and hashing names +//! would reject every plugin. Field names come straight from shared source, so they +//! are safe to hash. +//! +//! **What it catches / misses.** Any change to a listed type's size/alignment, its +//! field set/order/offsets, or a vtable hook's parameter or return *types* changes the +//! fingerprint. A signature change that swaps one parameter type for another of the +//! same size/alignment is not caught — acceptable for a load-time guard. Every data +//! type that crosses the boundary should appear in the caller's root list so its own +//! layout is folded in directly (the per-field walk records a field's structural shape +//! one level down, not the full transitive layout of an arbitrarily nested type). +const std = @import("std"); + +/// FNV-1a 64-bit offset basis. Callers seed their accumulator with this. +pub const seed: u64 = 0xcbf29ce484222325; + +const prime: u64 = 0x00000100000001b3; + +fn mixByte(h: u64, b: u8) u64 { + return (h ^ b) *% prime; +} + +fn mixStr(h_in: u64, s: []const u8) u64 { + var h = h_in; + for (s) |b| h = mixByte(h, b); + return h; +} + +fn mixU64(h_in: u64, v: u64) u64 { + var h = h_in; + var x = v; + var i: usize = 0; + while (i < 8) : (i += 1) { + h = mixByte(h, @intCast(x & 0xff)); + x >>= 8; + } + return h; +} + +/// Fold every type in `types` (an anonymous tuple of `type`) into `h_in` at `depth`. +/// `depth` bounds how far function-pointer signatures and by-value aggregates are +/// followed; data types should be listed at a depth that reaches their fields, while +/// large opaque-by-pointer types (e.g. `dvui.Window`) can be folded at depth 0 (size +/// + alignment only), matching the original size-based dvui check. +pub fn hashAll(h_in: u64, comptime types: anytype, comptime depth: comptime_int) u64 { + comptime { + var h = h_in; + for (types) |T| h = hashType(h, T, depth); + return h; + } +} + +fn hashType(h_in: u64, comptime T: type, comptime depth: comptime_int) u64 { + comptime { + const info = @typeInfo(T); + var h = mixU64(h_in, @intFromEnum(std.meta.activeTag(info))); + // Bare function and opaque types are comptime-only / unsized; everything else + // reached here has a concrete size and alignment worth folding in. + if (info != .@"fn" and info != .@"opaque") { + h = mixU64(h, @sizeOf(T)); + h = mixU64(h, @alignOf(T)); + } + if (depth <= 0) return h; + + switch (info) { + .@"struct" => |s| { + h = mixU64(h, s.fields.len); + for (s.fields, 0..) |f, i| { + h = mixStr(h, f.name); + // Packed structs have no byte offsets; fall back to declaration order. + h = mixU64(h, if (s.layout == .@"packed") i else @offsetOf(T, f.name)); + h = hashType(h, f.type, depth - 1); + } + }, + .@"union" => |u| { + h = mixU64(h, u.fields.len); + for (u.fields) |f| { + h = mixStr(h, f.name); + h = hashType(h, f.type, depth - 1); + } + }, + .@"enum" => |e| { + h = mixU64(h, e.fields.len); + for (e.fields, 0..) |f, i| { + h = mixStr(h, f.name); + h = mixU64(h, i); + } + }, + .optional => |o| h = hashType(h, o.child, depth - 1), + .array => |a| { + h = mixU64(h, a.len); + h = hashType(h, a.child, depth - 1); + }, + .pointer => |p| { + h = mixU64(h, @intFromEnum(p.size)); + h = mixU64(h, @intFromBool(p.is_const)); + // Follow function pointers so vtable hook signatures are part of the + // hash, but never follow data pointers: that would deep-walk types we + // only pass by reference (e.g. `*dvui.Window`) and risk reference cycles. + if (@typeInfo(p.child) == .@"fn") h = hashType(h, p.child, depth - 1); + }, + .@"fn" => |fninfo| { + h = mixU64(h, @intFromEnum(std.meta.activeTag(fninfo.calling_convention))); + h = mixU64(h, fninfo.params.len); + for (fninfo.params) |param| { + if (param.type) |pt| { + h = hashType(h, pt, depth - 1); + } else { + h = mixStr(h, "anytype"); + } + } + if (fninfo.return_type) |rt| h = hashType(h, rt, depth - 1); + }, + else => {}, + } + return h; + } +} + +test "fingerprint is stable and order-sensitive" { + const A = struct { x: u32, y: u64 }; + const B = struct { y: u64, x: u32 }; + const a = comptime hashAll(seed, .{A}, 4); + const a2 = comptime hashAll(seed, .{A}, 4); + const b = comptime hashAll(seed, .{B}, 4); + try std.testing.expectEqual(a, a2); + try std.testing.expect(a != b); // field reorder changes the fingerprint + try std.testing.expect(a != seed); +} + +test "fingerprint catches function-pointer signature changes" { + const V1 = struct { call: *const fn (u32) void }; + const V2 = struct { call: *const fn (u64) void }; + const v1 = comptime hashAll(seed, .{V1}, 6); + const v2 = comptime hashAll(seed, .{V2}, 6); + try std.testing.expect(v1 != v2); +} diff --git a/src/sdk/manifest.zig b/src/sdk/manifest.zig new file mode 100644 index 00000000..ac683ecf --- /dev/null +++ b/src/sdk/manifest.zig @@ -0,0 +1,28 @@ +//! Plugin identity and version metadata embedded in dylibs and optional sidecar JSON. +const std = @import("std"); +const version = @import("version.zig"); + +pub const PluginManifest = struct { + /// Stable plugin id (snake_case). Must match the dylib basename (`{id}.dylib`). + id: []const u8, + /// User-facing name shown in UI / store listings. + name: []const u8, + /// Plugin release version (author bumps on publish). + version: std.SemanticVersion, + /// Minimum host SDK version required to load this plugin. + min_sdk_version: std.SemanticVersion = version.sdk_version, +}; + +/// `[major, minor, patch]` for C exports. +pub fn versionTriplet(v: std.SemanticVersion) [3]u32 { + return .{ v.major, v.minor, v.patch }; +} + +test "manifest defaults min sdk to current" { + const m = PluginManifest{ + .id = "test", + .name = "Test", + .version = .{ .major = 1, .minor = 0, .patch = 0 }, + }; + try std.testing.expectEqual(version.sdk_version, m.min_sdk_version); +} diff --git a/src/sdk/menu.zig b/src/sdk/menu.zig new file mode 100644 index 00000000..6c815d8c --- /dev/null +++ b/src/sdk/menu.zig @@ -0,0 +1,72 @@ +//! Thin menu helpers for plugin contributions. Mirrors shell `Menu.zig` patterns +//! without importing the editor. +const std = @import("std"); +const dvui = @import("dvui"); + +pub fn menuItem( + src: std.builtin.SourceLocation, + label_str: []const u8, + init_opts: dvui.MenuItemWidget.InitOptions, + + opts: dvui.Options, +) ?dvui.Rect.Natural { + var mi = dvui.menuItem(src, init_opts, opts); + + var ret: ?dvui.Rect.Natural = null; + if (mi.activeRect()) |r| ret = r; + + var label_opts = opts; + label_opts.margin = dvui.Rect.all(0); + label_opts.padding = dvui.Rect.all(0); + dvui.labelNoFmt(src, label_str, .{}, label_opts); + mi.deinit(); + return ret; +} + +pub fn menuItemWithChevron( + src: std.builtin.SourceLocation, + label_str: []const u8, + init_opts: dvui.MenuItemWidget.InitOptions, + opts: dvui.Options, +) ?dvui.Rect.Natural { + var mi = dvui.menuItem(src, init_opts, opts); + + var ret: ?dvui.Rect.Natural = null; + if (mi.activeRect()) |r| ret = r; + + var label_opts = opts; + label_opts.margin = dvui.Rect.all(0); + label_opts.padding = dvui.Rect.all(0); + dvui.labelNoFmt(src, label_str, .{}, label_opts); + + dvui.icon(src, "chevron_right", dvui.entypo.chevron_small_right, .{ + .stroke_color = dvui.themeGet().color(.control, .text).opacity(0.5), + .fill_color = dvui.themeGet().color(.control, .text).opacity(0.5), + }, .{ + .expand = .none, + .gravity_x = 1.0, + .gravity_y = 0.5, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(0), + }); + + mi.deinit(); + return ret; +} + +pub fn submenu( + src: std.builtin.SourceLocation, + label_str: []const u8, + opts: dvui.Options, + draw_body: *const fn () anyerror!void, +) !void { + if (menuItemWithChevron(src, label_str, .{ .submenu = true }, opts)) |r| { + var anim = dvui.animate(src, .{ .kind = .alpha, .duration = 250_000 }, .{ .expand = .both }); + defer anim.deinit(); + + var fw = dvui.floatingMenu(src, .{ .from = r }, .{}); + defer fw.deinit(); + + try draw_body(); + } +} diff --git a/src/sdk/pane_layout.zig b/src/sdk/pane_layout.zig new file mode 100644 index 00000000..a896fff5 --- /dev/null +++ b/src/sdk/pane_layout.zig @@ -0,0 +1,27 @@ +//! Shared dvui layout helpers for workbench content panes. Used by the workbench when +//! drawing document canvases and by plugins that take over a pane via `draw_workspace` +//! (e.g. pixel art's Project atlas preview). Stable `@src()` + `grouping` ids avoid +//! widget churn when switching between document and project views. +const dvui = @import("dvui"); + +/// Main vertical canvas region inside a workspace pane. +pub fn mainCanvasVbox(content_color: dvui.Color, background: bool, grouping: u64) *dvui.BoxWidget { + return dvui.box(@src(), .{ .dir = .vertical }, .{ + .expand = .both, + .background = background, + .color_fill = content_color, + .id_extra = @intCast(grouping), + }); +} + +/// Rounded card behind empty states (homepage, project hint, etc.). +pub fn emptyStateCard(content_color: dvui.Color, grouping: u64) *dvui.BoxWidget { + return dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .both, + .background = true, + .color_fill = content_color, + .corner_radius = dvui.Rect.all(16), + .margin = .{ .y = 10 }, + .id_extra = @intCast(grouping), + }); +} diff --git a/src/sdk/regions.zig b/src/sdk/regions.zig new file mode 100644 index 00000000..82d99e26 --- /dev/null +++ b/src/sdk/regions.zig @@ -0,0 +1,106 @@ +//! Shell region contributions. A plugin's `register(host)` imperatively adds as +//! many of these as it wants (multiple sidebar icons, bottom-panel views, center +//! providers, menubar entries). The near-empty shell owns no features of its own — +//! it just iterates these registries (see `Host`) and draws whatever plugins +//! contributed. Built-in shell items (e.g. Settings) register with `owner = null`. +//! +//! `ctx` is contribution-owned opaque state passed back to its `draw` fn (null for +//! contributions that reach through the `fizzy.*` globals directly). `id`s are +//! stable and plugin-namespaced (e.g. "pixelart.sprites") so selection state and +//! cross-plugin references survive without a compile-time dependency. +const dvui = @import("dvui"); +const Plugin = @import("Plugin.zig"); +const WorkbenchPaneView = @import("WorkbenchPane.zig").WorkbenchPaneView; + +/// A left-region (explorer) view, selected by its sidebar icon. Exactly one +/// sidebar view is active at a time; its `draw` fills the left pane. +pub const SidebarView = struct { + id: []const u8, + owner: ?*Plugin = null, + /// Icon byte slice (tvg/entypo) shown in the sidebar rail. + icon: []const u8, + /// User-facing title (sidebar tooltip + pane header). + title: []const u8, + /// When true the view is registered but omitted from the sidebar icon rail. + hidden: bool = false, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, + /// Optional: while this view is the active sidebar view, it takes over the workspace + /// content region instead of the normal document tabs+canvas. The workbench calls this + /// per workspace pane with a `WorkbenchPaneView` (grouping + toast rect slot). + draw_workspace: ?*const fn (ctx: ?*anyopaque, pane: *WorkbenchPaneView) anyerror!void = null, +}; + +/// A bottom-panel view. The panel shows a tab strip across all registered views; +/// the active one's `draw` fills the panel body. +pub const BottomView = struct { + id: []const u8, + owner: ?*Plugin = null, + title: []const u8, + /// When true the bottom panel stays visible even with no active document. + persistent: bool = false, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; + +/// A center ("main window") provider. The active provider draws the ENTIRE center +/// region and may render a single view or its own recursive tabs/splits. The +/// workbench registers one (its tabs/splits + canvas); others may take over. +pub const CenterProvider = struct { + id: []const u8, + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!dvui.App.Result, +}; + +/// A menubar contribution. Its `draw` adds top-level menu(s) to the in-app menu +/// bar (non-macOS). A plugin may register several. +pub const MenuContribution = struct { + id: []const u8, + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; + +/// Items injected into an already-open parent menu (e.g. shell View). The parent +/// menu's `draw` iterates sections whose `parent_menu_id` matches and calls `draw` +/// while its floating submenu is open. +pub const MenuSectionContribution = struct { + id: []const u8, + /// Parent top-level menu id, e.g. "shell.menu.view". + parent_menu_id: []const u8, + owner: ?*Plugin = null, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; + +/// A named, invocable action a plugin registers with the Host. The shell, menus, and +/// keybindings trigger it by `id` via `Host.runCommand(id)` **without knowing what it +/// does** — this is how a plugin contributes its own features (atlas pack, raster +/// transform, a grid-layout dialog, …) without the SDK or shell naming them. Ids are +/// plugin-namespaced (`"pixelart.packProject"`). The owner resolves any context it needs +/// (active doc, selection, …) inside `run`; the shell passes only the owner's opaque state. +pub const Command = struct { + id: []const u8, + owner: ?*Plugin = null, + /// User-facing label (menus / future command palette). + title: []const u8, + /// Invoke the command. `state` is the owning plugin's opaque state (`owner.state`). + run: *const fn (state: *anyopaque) anyerror!void, + /// Optional enabled-state query — e.g. grey out while busy or with no active document. + /// Absent = always enabled. + isEnabled: ?*const fn (state: *anyopaque) bool = null, +}; + +/// A settings section. The Settings view renders each registered section under its +/// own `title` heading, grouped by plugin (VSCode-style). The shell registers its +/// own "Editor" section; plugins register theirs (e.g. pixel art's canvas/ruler +/// prefs). `draw` fills the section body with that owner's controls. +pub const SettingsSection = struct { + id: []const u8, + owner: ?*Plugin = null, + /// Heading shown above this section's controls. + title: []const u8, + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque) anyerror!void, +}; diff --git a/src/sdk/render_bridge.zig b/src/sdk/render_bridge.zig new file mode 100644 index 00000000..f552d424 --- /dev/null +++ b/src/sdk/render_bridge.zig @@ -0,0 +1,263 @@ +//! Host-side thunks for the dvui proxy render bridge. +//! +//! Loaded plugin dylibs draw through `proxy_bridge.RenderBridge` into the shell's real +//! SDL backend. `ctx` is the host `dvui.Window` pointer (stable for the session). +const std = @import("std"); +const dvui = @import("dvui"); +const proxy_bridge = @import("proxy_bridge"); + +pub const SetRenderBridgeFn = *const fn (?*const proxy_bridge.RenderBridge) callconv(.c) void; + +var table: proxy_bridge.RenderBridge = undefined; +var table_ready = false; + +fn emptyTextureDesc() proxy_bridge.TextureDesc { + return std.mem.zeroes(proxy_bridge.TextureDesc); +} + +fn windowFromCtx(ctx: ?*anyopaque) *dvui.Window { + return @ptrCast(@alignCast(ctx orelse @panic("render bridge ctx is null"))); +} + +fn textureFromDesc(desc: *const proxy_bridge.TextureDesc) !dvui.Texture { + return proxy_bridge.textureFromDesc(desc.*); +} + +fn targetFromDesc(desc: *const proxy_bridge.TextureDesc) !dvui.TextureTarget { + return proxy_bridge.targetFromDesc(desc.*); +} + +fn clipFromDesc(has_clip: u8, clip: proxy_bridge.ClipRect) ?dvui.Rect.Physical { + if (has_clip == 0) return null; + return .{ .x = clip.x, .y = clip.y, .w = clip.w, .h = clip.h }; +} + +fn drawClippedTriangles( + ctx: ?*anyopaque, + texture: ?*const proxy_bridge.TextureDesc, + vtx: [*]const dvui.Vertex, + vtx_len: usize, + idx: [*]const dvui.Vertex.Index, + idx_len: usize, + has_clip: u8, + clip: proxy_bridge.ClipRect, +) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex: ?dvui.Texture = if (texture) |desc| textureFromDesc(desc) catch return 0 else null; + win.backend.drawClippedTriangles( + tex, + vtx[0..vtx_len], + idx[0..idx_len], + clipFromDesc(has_clip, clip), + ) catch return 0; + return 1; +} + +fn textureCreate( + ctx: ?*anyopaque, + pixels: [*]const u8, + options: proxy_bridge.CreateOptions, +) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const created = win.backend.textureCreate(pixels, .{ + .width = options.width, + .height = options.height, + .format = @enumFromInt(options.format), + .interpolation = @enumFromInt(options.interpolation), + .wrap_u = @enumFromInt(options.wrap_u), + .wrap_v = @enumFromInt(options.wrap_v), + }) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFrom(created); +} + +fn textureUpdate( + ctx: ?*anyopaque, + texture: *const proxy_bridge.TextureDesc, + pixels: [*]const u8, +) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex = textureFromDesc(texture) catch return 0; + win.backend.textureUpdate(tex, pixels) catch return 0; + return 1; +} + +fn textureUpdateSubRect( + ctx: ?*anyopaque, + texture: *const proxy_bridge.TextureDesc, + pixels: [*]const u8, + x: u32, + y: u32, + w: u32, + h: u32, +) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex = textureFromDesc(texture) catch return 0; + win.backend.textureUpdateSubRect(tex, pixels, x, y, w, h) catch return 0; + return 1; +} + +fn textureDestroy(ctx: ?*anyopaque, texture: *const proxy_bridge.TextureDesc) callconv(.c) void { + const win = windowFromCtx(ctx); + const tex = textureFromDesc(texture) catch return; + win.backend.textureDestroy(tex); +} + +fn textureCreateTarget(ctx: ?*anyopaque, options: proxy_bridge.CreateOptions) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const target = win.backend.textureCreateTarget(.{ + .width = options.width, + .height = options.height, + .format = @enumFromInt(options.format), + .interpolation = @enumFromInt(options.interpolation), + .wrap_u = @enumFromInt(options.wrap_u), + .wrap_v = @enumFromInt(options.wrap_v), + }) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFromTarget(target); +} + +fn textureReadTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc, pixels_out: [*]u8) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return 0; + win.backend.textureReadTarget(tex_target, pixels_out) catch return 0; + return 1; +} + +fn textureDestroyTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) void { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return; + win.backend.textureDestroyTarget(tex_target); +} + +fn textureClearTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) void { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return; + win.backend.textureClearTarget(tex_target); +} + +fn textureFromTarget(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return emptyTextureDesc(); + const tex = win.backend.textureFromTarget(tex_target) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFrom(tex); +} + +fn textureFromTargetTemp(ctx: ?*anyopaque, target: *const proxy_bridge.TextureDesc) callconv(.c) proxy_bridge.TextureDesc { + const win = windowFromCtx(ctx); + const tex_target = targetFromDesc(target) catch return emptyTextureDesc(); + const tex = win.backend.textureFromTargetTemp(tex_target) catch return emptyTextureDesc(); + return proxy_bridge.textureDescFrom(tex); +} + +fn renderTarget(ctx: ?*anyopaque, target: ?*const proxy_bridge.TextureDesc) callconv(.c) u8 { + const win = windowFromCtx(ctx); + const tex_target: ?dvui.TextureTarget = if (target) |desc| targetFromDesc(desc) catch return 0 else null; + win.backend.renderTarget(tex_target) catch return 0; + return 1; +} + +fn pixelSize(ctx: ?*anyopaque) callconv(.c) proxy_bridge.SizePair { + const win = windowFromCtx(ctx); + const size = win.backend.pixelSize(); + return .{ .w = size.w, .h = size.h }; +} + +fn windowSize(ctx: ?*anyopaque) callconv(.c) proxy_bridge.SizePair { + const win = windowFromCtx(ctx); + const size = win.backend.windowSize(); + return .{ .w = size.w, .h = size.h }; +} + +fn contentScale(ctx: ?*anyopaque) callconv(.c) f32 { + const win = windowFromCtx(ctx); + return win.backend.contentScale(); +} + +threadlocal var clipboard_scratch: [8192]u8 = undefined; + +fn clipboardText(ctx: ?*anyopaque) callconv(.c) proxy_bridge.TextSlice { + const win = windowFromCtx(ctx); + const text = win.backend.clipboardText() catch return .{ .ptr = &.{}, .len = 0 }; + const len = @min(text.len, clipboard_scratch.len); + @memcpy(clipboard_scratch[0..len], text[0..len]); + return .{ .ptr = clipboard_scratch[0..len].ptr, .len = len }; +} + +fn clipboardTextSet(ctx: ?*anyopaque, text: [*]const u8, text_len: usize) callconv(.c) u8 { + const win = windowFromCtx(ctx); + win.backend.clipboardTextSet(text[0..text_len]) catch return 0; + return 1; +} + +fn openURL(ctx: ?*anyopaque, url: [*]const u8, url_len: usize, new_window: u8) callconv(.c) u8 { + const win = windowFromCtx(ctx); + win.backend.openURL(url[0..url_len], new_window != 0) catch return 0; + return 1; +} + +fn setCursor(ctx: ?*anyopaque, cursor: u8) callconv(.c) void { + const win = windowFromCtx(ctx); + win.backend.setCursor(@enumFromInt(cursor)); +} + +fn textInputRect(ctx: ?*anyopaque, has_rect: u8, rect: proxy_bridge.ClipRect) callconv(.c) void { + const win = windowFromCtx(ctx); + const natural: ?dvui.Rect.Natural = if (has_rect != 0) + .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = rect.h } + else + null; + win.backend.textInputRect(natural); +} + +fn preferredColorScheme(ctx: ?*anyopaque) callconv(.c) i8 { + const win = windowFromCtx(ctx); + const scheme = win.backend.preferredColorScheme(); + if (scheme) |s| { + return switch (s) { + .light => 0, + .dark => 1, + }; + } + return -1; +} + +fn prefersReducedMotion(ctx: ?*anyopaque) callconv(.c) u8 { + const win = windowFromCtx(ctx); + return @intFromBool(win.backend.prefersReducedMotion()); +} + +fn ensureTable() void { + if (table_ready) return; + table = .{ + .ctx = null, + .draw_clipped_triangles = drawClippedTriangles, + .texture_create = textureCreate, + .texture_update = textureUpdate, + .texture_update_sub_rect = textureUpdateSubRect, + .texture_destroy = textureDestroy, + .texture_create_target = textureCreateTarget, + .texture_read_target = textureReadTarget, + .texture_destroy_target = textureDestroyTarget, + .texture_clear_target = textureClearTarget, + .texture_from_target = textureFromTarget, + .texture_from_target_temp = textureFromTargetTemp, + .render_target = renderTarget, + .pixel_size = pixelSize, + .window_size = windowSize, + .content_scale = contentScale, + .clipboard_text = clipboardText, + .clipboard_text_set = clipboardTextSet, + .open_url = openURL, + .set_cursor = setCursor, + .text_input_rect = textInputRect, + .preferred_color_scheme = preferredColorScheme, + .prefers_reduced_motion = prefersReducedMotion, + }; + table_ready = true; +} + +/// Push the host render bridge table into a loaded plugin dylib (once at load). +pub fn syncHostIntoPlugin(setter: SetRenderBridgeFn) void { + ensureTable(); + table.ctx = @ptrCast(dvui.current_window); + setter(&table); +} diff --git a/src/sdk/runtime.zig b/src/sdk/runtime.zig new file mode 100644 index 00000000..07f25965 --- /dev/null +++ b/src/sdk/runtime.zig @@ -0,0 +1,47 @@ +//! Host-injected plugin runtime: the allocator and `*Host` the shell pushes into a plugin +//! dylib at load (`fizzy_plugin_set_globals`). Plugin code reads them through +//! `sdk.allocator()` and `sdk.host()` — there is no per-plugin file to store them. +//! +//! Each loaded dylib compiles its own `sdk` and `core`, so these statics are private to one +//! plugin image; the host injects them before `register` (and re-injects if they change). +//! `installRuntime` also wires the matching `core.gpa` so allocating `core` helpers work +//! without each plugin remembering to sync it. +const std = @import("std"); +const core = @import("core"); +const Host = @import("Host.zig"); + +var gpa: std.mem.Allocator = undefined; +var host_ptr: *Host = undefined; +/// Shell-owned plugin state injected before `register` (built-in static/dylib path). +var injected_state: ?*anyopaque = null; + +/// The persistent host allocator. Use for anything that outlives a frame; you own every +/// allocation and must free it. Frame-scoped scratch is `host().arena()`. +pub fn allocator() std.mem.Allocator { + return gpa; +} + +/// The shell `*Host` — registries, services, and the `EditorAPI` read surface. +pub fn host() *Host { + return host_ptr; +} + +/// Called by `dylib.exportEntry`'s `fizzy_plugin_set_globals` export. Third-party plugins +/// own their state in `register`; built-ins may inject a shell-owned pointer here. +pub fn installRuntime( + gpa_in: ?*const std.mem.Allocator, + host_in: ?*Host, + state_ptr: ?*anyopaque, +) void { + if (gpa_in) |a| { + gpa = a.*; + core.gpa = a.*; + } + if (host_in) |h| host_ptr = h; + if (state_ptr) |s| injected_state = s; +} + +pub fn injectedState(comptime T: type) ?*T { + const s = injected_state orelse return null; + return @ptrCast(@alignCast(s)); +} diff --git a/src/sdk/sdk.zig b/src/sdk/sdk.zig new file mode 100644 index 00000000..2890b3f7 --- /dev/null +++ b/src/sdk/sdk.zig @@ -0,0 +1,75 @@ +//! Fizzy plugin SDK — the surface a plugin module depends on. +//! +//! A plugin receives a `*Host` and registers its menus, panes, document types, and +//! settings through these types instead of reaching into editor globals. File +//! management, the workspace/tabs system, and the editors (pixel art, …) all live +//! behind this boundary, which also supports loading plugins as runtime dylibs. + +// Eagerly evaluate the ABI fingerprint lock (see `version.zig`). +comptime { + _ = @import("version.zig"); +} + +pub const Host = @import("Host.zig"); +pub const Plugin = @import("Plugin.zig"); +pub const DocHandle = @import("DocHandle.zig"); + +/// Shell region contribution types (sidebar / bottom / center / menu / settings). +pub const regions = @import("regions.zig"); +pub const SidebarView = regions.SidebarView; +pub const BottomView = regions.BottomView; +pub const CenterProvider = regions.CenterProvider; +pub const MenuContribution = regions.MenuContribution; +pub const MenuSectionContribution = regions.MenuSectionContribution; +pub const SettingsSection = regions.SettingsSection; +pub const Command = regions.Command; +pub const menu = @import("menu.zig"); + +/// Shell-provided read/utility surface plugins reach through the `Host` +/// (arena, folder, shared settings, dirty-marking). +pub const EditorAPI = @import("EditorAPI.zig"); +pub const SaveDialogFilter = EditorAPI.SaveDialogFilter; +pub const SaveDialogCallback = EditorAPI.SaveDialogCallback; +pub const UiSprite = EditorAPI.UiSprite; +pub const UiAtlasView = EditorAPI.UiAtlasView; + +pub const WorkbenchPane = @import("WorkbenchPane.zig"); +pub const WorkbenchPaneView = WorkbenchPane.WorkbenchPaneView; +pub const pane_layout = @import("pane_layout.zig"); + +/// Host-injected runtime: `sdk.allocator()` (the persistent host allocator) and +/// `sdk.host()` (the shell `*Host`). The dylib entry injects these before `register`; +/// plugin code reads them directly, with no per-plugin storage file. +pub const allocator = @import("runtime.zig").allocator; +pub const host = @import("runtime.zig").host; +pub const installRuntime = @import("runtime.zig").installRuntime; +pub const injectedState = @import("runtime.zig").injectedState; + +/// Wake the app event loop for another frame. Safe from worker threads. +pub fn refresh() void { + host().refresh(); +} + +/// Document staging helpers (`allocStaging`, `loadPathInto`, …). +pub const document = @import("document.zig"); + +/// Plugin identity/version metadata for dylib exports. +pub const manifest = @import("manifest.zig"); +pub const PluginManifest = manifest.PluginManifest; + +/// Workbench inter-plugin service (`"workbench"`). +pub const services = struct { + pub const workbench = @import("services/workbench.zig"); +}; + +/// SDK version + ABI fingerprint lock (`sdk_version`, `recorded_abi_fingerprint`). +pub const version = @import("version.zig"); + +/// Runtime dylib entry contract (`fizzy_plugin_abi_fingerprint` / `fizzy_plugin_register`). +pub const dylib = @import("dylib.zig"); +/// Compile-time structural ABI fingerprint used by `dylib.abi_fingerprint`. +pub const fingerprint = @import("fingerprint.zig"); +/// Dvui global injection for loaded plugin images. +pub const dvui_context = @import("dvui_context.zig"); +/// Host thunks that forward plugin proxy draws to the shell backend. +pub const render_bridge = @import("render_bridge.zig"); diff --git a/src/sdk/services/workbench.zig b/src/sdk/services/workbench.zig new file mode 100644 index 00000000..f759283f --- /dev/null +++ b/src/sdk/services/workbench.zig @@ -0,0 +1,78 @@ +//! Workbench inter-plugin service — SDK-facing definition of the `"workbench"` service. +//! +//! The workbench plugin registers an instance via `host.registerService`. Plugin code +//! uses `host.getServiceTyped(workbench.Api)`. The layout is part of the ABI fingerprint. +const std = @import("std"); +const dvui = @import("dvui"); + +pub const Api = struct { + pub const service_name = "workbench"; + + ctx: *anyopaque, + vtable: *const VTable, + + pub const BranchDecorator = struct { + ctx: ?*anyopaque = null, + draw: *const fn (ctx: ?*anyopaque, path: []const u8, id_extra: usize) void, + }; + + pub const VTable = struct { + open: *const fn (ctx: *anyopaque, path: []const u8, grouping: u64) anyerror!bool, + currentGrouping: *const fn (ctx: *anyopaque) u64, + newGrouping: *const fn (ctx: *anyopaque) u64, + close: *const fn (ctx: *anyopaque, id: u64) anyerror!void, + save: *const fn (ctx: *anyopaque) anyerror!void, + isOpen: *const fn (ctx: *anyopaque, path: []const u8) bool, + openCount: *const fn (ctx: *anyopaque) usize, + openPathAt: *const fn (ctx: *anyopaque, index: usize) ?[]const u8, + createFile: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + createDir: *const fn (ctx: *anyopaque, path: []const u8) anyerror!void, + rename: *const fn (ctx: *anyopaque, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) anyerror!void, + delete: *const fn (ctx: *anyopaque, path: []const u8) void, + move: *const fn (ctx: *anyopaque, path: []const u8, target_dir: []const u8) anyerror!bool, + registerBranchDecorator: *const fn (ctx: *anyopaque, decorator: BranchDecorator) anyerror!void, + }; + + pub fn open(self: Api, path: []const u8, grouping: u64) !bool { + return self.vtable.open(self.ctx, path, grouping); + } + pub fn currentGrouping(self: Api) u64 { + return self.vtable.currentGrouping(self.ctx); + } + pub fn newGrouping(self: Api) u64 { + return self.vtable.newGrouping(self.ctx); + } + pub fn close(self: Api, id: u64) !void { + return self.vtable.close(self.ctx, id); + } + pub fn save(self: Api) !void { + return self.vtable.save(self.ctx); + } + pub fn isOpen(self: Api, path: []const u8) bool { + return self.vtable.isOpen(self.ctx, path); + } + pub fn openCount(self: Api) usize { + return self.vtable.openCount(self.ctx); + } + pub fn openPathAt(self: Api, index: usize) ?[]const u8 { + return self.vtable.openPathAt(self.ctx, index); + } + pub fn createFile(self: Api, path: []const u8) !void { + return self.vtable.createFile(self.ctx, path); + } + pub fn createDir(self: Api, path: []const u8) !void { + return self.vtable.createDir(self.ctx, path); + } + pub fn rename(self: Api, path: []const u8, new_path: []const u8, kind: std.Io.File.Kind) !void { + return self.vtable.rename(self.ctx, path, new_path, kind); + } + pub fn delete(self: Api, path: []const u8) void { + return self.vtable.delete(self.ctx, path); + } + pub fn move(self: Api, path: []const u8, target_dir: []const u8) !bool { + return self.vtable.move(self.ctx, path, target_dir); + } + pub fn registerBranchDecorator(self: Api, decorator: BranchDecorator) !void { + return self.vtable.registerBranchDecorator(self.ctx, decorator); + } +}; diff --git a/src/sdk/version.zig b/src/sdk/version.zig new file mode 100644 index 00000000..008198c1 --- /dev/null +++ b/src/sdk/version.zig @@ -0,0 +1,57 @@ +//! SDK version and ABI fingerprint lock. +//! +//! `sdk_version` is bumped when the plugin ABI boundary changes. `recorded_abi_fingerprint` +//! must be updated in the same commit — CI fails at compile time if the live fingerprint +//! drifts without an intentional version bump. +const std = @import("std"); +const builtin = @import("builtin"); +const dylib = @import("dylib.zig"); + +pub const VersionTriplet = dylib.VersionTriplet; + +/// ABI contract version. Bump minor (or major for breaking changes) when +/// `recorded_abi_fingerprint` changes. +pub const sdk_version = std.SemanticVersion{ + .major = 0, + .minor = 4, + .patch = 0, +}; + +/// Commit this literal alongside `sdk_version` when the ABI boundary changes. +pub const recorded_abi_fingerprint: u64 = 0x868c117d77f99593; + +comptime { + // The ABI fingerprint guards the *dynamic* plugin-loading boundary, which is native-only + // (no `dlopen` on wasm; web plugins are statically linked into the app). The fingerprint is + // target-dependent — pointer width, etc. — so the recorded literal tracks native targets; + // enforcing it on wasm would fail spuriously. Skip the lock there. + if (builtin.target.cpu.arch != .wasm32 and dylib.abi_fingerprint != recorded_abi_fingerprint) { + @compileError(std.fmt.comptimePrint( + "ABI fingerprint is 0x{x} — bump sdk_version and set recorded_abi_fingerprint in src/sdk/version.zig", + .{dylib.abi_fingerprint}, + )); + } +} + +pub fn sdkVersionTriplet() VersionTriplet { + return .{ + .major = sdk_version.major, + .minor = sdk_version.minor, + .patch = sdk_version.patch, + }; +} + +/// True when `required` (plugin min SDK) is satisfied by `host` (this Fizzy build). +pub fn sdkVersionSatisfies(host: std.SemanticVersion, required: std.SemanticVersion) bool { + if (host.major != required.major) return host.major > required.major; + if (host.minor != required.minor) return host.minor > required.minor; + return host.patch >= required.patch; +} + +pub fn formatVersion(v: std.SemanticVersion, writer: *std.Io.Writer) !void { + try writer.print("{d}.{d}.{d}", .{ v.major, v.minor, v.patch }); +} + +test "sdk version lock is self-consistent" { + try std.testing.expect(dylib.abi_fingerprint == recorded_abi_fingerprint); +} diff --git a/src/tools/font_awesome.zig b/src/tools/font_awesome.zig deleted file mode 100644 index 8f07f6e0..00000000 --- a/src/tools/font_awesome.zig +++ /dev/null @@ -1,1005 +0,0 @@ -pub const font_icon_filename_far = "fa-regular-400.ttf"; -pub const font_icon_filename_fas = "fa-solid-900.ttf"; - -pub const icon_range_min = 0xf000; -pub const icon_range_max = 0xf976; -pub const ad = "\u{f641}"; -pub const address_book = "\u{f2b9}"; -pub const address_card = "\u{f2bb}"; -pub const adjust = "\u{f042}"; -pub const air_freshener = "\u{f5d0}"; -pub const align_center = "\u{f037}"; -pub const align_justify = "\u{f039}"; -pub const align_left = "\u{f036}"; -pub const align_right = "\u{f038}"; -pub const allergies = "\u{f461}"; -pub const ambulance = "\u{f0f9}"; -pub const american_sign_language_interpreting = "\u{f2a3}"; -pub const anchor = "\u{f13d}"; -pub const angle_double_down = "\u{f103}"; -pub const angle_double_left = "\u{f100}"; -pub const angle_double_right = "\u{f101}"; -pub const angle_double_up = "\u{f102}"; -pub const angle_down = "\u{f107}"; -pub const angle_left = "\u{f104}"; -pub const angle_right = "\u{f105}"; -pub const angle_up = "\u{f106}"; -pub const angry = "\u{f556}"; -pub const ankh = "\u{f644}"; -pub const apple_alt = "\u{f5d1}"; -pub const archive = "\u{f187}"; -pub const archway = "\u{f557}"; -pub const arrow_alt_circle_down = "\u{f358}"; -pub const arrow_alt_circle_left = "\u{f359}"; -pub const arrow_alt_circle_right = "\u{f35a}"; -pub const arrow_alt_circle_up = "\u{f35b}"; -pub const arrow_circle_down = "\u{f0ab}"; -pub const arrow_circle_left = "\u{f0a8}"; -pub const arrow_circle_right = "\u{f0a9}"; -pub const arrow_circle_up = "\u{f0aa}"; -pub const arrow_down = "\u{f063}"; -pub const arrow_left = "\u{f060}"; -pub const arrow_right = "\u{f061}"; -pub const arrow_up = "\u{f062}"; -pub const arrows_alt = "\u{f0b2}"; -pub const arrows_alt_h = "\u{f337}"; -pub const arrows_alt_v = "\u{f338}"; -pub const assistive_listening_systems = "\u{f2a2}"; -pub const asterisk = "\u{f069}"; -pub const at = "\u{f1fa}"; -pub const atlas = "\u{f558}"; -pub const atom = "\u{f5d2}"; -pub const audio_description = "\u{f29e}"; -pub const award = "\u{f559}"; -pub const baby = "\u{f77c}"; -pub const baby_carriage = "\u{f77d}"; -pub const backspace = "\u{f55a}"; -pub const backward = "\u{f04a}"; -pub const bacon = "\u{f7e5}"; -pub const bacteria = "\u{f959}"; -pub const bacterium = "\u{f95a}"; -pub const bahai = "\u{f666}"; -pub const balance_scale = "\u{f24e}"; -pub const balance_scale_left = "\u{f515}"; -pub const balance_scale_right = "\u{f516}"; -pub const ban = "\u{f05e}"; -pub const band_aid = "\u{f462}"; -pub const barcode = "\u{f02a}"; -pub const bars = "\u{f0c9}"; -pub const baseball_ball = "\u{f433}"; -pub const basketball_ball = "\u{f434}"; -pub const bath = "\u{f2cd}"; -pub const battery_empty = "\u{f244}"; -pub const battery_full = "\u{f240}"; -pub const battery_half = "\u{f242}"; -pub const battery_quarter = "\u{f243}"; -pub const battery_three_quarters = "\u{f241}"; -pub const bed = "\u{f236}"; -pub const beer = "\u{f0fc}"; -pub const bell = "\u{f0f3}"; -pub const bell_slash = "\u{f1f6}"; -pub const bezier_curve = "\u{f55b}"; -pub const bible = "\u{f647}"; -pub const bicycle = "\u{f206}"; -pub const biking = "\u{f84a}"; -pub const binoculars = "\u{f1e5}"; -pub const biohazard = "\u{f780}"; -pub const birthday_cake = "\u{f1fd}"; -pub const blender = "\u{f517}"; -pub const blender_phone = "\u{f6b6}"; -pub const blind = "\u{f29d}"; -pub const blog = "\u{f781}"; -pub const bold = "\u{f032}"; -pub const bolt = "\u{f0e7}"; -pub const bomb = "\u{f1e2}"; -pub const bone = "\u{f5d7}"; -pub const bong = "\u{f55c}"; -pub const book = "\u{f02d}"; -pub const book_dead = "\u{f6b7}"; -pub const book_medical = "\u{f7e6}"; -pub const book_open = "\u{f518}"; -pub const book_reader = "\u{f5da}"; -pub const bookmark = "\u{f02e}"; -pub const border_all = "\u{f84c}"; -pub const border_none = "\u{f850}"; -pub const border_style = "\u{f853}"; -pub const bowling_ball = "\u{f436}"; -pub const box = "\u{f466}"; -pub const box_open = "\u{f49e}"; -pub const box_tissue = "\u{f95b}"; -pub const boxes = "\u{f468}"; -pub const braille = "\u{f2a1}"; -pub const brain = "\u{f5dc}"; -pub const bread_slice = "\u{f7ec}"; -pub const briefcase = "\u{f0b1}"; -pub const briefcase_medical = "\u{f469}"; -pub const broadcast_tower = "\u{f519}"; -pub const broom = "\u{f51a}"; -pub const brush = "\u{f55d}"; -pub const bug = "\u{f188}"; -pub const building = "\u{f1ad}"; -pub const bullhorn = "\u{f0a1}"; -pub const bullseye = "\u{f140}"; -pub const burn = "\u{f46a}"; -pub const bus = "\u{f207}"; -pub const bus_alt = "\u{f55e}"; -pub const business_time = "\u{f64a}"; -pub const calculator = "\u{f1ec}"; -pub const calendar = "\u{f133}"; -pub const calendar_alt = "\u{f073}"; -pub const calendar_check = "\u{f274}"; -pub const calendar_day = "\u{f783}"; -pub const calendar_minus = "\u{f272}"; -pub const calendar_plus = "\u{f271}"; -pub const calendar_times = "\u{f273}"; -pub const calendar_week = "\u{f784}"; -pub const camera = "\u{f030}"; -pub const camera_retro = "\u{f083}"; -pub const campground = "\u{f6bb}"; -pub const candy_cane = "\u{f786}"; -pub const cannabis = "\u{f55f}"; -pub const capsules = "\u{f46b}"; -pub const car = "\u{f1b9}"; -pub const car_alt = "\u{f5de}"; -pub const car_battery = "\u{f5df}"; -pub const car_crash = "\u{f5e1}"; -pub const car_side = "\u{f5e4}"; -pub const caravan = "\u{f8ff}"; -pub const caret_down = "\u{f0d7}"; -pub const caret_left = "\u{f0d9}"; -pub const caret_right = "\u{f0da}"; -pub const caret_square_down = "\u{f150}"; -pub const caret_square_left = "\u{f191}"; -pub const caret_square_right = "\u{f152}"; -pub const caret_square_up = "\u{f151}"; -pub const caret_up = "\u{f0d8}"; -pub const carrot = "\u{f787}"; -pub const cart_arrow_down = "\u{f218}"; -pub const cart_plus = "\u{f217}"; -pub const cash_register = "\u{f788}"; -pub const cat = "\u{f6be}"; -pub const certificate = "\u{f0a3}"; -pub const chair = "\u{f6c0}"; -pub const chalkboard = "\u{f51b}"; -pub const chalkboard_teacher = "\u{f51c}"; -pub const charging_station = "\u{f5e7}"; -pub const chart_area = "\u{f1fe}"; -pub const chart_bar = "\u{f080}"; -pub const chart_line = "\u{f201}"; -pub const chart_pie = "\u{f200}"; -pub const check = "\u{f00c}"; -pub const check_circle = "\u{f058}"; -pub const check_double = "\u{f560}"; -pub const check_square = "\u{f14a}"; -pub const cheese = "\u{f7ef}"; -pub const chess = "\u{f439}"; -pub const chess_bishop = "\u{f43a}"; -pub const chess_board = "\u{f43c}"; -pub const chess_king = "\u{f43f}"; -pub const chess_knight = "\u{f441}"; -pub const chess_pawn = "\u{f443}"; -pub const chess_queen = "\u{f445}"; -pub const chess_rook = "\u{f447}"; -pub const chevron_circle_down = "\u{f13a}"; -pub const chevron_circle_left = "\u{f137}"; -pub const chevron_circle_right = "\u{f138}"; -pub const chevron_circle_up = "\u{f139}"; -pub const chevron_down = "\u{f078}"; -pub const chevron_left = "\u{f053}"; -pub const chevron_right = "\u{f054}"; -pub const chevron_up = "\u{f077}"; -pub const child = "\u{f1ae}"; -pub const church = "\u{f51d}"; -pub const circle = "\u{f111}"; -pub const circle_notch = "\u{f1ce}"; -pub const city = "\u{f64f}"; -pub const clinic_medical = "\u{f7f2}"; -pub const clipboard = "\u{f328}"; -pub const clipboard_check = "\u{f46c}"; -pub const clipboard_list = "\u{f46d}"; -pub const clock = "\u{f017}"; -pub const clone = "\u{f24d}"; -pub const closed_captioning = "\u{f20a}"; -pub const cloud = "\u{f0c2}"; -pub const cloud_download_alt = "\u{f381}"; -pub const cloud_meatball = "\u{f73b}"; -pub const cloud_moon = "\u{f6c3}"; -pub const cloud_moon_rain = "\u{f73c}"; -pub const cloud_rain = "\u{f73d}"; -pub const cloud_showers_heavy = "\u{f740}"; -pub const cloud_sun = "\u{f6c4}"; -pub const cloud_sun_rain = "\u{f743}"; -pub const cloud_upload_alt = "\u{f382}"; -pub const cocktail = "\u{f561}"; -pub const code = "\u{f121}"; -pub const code_branch = "\u{f126}"; -pub const coffee = "\u{f0f4}"; -pub const cog = "\u{f013}"; -pub const cogs = "\u{f085}"; -pub const coins = "\u{f51e}"; -pub const columns = "\u{f0db}"; -pub const comment = "\u{f075}"; -pub const comment_alt = "\u{f27a}"; -pub const comment_dollar = "\u{f651}"; -pub const comment_dots = "\u{f4ad}"; -pub const comment_medical = "\u{f7f5}"; -pub const comment_slash = "\u{f4b3}"; -pub const comments = "\u{f086}"; -pub const comments_dollar = "\u{f653}"; -pub const compact_disc = "\u{f51f}"; -pub const compass = "\u{f14e}"; -pub const compress = "\u{f066}"; -pub const compress_alt = "\u{f422}"; -pub const compress_arrows_alt = "\u{f78c}"; -pub const concierge_bell = "\u{f562}"; -pub const cookie = "\u{f563}"; -pub const cookie_bite = "\u{f564}"; -pub const copy = "\u{f0c5}"; -pub const copyright = "\u{f1f9}"; -pub const couch = "\u{f4b8}"; -pub const credit_card = "\u{f09d}"; -pub const crop = "\u{f125}"; -pub const crop_alt = "\u{f565}"; -pub const cross = "\u{f654}"; -pub const crosshairs = "\u{f05b}"; -pub const crow = "\u{f520}"; -pub const crown = "\u{f521}"; -pub const crutch = "\u{f7f7}"; -pub const cube = "\u{f1b2}"; -pub const cubes = "\u{f1b3}"; -pub const cut = "\u{f0c4}"; -pub const database = "\u{f1c0}"; -pub const deaf = "\u{f2a4}"; -pub const democrat = "\u{f747}"; -pub const desktop = "\u{f108}"; -pub const dharmachakra = "\u{f655}"; -pub const diagnoses = "\u{f470}"; -pub const dice = "\u{f522}"; -pub const dice_d20 = "\u{f6cf}"; -pub const dice_d6 = "\u{f6d1}"; -pub const dice_five = "\u{f523}"; -pub const dice_four = "\u{f524}"; -pub const dice_one = "\u{f525}"; -pub const dice_six = "\u{f526}"; -pub const dice_three = "\u{f527}"; -pub const dice_two = "\u{f528}"; -pub const digital_tachograph = "\u{f566}"; -pub const directions = "\u{f5eb}"; -pub const disease = "\u{f7fa}"; -pub const divide = "\u{f529}"; -pub const dizzy = "\u{f567}"; -pub const dna = "\u{f471}"; -pub const dog = "\u{f6d3}"; -pub const dollar_sign = "\u{f155}"; -pub const dolly = "\u{f472}"; -pub const dolly_flatbed = "\u{f474}"; -pub const donate = "\u{f4b9}"; -pub const door_closed = "\u{f52a}"; -pub const door_open = "\u{f52b}"; -pub const dot_circle = "\u{f192}"; -pub const dove = "\u{f4ba}"; -pub const download = "\u{f019}"; -pub const drafting_compass = "\u{f568}"; -pub const dragon = "\u{f6d5}"; -pub const draw_polygon = "\u{f5ee}"; -pub const drum = "\u{f569}"; -pub const drum_steelpan = "\u{f56a}"; -pub const drumstick_bite = "\u{f6d7}"; -pub const dumbbell = "\u{f44b}"; -pub const dumpster = "\u{f793}"; -pub const dumpster_fire = "\u{f794}"; -pub const dungeon = "\u{f6d9}"; -pub const edit = "\u{f044}"; -pub const egg = "\u{f7fb}"; -pub const eject = "\u{f052}"; -pub const ellipsis_h = "\u{f141}"; -pub const ellipsis_v = "\u{f142}"; -pub const envelope = "\u{f0e0}"; -pub const envelope_open = "\u{f2b6}"; -pub const envelope_open_text = "\u{f658}"; -pub const envelope_square = "\u{f199}"; -pub const equals = "\u{f52c}"; -pub const eraser = "\u{f12d}"; -pub const ethernet = "\u{f796}"; -pub const euro_sign = "\u{f153}"; -pub const exchange_alt = "\u{f362}"; -pub const exclamation = "\u{f12a}"; -pub const exclamation_circle = "\u{f06a}"; -pub const exclamation_triangle = "\u{f071}"; -pub const expand = "\u{f065}"; -pub const expand_alt = "\u{f424}"; -pub const expand_arrows_alt = "\u{f31e}"; -pub const external_link_alt = "\u{f35d}"; -pub const external_link_square_alt = "\u{f360}"; -pub const eye = "\u{f06e}"; -pub const eye_dropper = "\u{f1fb}"; -pub const eye_slash = "\u{f070}"; -pub const fan = "\u{f863}"; -pub const fast_backward = "\u{f049}"; -pub const fast_forward = "\u{f050}"; -pub const faucet = "\u{f905}"; -pub const fax = "\u{f1ac}"; -pub const feather = "\u{f52d}"; -pub const feather_alt = "\u{f56b}"; -pub const female = "\u{f182}"; -pub const fighter_jet = "\u{f0fb}"; -pub const file = "\u{f15b}"; -pub const file_alt = "\u{f15c}"; -pub const file_archive = "\u{f1c6}"; -pub const file_audio = "\u{f1c7}"; -pub const file_code = "\u{f1c9}"; -pub const file_contract = "\u{f56c}"; -pub const file_csv = "\u{f6dd}"; -pub const file_download = "\u{f56d}"; -pub const file_excel = "\u{f1c3}"; -pub const file_export = "\u{f56e}"; -pub const file_image = "\u{f1c5}"; -pub const file_import = "\u{f56f}"; -pub const file_invoice = "\u{f570}"; -pub const file_invoice_dollar = "\u{f571}"; -pub const file_medical = "\u{f477}"; -pub const file_medical_alt = "\u{f478}"; -pub const file_pdf = "\u{f1c1}"; -pub const file_powerpoint = "\u{f1c4}"; -pub const file_prescription = "\u{f572}"; -pub const file_signature = "\u{f573}"; -pub const file_upload = "\u{f574}"; -pub const file_video = "\u{f1c8}"; -pub const file_word = "\u{f1c2}"; -pub const fill = "\u{f575}"; -pub const fill_drip = "\u{f576}"; -pub const film = "\u{f008}"; -pub const filter = "\u{f0b0}"; -pub const fingerprint = "\u{f577}"; -pub const fire = "\u{f06d}"; -pub const fire_alt = "\u{f7e4}"; -pub const fire_extinguisher = "\u{f134}"; -pub const first_aid = "\u{f479}"; -pub const fish = "\u{f578}"; -pub const fist_raised = "\u{f6de}"; -pub const flag = "\u{f024}"; -pub const flag_checkered = "\u{f11e}"; -pub const flag_usa = "\u{f74d}"; -pub const flask = "\u{f0c3}"; -pub const flushed = "\u{f579}"; -pub const folder = "\u{f07b}"; -pub const folder_minus = "\u{f65d}"; -pub const folder_open = "\u{f07c}"; -pub const folder_plus = "\u{f65e}"; -pub const font = "\u{f031}"; -pub const font_awesome_logo_full = "\u{f4e6}"; -pub const football_ball = "\u{f44e}"; -pub const forward = "\u{f04e}"; -pub const frog = "\u{f52e}"; -pub const frown = "\u{f119}"; -pub const frown_open = "\u{f57a}"; -pub const funnel_dollar = "\u{f662}"; -pub const futbol = "\u{f1e3}"; -pub const gamepad = "\u{f11b}"; -pub const gas_pump = "\u{f52f}"; -pub const gavel = "\u{f0e3}"; -pub const gem = "\u{f3a5}"; -pub const genderless = "\u{f22d}"; -pub const ghost = "\u{f6e2}"; -pub const gift = "\u{f06b}"; -pub const gifts = "\u{f79c}"; -pub const glass_cheers = "\u{f79f}"; -pub const glass_martini = "\u{f000}"; -pub const glass_martini_alt = "\u{f57b}"; -pub const glass_whiskey = "\u{f7a0}"; -pub const glasses = "\u{f530}"; -pub const globe = "\u{f0ac}"; -pub const globe_africa = "\u{f57c}"; -pub const globe_americas = "\u{f57d}"; -pub const globe_asia = "\u{f57e}"; -pub const globe_europe = "\u{f7a2}"; -pub const golf_ball = "\u{f450}"; -pub const gopuram = "\u{f664}"; -pub const graduation_cap = "\u{f19d}"; -pub const greater_than = "\u{f531}"; -pub const greater_than_equal = "\u{f532}"; -pub const grimace = "\u{f57f}"; -pub const grin = "\u{f580}"; -pub const grin_alt = "\u{f581}"; -pub const grin_beam = "\u{f582}"; -pub const grin_beam_sweat = "\u{f583}"; -pub const grin_hearts = "\u{f584}"; -pub const grin_squint = "\u{f585}"; -pub const grin_squint_tears = "\u{f586}"; -pub const grin_stars = "\u{f587}"; -pub const grin_tears = "\u{f588}"; -pub const grin_tongue = "\u{f589}"; -pub const grin_tongue_squint = "\u{f58a}"; -pub const grin_tongue_wink = "\u{f58b}"; -pub const grin_wink = "\u{f58c}"; -pub const grip_horizontal = "\u{f58d}"; -pub const grip_lines = "\u{f7a4}"; -pub const grip_lines_vertical = "\u{f7a5}"; -pub const grip_vertical = "\u{f58e}"; -pub const guitar = "\u{f7a6}"; -pub const h_square = "\u{f0fd}"; -pub const hamburger = "\u{f805}"; -pub const hammer = "\u{f6e3}"; -pub const hamsa = "\u{f665}"; -pub const hand_holding = "\u{f4bd}"; -pub const hand_holding_heart = "\u{f4be}"; -pub const hand_holding_medical = "\u{f95c}"; -pub const hand_holding_usd = "\u{f4c0}"; -pub const hand_holding_water = "\u{f4c1}"; -pub const hand_lizard = "\u{f258}"; -pub const hand_middle_finger = "\u{f806}"; -pub const hand_paper = "\u{f256}"; -pub const hand_peace = "\u{f25b}"; -pub const hand_point_down = "\u{f0a7}"; -pub const hand_point_left = "\u{f0a5}"; -pub const hand_point_right = "\u{f0a4}"; -pub const hand_point_up = "\u{f0a6}"; -pub const hand_pointer = "\u{f25a}"; -pub const hand_rock = "\u{f255}"; -pub const hand_scissors = "\u{f257}"; -pub const hand_sparkles = "\u{f95d}"; -pub const hand_spock = "\u{f259}"; -pub const hands = "\u{f4c2}"; -pub const hands_helping = "\u{f4c4}"; -pub const hands_wash = "\u{f95e}"; -pub const handshake = "\u{f2b5}"; -pub const handshake_alt_slash = "\u{f95f}"; -pub const handshake_slash = "\u{f960}"; -pub const hanukiah = "\u{f6e6}"; -pub const hard_hat = "\u{f807}"; -pub const hashtag = "\u{f292}"; -pub const hat_cowboy = "\u{f8c0}"; -pub const hat_cowboy_side = "\u{f8c1}"; -pub const hat_wizard = "\u{f6e8}"; -pub const hdd = "\u{f0a0}"; -pub const head_side_cough = "\u{f961}"; -pub const head_side_cough_slash = "\u{f962}"; -pub const head_side_mask = "\u{f963}"; -pub const head_side_virus = "\u{f964}"; -pub const heading = "\u{f1dc}"; -pub const headphones = "\u{f025}"; -pub const headphones_alt = "\u{f58f}"; -pub const headset = "\u{f590}"; -pub const heart = "\u{f004}"; -pub const heart_broken = "\u{f7a9}"; -pub const heartbeat = "\u{f21e}"; -pub const helicopter = "\u{f533}"; -pub const highlighter = "\u{f591}"; -pub const hiking = "\u{f6ec}"; -pub const hippo = "\u{f6ed}"; -pub const history = "\u{f1da}"; -pub const hockey_puck = "\u{f453}"; -pub const holly_berry = "\u{f7aa}"; -pub const home = "\u{f015}"; -pub const horse = "\u{f6f0}"; -pub const horse_head = "\u{f7ab}"; -pub const hospital = "\u{f0f8}"; -pub const hospital_alt = "\u{f47d}"; -pub const hospital_symbol = "\u{f47e}"; -pub const hospital_user = "\u{f80d}"; -pub const hot_tub = "\u{f593}"; -pub const hotdog = "\u{f80f}"; -pub const hotel = "\u{f594}"; -pub const hourglass = "\u{f254}"; -pub const hourglass_end = "\u{f253}"; -pub const hourglass_half = "\u{f252}"; -pub const hourglass_start = "\u{f251}"; -pub const house_damage = "\u{f6f1}"; -pub const house_user = "\u{f965}"; -pub const hryvnia = "\u{f6f2}"; -pub const i_cursor = "\u{f246}"; -pub const ice_cream = "\u{f810}"; -pub const icicles = "\u{f7ad}"; -pub const icons = "\u{f86d}"; -pub const id_badge = "\u{f2c1}"; -pub const id_card = "\u{f2c2}"; -pub const id_card_alt = "\u{f47f}"; -pub const igloo = "\u{f7ae}"; -pub const image = "\u{f03e}"; -pub const images = "\u{f302}"; -pub const inbox = "\u{f01c}"; -pub const indent = "\u{f03c}"; -pub const industry = "\u{f275}"; -pub const infinity = "\u{f534}"; -pub const info = "\u{f129}"; -pub const info_circle = "\u{f05a}"; -pub const italic = "\u{f033}"; -pub const jedi = "\u{f669}"; -pub const joint = "\u{f595}"; -pub const journal_whills = "\u{f66a}"; -pub const kaaba = "\u{f66b}"; -pub const key = "\u{f084}"; -pub const keyboard = "\u{f11c}"; -pub const khanda = "\u{f66d}"; -pub const kiss = "\u{f596}"; -pub const kiss_beam = "\u{f597}"; -pub const kiss_wink_heart = "\u{f598}"; -pub const kiwi_bird = "\u{f535}"; -pub const landmark = "\u{f66f}"; -pub const language = "\u{f1ab}"; -pub const laptop = "\u{f109}"; -pub const laptop_code = "\u{f5fc}"; -pub const laptop_house = "\u{f966}"; -pub const laptop_medical = "\u{f812}"; -pub const laugh = "\u{f599}"; -pub const laugh_beam = "\u{f59a}"; -pub const laugh_squint = "\u{f59b}"; -pub const laugh_wink = "\u{f59c}"; -pub const layer_group = "\u{f5fd}"; -pub const leaf = "\u{f06c}"; -pub const lemon = "\u{f094}"; -pub const less_than = "\u{f536}"; -pub const less_than_equal = "\u{f537}"; -pub const level_down_alt = "\u{f3be}"; -pub const level_up_alt = "\u{f3bf}"; -pub const life_ring = "\u{f1cd}"; -pub const lightbulb = "\u{f0eb}"; -pub const link = "\u{f0c1}"; -pub const lira_sign = "\u{f195}"; -pub const list = "\u{f03a}"; -pub const list_alt = "\u{f022}"; -pub const list_ol = "\u{f0cb}"; -pub const list_ul = "\u{f0ca}"; -pub const location_arrow = "\u{f124}"; -pub const lock = "\u{f023}"; -pub const lock_open = "\u{f3c1}"; -pub const long_arrow_alt_down = "\u{f309}"; -pub const long_arrow_alt_left = "\u{f30a}"; -pub const long_arrow_alt_right = "\u{f30b}"; -pub const long_arrow_alt_up = "\u{f30c}"; -pub const low_vision = "\u{f2a8}"; -pub const luggage_cart = "\u{f59d}"; -pub const lungs = "\u{f604}"; -pub const lungs_virus = "\u{f967}"; -pub const magic = "\u{f0d0}"; -pub const magnet = "\u{f076}"; -pub const mail_bulk = "\u{f674}"; -pub const male = "\u{f183}"; -pub const map = "\u{f279}"; -pub const map_marked = "\u{f59f}"; -pub const map_marked_alt = "\u{f5a0}"; -pub const map_marker = "\u{f041}"; -pub const map_marker_alt = "\u{f3c5}"; -pub const map_pin = "\u{f276}"; -pub const map_signs = "\u{f277}"; -pub const marker = "\u{f5a1}"; -pub const mars = "\u{f222}"; -pub const mars_double = "\u{f227}"; -pub const mars_stroke = "\u{f229}"; -pub const mars_stroke_h = "\u{f22b}"; -pub const mars_stroke_v = "\u{f22a}"; -pub const mask = "\u{f6fa}"; -pub const medal = "\u{f5a2}"; -pub const medkit = "\u{f0fa}"; -pub const meh = "\u{f11a}"; -pub const meh_blank = "\u{f5a4}"; -pub const meh_rolling_eyes = "\u{f5a5}"; -pub const memory = "\u{f538}"; -pub const menorah = "\u{f676}"; -pub const mercury = "\u{f223}"; -pub const meteor = "\u{f753}"; -pub const microchip = "\u{f2db}"; -pub const microphone = "\u{f130}"; -pub const microphone_alt = "\u{f3c9}"; -pub const microphone_alt_slash = "\u{f539}"; -pub const microphone_slash = "\u{f131}"; -pub const microscope = "\u{f610}"; -pub const minus = "\u{f068}"; -pub const minus_circle = "\u{f056}"; -pub const minus_square = "\u{f146}"; -pub const mitten = "\u{f7b5}"; -pub const mobile = "\u{f10b}"; -pub const mobile_alt = "\u{f3cd}"; -pub const money_bill = "\u{f0d6}"; -pub const money_bill_alt = "\u{f3d1}"; -pub const money_bill_wave = "\u{f53a}"; -pub const money_bill_wave_alt = "\u{f53b}"; -pub const money_check = "\u{f53c}"; -pub const money_check_alt = "\u{f53d}"; -pub const monument = "\u{f5a6}"; -pub const moon = "\u{f186}"; -pub const mortar_pestle = "\u{f5a7}"; -pub const mosque = "\u{f678}"; -pub const motorcycle = "\u{f21c}"; -pub const mountain = "\u{f6fc}"; -pub const mouse = "\u{f8cc}"; -pub const mouse_pointer = "\u{f245}"; -pub const mug_hot = "\u{f7b6}"; -pub const music = "\u{f001}"; -pub const network_wired = "\u{f6ff}"; -pub const neuter = "\u{f22c}"; -pub const newspaper = "\u{f1ea}"; -pub const not_equal = "\u{f53e}"; -pub const notes_medical = "\u{f481}"; -pub const object_group = "\u{f247}"; -pub const object_ungroup = "\u{f248}"; -pub const oil_can = "\u{f613}"; -pub const om = "\u{f679}"; -pub const otter = "\u{f700}"; -pub const outdent = "\u{f03b}"; -pub const pager = "\u{f815}"; -pub const paint_brush = "\u{f1fc}"; -pub const paint_roller = "\u{f5aa}"; -pub const palette = "\u{f53f}"; -pub const pallet = "\u{f482}"; -pub const paper_plane = "\u{f1d8}"; -pub const paperclip = "\u{f0c6}"; -pub const parachute_box = "\u{f4cd}"; -pub const paragraph = "\u{f1dd}"; -pub const parking = "\u{f540}"; -pub const passport = "\u{f5ab}"; -pub const pastafarianism = "\u{f67b}"; -pub const paste = "\u{f0ea}"; -pub const pause = "\u{f04c}"; -pub const pause_circle = "\u{f28b}"; -pub const paw = "\u{f1b0}"; -pub const peace = "\u{f67c}"; -pub const pen = "\u{f304}"; -pub const pen_alt = "\u{f305}"; -pub const pen_fancy = "\u{f5ac}"; -pub const pen_nib = "\u{f5ad}"; -pub const pen_square = "\u{f14b}"; -pub const pencil_alt = "\u{f303}"; -pub const pencil_ruler = "\u{f5ae}"; -pub const people_arrows = "\u{f968}"; -pub const people_carry = "\u{f4ce}"; -pub const pepper_hot = "\u{f816}"; -pub const percent = "\u{f295}"; -pub const percentage = "\u{f541}"; -pub const person_booth = "\u{f756}"; -pub const phone = "\u{f095}"; -pub const phone_alt = "\u{f879}"; -pub const phone_slash = "\u{f3dd}"; -pub const phone_square = "\u{f098}"; -pub const phone_square_alt = "\u{f87b}"; -pub const phone_volume = "\u{f2a0}"; -pub const photo_video = "\u{f87c}"; -pub const piggy_bank = "\u{f4d3}"; -pub const pills = "\u{f484}"; -pub const pizza_slice = "\u{f818}"; -pub const place_of_worship = "\u{f67f}"; -pub const plane = "\u{f072}"; -pub const plane_arrival = "\u{f5af}"; -pub const plane_departure = "\u{f5b0}"; -pub const plane_slash = "\u{f969}"; -pub const play = "\u{f04b}"; -pub const play_circle = "\u{f144}"; -pub const plug = "\u{f1e6}"; -pub const plus = "\u{f067}"; -pub const plus_circle = "\u{f055}"; -pub const plus_square = "\u{f0fe}"; -pub const podcast = "\u{f2ce}"; -pub const poll = "\u{f681}"; -pub const poll_h = "\u{f682}"; -pub const poo = "\u{f2fe}"; -pub const poo_storm = "\u{f75a}"; -pub const poop = "\u{f619}"; -pub const portrait = "\u{f3e0}"; -pub const pound_sign = "\u{f154}"; -pub const power_off = "\u{f011}"; -pub const pray = "\u{f683}"; -pub const praying_hands = "\u{f684}"; -pub const prescription = "\u{f5b1}"; -pub const prescription_bottle = "\u{f485}"; -pub const prescription_bottle_alt = "\u{f486}"; -pub const print = "\u{f02f}"; -pub const procedures = "\u{f487}"; -pub const project_diagram = "\u{f542}"; -pub const pump_medical = "\u{f96a}"; -pub const pump_soap = "\u{f96b}"; -pub const puzzle_piece = "\u{f12e}"; -pub const qrcode = "\u{f029}"; -pub const question = "\u{f128}"; -pub const question_circle = "\u{f059}"; -pub const quidditch = "\u{f458}"; -pub const quote_left = "\u{f10d}"; -pub const quote_right = "\u{f10e}"; -pub const quran = "\u{f687}"; -pub const radiation = "\u{f7b9}"; -pub const radiation_alt = "\u{f7ba}"; -pub const rainbow = "\u{f75b}"; -pub const random = "\u{f074}"; -pub const receipt = "\u{f543}"; -pub const record_vinyl = "\u{f8d9}"; -pub const recycle = "\u{f1b8}"; -pub const redo = "\u{f01e}"; -pub const redo_alt = "\u{f2f9}"; -pub const registered = "\u{f25d}"; -pub const remove_format = "\u{f87d}"; -pub const reply = "\u{f3e5}"; -pub const reply_all = "\u{f122}"; -pub const republican = "\u{f75e}"; -pub const restroom = "\u{f7bd}"; -pub const retweet = "\u{f079}"; -pub const ribbon = "\u{f4d6}"; -pub const ring = "\u{f70b}"; -pub const road = "\u{f018}"; -pub const robot = "\u{f544}"; -pub const rocket = "\u{f135}"; -pub const route = "\u{f4d7}"; -pub const rss = "\u{f09e}"; -pub const rss_square = "\u{f143}"; -pub const ruble_sign = "\u{f158}"; -pub const ruler = "\u{f545}"; -pub const ruler_combined = "\u{f546}"; -pub const ruler_horizontal = "\u{f547}"; -pub const ruler_vertical = "\u{f548}"; -pub const running = "\u{f70c}"; -pub const rupee_sign = "\u{f156}"; -pub const sad_cry = "\u{f5b3}"; -pub const sad_tear = "\u{f5b4}"; -pub const satellite = "\u{f7bf}"; -pub const satellite_dish = "\u{f7c0}"; -pub const save = "\u{f0c7}"; -pub const school = "\u{f549}"; -pub const screwdriver = "\u{f54a}"; -pub const scroll = "\u{f70e}"; -pub const sd_card = "\u{f7c2}"; -pub const search = "\u{f002}"; -pub const search_dollar = "\u{f688}"; -pub const search_location = "\u{f689}"; -pub const search_minus = "\u{f010}"; -pub const search_plus = "\u{f00e}"; -pub const seedling = "\u{f4d8}"; -pub const server = "\u{f233}"; -pub const shapes = "\u{f61f}"; -pub const share = "\u{f064}"; -pub const share_alt = "\u{f1e0}"; -pub const share_alt_square = "\u{f1e1}"; -pub const share_square = "\u{f14d}"; -pub const shekel_sign = "\u{f20b}"; -pub const shield_alt = "\u{f3ed}"; -pub const shield_virus = "\u{f96c}"; -pub const ship = "\u{f21a}"; -pub const shipping_fast = "\u{f48b}"; -pub const shoe_prints = "\u{f54b}"; -pub const shopping_bag = "\u{f290}"; -pub const shopping_basket = "\u{f291}"; -pub const shopping_cart = "\u{f07a}"; -pub const shower = "\u{f2cc}"; -pub const shuttle_van = "\u{f5b6}"; -pub const sign = "\u{f4d9}"; -pub const sign_in_alt = "\u{f2f6}"; -pub const sign_language = "\u{f2a7}"; -pub const sign_out_alt = "\u{f2f5}"; -pub const signal = "\u{f012}"; -pub const signature = "\u{f5b7}"; -pub const sim_card = "\u{f7c4}"; -pub const sink = "\u{f96d}"; -pub const sitemap = "\u{f0e8}"; -pub const skating = "\u{f7c5}"; -pub const skiing = "\u{f7c9}"; -pub const skiing_nordic = "\u{f7ca}"; -pub const skull = "\u{f54c}"; -pub const skull_crossbones = "\u{f714}"; -pub const slash = "\u{f715}"; -pub const sleigh = "\u{f7cc}"; -pub const sliders_h = "\u{f1de}"; -pub const smile = "\u{f118}"; -pub const smile_beam = "\u{f5b8}"; -pub const smile_wink = "\u{f4da}"; -pub const smog = "\u{f75f}"; -pub const smoking = "\u{f48d}"; -pub const smoking_ban = "\u{f54d}"; -pub const sms = "\u{f7cd}"; -pub const snowboarding = "\u{f7ce}"; -pub const snowflake = "\u{f2dc}"; -pub const snowman = "\u{f7d0}"; -pub const snowplow = "\u{f7d2}"; -pub const soap = "\u{f96e}"; -pub const socks = "\u{f696}"; -pub const solar_panel = "\u{f5ba}"; -pub const sort = "\u{f0dc}"; -pub const sort_alpha_down = "\u{f15d}"; -pub const sort_alpha_down_alt = "\u{f881}"; -pub const sort_alpha_up = "\u{f15e}"; -pub const sort_alpha_up_alt = "\u{f882}"; -pub const sort_amount_down = "\u{f160}"; -pub const sort_amount_down_alt = "\u{f884}"; -pub const sort_amount_up = "\u{f161}"; -pub const sort_amount_up_alt = "\u{f885}"; -pub const sort_down = "\u{f0dd}"; -pub const sort_numeric_down = "\u{f162}"; -pub const sort_numeric_down_alt = "\u{f886}"; -pub const sort_numeric_up = "\u{f163}"; -pub const sort_numeric_up_alt = "\u{f887}"; -pub const sort_up = "\u{f0de}"; -pub const spa = "\u{f5bb}"; -pub const space_shuttle = "\u{f197}"; -pub const spell_check = "\u{f891}"; -pub const spider = "\u{f717}"; -pub const spinner = "\u{f110}"; -pub const splotch = "\u{f5bc}"; -pub const spray_can = "\u{f5bd}"; -pub const square = "\u{f0c8}"; -pub const square_full = "\u{f45c}"; -pub const square_root_alt = "\u{f698}"; -pub const stamp = "\u{f5bf}"; -pub const star = "\u{f005}"; -pub const star_and_crescent = "\u{f699}"; -pub const star_half = "\u{f089}"; -pub const star_half_alt = "\u{f5c0}"; -pub const star_of_david = "\u{f69a}"; -pub const star_of_life = "\u{f621}"; -pub const step_backward = "\u{f048}"; -pub const step_forward = "\u{f051}"; -pub const stethoscope = "\u{f0f1}"; -pub const sticky_note = "\u{f249}"; -pub const stop = "\u{f04d}"; -pub const stop_circle = "\u{f28d}"; -pub const stopwatch = "\u{f2f2}"; -pub const stopwatch_20 = "\u{f96f}"; -pub const store = "\u{f54e}"; -pub const store_alt = "\u{f54f}"; -pub const store_alt_slash = "\u{f970}"; -pub const store_slash = "\u{f971}"; -pub const stream = "\u{f550}"; -pub const street_view = "\u{f21d}"; -pub const strikethrough = "\u{f0cc}"; -pub const stroopwafel = "\u{f551}"; -pub const subscript = "\u{f12c}"; -pub const subway = "\u{f239}"; -pub const suitcase = "\u{f0f2}"; -pub const suitcase_rolling = "\u{f5c1}"; -pub const sun = "\u{f185}"; -pub const superscript = "\u{f12b}"; -pub const surprise = "\u{f5c2}"; -pub const swatchbook = "\u{f5c3}"; -pub const swimmer = "\u{f5c4}"; -pub const swimming_pool = "\u{f5c5}"; -pub const synagogue = "\u{f69b}"; -pub const sync = "\u{f021}"; -pub const sync_alt = "\u{f2f1}"; -pub const syringe = "\u{f48e}"; -pub const table = "\u{f0ce}"; -pub const table_tennis = "\u{f45d}"; -pub const tablet = "\u{f10a}"; -pub const tablet_alt = "\u{f3fa}"; -pub const tablets = "\u{f490}"; -pub const tachometer_alt = "\u{f3fd}"; -pub const tag = "\u{f02b}"; -pub const tags = "\u{f02c}"; -pub const tape = "\u{f4db}"; -pub const tasks = "\u{f0ae}"; -pub const taxi = "\u{f1ba}"; -pub const teeth = "\u{f62e}"; -pub const teeth_open = "\u{f62f}"; -pub const temperature_high = "\u{f769}"; -pub const temperature_low = "\u{f76b}"; -pub const tenge = "\u{f7d7}"; -pub const terminal = "\u{f120}"; -pub const text_height = "\u{f034}"; -pub const text_width = "\u{f035}"; -pub const th = "\u{f00a}"; -pub const th_large = "\u{f009}"; -pub const th_list = "\u{f00b}"; -pub const theater_masks = "\u{f630}"; -pub const thermometer = "\u{f491}"; -pub const thermometer_empty = "\u{f2cb}"; -pub const thermometer_full = "\u{f2c7}"; -pub const thermometer_half = "\u{f2c9}"; -pub const thermometer_quarter = "\u{f2ca}"; -pub const thermometer_three_quarters = "\u{f2c8}"; -pub const thumbs_down = "\u{f165}"; -pub const thumbs_up = "\u{f164}"; -pub const thumbtack = "\u{f08d}"; -pub const ticket_alt = "\u{f3ff}"; -pub const times = "\u{f00d}"; -pub const times_circle = "\u{f057}"; -pub const tint = "\u{f043}"; -pub const tint_slash = "\u{f5c7}"; -pub const tired = "\u{f5c8}"; -pub const toggle_off = "\u{f204}"; -pub const toggle_on = "\u{f205}"; -pub const toilet = "\u{f7d8}"; -pub const toilet_paper = "\u{f71e}"; -pub const toilet_paper_slash = "\u{f972}"; -pub const toolbox = "\u{f552}"; -pub const tools = "\u{f7d9}"; -pub const tooth = "\u{f5c9}"; -pub const torah = "\u{f6a0}"; -pub const torii_gate = "\u{f6a1}"; -pub const tractor = "\u{f722}"; -pub const trademark = "\u{f25c}"; -pub const traffic_light = "\u{f637}"; -pub const trailer = "\u{f941}"; -pub const train = "\u{f238}"; -pub const tram = "\u{f7da}"; -pub const transgender = "\u{f224}"; -pub const transgender_alt = "\u{f225}"; -pub const trash = "\u{f1f8}"; -pub const trash_alt = "\u{f2ed}"; -pub const trash_restore = "\u{f829}"; -pub const trash_restore_alt = "\u{f82a}"; -pub const tree = "\u{f1bb}"; -pub const trophy = "\u{f091}"; -pub const truck = "\u{f0d1}"; -pub const truck_loading = "\u{f4de}"; -pub const truck_monster = "\u{f63b}"; -pub const truck_moving = "\u{f4df}"; -pub const truck_pickup = "\u{f63c}"; -pub const tshirt = "\u{f553}"; -pub const tty = "\u{f1e4}"; -pub const tv = "\u{f26c}"; -pub const umbrella = "\u{f0e9}"; -pub const umbrella_beach = "\u{f5ca}"; -pub const underline = "\u{f0cd}"; -pub const undo = "\u{f0e2}"; -pub const undo_alt = "\u{f2ea}"; -pub const universal_access = "\u{f29a}"; -pub const university = "\u{f19c}"; -pub const unlink = "\u{f127}"; -pub const unlock = "\u{f09c}"; -pub const unlock_alt = "\u{f13e}"; -pub const upload = "\u{f093}"; -pub const user = "\u{f007}"; -pub const user_alt = "\u{f406}"; -pub const user_alt_slash = "\u{f4fa}"; -pub const user_astronaut = "\u{f4fb}"; -pub const user_check = "\u{f4fc}"; -pub const user_circle = "\u{f2bd}"; -pub const user_clock = "\u{f4fd}"; -pub const user_cog = "\u{f4fe}"; -pub const user_edit = "\u{f4ff}"; -pub const user_friends = "\u{f500}"; -pub const user_graduate = "\u{f501}"; -pub const user_injured = "\u{f728}"; -pub const user_lock = "\u{f502}"; -pub const user_md = "\u{f0f0}"; -pub const user_minus = "\u{f503}"; -pub const user_ninja = "\u{f504}"; -pub const user_nurse = "\u{f82f}"; -pub const user_plus = "\u{f234}"; -pub const user_secret = "\u{f21b}"; -pub const user_shield = "\u{f505}"; -pub const user_slash = "\u{f506}"; -pub const user_tag = "\u{f507}"; -pub const user_tie = "\u{f508}"; -pub const user_times = "\u{f235}"; -pub const users = "\u{f0c0}"; -pub const users_cog = "\u{f509}"; -pub const users_slash = "\u{f973}"; -pub const utensil_spoon = "\u{f2e5}"; -pub const utensils = "\u{f2e7}"; -pub const vector_square = "\u{f5cb}"; -pub const venus = "\u{f221}"; -pub const venus_double = "\u{f226}"; -pub const venus_mars = "\u{f228}"; -pub const vial = "\u{f492}"; -pub const vials = "\u{f493}"; -pub const video = "\u{f03d}"; -pub const video_slash = "\u{f4e2}"; -pub const vihara = "\u{f6a7}"; -pub const virus = "\u{f974}"; -pub const virus_slash = "\u{f975}"; -pub const viruses = "\u{f976}"; -pub const voicemail = "\u{f897}"; -pub const volleyball_ball = "\u{f45f}"; -pub const volume_down = "\u{f027}"; -pub const volume_mute = "\u{f6a9}"; -pub const volume_off = "\u{f026}"; -pub const volume_up = "\u{f028}"; -pub const vote_yea = "\u{f772}"; -pub const vr_cardboard = "\u{f729}"; -pub const walking = "\u{f554}"; -pub const wallet = "\u{f555}"; -pub const warehouse = "\u{f494}"; -pub const water = "\u{f773}"; -pub const wave_square = "\u{f83e}"; -pub const weight = "\u{f496}"; -pub const weight_hanging = "\u{f5cd}"; -pub const wheelchair = "\u{f193}"; -pub const wifi = "\u{f1eb}"; -pub const wind = "\u{f72e}"; -pub const window_close = "\u{f410}"; -pub const window_maximize = "\u{f2d0}"; -pub const window_minimize = "\u{f2d1}"; -pub const window_restore = "\u{f2d2}"; -pub const wine_bottle = "\u{f72f}"; -pub const wine_glass = "\u{f4e3}"; -pub const wine_glass_alt = "\u{f5ce}"; -pub const won_sign = "\u{f159}"; -pub const wrench = "\u{f0ad}"; -pub const x_ray = "\u{f497}"; -pub const yen_sign = "\u{f157}"; -pub const yin_yang = "\u{f6ad}"; diff --git a/src/tools/timer.zig b/src/tools/timer.zig deleted file mode 100644 index f1891125..00000000 --- a/src/tools/timer.zig +++ /dev/null @@ -1,23 +0,0 @@ -// A simple timer utility for benchmarking. -const std = @import("std"); - -const Self = @This(); -start_time: i64 = -1, -done: bool = false, - -pub fn start(self: *Self) void { - self.start_time = std.time.milliTimestamp(); - self.done = false; -} - -pub fn end(self: *Self) i64 { - if (self.start_time == -1 or self.done) { - std.debug.panic("Timer already ended", .{}); - return -1; - } - self.done = true; - - const end_time = std.time.milliTimestamp(); - const elapsed = end_time - self.start_time; - return elapsed; -} diff --git a/src/tools/watcher/LinuxWatcher.zig b/src/tools/watcher/LinuxWatcher.zig deleted file mode 100644 index 790b26a2..00000000 --- a/src/tools/watcher/LinuxWatcher.zig +++ /dev/null @@ -1,442 +0,0 @@ -const LinuxWatcher = @This(); - -const std = @import("std"); -const Assets = @import("../../Assets.zig"); - -const log = std.log.scoped(.watcher); - -notify_fd: std.posix.fd_t, - -/// active watch entries -watch_fds: std.AutoHashMapUnmanaged(std.posix.fd_t, WatchEntry) = .{}, - -/// direct descendant tracker -children_fds: std.AutoHashMapUnmanaged(std.posix.fd_t, std.ArrayListUnmanaged(std.posix.fd_t)) = .{}, - -/// inotify cookie tracker for move events -cookie_fds: std.AutoHashMapUnmanaged(u32, std.posix.fd_t) = .{}, - -const TreeKind = enum { input, output }; - -const WatchEntry = struct { - dir_path: []const u8, - name: []const u8, - kind: TreeKind, -}; - -pub fn stop(_: *LinuxWatcher) void {} - -pub fn init( - _: std.mem.Allocator, -) !LinuxWatcher { - const notify_fd = try std.posix.inotify_init1(0); - return .{ .notify_fd = notify_fd }; -} - -/// Register `child` with the `parent` -fn addChild( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - parent: std.posix.fd_t, - child: std.posix.fd_t, -) !void { - const children = try self.children_fds.getOrPut(gpa, parent); - if (!children.found_existing) { - children.value_ptr.* = .{}; - } - try children.value_ptr.append(gpa, child); -} - -/// Remove `child` from the `parent`, if present -fn removeChild( - self: *LinuxWatcher, - parent: std.posix.fd_t, - child: std.posix.fd_t, -) ?std.posix.fd_t { - if (self.children_fds.getEntry(parent)) |entry| { - for (0.., entry.value_ptr.items) |i, fd| { - if (child == fd) { - return entry.value_ptr.swapRemove(i); - } - } - } - return null; -} - -/// Remove child identified by `name`, if present -fn removeChildByName( - self: *LinuxWatcher, - parent: std.posix.fd_t, - name: []const u8, -) ?std.posix.fd_t { - if (self.children_fds.getEntry(parent)) |entry| { - for (0.., entry.value_ptr.items) |i, fd| { - if (self.watch_fds.get(fd)) |data| { - if (std.mem.eql(u8, data.name, name)) { - return entry.value_ptr.swapRemove(i); - } - } - } - } - return null; -} - -/// Start tracking directory tree and returns the watch descriptor for `root_dir_path` -/// Register children within the tree -/// **NOTE**: caller is expected to register the returned watch fd as a child -fn addTree( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - tree_kind: TreeKind, - root_dir_path: []const u8, -) !std.posix.fd_t { - var root_dir = try std.fs.cwd().openDir(root_dir_path, .{ .iterate = true }); - defer root_dir.close(); - const parent_fd = try self.addDir(gpa, tree_kind, root_dir_path); - - // tracker for fds associated with dir paths - // helps to track children within a recursive walk - var lookup = std.StringHashMap(std.posix.fd_t).init(gpa); - defer lookup.deinit(); - - try lookup.put(root_dir_path, parent_fd); - - var it = try root_dir.walk(gpa); - while (try it.next()) |entry| switch (entry.kind) { - else => continue, - .directory => { - const dir_path = try std.fs.path.join(gpa, &.{ root_dir_path, entry.path }); - const dir_fd = try self.addDir(gpa, tree_kind, dir_path); - const p_dir = std.fs.path.dirname(dir_path).?; - const p_fd = lookup.get(p_dir).?; - - try self.addChild(gpa, p_fd, dir_fd); - try lookup.put(dir_path, dir_fd); - }, - }; - - return parent_fd; -} - -fn addDir( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - tree_kind: TreeKind, - dir_path: []const u8, -) !std.posix.fd_t { - const mask = Mask.all(&.{ - .IN_ONLYDIR, .IN_CLOSE_WRITE, - .IN_MOVE, .IN_MOVE_SELF, - .IN_CREATE, .IN_DELETE, - .IN_EXCL_UNLINK, - }); - const watch_fd = try std.posix.inotify_add_watch( - self.notify_fd, - dir_path, - mask, - ); - const name_copy = try gpa.dupe(u8, std.fs.path.basename(dir_path)); - try self.watch_fds.put(gpa, watch_fd, .{ - .dir_path = dir_path, - .name = name_copy, - .kind = tree_kind, - }); - log.debug("added {s} -> {}", .{ dir_path, watch_fd }); - return watch_fd; -} - -/// Explicitly stop watching a descriptor -/// **NOTE**: should only be called on an active `fd` -fn rmWatch( - self: *LinuxWatcher, - fd: std.posix.fd_t, -) void { - if (self.children_fds.getEntry(fd)) |entry| { - for (entry.value_ptr.items) |child_fd| { - self.rmWatch(child_fd); - } - self.children_fds.removeByPtr(entry.key_ptr); - } - std.posix.inotify_rm_watch(self.notify_fd, fd); -} - -/// Handle the start of the move process -/// Remove `name`-identified fd from children of `from_fd` -/// Register `cookie` for the moved fd for future identification -fn moveDirStart( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - from_fd: std.posix.fd_t, - cookie: u32, - name: []const u8, -) !void { - const moved_fd = self.removeChildByName(from_fd, name).?; - - try self.cookie_fds.put( - gpa, - cookie, - moved_fd, - ); -} - -/// Handle the end of the move process and returns the resulting moved fd -/// Register the moved fd as a child of `to_fd` -fn moveDirEnd( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - to_fd: std.posix.fd_t, - cookie: u32, - name: []const u8, -) !std.posix.fd_t { - const parent = self.watch_fds.get(to_fd).?; - - // known cookie - move within watched directories - if (self.cookie_fds.fetchRemove(cookie)) |entry| { - const moved_fd = entry.value; - - var watch_entry = self.watch_fds.getEntry(moved_fd).?.value_ptr; - gpa.free(watch_entry.name); - const name_copy = try gpa.dupe(u8, name); - watch_entry.name = name_copy; - watch_entry.kind = parent.kind; - - try self.updateDirPath(gpa, moved_fd, parent.dir_path); - try self.addChild(gpa, to_fd, moved_fd); - return moved_fd; - } else { // unknown cookie - move from the outside - const dir_path = try std.fs.path.join(gpa, &.{ parent.dir_path, name }); - const moved_fd = try self.addTree(gpa, parent.kind, dir_path); - try self.addChild(gpa, to_fd, moved_fd); - return moved_fd; - } -} - -/// Cascade path updates for `fd` and its children -fn updateDirPath( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - fd: std.posix.fd_t, - parent_dir: []const u8, -) !void { - var data = self.watch_fds.getEntry(fd).?.value_ptr; - gpa.free(data.dir_path); - const dir_path = try std.fs.path.join(gpa, &.{ parent_dir, data.name }); - data.dir_path = dir_path; - - if (self.children_fds.getEntry(fd)) |entry| { - for (entry.value_ptr.items) |child_fd| { - try self.updateDirPath(gpa, child_fd, dir_path); - } - } -} - -/// Handle the post-move event -/// Remove stale cookie waiting for the `moved_fd`, if present -fn moveDirComplete( - self: *LinuxWatcher, - moved_fd: std.posix.fd_t, -) !void { - var it = self.cookie_fds.iterator(); - while (it.next()) |entry| { - // cookie for fd exists - moved outside the watched directory - if (entry.value_ptr.* == moved_fd) { - self.rmWatch(moved_fd); - self.cookie_fds.removeByPtr(entry.key_ptr); - break; - } - } -} - -/// Clean up `fd`-related bookkeeping -/// **NOTE**: expects `fd` to be a no-longer-watched descriptor -fn dropWatch( - self: *LinuxWatcher, - gpa: std.mem.Allocator, - fd: std.posix.fd_t, -) void { - if (self.watch_fds.fetchRemove(fd)) |entry| { - gpa.free(entry.value.dir_path); - gpa.free(entry.value.name); - } - - var it = self.children_fds.keyIterator(); - while (it.next()) |parent_fd| { - _ = self.removeChild(parent_fd.*, fd); - } - - if (self.children_fds.fetchRemove(fd)) |entry| { - log.warn("Stopping watch for {d} that has known children: {any}", .{ fd, entry.value }); - } -} - -pub fn listen( - self: *LinuxWatcher, - assets: *Assets, -) !void { - for (try assets.getWatchDirs(assets.allocator)) |p| { - _ = try self.addTree(assets.allocator, .input, p); - } - - const Event = std.os.linux.inotify_event; - const event_size = @sizeOf(Event); - while (assets.watching) { - var buffer: [event_size * 10]u8 = undefined; - const len = try std.posix.read(self.notify_fd, &buffer); - if (len < 0) @panic("notify fd read error"); - - var event_data = buffer[0..len]; - while (event_data.len > 0) { - const event: *Event = @alignCast(@ptrCast(event_data[0..event_size])); - const parent = self.watch_fds.get(event.wd).?; - event_data = event_data[event_size + event.len ..]; - - if (Mask.is(event.mask, .IN_IGNORED)) { - log.debug("IGNORE {s}", .{parent.dir_path}); - self.dropWatch(assets.allocator, event.wd); - continue; - } else if (Mask.is(event.mask, .IN_MOVE_SELF)) { - if (event.getName() == null) { - try self.moveDirComplete(event.wd); - } - continue; - } - - if (Mask.is(event.mask, .IN_ISDIR)) { - if (Mask.is(event.mask, .IN_CREATE)) { - const dir_name = event.getName().?; - const dir_path = try std.fs.path.join(assets.allocator, &.{ - parent.dir_path, - dir_name, - }); - - log.debug("ISDIR CREATE {s}", .{dir_path}); - - const new_fd = try self.addTree(assets.allocator, parent.kind, dir_path); - try self.addChild(assets.allocator, event.wd, new_fd); - const data = self.watch_fds.get(new_fd).?; - switch (data.kind) { - .input => { - assets.onAssetChange(data.dir_path, ""); - }, - .output => { - assets.onAssetChange(data.dir_path, ""); - }, - } - continue; - } else if (Mask.is(event.mask, .IN_MOVED_FROM)) { - log.debug("MOVING {s}/{s}", .{ parent.dir_path, event.getName().? }); - try self.moveDirStart(assets.allocator, event.wd, event.cookie, event.getName().?); - continue; - } else if (Mask.is(event.mask, .IN_MOVED_TO)) { - log.debug("MOVED {s}/{s}", .{ parent.dir_path, event.getName().? }); - const moved_fd = try self.moveDirEnd(assets.allocator, event.wd, event.cookie, event.getName().?); - const moved = self.watch_fds.get(moved_fd).?; - switch (moved.kind) { - .input => { - assets.onAssetChange(moved.dir_path, ""); - }, - .output => { - assets.onAssetChange(moved.dir_path, ""); - }, - } - continue; - } - } else { - if (Mask.is(event.mask, .IN_CLOSE_WRITE) or - Mask.is(event.mask, .IN_MOVED_TO)) - { - switch (parent.kind) { - .input => { - const name = event.getName() orelse continue; - assets.onAssetChange(parent.dir_path, name); - }, - .output => { - const name = event.getName() orelse continue; - assets.onAssetChange(parent.dir_path, name); - }, - } - } - } - } - } -} - -const Mask = struct { - pub const IN_ACCESS = 0x00000001; - pub const IN_MODIFY = 0x00000002; - pub const IN_ATTRIB = 0x00000004; - pub const IN_CLOSE_WRITE = 0x00000008; - pub const IN_CLOSE_NOWRITE = 0x00000010; - pub const IN_CLOSE = (IN_CLOSE_WRITE | IN_CLOSE_NOWRITE); - pub const IN_OPEN = 0x00000020; - pub const IN_MOVED_FROM = 0x00000040; - pub const IN_MOVED_TO = 0x00000080; - pub const IN_MOVE = (IN_MOVED_FROM | IN_MOVED_TO); - pub const IN_CREATE = 0x00000100; - pub const IN_DELETE = 0x00000200; - pub const IN_DELETE_SELF = 0x00000400; - pub const IN_MOVE_SELF = 0x00000800; - pub const IN_ALL_EVENTS = 0x00000fff; - - pub const IN_UNMOUNT = 0x00002000; - pub const IN_Q_OVERFLOW = 0x00004000; - pub const IN_IGNORED = 0x00008000; - - pub const IN_ONLYDIR = 0x01000000; - pub const IN_DONT_FOLLOW = 0x02000000; - pub const IN_EXCL_UNLINK = 0x04000000; - pub const IN_MASK_CREATE = 0x10000000; - pub const IN_MASK_ADD = 0x20000000; - - pub const IN_ISDIR = 0x40000000; - pub const IN_ONESHOT = 0x80000000; - - pub fn is(m: u32, comptime flag: std.meta.DeclEnum(Mask)) bool { - const f = @field(Mask, @tagName(flag)); - return (m & f) != 0; - } - - pub fn all(comptime flags: []const std.meta.DeclEnum(Mask)) u32 { - var result: u32 = 0; - inline for (flags) |f| result |= @field(Mask, @tagName(f)); - return result; - } - - pub fn debugPrint(m: u32) void { - const flags = .{ - .IN_ACCESS, - .IN_MODIFY, - .IN_ATTRIB, - .IN_CLOSE_WRITE, - .IN_CLOSE_NOWRITE, - .IN_CLOSE, - .IN_OPEN, - .IN_MOVED_FROM, - .IN_MOVED_TO, - .IN_MOVE, - .IN_CREATE, - .IN_DELETE, - .IN_DELETE_SELF, - .IN_MOVE_SELF, - .IN_ALL_EVENTS, - - .IN_UNMOUNT, - .IN_Q_OVERFLOW, - .IN_IGNORED, - - .IN_ONLYDIR, - .IN_DONT_FOLLOW, - .IN_EXCL_UNLINK, - .IN_MASK_CREATE, - .IN_MASK_ADD, - - .IN_ISDIR, - .IN_ONESHOT, - }; - inline for (flags) |f| { - if (is(m, f)) { - std.debug.print("{s} ", .{@tagName(f)}); - } - } - } -}; diff --git a/src/tools/watcher/MacosWatcher.zig b/src/tools/watcher/MacosWatcher.zig deleted file mode 100644 index a3720b7b..00000000 --- a/src/tools/watcher/MacosWatcher.zig +++ /dev/null @@ -1,110 +0,0 @@ -const MacosWatcher = @This(); - -const std = @import("std"); -const Assets = @import("../../Assets.zig"); -const c = @cImport({ - @cInclude("CoreServices/CoreServices.h"); -}); - -const log = std.log.scoped(.watcher); - -pub fn init( - allocator: std.mem.Allocator, -) !MacosWatcher { - _ = allocator; - - return .{}; -} - -pub fn callback( - streamRef: c.ConstFSEventStreamRef, - clientCallBackInfo: ?*anyopaque, - numEvents: usize, - eventPaths: ?*anyopaque, - eventFlags: ?[*]const c.FSEventStreamEventFlags, - eventIds: ?[*]const c.FSEventStreamEventId, -) callconv(.C) void { - _ = eventIds; - _ = eventFlags; - _ = streamRef; - const ctx: *Context = @alignCast(@ptrCast(clientCallBackInfo)); - - const paths: [*][*:0]u8 = @alignCast(@ptrCast(eventPaths)); - for (paths[0..numEvents]) |p| { - const path = std.mem.span(p); - - const basename = std.fs.path.basename(path); - var base_path = path[0 .. path.len - basename.len]; - if (std.mem.endsWith(u8, base_path, "/")) - base_path = base_path[0 .. base_path.len - 1]; - - ctx.assets.onAssetChange(base_path, basename); - } -} - -pub fn stop(_: *MacosWatcher) void { - c.CFRunLoopStop(c.CFRunLoopGetCurrent()); -} - -const Context = struct { - assets: *Assets, -}; -pub fn listen( - _: *MacosWatcher, - assets: *Assets, -) !void { - const in_paths = try assets.getWatchPaths(assets.allocator); - var macos_paths = try assets.allocator.alloc(c.CFStringRef, in_paths.len); - - for (in_paths, macos_paths[0..]) |str, *ref| { - ref.* = c.CFStringCreateWithCString( - null, - str.ptr, - c.kCFStringEncodingUTF8, - ); - } - - const paths_to_watch: c.CFArrayRef = c.CFArrayCreate( - null, - @ptrCast(macos_paths.ptr), - @intCast(macos_paths.len), - null, - ); - - var ctx: Context = .{ - .assets = assets, - }; - - var stream_context: c.FSEventStreamContext = .{ .info = &ctx }; - const stream: c.FSEventStreamRef = c.FSEventStreamCreate( - null, - &callback, - &stream_context, - paths_to_watch, - c.kFSEventStreamEventIdSinceNow, - 0.05, - c.kFSEventStreamCreateFlagFileEvents, - ); - - c.FSEventStreamScheduleWithRunLoop( - stream, - c.CFRunLoopGetCurrent(), - c.kCFRunLoopDefaultMode, - ); - - if (c.FSEventStreamStart(stream) == 0) { - @panic("failed to start the event stream"); - } - - // Free allocations before entering the run loop, it will not return - assets.allocator.free(macos_paths); - assets.allocator.free(in_paths); - - c.CFRunLoopRun(); - - c.FSEventStreamStop(stream); - c.FSEventStreamInvalidate(stream); - c.FSEventStreamRelease(stream); - - c.CFRelease(paths_to_watch); -} diff --git a/src/tools/watcher/WindowsWatcher.zig b/src/tools/watcher/WindowsWatcher.zig deleted file mode 100644 index ae7c8bb6..00000000 --- a/src/tools/watcher/WindowsWatcher.zig +++ /dev/null @@ -1,224 +0,0 @@ -const WindowsWatcher = @This(); - -const std = @import("std"); -const fizzy = @import("../../fizzy.zig"); -const windows = std.os.windows; -const Assets = @import("../../Assets.zig"); - -const log = std.log.scoped(.watcher); - -const notify_filter = windows.FileNotifyChangeFilter{ - .file_name = true, - .dir_name = true, - .attributes = false, - .size = false, - .last_write = true, - .last_access = false, - .creation = false, - .security = false, -}; - -const Error = error{ InvalidHandle, QueueFailed, WaitFailed }; - -const CompletionKey = usize; -/// Values should be a multiple of `ReadBufferEntrySize` -const ReadBufferIndex = u32; -const ReadBufferEntrySize = 1024; - -const WatchEntry = struct { - dir_path: [:0]const u8, - dir_handle: windows.HANDLE, - - overlap: windows.OVERLAPPED = std.mem.zeroes(windows.OVERLAPPED), - buf_idx: ReadBufferIndex, -}; - -iocp_port: windows.HANDLE, -entries: std.AutoHashMap(CompletionKey, WatchEntry), -read_buffer: []u8, - -pub fn stop(_: *WindowsWatcher) void {} - -pub fn init( - allocator: std.mem.Allocator, -) !WindowsWatcher { - const watcher = WindowsWatcher{ - .iocp_port = windows.INVALID_HANDLE_VALUE, - .entries = std.AutoHashMap(CompletionKey, WatchEntry).init(allocator), - .read_buffer = undefined, - }; - - return watcher; -} - -fn addPath( - path: [:0]const u8, - /// Assumed to increment by 1 after each invocation, starting at 0. - key: CompletionKey, - port: *windows.HANDLE, -) !WatchEntry { - const dir_handle = CreateFileA( - path, - windows.GENERIC_READ, // FILE_LIST_DIRECTORY, - windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE, - null, - windows.OPEN_EXISTING, - windows.FILE_FLAG_BACKUP_SEMANTICS | windows.FILE_FLAG_OVERLAPPED, - null, - ); - if (dir_handle == windows.INVALID_HANDLE_VALUE) { - log.err( - "Unable to open directory {s}: {s}", - .{ path, @tagName(windows.kernel32.GetLastError()) }, - ); - return Error.InvalidHandle; - } - - if (port.* == windows.INVALID_HANDLE_VALUE) { - port.* = try windows.CreateIoCompletionPort(dir_handle, null, key, 0); - } else { - _ = try windows.CreateIoCompletionPort(dir_handle, port.*, key, 0); - } - - return .{ - .dir_path = path, - .dir_handle = dir_handle, - .buf_idx = @intCast(ReadBufferEntrySize * key), - }; -} - -pub fn listen( - watcher: *WindowsWatcher, - assets: *Assets, -) !void { - // Doubles as the number of WatchEntries - var comp_key: CompletionKey = 0; - - const in_paths = try assets.getWatchDirs(assets.allocator); - defer assets.allocator.free(in_paths); - - for (in_paths) |path| { - const in_path = try assets.allocator.dupeZ(u8, path); - //defer assets.allocator.free(in_path); - - try watcher.entries.put( - comp_key, - try addPath(in_path, comp_key, &watcher.iocp_port), - ); - comp_key += 1; - } - - watcher.read_buffer = try assets.allocator.alloc(u8, ReadBufferEntrySize * comp_key); - defer assets.allocator.free(watcher.read_buffer); - // Here we need pointers to both the read_buffer and entry overlapped structs, - // which we can only do after setting up everything else. - watcher.entries.lockPointers(); - for (0..comp_key) |key| { - const entry = watcher.entries.getPtr(key).?; - - if (windows.kernel32.ReadDirectoryChangesW( - entry.dir_handle, - @ptrCast(@alignCast(&watcher.read_buffer[entry.buf_idx])), - ReadBufferEntrySize, - @intFromBool(true), - notify_filter, - null, - &entry.overlap, - null, - ) == 0) { - log.err("ReadDirectoryChanges error: {s}", .{@tagName(windows.kernel32.GetLastError())}); - return Error.QueueFailed; - } - } - - var dont_care: struct { - bytes_transferred: windows.DWORD = undefined, - overlap: ?*windows.OVERLAPPED = undefined, - } = .{}; - - var key: CompletionKey = undefined; - while (assets.watching) { - // Waits here until any of the directory handles associated with the iocp port - // have been updated. - const wait_result = windows.GetQueuedCompletionStatus( - watcher.iocp_port, - &dont_care.bytes_transferred, - &key, - &dont_care.overlap, - windows.INFINITE, - ); - if (wait_result != .Normal) { - log.err("GetQueuedCompletionStatus error: {s}", .{@tagName(wait_result)}); - return Error.WaitFailed; - } - - const entry = watcher.entries.getPtr(key) orelse @panic("Invalid CompletionKey"); - - var info_iter = windows.FileInformationIterator(FILE_NOTIFY_INFORMATION){ - .buf = watcher.read_buffer[entry.buf_idx..][0..ReadBufferEntrySize], - }; - var path_buf: [windows.MAX_PATH]u8 = undefined; - while (info_iter.next()) |info| { - const filename: []const u8 = blk: { - const n = try std.unicode.utf16LeToUtf8( - &path_buf, - @as([*]u16, @ptrCast(&info.FileName))[0 .. info.FileNameLength / 2], - ); - break :blk path_buf[0..n]; - }; - - // const args = .{ entry.dir_path, filename }; - // switch (info.Action) { - // windows.FILE_ACTION_ADDED => log.debug("added {s}/{s}", args), - // windows.FILE_ACTION_REMOVED => log.debug("removed {s}/{s}", args), - // windows.FILE_ACTION_MODIFIED => log.debug("modified {s}/{s}", args), - // windows.FILE_ACTION_RENAMED_OLD_NAME => log.debug("renamed_old_name {s}/{s}", args), - // windows.FILE_ACTION_RENAMED_NEW_NAME => log.debug("renamed_new_name {s}/{s}", args), - // else => log.debug("Unknown Action {s}/{s}", args), - // } - - assets.onAssetChange(entry.dir_path, filename); - } - - // Re-queue the directory entry - if (windows.kernel32.ReadDirectoryChangesW( - entry.dir_handle, - @ptrCast(@alignCast(&watcher.read_buffer[entry.buf_idx])), - ReadBufferEntrySize, - @intFromBool(true), - notify_filter, - null, - &entry.overlap, - null, - ) == 0) { - log.err("ReadDirectoryChanges error: {s}", .{@tagName(windows.kernel32.GetLastError())}); - return Error.QueueFailed; - } - } - - watcher.entries.unlockPointers(); - var iter = watcher.entries.valueIterator(); - while (iter.next()) |entry| { - windows.CloseHandle(entry.dir_handle); - assets.allocator.free(entry.dir_path); - } - watcher.entries.deinit(); -} - -const FILE_NOTIFY_INFORMATION = extern struct { - NextEntryOffset: windows.DWORD, - Action: windows.DWORD, - FileNameLength: windows.DWORD, - /// Flexible array member - FileName: windows.WCHAR, -}; - -extern "kernel32" fn CreateFileA( - lpFileName: windows.LPCSTR, - dwDesiredAccess: windows.DWORD, - dwShareMode: windows.DWORD, - lpSecurityAttributes: ?*windows.SECURITY_ATTRIBUTES, - dwCreationDisposition: windows.DWORD, - dwFlagsAndAttributes: windows.DWORD, - hTemplateFile: ?windows.HANDLE, -) callconv(windows.WINAPI) windows.HANDLE; diff --git a/src/web_main.zig b/src/web_main.zig index 63524544..e2f05e34 100644 --- a/src/web_main.zig +++ b/src/web_main.zig @@ -11,6 +11,8 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy.zig"); +const pixi = @import("pixi"); +const Internal = pixi.internal; // Wasm-cleanliness probes. Referencing each symbol forces semantic analysis of its // module graph; any compile error pinpoints what to gate next. Zero-cost at runtime. @@ -23,29 +25,28 @@ const fizzy = @import("fizzy.zig"); comptime { // Pure constants / re-exports _ = fizzy.version; - _ = fizzy.fa.adjust; _ = fizzy.atlas; // Algorithms — pure Zig + dvui - _ = fizzy.algorithms.brezenham; - _ = fizzy.algorithms.reduce; + _ = pixi.algorithms.brezenham; + _ = pixi.algorithms.reduce; // Top-level data types (.pixi format on-disk shapes) - _ = fizzy.Animation; - _ = fizzy.Atlas; - _ = fizzy.File; - _ = fizzy.Layer; - _ = fizzy.Sprite; + _ = pixi.Animation; + _ = pixi.Atlas; + _ = pixi.File; + _ = pixi.Layer; + _ = pixi.Sprite; // Internal editor-side data types - _ = fizzy.Internal.Animation; - _ = fizzy.Internal.Atlas; - _ = fizzy.Internal.Buffers; - _ = fizzy.Internal.File.init; - _ = fizzy.Internal.History; - _ = fizzy.Internal.Layer; - _ = fizzy.Internal.Palette; - _ = fizzy.Internal.Sprite; + _ = Internal.Animation; + _ = Internal.Atlas; + _ = Internal.Buffers; + _ = Internal.File.init; + _ = Internal.History; + _ = Internal.Layer; + _ = Internal.Palette; + _ = Internal.Sprite; // Math + graphics helpers _ = fizzy.math.checker; @@ -54,11 +55,11 @@ comptime { _ = fizzy.image.init; _ = fizzy.image.pixels; _ = fizzy.perf.record; - _ = fizzy.render; + _ = pixi.render; // Custom dvui wrapper + widgets — types compile even though the widget files // contain dead `@import("backend")` SDL3 imports at file scope. - _ = fizzy.dvui.FileWidget; + _ = pixi.widgets.FileWidget; _ = fizzy.dvui.CanvasWidget; // The big ones: Editor + App. Type-level reference only — passes because Zig diff --git a/tests/README.md b/tests/README.md index 39241bac..7b226459 100644 --- a/tests/README.md +++ b/tests/README.md @@ -67,9 +67,9 @@ covered: direction encoding, `fromRadians`, rotation inverses. - `[src/math/easing.zig](../src/math/easing.zig)` — `lerp`, `ease`, endpoint pinning, midpoint bias. -- `[src/internal/layer_order.zig](../src/internal/layer_order.zig)` — +- `[src/plugins/pixelart/internal/layer_order.zig](../src/plugins/pixelart/internal/layer_order.zig)` — the layer-reorder algorithm used by the layers tree drag-and-drop. -- `[src/internal/palette_parse.zig](../src/internal/palette_parse.zig)` +- `[src/plugins/pixelart/internal/palette_parse.zig](../src/plugins/pixelart/internal/palette_parse.zig)` — `.hex` palette file parser (valid hex, comments/blanks, malformed input, CRLF). diff --git a/tests/fizzy_shim.zig b/tests/fizzy_shim.zig index 13f7a0f6..5493d3cb 100644 --- a/tests/fizzy_shim.zig +++ b/tests/fizzy_shim.zig @@ -22,6 +22,8 @@ pub const Ctx = struct { editor: *fizzy.Editor, pub fn deinit(self: *Ctx, gpa: std.mem.Allocator) void { + self.editor.pixi_state.deinit(gpa); + gpa.destroy(self.editor.pixi_state); self.editor.arena.deinit(); gpa.destroy(self.editor); gpa.destroy(self.app); @@ -51,10 +53,17 @@ pub fn init(gpa: std.mem.Allocator) !Ctx { // top of that test rather than expanding the shim. const editor_ptr = try gpa.create(fizzy.Editor); @memset(@as([*]u8, @ptrCast(editor_ptr))[0..@sizeOf(fizzy.Editor)], 0); - editor_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 }; - editor_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 }; editor_ptr.arena = std.heap.ArenaAllocator.init(gpa); + editor_ptr.host.allocator = gpa; fizzy.editor = editor_ptr; + const pixi = fizzy.pixi_mod; + const state_ptr = try gpa.create(pixi.State); + pixi.runtime.adoptShellState(state_ptr); + state_ptr.* = pixi.State.init(gpa, &editor_ptr.host) catch unreachable; + editor_ptr.pixi_state = state_ptr; + state_ptr.settings.checker_color_even = .{ 200, 200, 200, 255 }; + state_ptr.settings.checker_color_odd = .{ 100, 100, 100, 255 }; + return .{ .t = t, .app = app_ptr, .editor = editor_ptr }; } diff --git a/tests/integration.zig b/tests/integration.zig index fcbc2361..97ba64f5 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -12,8 +12,9 @@ const std = @import("std"); const dvui = @import("dvui"); const fizzy = @import("fizzy"); const shim = @import("fizzy_shim.zig"); +const pixi = fizzy.pixi_mod; -const Internal = fizzy.Internal; +const Internal = pixi.internal; /// Create a small in-memory `Internal.File` suitable for tests. The /// caller must already have a live shim context (so `fizzy.app` / @@ -206,15 +207,15 @@ test "selectColorFloodFromPoint out-of-bounds is a no-op" { // ------------------------------------------------------------------- // `.pixi` JSON parser fallbacks. The on-disk format has been bumped -// three times. `fromPathFizzy` first tries the current `fizzy.File` +// three times. `fromPathFizzy` first tries the current `pixi.File` // shape and, on failure, retries against `FileV3`, `FileV2`, and // `FileV1`. This test exercises just the JSON layer (no zip, no // `Internal.File` materialization) by parsing a small in-memory // fixture for each version. It catches the kind of bug where someone -// renames or retypes a field on the public `fizzy.File` types and +// renames or retypes a field on the public `pixi.File` types and // silently breaks loading older saves. // ------------------------------------------------------------------- -test "fizzy.File parses current-format JSON and round-trips" { +test "pixi.File parses current-format JSON and round-trips" { const json = \\{ \\ "version": { "major": 1, "minor": 0, "patch": 0, "pre": null, "build": null }, @@ -234,7 +235,7 @@ test "fizzy.File parses current-format JSON and round-trips" { ; const parsed = try std.json.parseFromSlice( - fizzy.File, + pixi.File, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -258,7 +259,7 @@ test "fizzy.File parses current-format JSON and round-trips" { defer std.testing.allocator.free(round_tripped); const reparsed = try std.json.parseFromSlice( - fizzy.File, + pixi.File, std.testing.allocator, round_tripped, .{ .ignore_unknown_fields = true }, @@ -275,7 +276,7 @@ test "fizzy.File parses current-format JSON and round-trips" { try std.testing.expectEqual(parsed.value.animations[0].frames[0].ms, reparsed.value.animations[0].frames[0].ms); } -test "fizzy.File.FileV3 fixture parses" { +test "pixi.File.FileV3 fixture parses" { // V3 keeps the columns/rows shape but uses the older `AnimationV2` // (frame indices + fps) form. const json = @@ -295,7 +296,7 @@ test "fizzy.File.FileV3 fixture parses" { ; const parsed = try std.json.parseFromSlice( - fizzy.File.FileV3, + pixi.File.FileV3, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -307,7 +308,7 @@ test "fizzy.File.FileV3 fixture parses" { try std.testing.expectEqual(@as(f32, 10.0), parsed.value.animations[0].fps); } -test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" { +test "pixi.File.FileV2 fixture parses (width/height + tile_size shape)" { const json = \\{ \\ "version": { "major": 0, "minor": 5, "patch": 0, "pre": null, "build": null }, @@ -325,7 +326,7 @@ test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" { ; const parsed = try std.json.parseFromSlice( - fizzy.File.FileV2, + pixi.File.FileV2, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -336,7 +337,7 @@ test "fizzy.File.FileV2 fixture parses (width/height + tile_size shape)" { try std.testing.expectEqual(@as(u32, 8), parsed.value.tile_width); } -test "fizzy.File.FileV1 fixture parses (start/length animation shape)" { +test "pixi.File.FileV1 fixture parses (start/length animation shape)" { const json = \\{ \\ "version": { "major": 0, "minor": 1, "patch": 0, "pre": null, "build": null }, @@ -354,7 +355,7 @@ test "fizzy.File.FileV1 fixture parses (start/length animation shape)" { ; const parsed = try std.json.parseFromSlice( - fizzy.File.FileV1, + pixi.File.FileV1, std.testing.allocator, json, .{ .ignore_unknown_fields = true }, @@ -469,7 +470,7 @@ test "Packer.append reduces painted sprite and offsets origin to keep anchor ali px[3 * 16 + 3] = .{ 255, 0, 0, 255 }; // Cell 1: leave fully transparent so the packer skips the bitmap (image == null). - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -517,7 +518,7 @@ test "Packer.append: tighten preserves world-space anchor across cells" { } } - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -552,7 +553,7 @@ test "Packer.append: tightened bitmap content matches the source pixels" { px[5 * 8 + 3] = .{ 21, 22, 23, 255 }; px[5 * 8 + 4] = .{ 31, 32, 33, 255 }; - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -590,7 +591,7 @@ test "Packer.append skips invisible layers" { .dirty = layer.dirty, }); - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -633,7 +634,7 @@ test "Packer.packRects: produced rects fit inside the texture and never overlap" } } - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -811,7 +812,7 @@ test "fillPoint on temporary layer leaves selected-layer mask cache alone" { test "Internal.Animation appendFrame, insertFrame, removeFrame" { const alloc = std.testing.allocator; - var initial_frames = [_]fizzy.Animation.Frame{.{ + var initial_frames = [_]pixi.Animation.Frame{.{ .sprite_index = 0, .ms = 100, }}; @@ -819,14 +820,14 @@ test "Internal.Animation appendFrame, insertFrame, removeFrame" { defer anim.deinit(alloc); try anim.appendFrame(alloc, .{ .sprite_index = 1, .ms = 50 }); - var expect_two = [_]fizzy.Animation.Frame{ + var expect_two = [_]pixi.Animation.Frame{ .{ .sprite_index = 0, .ms = 100 }, .{ .sprite_index = 1, .ms = 50 }, }; try std.testing.expect(anim.eqlFrames(expect_two[0..])); try anim.insertFrame(alloc, 1, .{ .sprite_index = 9, .ms = 12 }); - var expect_three = [_]fizzy.Animation.Frame{ + var expect_three = [_]pixi.Animation.Frame{ .{ .sprite_index = 0, .ms = 100 }, .{ .sprite_index = 9, .ms = 12 }, .{ .sprite_index = 1, .ms = 50 }, @@ -834,7 +835,7 @@ test "Internal.Animation appendFrame, insertFrame, removeFrame" { try std.testing.expect(anim.eqlFrames(expect_three[0..])); anim.removeFrame(alloc, 0); - var expect_after_remove = [_]fizzy.Animation.Frame{ + var expect_after_remove = [_]pixi.Animation.Frame{ .{ .sprite_index = 9, .ms = 12 }, .{ .sprite_index = 1, .ms = 50 }, }; @@ -983,7 +984,7 @@ test "Packer.append merges collapsed layer stack before reducing sprites" { file.layers.get(0).pixels()[0] = .{ 255, 0, 0, 255 }; file.layers.get(1).pixels()[7 * 8 + 7] = .{ 0, 255, 0, 255 }; - var packer = try fizzy.Packer.init(std.testing.allocator); + var packer = try pixi.Packer.init(std.testing.allocator); defer packer.deinit(); try packer.append(&file); @@ -1006,10 +1007,10 @@ test "drawPoint with to_change records history; undo restores pixels" { file.editor.canvas.id = .zero; - // `drawPoint` reads `fizzy.editor.tools.stroke_size` for stamps smaller than `min_full_stroke_size`; + // `drawPoint` reads plugin tools stroke size for stamps smaller than `min_full_stroke_size`; // the shim zero-fills the editor, so brush size must be set explicitly. - fizzy.editor.tools.stroke_size = 1; - fizzy.editor.tools.pencil_stroke_size = 1; + ctx.editor.pixi_state.tools.stroke_size = 1; + ctx.editor.pixi_state.tools.pencil_stroke_size = 1; const idx: usize = 3 * 8 + 4; diff --git a/tests/plugin_loader_integration.zig b/tests/plugin_loader_integration.zig new file mode 100644 index 00000000..6f3ec2aa --- /dev/null +++ b/tests/plugin_loader_integration.zig @@ -0,0 +1,33 @@ +//! Integration test: dlopen the pixelart dylib and register into a Host. +const std = @import("std"); +const builtin = @import("builtin"); + +const sdk = @import("sdk"); +const PluginLoader = @import("plugin_loader"); +const test_opts = @import("plugin_loader_test_opts"); + +test "load pixelart dylib and register" { + if (comptime builtin.target.cpu.arch == .wasm32) return error.SkipZigTest; + + var host = sdk.Host.init(std.testing.allocator); + defer host.deinit(); + + // Stand-in for app-owned `pixi.State` — register only stores the pointer. + var state_buf: [8192]u8 align(16) = undefined; + + const before = host.plugins.items.len; + var loaded = try PluginLoader.loadAndRegister(&host, test_opts.pixi_dylib, "pixi", .{ + .gpa = &std.testing.allocator, + .arg_b = &state_buf, // pixelart convention: arg_b = *State + .arg_c = null, + }); + defer loaded.lib.close(); + + try std.testing.expect(host.plugins.items.len == before + 1); + const pa = host.pluginById("pixi") orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("pixi", pa.id); + try std.testing.expect(host.sidebar_views.items.len >= 3); + + loaded.set_dvui_context(null, null, null, null); + loaded.set_globals(@ptrCast(&std.testing.allocator), &state_buf, null); +} diff --git a/tests/root.zig b/tests/root.zig index 54386d37..1606de7c 100644 --- a/tests/root.zig +++ b/tests/root.zig @@ -16,4 +16,5 @@ comptime { _ = @import("fizzy-grid-validate"); _ = @import("fizzy-animation"); _ = @import("fizzy-window-layout"); + _ = @import("fizzy-plugin-dylib"); }