diff --git a/CLAUDE.md b/CLAUDE.md index 3ea59935d..1d31c94fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,7 @@ Fizzy (Editor) ←── Host registries + EditorAPI ──→ Plugin (register( 7. Memory: `host.allocator` vs `host.arena()`; never touch `dvui.currentWindow().gpa` directly. 8. ABI: structural fingerprint at `dlopen` (`fizzy_plugin_abi_fingerprint`). A change that moves the boundary's shape records the new `recorded_sdk_shape_fingerprint` and leaves `sdk_version` alone; only an SDK release PR bumps the version (`CONTRIBUTING.md`, "Changing the SDK"). -Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: **[`docs/PLUGIN_MANIFEST_PLAN.md`](docs/PLUGIN_MANIFEST_PLAN.md)**. +Full contract: **[`docs/PLUGINS.md`](docs/PLUGINS.md)**. Living reshape plan: **[`plans/PLUGIN_MANIFEST_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/PLUGIN_MANIFEST_PLAN.md)**. ## Bundling plugins into an app @@ -175,13 +175,19 @@ CI builds plugins for all 6 host targets by cross-compiling with `-Dtarget=` (se ## When you need more than this file -- **The library/framework work (merged from bookmark `fizzy-lib` in #194)** → [`docs/LIB_CHECKPOINT.md`](docs/LIB_CHECKPOINT.md): +**Plans live in a private repo, [`fizzyedit/plans`](https://github.com/fizzyedit/plans)**, open to +the `fizzyedit` organization. A path written `plans/X.md` — here, in the code's comments, in +PRs — means `plans/X.md` in that repo. Clone it beside the others (`~/dev/fizzyedit/plans`, +`gh repo clone fizzyedit/plans`) and read the plan before working on its step; a session without +access works from what the code and its public docs say. + +- **The library/framework work (merged from bookmark `fizzy-lib` in #194)** → [`plans/LIB_CHECKPOINT.md`](https://github.com/fizzyedit/plans/blob/main/plans/LIB_CHECKPOINT.md): what was done and the agreed next steps. Its ground rules predate `CONTRIBUTING.md`, which wins where they differ. - Demos that play the real app (tapes, the player, rewind, writing a demo, the anchors widgets publish) → [`docs/AUTOMATION.md`](docs/AUTOMATION.md); where it is going (recording, seeking, - libraries) → [`docs/AUTOMATION_PLAN.md`](docs/AUTOMATION_PLAN.md). The tape, sequencer and + libraries) → [`plans/AUTOMATION_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/AUTOMATION_PLAN.md). The tape, sequencer and script are the `tape` library in `sdk/tape/` (std-only); the players, their input and anchors the `replay` library in `sdk/replay/` (dvui and `tape` only, for any dvui app); fizzy's overlay and plugin service in `app/automation/`; fizzy's stage and bundled demos in @@ -189,10 +195,10 @@ CI builds plugins for all 6 host targets by cross-compiling with `-Dtarget=` (se - Floating views (a view dropped on its own place's middle floats into a glass window: the rule, occlusion, close-home, persistence) → `app/layout/SPLITS.md` "Floating a view"; taking floats out into OS windows (ImGui-style viewports, per-OS dressing, the dvui-dev replay hook) → - [`docs/POPOUT_WINDOWS_PLAN.md`](docs/POPOUT_WINDOWS_PLAN.md). + [`plans/POPOUT_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/POPOUT_WINDOWS_PLAN.md). - Full plugin contract + lifecycle/hook tables → `docs/PLUGINS.md` - The forks fizzy builds on (dvui-dev, SDL, sdl_zig): what each patches, where each is pinned, how to bump → [`docs/DEPENDENCIES.md`](docs/DEPENDENCIES.md) - Native windows and glass: what Liquid Glass can do (measured), the drag's glass as one native overlay on macOS 26, and fizzy's backend owning its windows while taking SDL's releases → - [`docs/NATIVE_WINDOWS_PLAN.md`](docs/NATIVE_WINDOWS_PLAN.md) -- Living reshape plan (identity `plugin.zig.zon`, comptime `settings.Schema`, ZON user config, no sidecars) → [`docs/PLUGIN_MANIFEST_PLAN.md`](docs/PLUGIN_MANIFEST_PLAN.md) + [`plans/NATIVE_WINDOWS_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/NATIVE_WINDOWS_PLAN.md) +- Living reshape plan (identity `plugin.zig.zon`, comptime `settings.Schema`, ZON user config, no sidecars) → [`plans/PLUGIN_MANIFEST_PLAN.md`](https://github.com/fizzyedit/plans/blob/main/plans/PLUGIN_MANIFEST_PLAN.md) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12e02fe09..22f5e999c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,10 @@ preferences, findings still in flight. ## One plan step, one branch, one PR - **A plan is an issue** with a checklist of steps, or a plan doc merged on its own and linked - from one. A plan is never the PR its implementation lands in. + from one, both in the private [`fizzyedit/plans`](https://github.com/fizzyedit/plans) repo. A + plan is never the PR its implementation lands in. A PR here says `Part of fizzyedit/plans#N`, + or names the plan file as `plans/_PLAN.md`; what a reviewer without access needs to judge + the change goes in the PR itself. - **Each step is its own PR**, saying `Part of #N`. A PR is one change a reviewer can hold in their head: aim for **800 lines or fewer** of diff outside tests and docs. Bigger work goes in a stack of PRs, or lands dark behind a flag (`FIZZY_POPOUT` is the pattern) and is switched on @@ -125,7 +128,7 @@ tarball is pinned by hash, so it is never replaced. | What fizzy is, and its architecture rules | `CLAUDE.md` | | How we work | this file | | The plugin contract | `docs/PLUGINS.md` | -| Plans | issues; a long design as `docs/_PLAN.md`, opening with a status line (`Status: proposed`, `in progress, #N`, or `done`) | +| Plans | private, in [`fizzyedit/plans`](https://github.com/fizzyedit/plans): issues, and a long design as `plans/_PLAN.md` opening with a status line (`Status: proposed`, `in progress, fizzyedit/fizzy#N`, or `done`) | | The forks fizzy builds on | `docs/DEPENDENCIES.md` | | Releases | `RELEASING.md` | diff --git a/app/App.zig b/app/App.zig index 69426e9a2..9bdf9de53 100644 --- a/app/App.zig +++ b/app/App.zig @@ -230,7 +230,7 @@ pending_app_close: bool = false, /// Hash of the last serialized settings.zon text written or captured at startup; avoids /// redundant writes without keeping a full duplicate copy of the text around. Doubles as the /// "was that change ours" filter for `settings_watcher` (see R11 in -/// docs/PLUGIN_MANIFEST_PLAN.md) — a change to disk whose hash matches this is our own last +/// plans/PLUGIN_MANIFEST_PLAN.md) — a change to disk whose hash matches this is our own last /// write, not an external edit worth reconciling. settings_last_saved_hash: ?u64 = null, @@ -788,7 +788,7 @@ pub fn closeProjectFolder(app: *App) void { } /// One-shot: moves any pre-R10 flat `{plugins_dir}/{id}.{ext}` into its own -/// `{plugins_dir}/{id}/{id}.{ext}` directory (see docs/PLUGIN_MANIFEST_PLAN.md R10). Collects the +/// `{plugins_dir}/{id}/{id}.{ext}` directory (see plans/PLUGIN_MANIFEST_PLAN.md R10). Collects the /// list of flat files first, then renames in a second pass, so mutating the directory never races /// the iterator that's still walking it. Best-effort: a single failed rename is logged and /// skipped rather than aborting the rest. diff --git a/app/RecentsMigration.zig b/app/RecentsMigration.zig index 4449fbbc2..2fa9807ae 100644 --- a/app/RecentsMigration.zig +++ b/app/RecentsMigration.zig @@ -1,5 +1,5 @@ //! One-shot migration of the legacy `recents.json` into `recents.zon` (see -//! docs/PLUGIN_MANIFEST_PLAN.md R3). All `std.json` usage on the recents path is isolated +//! plans/PLUGIN_MANIFEST_PLAN.md R3). All `std.json` usage on the recents path is isolated //! to this file so it can be deleted once every install has migrated. const std = @import("std"); const core = @import("core"); diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index b156bba70..2f1c95373 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -83,7 +83,7 @@ pub const Float = struct { /// the drop was cancelled and the photograph is still a true picture of it; gone, it landed /// elsewhere, and the float comes back as it is now. aside_view: []const u8 = "", - /// Out of the main window, in an OS window of its own (`docs/POPOUT_WINDOWS_PLAN.md`); null + /// Out of the main window, in an OS window of its own (`plans/POPOUT_WINDOWS_PLAN.md`); null /// while it is in the main window. The application that owns the OS windows sets and clears /// it, replays the float's drawing into its window and routes that window's pointer back. viewport: ?Viewport = null, diff --git a/app/layout/SPLITS.md b/app/layout/SPLITS.md index 50f306eb7..c0a5b5cab 100644 --- a/app/layout/SPLITS.md +++ b/app/layout/SPLITS.md @@ -206,7 +206,7 @@ by again, the drop zones, splits, the picker. A place of several views keeps the its floats held on screen, and gives them back their places when it grows. A float is a window inside fizzy's own window for now. Taking one out into an OS window of its -own is the plan in `docs/POPOUT_WINDOWS_PLAN.md`. +own is the plan in `plans/POPOUT_WINDOWS_PLAN.md`. ## Edges and bands diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index c6d7e07ba..0ed4c9424 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -218,7 +218,7 @@ fn fieldEqual(comptime FT: type, a: FT, b: FT) bool { /// Serialize fizzy's own fields as `.{ ...fizzy fields... }`, emitting **only the fields that /// differ from the declared defaults above** — the same non-default-only rule plugin settings -/// follow (`sdk.settings.Schema(T).diffSerialize`, R12 in docs/PLUGIN_MANIFEST_PLAN.md), so both +/// follow (`sdk.settings.Schema(T).diffSerialize`, R12 in plans/PLUGIN_MANIFEST_PLAN.md), so both /// halves of `settings.zon` record just what the user actually changed. An untouched fizzy /// serializes to `.{}`; a field hand-written back at its default drops out on the next write. /// Reading back is unaffected: `load`/`parseOnly` fill every missing field from these same diff --git a/app/settings/SettingsMigration.zig b/app/settings/SettingsMigration.zig index 053bb78e9..a8287e423 100644 --- a/app/settings/SettingsMigration.zig +++ b/app/settings/SettingsMigration.zig @@ -1,4 +1,4 @@ -//! One-shot migrations for `settings.zon` layout changes — see docs/PLUGIN_MANIFEST_PLAN.md. +//! One-shot migrations for `settings.zon` layout changes — see plans/PLUGIN_MANIFEST_PLAN.md. //! //! 1. `mergeLegacyPerPluginFiles` — pre-R10 one-file-per-plugin (`/.settings.zon`) //! into the merged `.plugins.` field (R10). diff --git a/app/settings/SettingsPluginsZon.zig b/app/settings/SettingsPluginsZon.zig index 2bbbbec64..bb787c75c 100644 --- a/app/settings/SettingsPluginsZon.zig +++ b/app/settings/SettingsPluginsZon.zig @@ -1,5 +1,5 @@ //! Reads/writes the `.plugins = .{ . = .{...}, ... }` sub-tree of the merged -//! `settings.zon` — see docs/PLUGIN_MANIFEST_PLAN.md R10/R12. +//! `settings.zon` — see plans/PLUGIN_MANIFEST_PLAN.md R10/R12. //! //! Every plugin's block lives as a real nested ZON struct literal keyed by plugin id, not an //! escaped-string blob. On-disk shape after R12: diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index a0ad11d23..421f78390 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -107,7 +107,7 @@ pub fn pluginExtension() []const u8 { } /// Bare `{name}.{ext}` filename (no directory) — the basename inside a plugin's own -/// `{plugins_dir}/{name}/` directory (see docs/PLUGIN_MANIFEST_PLAN.md R10; every plugin, built-in +/// `{plugins_dir}/{name}/` directory (see plans/PLUGIN_MANIFEST_PLAN.md R10; every plugin, built-in /// or third-party, gets its own directory rather than sitting flat in `plugins/`). pub fn pluginFilename(name: []const u8, allocator: std.mem.Allocator) ![]const u8 { return std.fmt.allocPrint(allocator, "{s}.{s}", .{ name, pluginExtension() }); diff --git a/app/store/PluginLoader_web.zig b/app/store/PluginLoader_web.zig index 802358dcb..827c95f21 100644 --- a/app/store/PluginLoader_web.zig +++ b/app/store/PluginLoader_web.zig @@ -1,5 +1,5 @@ //! Runtime plugin loading on the web: a plugin built as a wasm *side module* (see -//! `sdk/plugin_sdk.zig`'s `create` for wasm32 and `docs/REVIEW_2026-09.md` §2), fetched and +//! `sdk/plugin_sdk.zig`'s `create` for wasm32 and `plans/REVIEW_2026-09.md` §2), fetched and //! linked into the page by `web/index.html`'s `loadPlugin`, which hands back one function-table //! index per entry point. On wasm32 a function pointer *is* a table index, so from there this //! runs the same sequence the desktop loader runs (`PluginLoader.loadAndRegister`): fingerprint, diff --git a/app/store/PluginStore.zig b/app/store/PluginStore.zig index e84fcac62..9410d5d96 100644 --- a/app/store/PluginStore.zig +++ b/app/store/PluginStore.zig @@ -1847,7 +1847,7 @@ fn scoreEntry(entry: StoreEntry, query: *const fuzzy.Query) ?f64 { // `descriptionFor`/`tagsFor` already fall back past the registry (built-in manifest, then a // dylib probe), so a plugin with no registry entry yet still scores on its own prose — only - // `author` has no such fallback (it's registry-only, see `docs/PLUGIN_MANIFEST_PLAN.md`). + // `author` has no such fallback (it's registry-only, see `plans/PLUGIN_MANIFEST_PLAN.md`). const author = if (entry.registry) |r| r.author else ""; var prose = fuzzy.scoreBest(&.{ descriptionFor(entry) orelse "", author }, query, .{ .plain = true }); for (tagsFor(entry)) |tag| { diff --git a/app/watch/SettingsWatcher.zig b/app/watch/SettingsWatcher.zig index 2b83f742e..959d1ee4d 100644 --- a/app/watch/SettingsWatcher.zig +++ b/app/watch/SettingsWatcher.zig @@ -1,5 +1,5 @@ //! Watches `/` (recursive) for external changes while fizzy is running — see -//! docs/PLUGIN_MANIFEST_PLAN.md R11/R12. +//! plans/PLUGIN_MANIFEST_PLAN.md R11/R12. //! //! Thin adapter over [`neurocyte/nightwatch`](https://github.com/neurocyte/nightwatch): one //! recursive `watch(config_folder)` covers `settings.zon` reconciliation, discovery of diff --git a/build/app.zig b/build/app.zig index 50c42042c..5f8906138 100644 --- a/build/app.zig +++ b/build/app.zig @@ -796,7 +796,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // `replay` (`sdk/replay/`) on its own: dvui and `tape`, nothing of fizzy's. A reach into // `core` or `app` fails to compile here, which is what keeps it a library any dvui app can - // take (`docs/AUTOMATION_PLAN.md`, "The libraries"). + // take (`plans/AUTOMATION_PLAN.md`, "The libraries"). { const replay_only = b.createModule(.{ .target = target, @@ -914,7 +914,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. integration_module.addAnonymousImport("markdown_sample", .{ .root_source_file = b.path("docs/PLUGINS.md") }); // The document with the 45KB table — the case table-row culling exists for, and the one it // could get wrong. - integration_module.addAnonymousImport("markdown_sample_tables", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") }); + integration_module.addAnonymousImport("markdown_sample_tables", .{ .root_source_file = b.path("tests/data/markdown_sample_tables.md") }); // The image-heavy fixture. Both docs above are prose and tables, so without this no test ever // laid out an image block — the one block kind whose height nothing in the source predicts // and which rescales with the pane right up until the pane is wider than the image. @@ -1022,7 +1022,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // reasoning as `bench-text`'s samples. `PLUGINS.md` is the document that prompted the // benchmark. bench_module.addAnonymousImport("sample_huge", .{ .root_source_file = b.path("docs/PLUGINS.md") }); - bench_module.addAnonymousImport("sample_prose", .{ .root_source_file = b.path("docs/PLUGIN_MANIFEST_PLAN.md") }); + bench_module.addAnonymousImport("sample_prose", .{ .root_source_file = b.path("tests/data/markdown_sample_tables.md") }); bench_module.addAnonymousImport("sample_medium", .{ .root_source_file = b.path("CLAUDE.md") }); bench_module.addAnonymousImport("sample_small", .{ .root_source_file = b.path("docs/MODULARIZATION_RELEASE_NOTES.md") }); diff --git a/core/core.zig b/core/core.zig index 432f005b4..02652e33a 100644 --- a/core/core.zig +++ b/core/core.zig @@ -103,7 +103,7 @@ pub const fuzzy = @import("fuzzy.zig"); pub const FileTable = @import("FileTable.zig"); /// The mountable-filesystem contract (`vfs.Fs`): path-addressed, completion-based, wasm-safe. /// A cloud plugin mounts one on the host's `FileTable`; the local disk is `LocalFs` behind the -/// same interface. See `docs/CLOUD_FS_PLAN.md`. +/// same interface. See `plans/CLOUD_FS_PLAN.md`. pub const vfs = @import("vfs/vfs.zig"); pub const LocalFs = @import("LocalFs.zig"); /// Long-running work as a stepped `Task`, run on a thread natively or from the frame on the diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig index f21114716..8d0e5966f 100644 --- a/core/gfx/glass_look.zig +++ b/core/gfx/glass_look.zig @@ -1,4 +1,4 @@ -//! One material on two sliders, the window's (`docs/NATIVE_WINDOWS_PLAN.md`): what every glass +//! One material on two sliders, the window's (`plans/NATIVE_WINDOWS_PLAN.md`): what every glass //! surface is at the window's opacity and roughness. //! //! - **Roughness** is the glass. At 0 it is clear, shiny glass, a lens bending what is behind it diff --git a/core/screens.zig b/core/screens.zig index 7aafa7487..0bbbd2eb9 100644 --- a/core/screens.zig +++ b/core/screens.zig @@ -1,6 +1,6 @@ //! The screens a floating thing — a menu, a tooltip, a popover, a dialog — is placed on and kept //! within. The main window's, and, while a float is out of it in an OS window of its own, that -//! window's part of the frame (`docs/POPOUT_WINDOWS_PLAN.md`): far past the main window's edge, +//! window's part of the frame (`plans/POPOUT_WINDOWS_PLAN.md`): far past the main window's edge, //! where anything opened from the float is drawn and from where it is copied into that window. //! Placed against the main window there, a menu opened in a float that is out was pulled all the //! way back onto the main window, and drawn there. diff --git a/core/vfs/vfs.zig b/core/vfs/vfs.zig index a35df5bee..5f70a4dc2 100644 --- a/core/vfs/vfs.zig +++ b/core/vfs/vfs.zig @@ -6,7 +6,7 @@ //! and implements `Fs` over `http.Transport`; nothing here knows any provider's name. //! //! See `Fs.zig` for the two decisions that shape the API (paths, not ids; completions from -//! `pump`, never a blocking call) and `docs/CLOUD_FS_PLAN.md` for the whole design. +//! `pump`, never a blocking call) and `plans/CLOUD_FS_PLAN.md` for the whole design. const FsFile = @import("Fs.zig"); pub const Error = FsFile.Error; diff --git a/core/widgets/FloatingWindowWidget.zig b/core/widgets/FloatingWindowWidget.zig index ca5d5ee64..56a3544b8 100644 --- a/core/widgets/FloatingWindowWidget.zig +++ b/core/widgets/FloatingWindowWidget.zig @@ -132,7 +132,7 @@ pub const InitOptions = struct { /// else places and sizes — a layout's float, which lands exactly where a drop put it. placed: bool = false, /// Drawn for an OS window of its own rather than the main one (a float popped out, - /// `docs/POPOUT_WINDOWS_PLAN.md`): its rect lies wherever that window's part of the frame is, + /// `plans/POPOUT_WINDOWS_PLAN.md`): its rect lies wherever that window's part of the frame is, /// usually past the main window's edge, so it is not held on the main window and is clipped /// to itself rather than to it. detached: bool = false, diff --git a/docs/AGENTS_PLAN.md b/docs/AGENTS_PLAN.md deleted file mode 100644 index 44ac79ff8..000000000 --- a/docs/AGENTS_PLAN.md +++ /dev/null @@ -1,609 +0,0 @@ -# Agents — the plan - -Status: in progress, #250. Milestone 1 (command parameters and results) is #251, with #253 (keybinds) and #254 (tapes); milestone 2 (profiles) is #255; milestone 3 is #257, #258, #261, #263, #274, #275, #276, with the snapshot in #277 and the standalone `replay` in #283. - -How fizzy becomes something an agent can drive, build plugins for and check its own work in — -**without fizzy, or any app built on it, carrying anything agent-shaped.** Agent support is an -external plugin, installed by whoever wants it, the same way pixi is. What fizzy owes it is a set -of seams in the SDK and the framework; this document is those seams. - -**Where it is going: fizzy building itself.** A person asks an agent — any agent — for a plugin, -and watches it being made inside fizzy, from the first file to the plugin running: the code -appearing in the editor, the build in the output panel, the plugin loading beside them. The same -agent draws in pixi, writes code into an open document, opens the explorer or a new window. Every -layer, from the backend to the UI, is designed so the agent and the person have as much freedom -as possible — without giving up the windowing, the polish or a millisecond of frame time. "The -whole loop" below is that end to end. - -## Principles - -1. **Agent support is a plugin.** Its socket, its protocol, its tools, its chat pane: all in a - plugin repo (`fizzyedit/agent`, say), never in `src/`, `app/` or `sdk/` by name. A user who - does not install it carries none of it, and neither does an app built on fizzy. -2. **Every seam has a consumer that is not an agent.** Each one below names it — demos, the - recorder, the palette, tests, an ordinary plugin. A seam only an agent would use is the agent - plugin's business leaking into the contract under a neutral name. The consumer goes in the - seam's doc comment, so the rule survives review. -3. **The agent plugin is the SDK's forcing function.** It is the most demanding third-party - plugin there will be: it wants to see everything and do anything. Anything it needs that only - fizzy can reach is a gap in the SDK, never a reason for a special case — the same rule the - shipped layout shapes keep for `Layout` (CLAUDE.md, "Shipped shapes"). -4. **An agent acts as a person does.** Input goes through the same anchors and tapes a demo uses; - changes to the model go through commands a plugin chose to offer. There is no back door that - writes a document's text or moves a split directly, so an agent can do nothing a person could - not, and everything it does shows up the way a person's would — on screen, in undo. -5. **The person wins.** A click, tap or key from the person interrupts whatever an agent is - playing, exactly as it pauses a demo (`docs/AUTOMATION.md`, "Playing one"). -6. **Performance is the edge, and comes first.** Fizzy is native, systems-level software: no - browser, no script runtime, no extension host between an agent and the app. A tool call is a - function call on the UI thread, a snapshot is a walk over one frame's widgets, a plugin is - machine code loaded in place. That is what makes "watch it being built" possible at all, and - it is kept: nothing agent-shaped costs anything while no agent is there, nothing an agent - does blocks a frame, and every step of the build loop is measured and made shorter. When a - design choice trades speed for convenience, speed wins. - -## What there is - -Most of what an agent needs exists already, for demos and tests. It is just not reachable from -a plugin. - -| An agent needs | Already in fizzy | Reachable by a plugin? | -|---|---|---| -| Actions it can take | the `Command` registry: id, title, `isEnabled` | runs by id (`Host.runCommand`); no arguments, no result; listed only by reading `host.commands` | -| Names for UI, not pixels | `core.anchor.mark`; `role:label` accessible names planned (AUTOMATION_PLAN, "Aiming without positions") | marks, yes; publishing is host-only (`anchor.publish`, on while a demo is loaded) | -| Input that lands | `Player` + `Sequencer`: synthetic dvui input aimed at anchors, with interruption | no — `app/automation`, host only | -| Knowing the app is idle | `Stage.idle`, and dvui's own convergence (no further refresh asked for) | no | -| The model as data | `Stage.capture`; per-document `captureDocumentState` / `documentFingerprint` | the per-document hooks, as an owner only | -| Pictures of the frame | frame readback (`core/gfx/FrameTarget.zig`, the glass) | no | -| A run without a display | the headless window behind `tests/integration.zig` (`tests/fizzy_shim.zig`) | no — fizzy's own tests only | -| Picking up a rebuilt plugin | `reconcileChangedPluginBinaries`: `zig build install` of a loaded user plugin hot-reloads it, documents reopened | happens, but reports only to the log | -| A channel into a running app | the single-instance listener (argv forwarding, wakes the app) | no; and a plugin can open its own anyway | - -## The plugin - -`fizzyedit/agent` — desktop first, the four-file shape like any other. - -- **A control socket.** A Unix domain socket (a named pipe on Windows) in the profile's runtime - directory, mode 0600, with a token file beside it. A background thread accepts requests and - queues them; the plugin drains the queue in `beginFrame`, on the UI thread, and wakes the app - with `host.refresh()` (proven from a dylib's thread, 2026-07-13). Replies that wait — "when it - has settled" — are answered from a later frame. -- **Tools, mostly generated.** One tool per enabled command, its arguments from the command's - parameters (below); plus a fixed few over the services: `ui.snapshot`, `ui.act`, - `wait.settled`, `state.get`, `screenshot`, `log.tail`, `plugins.status`. A plugin installed - next week contributes tools without the agent plugin knowing it exists. -- **A bridge, `fizzy-mcp`.** A second, std-only artifact in the same repo: an MCP server on stdio - that relays to the socket. Pointed at a profile, it can launch a sandbox fizzy with the agent - plugin installed there and drive that one, leaving the person's own instance alone. -- **Showing that an agent is there.** An infobar entry while a client is connected; the demo - overlay's pointer while it is playing input, so a person sees what is being clicked. -- **Later, a chat surface** — a plugin of its own, speaking ACP, JSON-RPC over stdio as LSP is - (`core.lsp.Client` is the template); see "Many agents, one contract" below. Edits shown as - diffs in the `text` plugin; permission prompts as fizzy dialogs. Nothing in it asks the SDK for - more than the rest does. - -## What an agent wants from it - -The plugin is for agents, so it is shaped by how an agent works: a turn is expensive, a token -is expensive, it sees nothing it does not ask for, and it is often on a Linux machine with no -display. Written from an agent's side, after a session spent working around each of these -(screenshots by window id, input that moved the person's pointer, a demo loader that only takes -bundled names, `HOME` and `TMPDIR` surgery, reading a 900-line file to learn what a panel does). -In priority order: - -1. **A run with no display, and none of the person's things.** A sandbox instance from a profile - (milestone 2), with the plugins asked for, at a window size asked for, either headless (dvui's - testing backend or an offscreen video driver) or in a hidden window that never takes focus. - Several at once. A cloud agent has no screen at all, so without this, nothing below can be - used where most agent work happens. -2. **Act, wait and observe in one call.** `ui.act` takes a step (click, type, key, scroll, aimed - at an anchor or a `role:label`), waits until the app has settled or a condition holds (an - anchor appears, a document is dirty, a log line matches) with a timeout, and returns what - changed. One turn per step rather than three. A failure says which part failed: the target - was not drawn, the wait timed out, a command was disabled. -3. **Observation as text, diffed.** `ui.snapshot` returns the tree (anchors, roles, labels, rects, - focus, open popups and dialogs), filterable to a subtree, and on later calls only what changed - since the last one. Names stay stable between calls. Screenshots exist, but only on request, of - an anchor or a region, downscaled: a picture costs far more than the text that says the same. -4. **The plugin loop as one tool.** `plugin.new` (from the template), then `plugin.build`: build, - install into the sandbox's profile, reload, and report. Compiler errors come back structured - (file, line, column, message), load failures by kind (fingerprint, missing symbol, - registration error, the plugin's own message), the reload's outcome (documents reopened, - state carried or dropped). Then `plugin.test` through the headless harness. This is what turns - "write a plugin" into a loop an agent can close on its own. -5. **The registries, described.** `sdk.describe`: every command with its parameters, surfaces and - their keywords, regions, services with versions, settings schemas, file kinds, which plugin - owns what, the SDK version and fingerprint. Writing a plugin correctly starts with knowing - what is already there; today that means reading source. -6. **Documents through commands.** Reading an open document's text or a range of it, the - selection, dirty state; editing through commands its owner offers (`text.replaceRange`, - `text.insert` — commands with arguments, so milestone 1 again). No back door, and every edit - lands in undo where a person can see it. -7. **Logs as a stream with a cursor.** `log.tail(since)`, filtered by level and source; plugin - load and reload events; a panic's message and stack where there is one; frame times from the - profiler, so "did this make it slower" has an answer. -8. **Time under the agent's control.** Motion off and the virtual clock ahead, so a wait for an - animation is free rather than 300 ms of wall time, and a run is deterministic. -9. **A session becomes a tape.** Everything the agent did, written out as a tape: a reproduction - for a bug, a regression test for the integration suite, a demo for the site. Loading a tape - from a file rather than only bundled ones. -10. **Small, stable output.** Compact by default, paged, with ids that do not change between - calls, and errors that say what to do next. - -Last, and for the person rather than the agent: a pane in fizzy showing what the agent is doing, -diffs of its edits to review, and a way to stop it. - -**What building it exercises.** The plugin is also the most thorough test of the SDK there will -be, and is meant to be: a dylib with background threads waking the host; commands with arguments -called across plugins; every service above, versioned; dvui's shared state (anchors, tags, the -frame dump) read from another image; frame capture through the render bridge; its own settings -schema (permissions, socket, limits), a surface (the pane above), an infobar entry; and hot reload -while connected. Each is something any plugin might do; this one does them all, so a break in any -shows up here first. - -## The whole loop: fizzy building itself - -### What the person sees - -They open the chat pane and ask for a plugin — "a color picker that writes hex into the open -document", say. The agent scaffolds it from the template: the files appear in the explorer, and -each opens in the editor as the agent writes it, edits arriving as edits (in undo, highlighted as -they land), not as files changing behind the editor's back. The build runs; its output streams -into the output panel, and a compile error opens at its line. The plugin loads — in a -development window beside theirs (below) — and its surface appears. The agent drives it with a -live tape, its pointer drawn over the window, reads the result back from a snapshot, and reports. -The person can stop it at any moment, undo any edit, and answer a permission prompt as a fizzy -dialog. Then they ask for a change, and the loop runs again. - -Every piece of that is either built (commands with arguments, profiles, live tapes, the snapshot, -hot reload of a rebuilt plugin) or a milestone below. - -### Many agents, one contract - -Fizzy speaks two open protocols and nothing vendor-specific, so whichever agent a person uses — -Claude Code, Codex, Gemini, Grok, or the next one — plugs in without fizzy changing: - -- **Outward, MCP.** `fizzy-mcp` serves fizzy's tools (milestone 5): every enabled command, the - snapshot, live tapes, `settled`, and the state, frames, log and plugins services. Any MCP - client drives fizzy with them, from a terminal, an IDE, or the cloud against a headless - instance. -- **Inward, ACP** (the Agent Client Protocol). The chat surface is an ACP *client*; the agent is - any ACP *agent*, run as a subprocess — natively or through an adapter. Starting a session, the - chat plugin hands the agent fizzy's own MCP server, so the agent the person talks to is also - the one driving fizzy. ACP carries the rest of the loop too: file reads and writes (which fizzy - applies to its open documents), diffs, plans, permission requests. - -An agent that speaks neither gets an adapter, in its own plugin; fizzy does not learn its API. - -**No browser library.** An agent observes fizzy as text (the snapshot), acts through commands and -input (live tapes), and sees pixels only when it asks (`frames`). Nothing in that needs a web -view or a headless browser. The web build gets agents through the page — a bridge the agent -plugin's web variant provides (see "The web") — not a browser inside fizzy; and an agent's own -web research is its tool, not fizzy's. - -### Every plugin, driven by its commands - -A plugin's agent API is its commands with arguments and results (milestone 1). The agent plugin -turns each into a tool; a plugin installed tomorrow is drivable the moment it registers, and -writes nothing for agents. The rule that makes this work: **anything a person can do in a plugin, -the plugin also offers as a command** — a menu row, a palette entry, a keybind, and a tool, all -the same thing. Where a command does not exist yet, the snapshot and live tapes still reach the -UI, as a person's hands would. - -| The person asks | How it happens | What is missing | -|---|---|---| -| "Open the explorer" | `fizzy.toggleExplorer`; the snapshot confirms `region:Sidebar` | nothing | -| "Open a new window" | a command from the workspaces plan (⌘N) | the command (`docs/WORKSPACES_PLAN.md`) | -| "Code me something" | ACP writes, applied by the `text` plugin to the open document: live, in undo, shown as a diff; `text.insert` / `text.replaceRange` for an agent on MCP | those `text` commands | -| "Draw me something in pixi" | pixi's commands: a new sprite (`.w`, `.h`), paint rows of palette indices at a place, fill, choose a layer or color; `frames` to see the result | an SDK release with command arguments, then the commands in pixi | -| "Build me a plugin" | the loop above | milestones 4–8 | - -Reading is commands too: a document's text or a range, pixi's layers and palette, what is open. -Each returns ZON (`Command.Call.returns`), which the bridge turns into JSON at its edge. - -### The development window - -A plugin being written runs native code, and a plugin half-written can crash. It is not loaded -into the person's own session. The agent's build installs into a **development profile** -(milestone 2), and a second fizzy runs from it — a separate process, in an OS window of its own -beside the person's, the way an editor runs its extension development host. The person watches it -come alive; their documents, layout and frames are never at stake; a crash restarts the -development window, not their work. The agent plugin talks to both: the person's instance for -the chat, the editor and the code, the development instance for loading, driving and checking the -plugin. Hot reload (`reconcileChangedPluginBinaries`), with state carried across a reload (see -"Hot reload keeps state"), makes each rebuild a swap rather than a restart. - -### Fast enough to watch - -Native code is the edge, and the loop is where it shows. The targets, each measured before it is -claimed and kept by a benchmark once it is: - -- **Free when no agent is there.** No socket, no thread, no per-frame work: anchors are asked - for a frame at a time, a snapshot is taken only on request, the frame dump is armed for one - frame. A build without the agent plugin carries none of it. -- **No frame waits on an agent.** The socket's thread blocks on its own; requests are drained in - `beginFrame` and answered from later frames. A snapshot is a walk over one captured frame — - measured, and budgeted well under a millisecond for a window of fizzy's size. A live tape step - is a frame. The development window is another process, so a slow plugin cannot stall the - person's frames. -- **A rebuild a person can wait for.** From an edit saved to the plugin running again: the - compile (the plugin's own module only — its dependencies are the SDK, cached, never the app - or Velopack), the link, the install, the `dlopen` (on macOS the first load of a new file costs - ~110–140 ms of OS validation), the reload and its reopened documents. Each stage is timed and - reported through `plugins` (milestone 4), and the slowest one is the next thing worked on. The - goal is seconds, not tens of seconds, for a small plugin in Debug. -- **Small output.** A snapshot is a line per node, and a later one can be just what changed; - arguments and results are ZON text, not screenshots. Fewer tokens is a faster agent. - -**Measured, 2026-10-09** (`scripts/plugin-loop/bench.py`; `examples/hello-plugin`, Debug, an -Apple-silicon Mac with other builds running, so upper bounds). From a one-line edit saved to the -plugin running again in a fizzy: **2.7–3.3 s**. - -| Stage | Time | What sets it | -|---|---|---| -| `zig build` (compile ~2 s, the build runner the rest) | 2.2–2.9 s | the plugin compiles as one unit with all of `core` and dvui; LLVM in Debug on arm64 | -| the watcher notices the new binary | ~215 ms | `SettingsWatcher`'s 200 ms coalesce, meant for `settings.zon` | -| the swap: unload | ~4 ms | | -| the swap: load (`dlopen`, register, `initPlugin`) | ~180 ms | on macOS, mostly the first-load validation of a new file | - -ReleaseFast: a one-line edit rebuilds in ~7.9 s. A new plugin's first build (its own cache -empty, the global one warm): 14–20 s. A rebuild with nothing changed: ~0.2 s. - -What that says, in order of what it would save: - -1. **The compile is most of it.** A three-line plugin recompiles everything it imports. Levers: - Zig's incremental compilation with a long-running `zig build --watch -fincremental` (tried: - save-to-installed 1.1–2.3 s, so not yet effective here — it wants the self-hosted backend, - which Debug on arm64 macOS does not use yet; on x86_64 Linux it is the default), and less for a - plugin to compile (the plugin-facing `core` is the whole of it today). -2. **A reload freezes the person's frame for ~190 ms**, on the UI thread. That is the one stage - that costs a person something even when nobody is waiting on the loop. Lever: `dlopen` on a - worker, and only registration on the UI thread; the development window (above) moves the - rest out of the person's process altogether. -3. **The watcher waits 200 ms** for a burst of writes to settle. A plugin's install writes once; - its own pass needs no coalesce. -4. **The build runner** costs ~0.2–0.9 s a run; a watching `zig build` pays it once. - -## The seams - -Two kinds. A change to `Host`, `Plugin` or `Command` moves the ABI fingerprint and needs every -store plugin rebuilt. A **service** (`Host.registerService` / `getServiceTyped`) does not: it is -optional by construction, checked by its own `service_version`, and an app can supply its own or -none (`sdk/src/services/files.zig` explains why `files` became one). So: services wherever the -shape allows, contract changes only where a plugin *contributes* something. - -### Commands take arguments and return a result — contract (moves the fingerprint) - -Today `run(state) !void`. Add, all optional: - -- **Parameters**, declared the way settings are: a comptime struct turned into descriptors - (name, kind, doc, default) by an `sdk.command.Params(struct { … })`, mirroring - `sdk.settings.Schema`. -- **`runWith(state, args, out)`** — `args` the arguments as ZON bytes, `out` a writer for a ZON - result. Bytes cross the boundary and types live on each side: the helper parses into the - plugin's struct and writes its result back, so the argument struct's layout never enters the - fingerprint and can change freely. -- **Failure as a message**, not an error name: error values are numbered per compilation, so a - name read on the host's side is another compilation's error (`Plugin.VTable`'s doc comment). -- **Listing** through functions (`commandCount`, `commandAt`) rather than the `commands` field, - so the registry can change shape behind them. - -Consumers: the palette prompting for arguments (Go to Line, Rename Symbol); keybinds that carry -them (`"text.goToLine"`, `.{ .line = 1 }`); a tape's `command` op with arguments; one plugin -running another's command with input. - -This is the one seam to land first: it is the only one that moves the fingerprint, and the -cheapest moment for that is before the next SDK release, batched with whatever else moves it. - -### `automation` — service - -What `app/automation` already does, offered to plugins. Registered by an app that has a -`Player`; fizzy does. - -- **`wantAnchors()`** — anyone may ask for anchors this frame; `core.anchor.publish` becomes - "someone asked", not "a demo is loaded". Consumers: the recorder, plugin demos, plugin tests. - *Built in #258 as `core.anchor.want()`, and not a service call:* `core.anchor` already agrees - across dylibs through the window's data, so a plugin asks the same way the player does. The - service need not wrap it, and the frame's named rects are dvui's tags, which a plugin can read - as directly. The service keeps what only the app can do: `play`, and `settled`. -- **`anchors(arena)`** — the frame's named rects (name, rect, visible), and accessible - `role:label` names (below). Consumers: the recorder, plugin tests. -- **`play(tape, opts)`** — play tape bytes (ZON or binary, `Tape.load`) **live**: no keyframe, - the person's session left where it is, ops acting on the app as it is. A plugin builds its - tapes with `tape.Script`, already in the SDK. Consumers: plugin tests, a scripted tutorial, a - macro. (A plugin *demo* is the other kind — keyframed, seekable — and goes to the `Player` by - `registerDemo`, AUTOMATION_PLAN milestone 6.) How a live tape is driven is below. -- **`settled()`** — `Stage.idle`, and nothing asked dvui for another frame, and nothing animating. - With a callback form, so a caller can wait without polling. Consumers: demos, tests, a plugin - that acts once a load has landed. "Nothing asked dvui for another frame, nothing animating" - needs no dvui change: it is what `Window.end` returns. `0` means a refresh or a running - animation wants the next frame now, a number means an animation starts later, and null means - dvui would sleep. fizzy's backends already hold that value as `end_micros`, so the app notes it - once a frame and `settled` reads the note, through public API only. It can't serve a tape's - own `waitIdle`, though: the live driver asks for a frame every frame while it plays, so `end` - never returns null then. A tape keeps pacing on `Stage.idle` and the sequencer's yields, which - the tests show is enough for input to land. - -#### Driving a live tape: its own driver, not the `Player` - -A live tape and a demo want different things, and the difference decides where one is played. - -| | A demo | A live tape | -|---|---|---| -| Starts from | a keyframe, the person's session set aside | the app as it is | -| Pacing | the tape's authored times (a viewer watches) | each op once the last has settled (a caller waits) | -| The person steps in | pause; on resume, replay to where it was | stop, and report the op it stopped before | -| Going back | seek: snapshots, keyframes | undo, through the commands it ran | -| Transport, chapters | yes | none | - -Two ways to play one: - -- **Through the `Player`, in a live mode.** One injection path; the overlay's pointer and - keystrokes come free; one tape at a time by construction. But every demo-shaped part of the - player — resume-by-replay, seeking, the transport, `Stage.begin`/`end`/`keyframe` — grows a - "not when live" branch, in the most intricate and most tested piece of the automation code. - That is a mode flag on a shipped thing, which this codebase prefers to avoid. -- **A `LiveDriver` beside it.** The `Sequencer` (already std-only) plus the dvui sink — the - injection now inside `Player` (`addEventMouseMotion`, `addEventKey`, …) — factored out so both - use it. The player stays as it is; the live driver is small and its rules are its own. It costs - that extraction first, one arbitration rule (a live tape is refused while a demo is loaded, and - the reverse), and the overlay learning to draw for either. - -**The `LiveDriver`.** The extraction is the split AUTOMATION_PLAN already wants for its `replay` -package (`Recorder`, `Player`, `Anchors` as separate pieces), so it is work that would happen -anyway. Of the `Stage`, a live tape needs only the read side — `idle`, `command`, `chordFor`, -`commandTitle`, `fastForward` — never `begin`, `end`, `keyframe` or the snapshot hooks, and a -tape with a keyframe in it is refused. - -#### Accessible names - -Not AccessKit. dvui builds its AccessKit tree only when built with it (fizzy's default is -`-Daccesskit=off`), never on the web or testing backends (`dvui.accesskit_enabled`), and only -while an assistive technology has switched it on. A snapshot that exists only while VoiceOver -runs is no use to a test. - -What is there instead: every widget's `Options.role` and `Options.label` as it registers -(`WidgetData.register`), and dvui main already has a machine-readable widget-tree dump -(`dvui.debug.captureFrame` / `dumpFrame`: ids, parents, rects, subwindow, focus, visibility, -call site) — on every backend, armed for one frame, free when not. It records neither role, label -nor tag. Adding those three fields, plus the text a label widget shows (a button's name is the -text of the label inside it), is a small, upstreamable dvui change and gives the snapshot a -whole tree: the anchors fizzy marks, named `role:label`s for the rest, and the structure between. -The snapshot no longer waits on the recorder; whichever lands first brings the names. - -What it shows about fizzy: 17 anchor marks, 20 `role`s and 4 `label`s across `src/`, `core/`, -`app/` and `plugins/`. dvui's own widgets set roles (a button is `.button`), but an icon button -with no text has no name a person or agent can use. Labelling them is accessibility work worth -doing for its own sake, and is what makes the snapshot useful past the anchored widgets. - -### `state` — service - -The app's model as data: the declarative shape a keyframe has (files, open documents, layout, -settings, focus by anchor) written as ZON, plus each document's owner state where it has one. -Not the in-memory seek snapshot, which is opaque and dies with the session. Consumers: bug -reports ("attach the session"), a session-restore that is more than the open-file list, -asserting in a test. - -### `frames` — service - -`capture(rect or whole window, callback)`: the next presented frame as PNG bytes, delivered a -frame later. Consumers: a Screenshot command, thumbnails, test assertions, images for the docs -and the site. - -### `log` — service - -Subscribe to the output log — level, source, line — from any thread. Consumers: a plugin's own -output pane, a test asserting nothing warned (the integration suite already fails on any log -output). - -### `plugins` — service - -What is loaded, at what version and from where, and what happened to each load and reload: -reloaded, refused (fingerprint, missing symbol), documents reopened or skipped. Today that is a -line in the log. Consumers: the plugin store UI, a plugin author's build loop, an "it didn't -pick up my rebuild" diagnosis. - -## In the framework, not the SDK - -These decide where plugins come from, so a plugin cannot provide them. - -- **Profiles.** `--profile ` (and `FIZZY_PROFILE`): the config directory, the plugins - directory, the single-instance lock, recents and the runtime directory, all under one root. - Before it, a sandbox meant moving `HOME` (CONTRIBUTING.md) and `TMPDIR` (the lock lived there), - and a plugin build installed into the real plugins dir unless `HOME` was moved. Consumers: every sandbox recipe, the integration - tests, portable installs, two configs side by side. Built: `app/profile.zig`. The root *is* - the config folder (`/plugins`, `/settings.zon`, …), `/run` is the runtime - directory, and the lock is named for the profile (`.p`), so it is its own on - Windows too, where a named pipe has no directory. A socket path too long for a socket address - (104 bytes on macOS) falls back to the temp directory under that same name. A plugin's - `zig build` installs into `/plugins`. The runtime directory is not offered to - plugins yet. The agent plugin's socket wants it, and it will reach plugins as an `EditorAPI` - read or a `paths` service, whichever lands with milestone 5. -- **Hot reload keeps state.** Reload exists: `zig build install` of a loaded user plugin swaps - it in (`reconcileChangedPluginBinaries`), its documents reopened from disk. What it drops is - everything else the plugin held — undo history, unsaved edits (a dirty document blocks the - reload), tool and view state. An optional hook pair that hands state across — the outgoing - build writes it as bytes, the incoming one reads what it understands — and the reload's result - reported through `plugins`. The per-document half exists already - (`captureDocumentState`/`restoreDocumentState`, for demo snapshots); a reload can use it first. -- **A headless harness for plugin tests.** `tests/fizzy_shim.zig` grown into something a plugin - repo can use: its plugin linked statically into a minimal host (fizzyedit/example-app is the - shape), a headless window, then open a document from bytes, run commands, play a tape, step - until settled, and assert on anchors, state or a frame. Exposed as a build step beside - `fizzy.plugin.create`. Consumers: every plugin's `zig build test`, and the plugin-build-action - CI. Of everything here this is the largest piece, and the one that most changes what writing - a plugin is like. - -## Running with no display (spike) - -Can fizzy run on Linux with no display: a CI runner, a server, a remote agent's machine? Two -ways were open: -SDL with a video driver that needs no display, or fizzy hosted over dvui's testing backend, the -way `tests/integration.zig` already draws pieces of it. - -**Over the testing backend: yes, the whole app.** `tests/integration.zig`, "headless: …", brings -fizzy up exactly as `Entry.AppInit` does: real `Editor.init` and `postInit`, the bundled plugins -registered, frames through `Entry.AppFrame` (demo player, frame target, editor). Then it opens a -`.md` from a project folder, plays the bundled tour to its end, and tears down. The profile is a -fresh temp directory (#255), so nothing of anyone's config comes in. In a Debug build: up in -about 130 ms, the document open in about 50 ms, the tour in 714 frames and about 2 s. The testing backend's clock moves 100 ms a step, so a minute of demo runs about -33 times faster than it plays. That is wishlist item 8 (time under the agent's control) for free. - -- **Plugin dylibs load into it.** `plugins/archive` built with `FIZZY_PROFILE= zig build` - (#255) was picked up by the ordinary user-plugin scan and registered beside the bundled ones. - The dvui context injection and the fingerprint check hold against a testing-backend host. -- **A dropped-in plugin is undecided until someone says yes.** It loaded only once the - profile's `settings.zon` said `.plugins.archive = .{ .enabled = true }`. That is the person's - consent, and a sandbox recipe has to write it ahead of time. Kept as it is: an agent's - `plugin.build` writes it into the sandbox profile it owns, never into the person's. -- **A reused profile restores its last session.** A second run in the same profile reopened the - first run's documents, from a temp project that no longer existed, and logged the failure. - That is session restore working as it should. A sandbox wants a fresh profile per run, or a way - to start without restoring. That is a wish below, not a bug. -- **What it does not have: pixels.** The testing backend draws nothing (`drawClippedTriangles` - is empty, `textureCreateTarget` fails, so the frame target draws straight through), and - `dvui.Picture` is unsupported. Nor are there OS dialogs, native menus or pop-out windows: those - are backend calls with nothing behind them. The clipboard is in memory. -- **Input is dvui events**: `dvui.testing`'s, or the `Player`'s sink. The same path a live tape - takes (milestone 3), so the `LiveDriver` works in it unchanged. - -**SDL with no display: not tried yet.** What it would take: SDL3's `offscreen` video driver (EGL -pbuffers) or `dummy`. Fizzy's default renderer is its own SDL_GPU backend (Vulkan on Linux), so -it would need a software Vulkan driver such as Mesa's lavapipe. Alternatively, -`-Dnative-backend=sdl3` with `SDL_RENDER_DRIVER=software` on the `dummy` driver draws into -memory, and `SDL_RenderReadPixels` reads the frame back. Either one gives pixels; either one -costs system packages, real-time pacing and a renderer that is not the one people see. - -**So:** the headless host is fizzy over the testing backend. It is the path the integration -tests already exercise, it needs nothing installed, it is deterministic, and it runs at the -speed of its own clock. It becomes milestone 6's harness and the agent's sandbox. Pixels are a -separate, later question with a named seam: the `frames` service answers "unsupported" under the -headless host until a renderer exists. That renderer is either a software dvui render backend -(`dvui.enums.RenderBackend` already has the slot) or the SDL software route above. - -The test build has a `bundled_plugins` module for it, so `Editor.postInit` has its plugins -(`build/app.zig`). What a product needs besides: an entry -point that runs this loop outside a test (a `fizzy-headless` executable, or a mode of the -harness), and tapes loaded from a file. - -Wishes from it, for "What an agent wants" (#252): - -- **Start without restoring the session**, for a reused sandbox profile: a flag or a profile - setting. -- **Approve a plugin in a sandbox profile in one step**, rather than editing its - `settings.zon` by hand. - -## The SDK tarball - -`fizzy-sdk-v*.tar.gz` ships an `AGENTS.md` — the four-file shape, the rules that bite, how to -build, test and load a plugin — so a third-party plugin repo starts with it. Most of it exists -already as the `fizzy` skill and CLAUDE.md; this is a copy that travels with the SDK, kept by -the same PR that changes what it describes. A person starting a plugin reads it too. - -## Safety - -- Nothing listens unless the agent plugin is installed, and it listens only on a local socket - only the user can open, behind a token. -- An agent reaches only what a person can: offered commands, and input through anchors. -- A person's input interrupts it, and the infobar says when one is connected. -- Commands with arguments let any plugin drive any other's commands with input. That is already - true without arguments; `docs/PLUGINS.md` §3.4 says so once the seam lands. - -## The web - -The agent plugin is desktop-only to start: a wasm side module cannot open a socket. Nothing in -the seams assumes a transport, so a web variant — the page's `postMessage`, a devtools bridge — -can come later as another plugin over the same services. - -## Milestones - -Each lands on its own and is useful without the next. - -1. **Command parameters and results**, with listing functions; the palette prompts for arguments - (the non-agent proof). Moves the fingerprint. Built in #251: `Command.Params` / - `Arg` / `runWith` / `Call`, `Host.callCommand` and `CommandOutcome`, `commandCount` / - `commandAt`, `EditorAPI.askCommandArguments`, `sdk.Command.answer`, the palette's argument - step, and `text.goToLine`. The rest of this seam, none of it moving the fingerprint again: - keybinds that carry arguments (`keybinds.zon`, #253) and a tape's `command` op with arguments - (#254). -2. **Profiles.** #255: `--profile ` / `FIZZY_PROFILE` (`app/profile.zig`; see "In the framework" above). -3. **The `automation` service**: anchors on request, settled, and live tapes — the dvui sink - factored out of the `Player` first, then the `LiveDriver` on it. Role, label and tag in dvui's - frame dump (a fizzy-dev patch), the snapshot over it, and a pass labelling fizzy's icon-only - buttons. First consumers: plugin tests, and plugin demos - (AUTOMATION_PLAN milestone 6) through the same anchors. So far: the sink as - `automation.Input` (#257); the `LiveDriver`, and anchors on request as `core.anchor.want` - (#258); the service (`play`, `stop`, `outcome`, `settled`, #261) and a live tape's pointer - (#263); the frame-dump fields (tag, role, label, and a label's text), patch 4 of - `foxnne/dvui-dev`'s stack, tagged `fizzy-sdk-0.2.19` (#274, `docs/DEPENDENCIES.md`) — - `settled` needed no fork patch, it reads `Window.end`'s return; icon-only controls labelled, - held by a headless test (#275); the dvui half moved to `sdk/replay/`, dvui and `tape` only - (#276). In review: the snapshot, through the service as `snapshot` / `snapshotText` (#277), - and `replay` for any dvui app, with a plain overlay and `examples/replay-app` (#283). Left: - the demo transport bar's buttons, which have no widgets to name. - Between this and the next: **an SDK release**, so store plugins (pixi first) can register - commands with arguments — they merged after `sdk-v0.2.18`. -4. **`state`, `frames`, `log`, `plugins`.** `plugins` reports each stage of a rebuild's loop, - timed ("Fast enough to watch"). -5. **`fizzyedit/agent` and `fizzy-mcp`**, desktop. Everything it needs exists by now; if it - needs anything else, that is a missing seam to add here, not a reach into fizzy. -6. **The headless plugin-test harness**, and `AGENTS.md` in the tarball. Over dvui's testing - backend ("Running with no display"): the whole editor already runs there. -7. **The chat surface, over ACP**: any ACP agent as a subprocess, handed fizzy's MCP server; - edits applied to open documents as diffs, permission prompts as dialogs. -8. **The development window**: the agent's plugin built into a development profile and run in - a second fizzy beside the person's, driven and checked there ("The whole loop"). - -## Decisions - -- **A plugin, never a feature.** Nothing names agents in `src/`, `app/` or `sdk/`. -- **Each seam's doc comment names its non-agent consumer.** -- **Services over `Host` members** wherever a plugin consumes rather than contributes: optional, - replaceable, versioned on their own, and no fingerprint move. -- **Bytes across the boundary, types on each side**, for command arguments and results — and the - bytes are **ZON**, like every other piece of data fizzy writes. The MCP bridge converts to and - from JSON at its edge; nothing inside fizzy sees JSON. -- **Live tapes get their own driver**, sharing the dvui sink with the `Player`, never a mode of it. -- **Accessible names come from widget options through dvui's frame dump**, not from AccessKit. - The dump's role, label and tag fields are a patch on the fizzy-dev stack - (`docs/DEPENDENCIES.md`), written to be upstreamed later. -- **Fizzy labels its own widgets** as part of milestone 3: every icon-only button gets a - `label`, so the snapshot (and a screen reader) can name it. -- **No back door.** Agents change the model only through commands, and the UI only through - input. -- **Arguments travel as ZON text wherever a command is named** (#253, #254): a tape's - `command` op is `.{ .id, .args }`, with `args` the text `Host.callCommand` takes, and a - keybind's `.args` is ZON written in place and kept verbatim. Neither the tape library nor the - keymap parses them; the command says whether they fit, when it runs. -- **A binding with arguments is a use of a command, not its shortcut** (#253): menus, the - palette and the Keyboard Shortcuts pane show and edit only the binding without. -- **A profile's root is its config folder** (#255), not a folder holding one: every path fizzy - keeps was already relative to the config folder, and a profile made from an existing one just - works. The lock is named for the profile, so it is its own on every OS. -- **The headless host is fizzy over dvui's testing backend** (#256), not SDL without a display: - no system packages, deterministic, and its own clock. Pixels come later, behind `frames`. -- **A live tape that loses its place stops** (#258): a wait that gives up, or a person's input, - ends it and says before which op. A demo carries on, and that difference is why they are two - drivers. -- **Anchors are asked for, a frame at a time, by anyone** (`core.anchor.want`, #258), through - `core` rather than a service call: the request already crosses dylibs through the window's - data. - -- **MCP outward, ACP inward** (2026-10-09): fizzy speaks open protocols and nothing - vendor-specific; any agent comes in through one or the other, an adapter in its own plugin - where it speaks neither. No browser library. -- **A plugin's agent API is its commands.** Anything a person can do in a plugin is also a - command with arguments; agents get tools from the registry, and plugins write nothing for them. -- **Performance first.** Nothing agent-shaped costs a frame while no agent is there, nothing an - agent does blocks one, and every stage of the build loop is measured before it is called fast. -- **A plugin under construction runs in its own process** (the development window), never in the - person's session. - -## Open - -- What a reload carries beyond documents, and whether that hook moves the fingerprint now or - waits for the next batch. -- The rebuild loop's budget per stage, now that it is measured ("Fast enough to watch"), and the - same measurement on Linux and Windows. -- How the agent plugin reaches the development instance: a second socket in its profile, or the - person's instance relaying. diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index d85650f6c..b7c93fb0e 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -19,7 +19,7 @@ the `tape` library (`sdk/tape/`): std-only, no window, shipped with the SDK so a author a demo too. The `Player`, the live driver and their input are the `replay` library (`sdk/replay/`: dvui and `tape`, nothing of fizzy's), which any dvui app can drive by filling in a `Stage`; fizzy's glass overlay and its plugin service are host framework (`app/automation/`). `src/editor/Demo.zig` is fizzy's stage and -`src/editor/demos/` its bundled demos. Where this is going: `docs/AUTOMATION_PLAN.md`. +`src/editor/demos/` its bundled demos. Where this is going: `plans/AUTOMATION_PLAN.md`. ## Playing one diff --git a/docs/AUTOMATION_PLAN.md b/docs/AUTOMATION_PLAN.md deleted file mode 100644 index 95afe0597..000000000 --- a/docs/AUTOMATION_PLAN.md +++ /dev/null @@ -1,223 +0,0 @@ -# Record and playback for dvui apps — the plan - -Where demo automation goes next: from fizzy's demo player (`docs/AUTOMATION.md`) to a few small -libraries any dvui app can take — **record** what a person does, **serialize** it fast, and **play -it back** with seeking as smooth as a video's: a scrubber that follows the finger, chapter jumps -that fast-forward rather than stutter, a viewer who can take over and hand back at any moment. -Game engines do this for replays; immediate mode makes it easier than it is for them, if we lean -into it. - -## What there is, and what it costs - -- The `tape` library (`sdk/tape/`, std-only): a `Tape` (keyframes, input ops aimed at anchors, - captions, chapters; ZON and binary), the `Sequencer`, the `Script` builder, key spelling. The - `replay` library (`sdk/replay/`, dvui and `tape` only): the `Player`, the `LiveDriver`, their - dvui `Input`, the `Stage` seam, anchors. In `app/automation/`: fizzy's glass overlay and the - `automation` service; in fizzy, its `Stage`. -- **Seeking replays silently, within a budget** (milestone 2), **from runtime snapshots** - (milestone 3). A seek goes back to the nearest snapshot the player took while playing — a cut - to the authored keyframe only when none will do — and replays the ops since, a frame each, - unseen, up to 8 ms of them per displayed frame. A seek lands in the frame it was asked in, and - the scrubber follows as it is dragged. -- **Keyframes are authored, and heavy** — cutting to one closes every document, remounts the files, - resets the layout and reopens, async loads and all — so a seek cuts to one only when it crosses - into another scene, or no snapshot of this one can be restored from where the app is. -- **No recorder**, and everything a tape aims at has to be marked by hand (`core.anchor.mark`). - -## Principles - -1. **A tape is inputs, never UI.** Immediate mode re-derives the whole UI every frame from two - things: the app's model (documents, settings, layout) and dvui's per-widget store (scroll - offsets, focus, carets, animations, text-layout caches). We record and replay only what goes - *into* that function — input events and time. -2. **Snapshot the model, rebuild the rest.** A snapshot is the app's model as data — the same - declarative shape as a keyframe — taken through the app's `Stage`. dvui's widget store is never - saved: after a restore it is rebuilt by running frames until dvui stops asking for more - (`refresh` → no extra frames needed). Immediate mode guarantees it converges; that is the - point of it. The one piece of dvui state worth carrying is **focus**, because it routes keys — - by name in anything saved, by widget id only in memory (see Decisions). -3. **Time is an input.** dvui already takes the frame's time from the app (`Window.begin(time_ns)`). - During playback the player supplies it — demo time — so animations, caret blink and timers are - deterministic, and run fast when playback does. -4. **Frames are cheap if nobody looks.** A frame that is laid out but not presented costs little; - seeking spends many of them inside one displayed frame. -5. **Name things by what they are, not where.** No pixel positions: an anchor and a point inside it. - -## The libraries - -Three small packages, layered so each is usable without the next. - -| Package | Depends on | Holds | -|---|---|---| -| `tape` | `std` | `Tape` and its ops, `Target`; the ZON and binary codecs; the `Sequencer`; the `Script` builder. Unit-tested without a window. | -| `replay` | `tape`, `dvui` | `Recorder`, `Player` (injection, interruption, the seek driver), `Anchors` (resolution and marking), the `Stage` seam, the virtual clock and silent-frame driver. | -| `replay-ui` | `replay` | An optional plain overlay: pointer, keys, captions, transport. Fizzy keeps its own (the glass one in `app/automation/overlay.zig`) on the same hooks. | - -In fizzy: `tape` is in `sdk/tape/` (plugins author their own demos with `Script`, and a plugin -contributes a demo as tape bytes — see the plugin-demo proposal). `replay` is in `sdk/replay/` -beside it: it ships with the SDK because its anchors are how plugins mark their widgets -(`core.anchor` re-exports `replay.anchor`), while the players are the host's to run. Its build -check (`fizzy-replay-tests`, under `test-integration`) compiles it with nothing but dvui and -`tape`, so it stays a library any dvui app can take. Each becomes a standalone package at -milestone 7. - -## Aiming without positions - -A target is a name and a point inside what it names: `{ anchor, x, y }` as fractions of its rect, -plus an offset in points. What a name can be, tried in this order: - -1. **An explicit anchor** — `dvui.tag` / `core.anchor.mark` (`workbench.file:`). -2. **An accessible name** — dvui widgets already carry an AccessKit `role` and `label` every frame - (a button "Save", a tab "README.md"). `role:label`, made unique by its nearest anchored or - labelled ancestor: free, semantic, survives any layout, and rewards accessible apps. -3. **A widget id** — stable within one build only; recorded for convenience, flagged on load. -4. **The window** — a fraction of it; the last resort, flagged. - -An anchor can define its own space: a canvas publishes the sprite's on-screen rect, so a fraction -is a pixel of the art whatever the zoom or pan (a pixi stroke). - -## Recording - -- **Where:** right after `Window.begin`, before any widget sees the frame — the same point the - player injects at. The recorder reads `dvui.events()` and appends; nothing is allocated per event - (a growing arena of fixed-size records). -- **Resolving a pointer event:** the innermost visible named thing under the point in the topmost - subwindow — tags first, then accessible names — and the point as a fraction of it. The event's - own `target_widgetId` breaks ties. -- **Coalescing:** text events into `type` spans with their timing; motion into glides — kept as a - simplified path (Ramer–Douglas–Peucker) only where it matters (drags, strokes), else just the - arrival; hovers that dwell (a tooltip opened) as `move` plus a hold. -- **Bookends:** a snapshot when recording starts (the tape's first keyframe), and the user's - chapters and captions added afterwards in an editor, or by `Script` over the recording. - -## Serialization - -- **ZON** stays the authoring and interchange format, exactly as now. -- **Binary** (`.tape`, `tape.binary`) for anything long — built in milestone 1: a header, a string - table holding each distinct string once (anchors repeat constantly), then fixed-size records - that index into it. Fixed records rather than varint-packed columns: reading stays one pass - with nothing to decode, and the form is already 4.5× smaller than ZON. Loading checks every - offset, index and enum and builds the `Tape` in one arena, so the sequencer and player never - know which form a tape came from. Measured (`zig build bench-tape`, ReleaseFast): ten minutes - of recording (10k ops) loads in 0.3 ms against ZON's 17 ms, and encodes in 0.7 ms. -- If recordings outgrow that, the records are already fixed-size and aligned, so a zero-copy - view over the bytes is a reader change, not a format change. The recorder keeps its records in - memory as it goes and encodes on Stop. -- Both are lossless and convert both ways (`Tape.load` takes either; a `tape` CLI step, and - `zig build demos` for the site, to come). - -## Seeking, scrubbing and chapters - -The heart of it. Three pieces, then three behaviours built from them. - -- **Silent frames** (built, milestone 2). An app frame run without presenting: `Window.end` - without the present, `Window.begin` again, the app's frame — inside the displayed frame, by - `Player.frames` wrapping the app's frame function. Its draws land in the frame's texture and are - dropped; nothing is swapped. Offered upstream to dvui as a window option once proven. -- **Snapshots at runtime** (built, milestone 3). On the first pass through a demo the player takes a snapshot at every - chapter and every few seconds of demo time (`Stage.capture`), keeping them in memory. Restoring - one (`Stage.restore`) applies it in place — replace a document's text, set its selection, - reopen only what differs — never a close-everything-and-reload. Then a few silent frames to - settle. -- **A frame budget.** Catching up runs silent frames until a budget (say 6 ms) is spent, then - presents and carries on next frame. A seek never blocks the app. - -With those: - -- **Scrubbing follows the finger.** Each frame the knob moves: the nearest snapshot at or before it - (if the way back or forward is shorter from there), then silent catch-up to the knob within the - budget. With snapshots every few seconds and silent frames at a millisecond or two, most moves - land in the same frame; a big jump lands within a few, the last good frame held meanwhile. Like - scrubbing a video, not replaying one. -- **Chapter forward is a visible fast-forward.** Play the span at a rate `r = clamp(distance / - 700 ms, 4, 24)`, frames drawn: the pointer zips, text streams in, and the app's animations run - `r` times faster too (the motion speed is published scaled, and the virtual clock agrees). Past - what fits in under a second: silent catch-up to a second before the chapter, then the visible - last second. -- **Back is a crossfade.** Freeze the current frame into a texture (the glass already reads the - frame back), restore and catch up silently, then fade from the frozen frame to the live one over - ~180 ms — or wipe it away as liquid. -- **Resuming after someone took over** is the same restore-and-catch-up, so it is seamless too. - -**Determinism**, checked rather than hoped for: on the first pass each snapshot records a hash of -the model; a seek that lands on a snapshot's time compares, and a mismatch is a bug in the tape -(an unnamed target, an unawaited load) — reported in debug builds and by the tests. - -## Performance targets - -Measured by a `zig build bench-replay` step on fizzy's tour, Debug and ReleaseFast: - -| What | Target | -|---|---| -| Recording overhead | < 0.05 ms a frame | -| Silent frame (fizzy, a document open) | < 2 ms — measured ~0.9 ms CPU (`bench-replay`: the text editor over a 400-line file, ReleaseFast) | -| Restore a snapshot, settled | < 5 ms for a demo-sized model | -| Scrub to any point of a two-minute demo | in the same frame for moves within a snapshot's span; < 100 ms worst — measured 2.5 ms mean, 5.3 ms worst (three-minute recording) | -| Load a 10-minute recording (binary) | < 1 ms — measured 0.3 ms (10k ops, ReleaseFast) | -| Anchors published while a demo runs | < 0.1 ms a frame | - -## What it asks of dvui and fizzy - -- **dvui:** a silent-frame option on `Window` (no draw, no swap); nothing else is required — time - already comes from the app, and tags and accessible names already exist. -- **The app's `Stage`** grows `capture() Snapshot`, `restore(Snapshot)` (in place) and `settled()`; - fizzy's implements them over its model (documents, layout, settings, explorer state). -- **The app loop** asks the player for the frame's time and for how many silent frames to run. - -## Milestones - -Each lands on its own and leaves the demos working. - -1. **Split `tape` out**, std-only, into `sdk/`; add the binary codec and the benchmark step. - Done: `sdk/tape/`, `tape.binary`, `zig build bench-tape`; key chords are the app's text, - checked by a `Tape.Check` it supplies, so the library needs no keymap. -2. **Virtual clock and silent frames** in fizzy's loop; seeking catches up silently within a budget. - Done: `Player.frames` runs the app's frame again inside a displayed frame while a seek catches - up, each run ended unseen; silent runs begin at the demo moment of what they apply - (`Sequencer.nextAt`) and the backend's clock is moved on to match (`clock_ahead_ns`); a glide - replayed in one frame passes along its path; `zig build bench-replay`. Two things turned out - differently from the sketch above: a silent frame *does* draw — into the frame's texture, - dropped unpresented — because icons and glass render into cached textures a frame without - draws would leave blank (on a GPU the draws are queued, not waited on, so the cost is the - CPU's layout, ~0.9 ms); and it lives in the player, wrapping the app's frame function, rather - than in each backend's loop, so it serves the SDL, callback and web paths alike. -3. **Snapshots** (`Stage.capture`/`restore`) and the determinism hash; scrubbing follows the knob. - Done: the player takes snapshots at calm moments (a scene settled, each chapter, every 3 s), - seeks back through the nearest it can restore, and checks each moment's fingerprint again - when a replay reaches it; the scrubber seeks as it is dragged. Fizzy's are in place — the - demo's files, each document's state from its owner through three new optional SDK hooks - (`captureDocumentState`, `restoreDocumentState`, `documentFingerprint`; `text` implements - them), the explorer, settings, focus — and refuse a scene they did not come from. Measured - (`bench-replay`, ReleaseFast): a random seek across a three-minute recording lands in 2.5 ms - on average and 5.3 ms at worst, against 76 ms and 225 ms from the keyframe. Not yet: a heavy - restore across scenes (it cuts to the keyframe), and snapshots of transient UI (the palette - open, a menu) — moments with one are not taken. -4. **Transitions:** visible fast-forward for chapter jumps, the crossfade back. -5. **The recorder**, with accessible-name anchors; Record / Stop in fizzy, saving ZON or binary. -6. **Plugin demos** (`registerDemo`, chapters with `requires`) and the first pixi demo. -7. **Standalone packages** for any dvui app, with a minimal example app. In progress: `replay` - is dvui and `tape` only (`sdk/replay/`, guarded by `fizzy-replay-tests`); any dvui app builds - the pair against its own dvui with `replay.modules` from the SDK package - (`sdk/replay_module.zig`); `replay.overlay` is the plain overlay (the pointer and its clicks), - which fizzy's glass one draws its pointer with; a `Stage` for live tapes needs only `idle` and - `command`; and `examples/replay-app` is a plain dvui app on dvui's own SDL3 backend that plays - a live tape into its window and prints its snapshot. Left: `replay` and `tape` as packages of - their own (their own `build.zig.zon`, outside the SDK tarball), and the plain overlay's - captions and keys. - -## Decisions - -- **Binary as well as ZON.** ZON to author and read; binary for recordings and anything long. Both - lossless, either loads through `Tape.load`. -- **Accessible names name click targets** a recorder can't find an explicit anchor for: an - anchor first, then `role:label` from dvui's AccessKit data, then (flagged) a widget id or the - window. Chapters and captions are written by people and need no such naming. -- **Focus: never a widget id in anything saved.** Widget ids hash the call site and parent chain, - so a saved one goes stale with any change to the app or its layout. A tape re-establishes focus - the way a person does — its ops click or tab into the editor after a keyframe — and a keyframe - that needs focus as it opens names it by anchor (`focus = "text.editor:demo://tour/main.zig"`), - which the stage resolves on the first frame the anchor is drawn. In-memory seek snapshots, which - live only for one session of one build, may carry the focused widget id: they restore exactly - and cost nothing to keep right. -- **Silent frames as a fizzy-side wrapper first** — no swap, wrapping the app's frame function - (`Player.frames`) — offered to dvui as a `Window` option once it has proved itself. diff --git a/docs/CLOUD_FS_PLAN.md b/docs/CLOUD_FS_PLAN.md deleted file mode 100644 index 921bb57a9..000000000 --- a/docs/CLOUD_FS_PLAN.md +++ /dev/null @@ -1,264 +0,0 @@ -# Cloud filesystems: Google Drive as a mounted `Fs` - -The goal: the web build signs into Google Drive and gets the same file explorer the native -build has over a local folder — create, rename, delete, move, open, save — with edits landing in -the cloud as they are made. Native gets the same thing beside its local disk. Dropbox/OneDrive -later are another backend behind the same seam, not another explorer. - -Two repos are involved: - -- **fizzy** — everything provider-agnostic: the mount contract (`core.vfs`: `Fs`, `Mem`, - `http.Transport`, zip), the disk behind it (`LocalFs`), the mount table and routing - (`FileTable`, `MountIo`), the two transports, a generic web OAuth popup, and the built-in - `archive` plugin. Nothing in fizzy names Google. -- **`fizzyedit/drive`** (`~/dev/fizzyedit/drive`) — the Google Drive plugin, an - ordinary third-party plugin (pixi's shape): the Drive v3 client over `core.vfs`, OAuth, and - the sign-in UI. Natively a store plugin; fizzy's web build links it in by directory - (`web_plugin_dirs`) because a browser cannot load plugins at runtime. - -## Design decisions (settled) - -1. **Path-addressed, not id-addressed.** Everything in fizzy is a path: `DocHandle` surface ids - (`.doc:`), `core.FileTable` listings keyed by directory, `sdk.services.files`, - `openFilePath`, extension→owner routing, recents, watchers. An id-addressed `Fs` (what - drive started as) forces a second code path into every one of those. So `Fs` speaks paths, - and the Drive backend keeps the path→id map to itself. In fizzy a cloud path is - self-identifying by scheme: `gdrive:///Notes/todo.md`; the local disk is "the empty - scheme". An `Fs` itself sees only the part after the mount, rooted at `/` — the host strips - `gdrive://` — so drive never learns fizzy's naming and `Mem` is reusable as-is. - Duplicate names inside one Drive folder (Drive allows them) resolve first-listed-wins. -2. **Async, completion-based.** wasm32-freestanding is single-threaded and cannot block on - `fetch`, so a synchronous `readFile() -> []u8` is unimplementable on the very target this - exists for. Every `Fs` op is start → complete-later; completions are delivered on the caller's - thread from a per-frame `pump()`. Native backends may run the request on a thread (or - complete inline), the web backend completes from the JS callback exports. This is the shape - fizzy already uses for `FileLoadJob`, `pollWebFileIo` and `web_fetch`. -3. **A path index that fills lazily, then deltas.** The Drive backend lists a directory once - (`files.list` with `'' in parents`) and records every child's id/kind/size/mtime under - its path, so `stat` and path→id resolution for anything beneath a listed directory cost no - further requests; resolving a cold path lists its ancestors in turn. Per-directory listing is - the base because it works under every scope — with `drive.file` and a picked folder it is - not guaranteed that descendants are granted, and a whole-drive `files.list` would hide that. - `changes.list` with a stored page token is the "watcher" and maps directly onto - `FileTable.invalidateListing` / `noteFileModified`; a whole-tree snapshot is an optimisation - layered on the same index later. Only file *bytes* hit the network per-file. -4. **The `drive` plugin is out of tree** (`fizzyedit/drive`), like every provider will be. - The web build, which cannot `dlopen`, links it in as app-level build data — the same - mechanism an app built on fizzy uses to bundle its own plugins — and that is the only place - fizzy's repo names it. -5. **Tokens live in the host, never in drive.** Native: desktop-client OAuth with PKCE, system - browser + `std.Io.net` loopback, refresh token persisted in `settings.zon`. Web: Google - Identity Services token client in `index.html` (Google will not do a browser PKCE code - exchange for a Web client without a secret) — 1-hour access tokens, silent re-request. Both - hand Zig a bearer string; drive sees `Authorization: Bearer …` and nothing else. - -## Roadblocks found up front - -| Roadblock | Status | -|---|---| -| `Fs` is id-addressed | Fix in drive (step 1). | -| `Fs` and `http.Transport` are synchronous | Fix in drive (step 1). Unimplementable on wasm otherwise. | -| `fizzy_web_fetch` is GET-only, no headers, no body | Generalize to `(method, url, headers, body)` in `web/index.html` + `app/store/web_fetch.zig` (step 4). | -| Native HTTP / TLS | Already works: `std.http.Client` in `app/store/plugin_repo_asset.zig`. | -| Native OAuth loopback | `std.Io.net.IpAddress.listen` + `std.http.Server` exist in 0.16; `dvui.openURL` opens the browser. | -| Web OAuth | GIS token client, JS side. No refresh token on web — accept re-prompt after an hour. | -| Windows path joins | `std.fs.path.sep_str` would produce `gdrive://acct\Notes\x.md`. Add a scheme-aware join to `core.paths`; audit the 8 `isAbsolute` call sites. | -| Document owners write disk themselves | `text`/`image` `Document.zig` call `cwd().writeFile`. Add a bytes-based save hook beside the existing `loadDocumentFromBytes` so owners never touch storage (step 3). Third-party owners (pixi) need the same change to save to a cloud path. | -| Atlas on a cloud vault | Out of scope here: atlas is SQLite-backed and walks the vault with `std.Io.Dir`. The mount layer is the prerequisite; atlas needs its own pass. | -| Google Docs / Sheets | Not bytes. Listed with `kind = .file` but reads return `error.NotBinary`; the explorer shows them, opening explains why. | - -## Steps - -Each step leaves both repos building and tested. Order is chosen so the first three are -verifiable without a Google account. - -### 1. drive: reshape `Fs` (path-addressed, async) — DONE - -Landed as drive `pxvpqtnz`. 18 tests, `check-wasm` links. - -- `Fs` vtable over `/`-rooted, `/`-separated paths within the mount. Ops: `listDir`, `stat`, - `readFile`, `writeFile`, `createFile`, `mkdir`, `rename` (covers move: a different parent in - `new_path`), `remove`. Every op takes a - completion callback + ctx and returns a `Job` handle; `cancel(job)`; `pump()` drains - completions. Errors split `Unauthorized` (refresh and retry) from `Forbidden`. -- `http.Transport` gets the same treatment: `request(req, on_done)` + `pump()`. -- `Mem` backend completes inline (in `pump`) so tests stay synchronous-looking. -- Drive backend: lazy path index, byte read/write (`files.get?alt=media`, `files.update` - media upload), metadata-only `files.create` for empty files and folders (no multipart needed: - fizzy's `createFile` creates empty and `writeFile` fills), `files.update` with - `addParents/removeParents` + `name` for rename/move, `trashed=true` for remove. - `changes.list` polling follows once the mount exists in fizzy (step 5 drives it). -- Canned-transport tests port over; add path resolution, duplicate-name and write-path tests. - -### 2. fizzy: mount table + `FileTable` routing — DONE - -- `core.vfs` (`core/vfs/`): the contract, `Mem`, `http.Transport`, zip. Fizzy's own; the SDK - has no dependency for it. -- `core.LocalFs`: the disk behind `vfs.Fs`; on wasm every op is `Unsupported`. -- `core.FileTable`: `mount`/`unmount`/`resolve`/`pump`; `listDir` on a miss asks the path's - filesystem and pumps it, so the disk still answers in the call while a cloud mount returns - null until its completion installs the listing and fires `Env.refresh`. Mutations complete - through a callback (`FilesService` moved its document bookkeeping into it). `isDir` on a - mount answers from the parent's cached listing. -- `Host.mount(prefix, fs)` / `Host.unmount(prefix)` (fingerprint bumped, SDK still 0.2.0). -- `Editor.tick` pumps the table before anything draws. -- Search indexes through the mount too (`FileTable.IndexJob`): the walk is a chain of - `listDir` completions, so the disk still finishes inside the `search` call while a cloud root - fills across frames — results grow as listings land, `indexing()` says when it is still - going. One index covers several roots at once (the disk beside a mount), each `search(root)` - ranking only its own. Ending a session cancels an unfinished walk. A cloud session re-walks - on every new search; the snapshot/`changes.list` work in decision 3 is what makes that cheap. -- (since resolved) cross-mount moves copy then remove; the workbench draws mounts as roots; - joins are mount-aware. - -### 2b. a zip archive as a mount (PhysicsFS-style) — DONE - -- drive `zip.zig`: by-hand reader over bytes (`std.zip.Iterator` wants a `File.Reader`; - the archive arrives as bytes on the web; std's own `EndRecord.findBuffer` does not compile) - and a store-method writer; `Mem.generation` for dirty tracking. -- `plugins/archive` (built-in, static in exe + web): owns `.zip` as a *document type*, so the - existing open paths do the work — the web "Open Files" picker, a double-click in the tree, - argv. Registering the document mounts its tree at `zip://`; the tab is the mount's - handle (name, dirty state); saving the tab packs the tree back (to the file natively, as a - download on the web via the new host-side download for any owner with `documentBytes`); - closing it unmounts. -- The workbench draws every mount as a root beside the project folder — one filter, one tree - code path, per-root menus (a mount has no "close project"/"reveal") — on the web too, where - the empty state now says a `.zip` opens as a folder. Its four "does this exist" probes and - its `Dir.walk` now go through `FileTable.exists`/listings, which is what let the tree code - compile for wasm at all. -- Verified in the browser end to end: upload `demo.zip` → root appears → open `hello.txt` - from inside it in the text editor → edit → ⌘S writes into the mount and the archive tab goes - dirty → ⌘S on the archive tab → the download is a valid zip with the edit and the empty - directory intact, tab back to clean. Natively: `fizzy demo.zip` mounts and lists it. -- Fixed along the way: a document tab titled by `basename(surface id)` showed - `owner.doc:name` for any web upload (no separator in a bare name). - -### 3. fizzy: documents read/write through the mount — DONE - -- `src/editor/MountIo.zig`: `openFilePath` on a mounted path reads through the mount and opens - from the bytes (`loadDocumentFromBytes`, which every owner already has for the browser - picker) — on the web build too, where the disk path bails. Save, Save All, Save As and the - quit save-all serialize through two new SDK hooks, `documentBytes` + `documentWritten`, and - the host writes through the mount; the owner hears back only when the write lands (Save As - adopts the path there). `Editor.docSaving` counts an in-flight mount write so a quit waits - for it. An owner without `documentBytes` gets one toast rather than a silent no-op. -- (since resolved) the host writes **every** document whose owner has `documentBytes`, disk - included — one save path per backend, the disk answering inline. `owner.saveDocument` is - the fallback for owners without the hooks and can only reach the disk. Disk opens keep - `FileLoadJob` (a worker thread), a performance path that ends in the same - `loadDocumentFromBytes`. `MountIo` became `DocumentIo`. -- `core.paths` is mount-aware (`mountPrefixLen`, `normalize`, `normalizeJoin`, - `isNormalizedAbsolute`): a `gdrive://` path is normalized after its prefix and never - joined onto the cwd. argv dispatch hands a mount path straight to the file sink instead of - `openDirAbsolute`-asserting on it (found by running it). -- `text` implements the hooks (format-on-save applies); `image` is read-only and needs - neither. Verified in-app with a temporary `Mem` mount: `fizzy mem://demo/notes/hello.txt` - opened the document with its contents. The write half is covered by the `LocalFs`/`Mem` - tests, not yet driven in-app. -- Not done: `reloadDocument` / the document watcher are disk-only (a mount has no watcher until - `changes.list`); conflict detection on a mount is therefore "last write wins" for now. - -### 4. the two `http.Transport`s — DONE - -- `core.transport.Web` (wasm): a new `fizzy_web_request` import in `index.html` — method, - `Name: value` header lines, body — answered through `FizzyWebRequestAlloc/Ready/Failed` - with the status. The store's GET-only `fizzy_web_fetch` is untouched. The exports are kept - live by a reference in `Editor` so the page has them whether or not a cloud plugin is - bundled. -- `core.transport.Native`: one `std.http.Client.fetch` per request on its own thread, results - parked under a spinlock and delivered from `pump`; `wake` (the host's refresh) runs the next - frame. Tested against a loopback `std.http.Server` (`fizzy-native-transport-tests`): method, - auth header, body and status round-trip; cancel joins the worker and leaks nothing. -- The web transport's first real exercise is the Drive plugin's first `files.list` (step 5). - -### 5. the Drive plugin (`fizzyedit/drive`) — BUILT, first contact with Google made - -- `fizzyedit/drive/plugin.zig`: settings (desktop client id + secret, web client id, root folder id, and - the refresh token / account Sign In writes back), commands `drive.sign_in` / - `drive.sign_out`, a File-menu section (in-app + native), and a per-frame `beginFrame` that - pumps the plugin's own requests, polls the loopback and refreshes the token two minutes - before expiry. A saved refresh token signs in silently at the next launch. -- Native flow (`src/oauth.zig`): PKCE (S256), the system browser, a one-shot loopback - `std.http.Server` on `127.0.0.1:` that checks `state` and answers the browser tab, the - token exchange and refresh as form POSTs through `core.transport.Native`, then - `about?fields=user` to name the mount `gdrive://`. The listener is tested against a - real `std.http.Client` (correct and forged `state`). -- Web flow: Google's implicit grant through `core.transport.WebOAuth` — a provider-agnostic - popup that lands on fizzy's `oauth-callback.html` and posts the redirect's query/fragment - back to wasm. `prompt=none` re-request before expiry. The same `about` → mount path follows. -- First real run (desktop): sign-in completed and the refresh token was saved; the `about` - call then returned 403 *API not enabled* — README step 2 had been skipped. `Full Drive - access` setting added because `drive.file` shows an empty drive on the desktop. -- Not yet: the explorer's web empty state still says "Open Files", not "Connect Google - Drive" (the File menu has it). Writes carry a `modifiedTime` precondition; a conflict is a toast and a dirty - document, not a silent overwrite. -- Publisher setup, step by step: `~/dev/fizzyedit/drive/README.md`. - -### 6. Verification - -- `zig build test` in both repos; `zig build check-wasm` in drive; `zig build check-web`, - `test`, `test-sdk-version` in fizzy after the vtable change. -- Native end-to-end against a real Drive: sign in, explorer shows the granted folder, create / - rename / move / delete / open / edit / save, confirm in drive.google.com. -- Web end-to-end on `zig build web` served locally with the localhost JS origin registered on - the Web OAuth client. - -## The 2026-09 review (`docs/REVIEW_2026-09.md` §5), folded in - -Fixed, with tests where the review named one: **D1** in-flight listing vs. `forget` (no `.?` -on the index; a forgotten ancestor fails that listing), **D2** cancel during delivery -(`Completions.drain` marks a later job of the batch skipped; the Drive client, `Mem`, -`LocalFs`, both transports honour it), **D3** refresh storm (failure backs off a minute; a -400/401 refresh signs out), **D4** a failed mount listing is remembered for the TTL, **D6** -edits during an upload stay dirty (`savedBytes` records the op id), **D7** Save As onto a -mount creates then writes (`Mem.writeFile` is create-or-replace too), **D9** cancel never joins -a blocking fetch (the worker owns an orphaned job; tested at <100 ms with a held connection), -**D10** the loopback accepts until the request carrying `state` (idle/favicon connections are -404'd; tested) and closes once, **D11** zip offsets in `u64` with a 1 GiB inflate budget -(tested), **D12** `Mem.rename` into its own subtree refused, **D13** `isDir` on a mount root, -**D17** unique archive prefixes, the header CR/LF injection, the same-folder Drive rename -(Google rejects `addParents == removeParents`). The layering/path-pin/GIS-outside-the-frame -points went away with the move out of tree. - -Also fixed since: **D15** a mount's completions land only on the host's per-frame pump, never -inside a draw-time call (the disk still answers in the call); **D8** `Env.unmounting(prefix)` -lets `MountIo` cancel loads and saves against a mount before it goes (tested); **D5** a -document whose save did not land stays open and aborts the quit; **D14** `core.paths.join` is -mount-aware and every join in the tree and the files service uses it. - -Since then: `settings.Value` has `.secret` (the pane masks it; the Drive plugin marks the client -secret and refresh token), and the native transport shares one `std.http.Client` (connection -pool, one TLS handshake per host). - -Since then: `Host.getSecret`/`setSecret` — the seam for credentials, backed today by -`app/Secrets.zig` (`/secrets`, `key=base64` lines, created `0600`, written whole and -renamed into place; tested). The Drive plugin keeps its refresh token there and moves an old -one out of settings on load. OS keychain backends slot in behind the same two calls. - -Still open, in the order they should be taken: -- Keychain / libsecret / DPAPI backends for `Secrets` (the file store is the fallback). -- (done) `vfs.Fs.readFile` returns the file's modified time with the bytes and `writeFile` - takes `if_unmodified_ms`; `MountIo` remembers each open document's and passes it on save - (a stat after a successful write refreshes it). The Drive client checks Drive's live - `modifiedTime` before uploading; `Mem` and `LocalFs` compare theirs. A `Conflict` leaves the - document dirty with a toast; the next save from the user overwrites. Proper reload/merge UI - is still to come. -- (done) a move across mounts (`FileTable.MoveJob`) copies the tree entry by entry, then - removes the source; nothing is removed until every copy landed. Tested disk↔`Mem` both ways. -- (done) Google's own folder picker is the folder chooser on both targets (`api_key` in - `credentials.zon`): the web build opens the plugin's `web/picker.html` (copied to - `plugins/drive/` by the web build, reached via `WebOAuth.pageUrl`) through the same popup - round trip as sign-in; the desktop serves the same page from the loopback listener and - opens it in the system browser. The picked folder re-roots the mount - (`gdrive:///`); "Open Google Drive" re-roots at My Drive. The in-app - `FolderChooser` dialog is gone. -- (done in pixi's working tree, uncommitted there) pixi implements `documentBytes`/ - `documentWritten` over its existing encoders, so a `.pixi`/`.png` on a mount saves. atlas, - ghostty and zig own no documents. - -## Out of scope for this pass - -Dropbox/OneDrive backends (the seam is the deliverable; a second backend is the proof it -holds), atlas on a cloud vault, offline edit queueing, shared-drive (`drives.list`) support, -Google-Docs export. diff --git a/docs/DEPENDENCIES.md b/docs/DEPENDENCIES.md index 0a0645ad3..1f66416f2 100644 --- a/docs/DEPENDENCIES.md +++ b/docs/DEPENDENCIES.md @@ -55,7 +55,7 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov string through the new `Debug.captureText`. A dump can then name widgets without AccessKit, which is never built on the testing or web backends. A button's name is the text of the label inside it; an icon-only button's is its `label`. The agents plan's widget snapshot is - built on this (`docs/AGENTS_PLAN.md`, "Accessible names"). Upstream: worth proposing as is, + built on this (`plans/AGENTS_PLAN.md`, "Accessible names"). Upstream: worth proposing as is, once fizzy's snapshot uses it. Test: dvui's `zig build test -Dbackend=testing`, "dumpFrame records tag, role, label and a label's text". @@ -63,7 +63,7 @@ backend and never link SDL, so an SDL bump is never an SDK release and never mov `endRendering` drew dialogs and toasts and then replayed every subwindow, with nothing between. `drawRetained` is the first half on its own, called by `endRendering` if the app did not. Popout calls it before taking subwindows into their OS windows, so a dialog exists by then - (`docs/WINDOWING_FOUNDATION_PLAN.md`). Upstream: branch `window-draw-retained`, to propose. + (`plans/WINDOWING_FOUNDATION_PLAN.md`). Upstream: branch `window-draw-retained`, to propose. Test: dvui's `zig build test`, "drawRetained draws dialogs once". 6. **screensSet: floating widgets can be kept on screens besides the window** (`a90bc70`). `dvui.screensSet(rects)` lists areas besides the window that are screens of their own; diff --git a/docs/LIB_CHECKPOINT.md b/docs/LIB_CHECKPOINT.md deleted file mode 100644 index 84b0f0817..000000000 --- a/docs/LIB_CHECKPOINT.md +++ /dev/null @@ -1,376 +0,0 @@ -# fizzy-lib checkpoint - -Resume point for the "fizzy as a library" work. Written to be picked up cold by any agent or -person. **Read `CLAUDE.md` first**, then this file. Last updated 2026-09-22 at bookmark -`fizzy-lib` (SDK at 0.2.0, unreleased). - -Since the 2026-09-16 pass below, the bookmark has also gained: plugins loading at runtime in the -browser as wasm side modules (`app/store/PluginLoader_web.zig`, `web/index.html`'s `loadPlugin`) -with the store installing, updating, enabling and uninstalling them there; `core.work` (a stepped -task run on a thread natively and from the frame on the web) and `core.fs` (one seam for the -small config files, localStorage in a browser) — so `settings.zon`, `recents.zon`, `keybinds.zon` -and `layout.zon` all persist on both; the account flyout and `core.widgets.Popover`; `archive` -and `drive` beside the four bundled plugins named below; and `-Dapp-layout=.zon`, a static -shape as data (`app/layout/Shape.zig`). `docs/REVIEW_NAYSAYER_2026-09-22.md` is the standing -review of that work — its open items are the ones its "Suggested order" still lists. - -## Ground rules that are easy to get wrong - -- This repo is **jj**, not git. `jj new` *before* starting a change, `jj describe` when done, - never `git commit`. For messages with backticks use `jj describe --stdin < file` — `-m` lets - zsh execute them. Work on bookmark `fizzy-lib`; move it with `jj bookmark set fizzy-lib -r @-` - after describing. Do not touch `main`, graphl, or dvui. -- Another agent session may commit in the same working copy. Check `jj log` before assuming a - change is yours; describe only what a change actually contains. -- **The SDK is 0.2.0 and unreleased.** The ABI fingerprint may move freely under it: update - `recorded_sdk_shape_fingerprint` in `sdk/src/version.zig` from the compile error, leave the - version alone. The first release of 0.2.0 is the first release of the library-shaped SDK; - pixi, brain, ghostty and zig are rebuilt against it (not migrated) — see the queue below. -- No single-line wrapper functions; write the library call at the site. Prefer root-cause fixes - over another patch to the same mechanism; use dvui's public API over touching its state. -- **Tag a dvui commit before pinning it.** `sdk/build.zig.zon`'s pin resolves through - `/archive/.tar.gz`, which GitHub serves only while the commit is reachable, so every - pinned commit carries an immutable `fizzy-sdk-` tag in the fork (`fizzy-sdk-0.2.0` - is the first). Rebase `fizzy-dev` as freely as ever; never move or delete those tags. -- Gates after any change: `zig build`, `zig build test`, `zig build test-integration`, - `zig build check-web`, `zig build test-sdk-version`. All green at this checkpoint. -- Draw icons with `core.icon.icon` (cached texture), never `dvui.icon` (a mesh replayed every - frame). Profile with `sample` on a sandbox instance (see the perf memory notes) before - optimising anything; the frame is measured, not guessed. -- A Zig `error` **does not survive the dylib boundary with its name** (errors are integers - numbered per compilation). Never `@errorName` an error returned from a plugin vtable; the - plugin logs the reason on its side. **Settled 2026-09-22:** this is the contract, written - into `Plugin.VTable`'s doc comment and `docs/PLUGINS.md`, and the host's five offending log - lines now say "see the plugin's own log" instead of printing another compilation's error - name. A shared status *enum* would let a reason travel, but that means converting ~250 - `anyerror` sites across six repos and editing every plugin's source, for the ability to - distinguish failures nothing currently distinguishes — not worth it, in 0.2.0 or later. If a - reason must travel, send it as data. **Amended 2026-09-30 (sdk 0.2.13):** plugins had not all - been logging, so a failure read as a wrong name or nothing at all (pixi's failed JPEG import - printed `BadString`). Still no shared enum: `Plugin.error_name` (a default field, so evaluated - in the plugin's own binary) lets the SDK wrappers log the real name and return - `error.PluginFailed` — no plugin source changes. `vfs.Fs` is the one place a reason is branched - on, so its errors now cross as `vfs.Result` codes. Details in `docs/PLUGINS.md`. - -## Shape of the repo now (the part that is done) - -``` -core/ shared floor: compiled into the app AND every plugin. Widgets (Split, Tabs, Tree, - Canvas), anim, dialogs, draw, image (incl. GIF Animation), fs, paths, math, fuzzy. - May never depend on app. -sdk/ the plugin contract, crosses dlopen, fingerprinted. `sdk.core` re-exports core so a - plugin names one dependency. Host has versioned, multi-provider services - (`registerService(T, impl, owner)`, `servicesNamed`, `getServiceTyped` refuses on - `service_version` mismatch). `Surface` is the only drawable contribution. -app/ framework an application switches on; never in a dylib. `AppInfo`, `layout` - {Layout, Region, State}, `watch` {FolderWatcher, SettingsWatcher, wake}, `update`, - `window`, `single_instance`, `store` {Store, Manager, Loader, registry}. Every - place it needed to name fizzy is a `{ctx, vtable}` seam the app fills in - (PluginManager, FolderWatcher.Sink, singleton.Sink, auto_update.Hooks, DialogDirs…). -plugins/ bundled plugins in third-party shape (workbench, text, image, markdown, shared). -src/ fizzy the application: Entry, editor/ (Editor, layout.zig, panel/, - explorer/, LayoutSettings), backend/. Examples own their layouts. -build/ app build API (`wireAppModule`, `buildOptsModule` in build/sdk.zig). A consumer is an - independent package: `b.dependency("fizzy", .{ .@"app-name", .@"app-layout", … })` - and `fizzy.artifact(name)` — `examples/*` are exactly that and build standalone. -``` - -Layout vocabulary: `Layout` is per-frame (`.init(host, state, gpa, arena)`), `State` persists -(regions registry double-buffered: `regions` = last completed shape, `regions_building`; -`publishRegions()` after the shape). `Layout.region(...)` returns a `Region` (a box: scope it, -`defer r.deinit()`); `Layout.split(...)` a `Split` (end before the next pane). Every region with -keywords registers, resizable or not. A surface draws where a region **accepts** its keywords -(`sdk.keywords.accepts`, asymmetric; a region nested in another qualifies its keywords with the -enclosing region's name — see arc (a) below). - -## Verification workflow (what actually works on this machine) - -Run a throwaway instance beside the user's: -```sh -SB=; mkdir -p /tmp/fzsb # TMPDIR must be short: unix socket path limit -HOME=$SB/home TMPDIR=/tmp/fzsb ./zig-out/arm64-macos/fizzy & -``` -Capture its window by pid with a Swift `CGWindowListCopyWindowInfo` script + `screencapture -x -l -`. Fails (black/none) when the display is locked — fall back to headless integration tests. -Kill by pid, not `%1`. A second launch hands argv to the first (singleton) and exits. - -## Landed: region-centric surface placement - -- Model: `State.assignments` (region name → surface ids), persisted in `layout.zon` as - `SavedRegion {name, extent?, surfaces?}`; keyword matching is the default, an assignment - overrides wholesale; `Layout.matching` answers from it, `unplaced()` reads the registry. -- `app/layout/Picker.zig`: the surface picker, on `State` (`openPicker(name, anchor)`), drawn - once per frame by the app after the shape (`layout.captureUnplaced(); state.picker.draw(&layout)`). - Cards carry **snapshots**: a placed surface is photographed where it draws on the next - frame (`Layout.draw` → `drawCaptured`, through `dvui.Picture` — a bare render-target switch - misses dvui's deferred draw queues); an unplaced one is drawn offscreen for 10 warm-up - frames, then photographed (first-frame layout is unsettled and reveals start hidden). - `Layout.drawn` decides "drew nowhere". Snapshots are discarded on close/reopen. -- `Region.init` draws a corner button (hover near top-right) that opens the picker for that - region — framework, every app gets it. -- Settings > Layout > Regions is a table by region that opens the same picker. - -Remaining from this arc: none. The endless-handles example landed (see below). - -Known gaps: sidebar surfaces still read fizzy's `explorer.scroll_info` through `EditorAPI` -(`explorerViewportWidth`), so an offscreen photograph of one is taken against the live -sidebar's width; fine today, and one more reason to peel that API. The picker's popup is -`dvui.popup`, which centres when unanchored and clamps to the window otherwise. - -## Next arc after placement: plugins declare regions, documents are surfaces (agreed 2026-09-11) - -Workbench's private subdivision of Main (workspaces, tab groups, tab drag/drop) and the app's -regions are the same idea at two depths. Unify them: - -- A tab group *is* a region accepting `document`; its tabs are `matching(region)`, the active - tab is the region's existing selection. Opening a file = registering a surface - (`.doc:`) and assigning it to the active document region; moving a tab = - reassigning; a split = a new region; the same doc in two groups = duplication; session - restore = the assignment list. -- Cost: the region registry moves from `app/layout/State` into `Host` beside surfaces so a - plugin can declare one across the dylib boundary (vtable on Host: declare region, read/set - extent — an ABI fingerprint bump, fine while unpublished); surfaces become dynamic - (register/unregister per open file); workbench's Workspace/tab-group code is replaced by - regions + assignments — the big rewrite and the one that pays. -- Order: (a) keyword semantics; (b) registry → Host, plugin-declarable; (c) workbench tab - groups → regions, documents → surfaces. **ABI breakage is agreed** (2026-09-11): nothing - ships until all three land and the out-of-tree plugins are rebuilt, so (b) need not contort - itself to keep the fingerprint. - -### (a) Keyword semantics — **done** (`sdk/src/keywords.zig`, `app/layout/`) - -Keywords no longer just *attract*; a region **accepts** a surface, asymmetrically, and a keyword -may name a place inside another (`main.document`). `keywords.Fit` orders the relation: - -| Fit | Region | Surface | Meaning | -|---|---|---|---| -| `exact` | `main.document` | `main.document` | the same word | -| `kind` | `main.document` | `document` | the shape says where the plugin's kind lives — the ordinary sub-region case | -| `place` | `main` | `main.document` | the sub-place does not exist here, so the enclosing region takes it — a plugin written for a nested shape works in a flat one | -| `none` | `main` | `document` | unrelated. Segment boundaries only: `mainly` is not inside `main` | - -- `accepts(region, surface)` / `strength(region, surface)` replace `intersects` everywhere the - question is "does this surface belong here" (`Layout.matching`/`unplaced`, `Surface.matches`, - `Host.selectedSurface`). `intersects` survives for *vocabulary lookup* — `State.regionFor`, - where a command holds `ide.sidebar` and wants whichever region speaks it. -- **Nesting qualifies automatically.** `Layout.Container.prefix` carries the enclosing region's - first keyword (not all its synonyms — `accepts` reaches the sub-place by kind anyway), and - `Region.init` runs the child's keywords through `State.qualify`. A container region with no - keywords of its own is not a place: it passes its parent's prefix through, which is why - fizzy's own shape (`work`/`content` are bare boxes) is unchanged. - `qualify` **interns** — the registry is read a frame later than it is written, so arena - strings would dangle; it also leaves an already-qualified word alone (no `main.main.document`). -- **The more specific region claims the surface** (`Layout.claimedElsewhere`), so a document pane - inside Main takes what Main would otherwise also draw behind it. Only a *strictly* stronger - claim wins: equal acceptance still means both regions show it (icon rail + sidebar), and a - region with an explicit assignment does not claim at all. Ties are a visible ambiguity the - picker can fix, which beats silently emptying the loser. -- Tests: `sdk/src/keywords.zig` (fit table, synonyms don't stack), `tests/integration.zig` - ("a nested region qualifies its keywords, and keeps them across frames", "the more specific - region claims a surface, an equal one shares it"). - -### (b) A plugin can declare a region — **done** (sdk 0.1.63, fingerprint `0x36440c0ce10a6c97`) - -The registry stayed in `app/layout/State`; what moved is the *ability to declare*, as three -vtable calls, because a region is the app's — it registers in the app's registry, persists its -size and assignment under the app's name, and answers the app's picker. - -- `sdk.RegionSpec` (name, unqualified keywords, `shows`, `dir`, `key`, `hide_when_empty`, - `expand`, `min_extent`) + `EditorAPI.beginRegion`/`drawRegionContents`/`endRegion`. - `Region.Shows` is now an alias of `RegionSpec.Shows` — one definition. -- Plugin-facing: `host.region(spec) ?Host.Region`, scoped like the box it is - (`defer pane.deinit()`), with `pane.drawContents()` **where the plugin wants the accepted - surfaces** — separate from `begin` because a tab strip has to be laid out before the document - it labels. App-side that is `Region.InitOptions.manual_contents`, for a caller that cannot pass - a `content` fn pointer taking a `*Layout` it does not have. -- `Layout.beginPluginRegion`/`drawPluginRegionContents`/`endPluginRegion` hold the open regions - on a second stack (`plugin_regions`, LIFO, bounded by `max_nesting`, out-of-order close - reported here rather than as a dvui stack mismatch two widgets later). The id is this - function's `@src()` plus `spec.key`, which is why `key` must be stable and unique per caller - per parent. `Editor.frame_layout` publishes the frame's `*Layout` for the duration of the - shape and is cleared after the picker draws. -- Keywords are qualified on the way in, so a plugin **cannot name a place outside the one it was - given**: `{"document"}` declared in Main becomes `main.document`. Collision is structural, not - policed. -- Test: "a plugin declares a region inside the one it was given" — a real frame, a surface - keyworded `document`, asserting the registry entry reads `main.document` and that the surface - drew once, inside the pane rather than behind it in Main. - -### (c) The workbench's panes are regions, its documents are surfaces — **done** - -- **Documents are surfaces the app registers** (`Editor.insertOpenDoc` → `registerDocSurface`; - `sdk.document.surfaceId` = `.doc:`, keywords `{"document"}`, `Surface.document` - carries the handle, `draw` = canvas box + `owner.drawDocument`). Taken back on close - (`Host.unregisterSurface`). Document plugins are untouched. -- **A pane is a plugin-declared region** (`plugins/workbench/src/Workspace.zig`): `host.region` - named `Pane `, `shows = .many`, inside `core.widgets.Panes`. Tabs are - `region.matching()`, the active tab is `region.selected()`, a reorder or cross-pane drag is - `Host.assignSurfaces` on the panes involved, an empty pane leaves (`rebuildWorkspaces`). - A document nobody holds is seated in the pane of the grouping the app stamped on it — the - `grouping` hooks on the document vtable survive only as that hint. -- **By-name resolution** (`Region.by_name`, `Layout.matchingIn/selectedIn/selectIn`, - `Host.selectionForKey`) because every pane accepts the same qualified keywords. App regions - stay keyword-resolved for the icon rail. -- **Session restore is the assignment list**: first `rebuildWorkspaces` reads - `Host.assignedRegionNames`, recreates each `Pane `, reopens its paths. A path reopening - under another owner replaces the stale id in place (`Workspace.removeTabsForPath`). -- **Takeover surfaces** (`Surface.takeover_when`, agreed with the user over keeping the hook): - a surface exists only while the named surface is some region's selection, and then it *is* - the selection of any region accepting it (`Layout.visibleNow`, `pick`). Trigger lookup - excludes takeovers so it terminates. Store README: `takeover_when = store tab` + `hidden` - while no card is selected. **pixi's packer must become** - `{keywords = {"main"}, takeover_when = "pixi.project"}` when pixi is updated. -- `Workbench.activeDoc` is what the active pane showed last frame (`Workspace.active`); - `setActiveDocIndex` selects by name (`Host.selectInRegion`). `EditorAPI` gained - `regionMatching/regionSelected/regionSelect/assignSurfaces/assignedSurfaces/ - assignedRegionNames/selectInRegion`. -- sdk 0.1.65, fingerprint `0x14061b7c63fe0b38` (`Host.layout_ctx`). Tests: same-keyword plugin regions keep - separate contents/selections; takeover appears only while triggered. -- **Verified by the user's screenshots** after landing: panes, tabs, open-to-the-side. Fixed - from those screenshots (commits after `nllllosx`): `Surface.document` removed (a document - is found from its surface id via `sdk.document.pathOfSurfaceId` + `host.docFromPath` — no - editor concept on `Surface`); a split is drawn by the region *after* it - (`Layout.drawPendingSplit`) so a `hide_when_empty` region leaves no orphan handle, and an - emptied region stays in the registry so the picker can refill it; plugin region names are - interned (`State.internName` — "Pane N" was a stack buffer read a frame later); `Panes.dragTo` - resolved into a slice alias of this frame's widths (pane after the boundary ran under the - next handle); only the active pane's tab wears active chrome. -- **Still to eyeball**: drag a tab between panes; drop on the right half of the last pane to - split; close every tab in a pane (pane should leave); quit and relaunch (session restore - from `layout.zon`); a pane emptied through its corner-button picker and refilled. -- Settled: `docByIndex` is *open* order, and says so — tab order belongs to whichever plugin - lays documents out, and the host cannot answer in it. - -## Landed: endless handles (`examples/endless-app`, `-Dapp-layout=`) - -A shape whose layout is data, owned by the example — not a shipped fizzy preset. - -- `examples/endless-app/src/layout.zig`: the middle is leftover space (`slot`, no default - surface) and can be dragged to nothing. Each edge already has a collapsed region; dragging - its split any amount opens it and a new collapsed region appears on the outer side. Edge - regions accept `slot` and resolve `by_name` — they stay empty until the picker fills them. -- `Region.drawContents` uses `selectedIn` for by-name regions, so two trays with the same - keywords do not draw the same assignment. `id_extra` includes the side, so a loop of - splits from one `@src()` does not collide. -- There is no `max_trays`: the container tracks resizable ids in an arena list. The flex - gap has no reserved floor. -- `zig build run` works from `examples/endless-app`. An empty region's corner button stays - visible; a filled one still hides until the pointer is near. -- The tree is reconstructed from extent names (`edge-left-1`, …). No new on-disk format. -- `-Dapp-layout=` is a LazyPath. When set, Editor calls `app_layout.layout(ctx, *Layout)` - (the file imports `dvui` / `app` / `core` / `fizzy_sdk` — not `Editor`). `ctx` is - `context()` if exported, else `Host.layout_ctx`; fizzy passes `*Editor`. There is no - `-Dlayout=` enum: fizzy uses `src/editor/layout.zig`; minimal, studio and endless - each own `examples/*/src/layout.zig`. -- Empty edge trays dragged shut are forgotten (`forget_when_empty`); a tray with an - assigned surface closes to the window edge and can reopen. Dragging past zero - `push_out`s only the tray behind it, not the opposite side. -- `app/layout/Picker.zig`: a Store section lists catalog plugins that are not installed; - choosing one queues an install and assigns that plugin's surfaces to the region once they - load (`State.requestStoreInstall`). The store is a hook on `State` so the picker does not - import `PluginStore` (that module graph is a cycle). -- Example: `examples/endless-app` (`endlessapp`). CI builds it beside the other two. -- Tests: names increment per side; the first frame declares a collapsed sentinel on each - edge; dragging the left split opens `edge-left-1` during the drag and appends a collapsed - `edge-left-2`; a full axis still has an outer sentinel. - -## In progress: `Editor.zig` → `app/App.zig` (agreed 2026-09-16) - -`src/editor/Editor.zig` (6100 lines, 250 functions) is almost entirely the host runtime an app -on fizzy needs unchanged: plugin lifecycle, file-type ownership, the document model and its -save/close flows, the `EditorAPI` adapters, settings persistence and reconcile, keybind -dispatch, region/surface bookkeeping, and the four seams `app/` already calls back through -(`SettingsWatcher.Sink`, `FolderWatcher.Sink`, `PluginManager`, `auto_update.Hooks`). What is -fizzy's own is small: `layout.zig` (the shape), `menu_model`/`Menu`, `fizzy_commands` in -`Keybinds`, `explorer/settings.zig`, themes and fonts, `Entry.zig`, and the chrome widgets. - -Target: `app/App.zig` is the runtime, holding that state and owning `Host`; `Editor` is -fizzy's contributions, registered the way a plugin does (commands, settings groups, surfaces -for its chrome, the shape), holding a `*App`. `Entry.zig` stays fizzy's dvui entry and builds -both. Not a rename: files hold `app: *App`, and the framework module is `@import("app")`. - -Done: `app.settings` (Settings, migration, ZON surgery, row chrome, plugin pane), -`app.Recents`, `app.keymap` moved as-is — they had no fizzy coupling. `app/App.zig` exists and -holds the runtime *state* (43 fields: allocators, config folders, `host`, `file_table`, -plugin lists and pending flags, extension ownership, keymap state, settings, recents, -folder, open documents, save/close/quit bookkeeping, watchers, `layout`); `Editor` embeds it -as `app` and every reader says `editor.app.x`. Fifty methods that touch only that state are -`App`'s now (plugin id/lib bookkeeping, `.plugins.` readers, extension ownership, -failure records, settings reconcile, the flat-layout migration, document lookups). Every -method still on `Editor` reaches something fizzy-only — the next step is seams, not moves: -`/tmp/movable.py`-style analysis (a fn is movable iff its body touches only `app` fields and -`App` methods and none of the fizzy imports) now returns only vtable adapters. - -What blocks a wholesale move is that `Editor.zig` imports what `app/` cannot see; each is a -seam to add, then the section moves. Inventory (from grepping the file): -- **Bundled plugins** — *the list is build data now*: `build/sdk.zig`'s - `bundledPluginsModule` generates a `bundled_plugins` module (`pub const modules = .{ - @import("workbench"), … }`) that the runtime iterates for registration, the bundled-id - check, each built-in's manifest, the dylib-or-static load (`App.bundledDylibEnabled`, - `App.loadBundledDylib`) and per-frame hooks. Nothing in the runtime names a plugin except - the workbench (agreed 2026-09-16: an app should be able to embed whatever plugins it lists - in its `build.zig.zon`). **Done:** `fizzy.plugin.create` exports the plugin's source as the - `"plugin"` module; a consumer passes `defer-app` to `b.dependency("fizzy")` and calls - `fizzy.buildApp(fizzy_dep, app_plugins)` (`build/app.zig` is split into `readConfig` + - `construct` so the deferred build still consumes its options); `build/exe.zig` copies each - app plugin's module per executable (the packaged exe has its own framework modules). - `examples/hello-plugin` + `examples/minimal-app` are the acceptance test and CI builds them. - **Left:** the `workbench: Workbench` field, `Workspace`, `FileLoadJob`, `view_files` — the - app must not hold a plugin's state, and the workbench's own state has to live behind its - services and `Host` (the "static/dylib duplicate globals" note in memory is the same - problem). -- **fizzy's contributions** referenced directly: `Keybinds.register/registerCommands/tick/ - buildKeymap`, `menu_model.menu_bar`, `Menu.drawModelMenu`, `Sidebar.drawOption`, - `SettingsTree.draw`, `OutputPanel.draw`, `Explorer.settings`, `Dialogs.*`. Each becomes an - `App.Hooks` member or a registration the application performs in its own init. -- **Assets and platform**: fonts (`assets`), `objc`, `fizzy.backend`, `fizzy.entry()`, - `Constants`. Fonts and window constants belong on `AppInfo`; backend calls behind a seam - like the existing `DialogDirs`. -- `DocumentWatcher`, `FilesService`, `file_glyphs`: framework, move with the sections that - use them (`DocumentWatcher` needs no seam once documents live on `App`). - -Order that keeps every change green: (1) `App` struct in `app/App.zig` holding `gpa`, -`arena`, `config_folder`, `host`, `file_table`, `layout`, settings/recents/keymap state, -watchers — `Editor` embeds it and its methods move over one `// ----` section at a time, -callers rewritten `editor.x` → `editor.app.x`; (2) the bundled-plugin and contribution -hooks; (3) documents and save/close flows; (4) `EditorAPI` adapters last, at which point -`Editor` is the thin layer and `Entry` constructs `App{ .info, .hooks }`. - -## Also queued (in rough priority) - -- **Update pixi/atlas/ghostty/zig to SDK 0.2.0** so the store, placement and services can be - tested end to end (touches four repos; do as its own pass). pixi's packer becomes - `{keywords = {"main"}, takeover_when = "pixi.project"}`; its `dvui.icon` calls become - `core.icon.icon`; `Atlas`/`Sprite` move from `core` into pixi with it. -- **Settings and keybinds as app opt-ins**: the machinery is in `app/` now; the state and the - dispatch move with the App extraction above, and fizzy registers its own sections/bindings - the way a plugin does. -- **Native menu as a runtime app-supplied model**: `src/backend/backend_native.zig` still builds - the menu from a comptime `menu_model` with `fizzy.editor()` refs. Then backend chrome → `app/`. -- Titlebar as configuration (height, menu, traffic lights, caption buttons). -- `Editor` → `App` rename (~460 refs; do not touch `EditorAPI`). -- Keep peeling `EditorAPI` into services (`showSaveDialog`, `drawFileKindGlyph`, `revealPosition`). -- Layered regions (`.blur_behind`) — design in `app/layout/LAYERS.md`; `BlurBackdrop` and - frosted floating windows exist, the region property does not. -- `DocumentWatcher` stays in src until a document-reload seam exists. - -## Recently landed (for orientation, newest first) - -- Cleanup pass: dead SDK members (`swapDocs`, `splitState`) and unreferenced functions gone, - Editor pass-throughs written at their call sites, stale docs (`FINDINGS`, `REVIEW`, `PHASE4`, - `PHASE5`, `PACKAGES`) deleted, `CLAUDE.md`/`PLUGINS.md` describe the tree as it is. -- Perf: `FrameTarget` no longer serializes CPU behind GPU (window clear off, copy-blend blit); - frost pyramid persistent and half-res, re-read every frame; `core.icon`; palette, output log - and settings tree bounded by what is visible. -- Picker cards drag out as a loose `ViewDrag`; frosted floating windows and dialogs - (`BlurBackdrop.frostPane`); the workbench pane row is a `DockingWidget` split tree. -- Endless-handles example: collapsed edge splits, by-name surfaces, `zig build run`. -- Loading card sizes from content; image checkerboard survives zoom-out. -- Animated GIF plays in the image viewer (`core.image.Animation`, dvui timer per frame). -- `.gif/.bmp/.tga` claimed by `image`; `text` opens unknown and binary files as plain text - (NULs / bad UTF-8 become U+FFFD); - a failed load no longer deinits an unwritten document buffer; user gets a toast. -- Every region registers (Main was missing from the placement pane). -- Region assignments + Regions settings table. -- Native dialogs ask the app for start dirs; AppInfo + backend allocator in `app/`; self-update, - window geometry, singleton, watchers, store all in `app/`; `files` is a service. diff --git a/docs/MENUS_AND_TABS_PLAN.md b/docs/MENUS_AND_TABS_PLAN.md deleted file mode 100644 index de8ec07ec..000000000 --- a/docs/MENUS_AND_TABS_PLAN.md +++ /dev/null @@ -1,106 +0,0 @@ -# Context menus and preview tabs - -Two features that are one change, because they want the same thing: a menu anyone can open, -that plugins can add to, that looks the same everywhere, and that a plugin can decline. - -Stages 1–4 are in; stage 5 (the release) is what remains. The contract for plugin authors is -in `docs/PLUGINS.md` §3.4.1–3.4.2; this file is the design and its reasons. - -## 1. One menu row, one menu surface - -Every menu in the app is fizzy's menu chain (`core/widgets/menu/`) drawing -`core.widgets.menuRow` rows: - -- `core.widgets.contextMenu(src, at, opts)` — a floating menu at a point, on the surface - vocabulary in `core.dialogs` (frost, `surface_corners`, `surfaceShadow`), with the `.popup` - dismissal every floating menu shares: a press outside every level closes the chain. -- `core.widgets.menuRow(src, label, opts)` — the macOS row: an icon column, the label, and the - shortcut right-aligned in its own column (`draw.labelWithKeybind`, drawn by `core.keycaps`). - `.submenu = true` adds the chevron. The menu bar, the tree, the tab strip, plugin sections - (`Host.drawMenuItem`) and the account flyout all draw this row. -- `core.widgets.textEntryMenu(te)` — Copy/Paste on any text field. - -## 2. A context menu is a menu with an id - -Plugins contribute to menus by id with `host.registerMenuSection`. Context menus reuse exactly -that; fizzy defines the ids and opens them: - -| id | opened by | -|---|---| -| `fizzy.menu.tab` | right-click on a tab | -| `fizzy.menu.filetree.file` | right-click on a file row | -| `fizzy.menu.filetree.folder` | right-click on a folder row | -| `fizzy.menu.filetree.root` | right-click on the project row or blank space | -| `.menu.document` | right-click inside a document, *if its owner names one* | - -A section needs to know what the menu is about: - -```zig -pub const MenuContext = struct { - menu_id: []const u8, - subject: Subject = .none, - - pub const Subject = union(enum) { - none, - path: []const u8, // a tree row, the project root - document: Document, // a tab, a document pane - pub const Document = struct { id: u64, path: []const u8, grouping: u64 }; - }; - pub fn path(self: MenuContext) ?[]const u8; -}; -pub fn menuContext(self: *Host) ?MenuContext; -pub fn drawMenuSections(self: *Host, ctx: MenuContext, after_rows: bool) void; -``` - -A union over the *subject*: an open document always has a path and a pane, a tree row has only -a path, and a flat struct with `0` / `""` for "absent" lets a plugin check one sentinel and -forget the other. - -The context lives on the Host, set by `drawMenuSections` while contributed rows draw, because -the workbench opens the tab and tree menus and is a plugin with no reach into app globals. - -## 3. A plugin can decline - -Pixi uses right-click for the colour dropper, so fizzy never opens a menu inside a document on -its own — it asks the owner (`Plugin.VTable.documentContextMenu`, default null = no menu). The -text plugin returns `"text.menu.document"` and fills it with its own sections. - -## 4. Preview tabs - -A preview tab is opened by a single click, shown in italic, and replaced by the next preview in -its pane. It is kept when the user edits it, drags it, or chooses **Keep Open** from the tab -menu. Not by double-click: fizzy uses double-click nowhere else, so users would have no reason -to guess it. - -State lives with the app, which owns open documents: at most one preview per grouping. - -```zig -pub const OpenMode = enum { keep, preview }; -pub const OpenOptions = struct { path: []const u8, grouping: u64 = 0, mode: OpenMode = .keep }; -pub fn openFile(self: *Host, opts: OpenOptions) !bool; -pub fn documentIsPreview(self: *Host, doc_id: u64) bool; -pub fn setDocumentPreview(self: *Host, doc_id: u64, preview: bool) void; -``` - -Store pages are documents (`app/store/PluginPage.zig`, mount `store://pages`) and open as -previews like any file; their tab title comes from `Plugin.VTable.documentTitle`. - -## The ABI bump - -One `recorded_sdk_shape_fingerprint` move, SDK **0.2.1**: - -- `EditorAPI`: `OpenMode`/`OpenOptions` + `openFile` (replacing `openFilePath`), - `documentIsPreview`/`setDocumentPreview`, `MenuContext`, `isRemotePath` -- `Host`: `menuContext()`, `drawMenuSections(ctx, after_rows)` -- `Plugin`: `internal`, `VTable.documentTitle`, `VTable.documentContextMenu` -- `Surface.scrolls_itself`; `core.vfs.Fs.remote` - -## Stages - -1. ~~One menu surface~~ — done. -2. ~~The SDK surface~~ — done. -3. ~~Preview tabs~~ — done. -4. ~~Menus everywhere~~ — done: the tab menu, the tree menus on the contribution points, the - text plugin's own, Copy/Paste on text fields, keycaps for every shortcut. -5. **Release** — tag `sdk-v0.2.1`, then repin and re-release drive, atlas (switch its scheme - check to `host.isRemotePath`), zig, ghostty and pixi. diff --git a/docs/NATIVE_WINDOWS_PLAN.md b/docs/NATIVE_WINDOWS_PLAN.md deleted file mode 100644 index c5b2482f9..000000000 --- a/docs/NATIVE_WINDOWS_PLAN.md +++ /dev/null @@ -1,613 +0,0 @@ -# Native windows: fizzy's backend between SDL and dvui - -Floats became OS windows (#223), and a carried view became a window of its own for the whole drag -(#224). That moved the drag's glass out of the app's shader and into the OS. On macOS 26 the OS -glass is Liquid Glass, and it can look and move like Control Center's, but only if fizzy controls -its windows more closely than SDL alone lets it. This plan covers three things: -- what Liquid Glass can do for fizzy, as measured; -- how the drag's glass (the carried bubble and the drop zones) becomes one native liquid on - macOS 26, and what other platforms do instead; -- how fizzy's backend grows into the layer between SDL and dvui that owns its windows, while still - taking SDL's releases. - -**The principle (the user's, 2026-10-05):** where the OS has Liquid Glass, fizzy uses it as much as -it can, safely. Private API is acceptable when it has proved stable and is built to fall back -gracefully when it is missing. Every platform gets as close to that glass as it can. Underneath, -fizzy supports the whole range from opaque through blurred to glass, takes each platform's newest -features optionally, and breaks nothing where they are missing. - -Related: [`POPOUT_WINDOWS_PLAN.md`](POPOUT_WINDOWS_PLAN.md) (viewports, bands, per-OS dressing) and -[`DEPENDENCIES.md`](DEPENDENCIES.md) (the SDL, sdl_zig and dvui-dev forks and their patches). The -backend review of 2026-10-04 is folded in at the end ("From the 2026-10-04 review"). - -## Where it stands - -- **Fizzy draws through its own backend** (`src/backend/native/`, about 8,300 lines): `SDLBackend` - (windows, events, presentation), `GpuRenderer` (SDL_GPU), `viewport_map` (bands: each float - window's part of one frame), and `platform/`. That last part holds the title bar hit tests, the - per-OS window monitors, the dialogs and menus, and the AppKit code in `platform/macos/*.m`. dvui - runs in its `custom` backend mode and knows none of this. -- **SDL creates every window.** Fizzy then reaches the native window through SDL's properties - (`SDL_PROP_WINDOW_COCOA_WINDOW_POINTER`, `..._WIN32_HWND_POINTER`) and dresses it: - - a style, a skin and the material behind it (`platform.window`, `visual_effect_view.m`); - - title bar hit testing, by replacing SDL's view class's `-mouseDownCanMoveWindow` at runtime - (`fizzy_macos_titlebar_hit_test_install`); - - placement in Core Animation transactions with the frame (`renderPresent`). -- **SDL carries six fizzy patches** (`DEPENDENCIES.md`): transparent window claims, DirectComposition - presentation, presenting inside a Core Animation transaction, live resize (two patches), and - Wayland frame insets. -- **Native materials so far:** - - vibrancy (`NSVisualEffectView`) behind the main window and float windows; - - Liquid Glass (`NSGlassEffectView`) in the carry window on macOS 26 (#224); - - Acrylic behind the window on Windows (`win32_titlebar.zig`). - -## What Liquid Glass does (measured on macOS 26.5, 2026-10-05) - -Throwaway AppKit programs put glass over a striped pattern and captured the screen: -- **The public styles are both frosted.** `Regular` renders dark and `Clear` light. Neither - refracts: what's behind is blurred past recognition, in a window and from a separate window alike. -- **One of the private variants is a lens.** `_variant` (default 2) chooses the glass: - - **11** is clear, unblurred and refracting. It bends and magnifies what's behind it toward its - rim, like a drop of water, and it ignores `tintColor`. - - **6** is a bright frost with a strong rim. - - **13** draws no glass at all. - - The rest are frosts of varying darkness. -- **The lens refracts across windows.** A glass view in a transparent window refracts the window - beneath it, so the window server does the lensing, not the app. That is how Control Center's - tiles refract the desktop. -- **It refracts Metal.** In a window, glass over a `CAMetalLayer` reads what Metal drew, so it works - over fizzy's own frame. -- **It merges.** `NSGlassEffectContainerView` runs its glass views together where they come within - its `spacing`. Two circles 16 points apart with a spacing of 40 joined with a liquid bridge, over - AppKit content and over Metal alike. This works only within one window. -- **It follows its window.** A glass window resized every 8 ms was its window's size in every - capture. -- **It is SwiftUI inside** (`NSGlassEffectView` hosts an `_NSCoreHostingView`). Setting its private - enum properties by key-value coding with an unexpected value can kill the process with a Swift - fatal error. Set them only through their typed setters (`set_variant:`), with measured values. - -What this means: -- **The look you see in Control Center is reachable**, but through a private variant. #224 gives - the carried bubble the lens (11), set only on macOS 26 and only where the setter exists. The - variant numbers could change in a later macOS, and a renumbered 11 could even draw nothing - (as 13 does). Each new macOS needs the variants measured again before the version gate moves. -- **Merging needs one window.** The bubble and the drop zones can merge as Liquid Glass only if - they are glass views in the same window. Today the bubble is its window and the drop zones are - the app's shader, so they cannot. -- **What isn't known yet:** Control Center's exact material. It may be a variant plus a subvariant - (`_subvariant` is a string whose values aren't known), or a tint over the lens. Next is matching - it by eye against captures. - -## The drag's glass on macOS 26: one overlay of Liquid Glass - -**Built** (the prototype after #224, on by default where Liquid Glass exists; `FIZZY_NATIVE_GLASS=0` -keeps the app's glass). One display only so far: the main window's. - -During a view drag, one transparent overlay window lies over each screen the drag can reach. It is -the carry window grown to its screen: passive, at the pop-up menu level, joining full-screen Spaces. -In it: -- **An `NSGlassEffectContainerView` holding a pool of glass views.** These are the carried view - (the drop's head and its tail, or a card or tab) and every drop-zone bubble that is showing, - whether over the main window's places or over float windows. Each is placed every frame from the - shapes the frame already computes, which draw nothing in the app while the OS draws them: they - are declared to `core.native_glass` instead (`DropZones.draw`, `ViewDrag.drawDrop`). The - container's `spacing` is the drop zones' merge distance (24 points), past the 16-point gap - between a wheel's bubbles, so they run partly together (the user's choice), the head pulled - toward the bubble it is aimed at bridges into it, and the head and its springy tail merge into - the wobbling drop. The aimed bubble lights in fizzy's fill over the glass (below): lit with the - glass's pressed look (`_interactionState`), it never ran together with the drop snapped onto it - (measured). -- **Their whole life is the OS's glass:** growing out of the drop and pinching off, and running - back together after the drag. The overlay stays until the last bubble has gone; handing the - going back to the app's glass showed them switch material as they left. -- **SDL's Metal view above the glass**, as in today's carry window. It shows what only fizzy draws: - the carried photograph and the drop zones' icons. -- **Placement and picture in one transaction.** The glass views' frames are set inside the Core - Animation transaction the overlay's Metal picture presents in (`presentsWithTransaction`, SDL - patch 3), so glass and picture move together. -- **Nothing of it in the app's windows.** While the overlay shows, the main window and float windows - draw no drop glass and no carried view. This is #224's rule: nothing in the app follows native - glass, unless all of it is the app's. -- **Opening a float out of it:** the carried glass grows to the new window's frame, then the window - shows, as #224's growth does now. - -Risks to measure in the prototype: -- the cost of a screen-sized Metal drawable during a drag, which is mostly clear; -- the cost of dozens of glass views re-placed every frame; -- whether merging and lensing keep up at 120 Hz; -- several screens, Stage Manager and full-screen Spaces; -- the private variant going away (then the overlay falls back to the public Clear style, or to - today's app-drawn drag). - -## Other platforms: simpler, and back into the window when over one - -Windows, X11, Wayland and the web have nothing like Liquid Glass to merge. -- **Over a window, the drag is the app's own glass** (`core.LiquidField`). The bubble merges with - the drop zones as it did before #224. -- **Outside every window**, it is a carry window with a simple material: Acrylic on Windows, and - translucent on X11. Wayland and the web have no carry window, and floats stay in the window. -- **Crossing a window's edge hands the bubble between the two.** That hand-off is accepted there. - The capability decides which path runs (`viewports.carries` today, a glass capability tomorrow); - the platform's name does not. - -## Floating surfaces: native popup windows that fizzy draws - -**Decided (the user, 2026-10-06):** a context menu is an OS popup window with fizzy's own menu -drawn in it — not `NSMenu` or a Win32 menu, and not a fizzy overlay inside the main window. The OS -gives it its shadow, corners, material and stacking; fizzy gives it its look and behaviour, the -same on every platform. Menus first, then the menu bar's dropdowns, tooltips, popovers and, in -time, dialogs. - -**The window:** an SDL popup window (`SDL_CreatePopupWindow`, `SDL_WINDOW_POPUP_MENU` or -`SDL_WINDOW_TOOLTIP`), parented to the window the menu opens from — the main window or a float's -own. SDL places it relative to its parent and keeps it above, on every platform; on Wayland it is -an `xdg_popup`, the one way an app may place a window there, so menus can leave the main window -even where floats cannot. - -**Its material, per platform:** -- **macOS 26:** the window glass pair (`fizzy_macos_window_liquid_glass`), its frost reaching the - edge, the transient role on the slider; before 26, vibrancy. -- **Windows 11:** DWM Acrylic (`DWMSBT_TRANSIENTWINDOW`) with round corners - (`DWMWCP_ROUND`) — Microsoft's own role for transient surfaces. Windows 10: opaque, or the in-app - frost. -- **Linux:** the compositor's blur region (`ext-background-effect-v1`, `org_kde_kwin_blur`) where - it offers one, the menu's colour over it; opaque where it does not. - -**Drawing it:** the menu stays a dvui subwindow in the one frame. Its drawing is taken out of the -frame and copied into its popup, as a float's window already takes the menus opened in it -(`Popout`), and input on the popup maps back into the frame at the menu's place. Out of the main -window's rect, the menu is drawn in the frame's band past it, as floats are. - -**Placing it:** with popups, a menu is kept on the display, not the window: the app publishes the -display as a screen (`core.screens`), so a menu near the window's edge hangs past it rather than -flipping inward. - -**Following its window (built on macOS, #227).** A menu of the main window's is the main window's -child window once it shows (`SDL_SetWindowParent`), so the window server moves it with the main -window, smoothly through a drag that fizzy's frames could only follow a frame behind; the app -re-places it only when where it lies over the main window changes. AppKit drops a child to its -parent's level, so a menu's is set back to a popup's. A menu opened in a float that is out is -drawn where it opened in the float's band and cannot follow the float's window; it closes when that -window moves, as an OS menu closes on a press outside it. Where a platform cannot attach a menu to -its window, the same rule applies to the main window: the menu closes when the window moves. - -**Dialogs (built on macOS, #227).** Every `core.dialogs` dialog goes the same way, ahead of the menu -bar's dropdowns and tooltips: its own OS window, never focused (the main window keeps the keyboard, -dvui routes keys to the dialog), the pointer read in the main window's frame, its window the main -window's child, at the main window's level rather than a popup's, other apps' windows going over -both. It draws no dim, frost, shadow or fill of its own, the main window is left undimmed, and its -window fades as it flies shut. dvui draws dialogs at the end of the frame, so Popout asks for them -first (`Window.drawRetained`, on the dvui fork) and then takes them into their windows. A setting, "Native dialogs", beside -"Native menus". - -**A dialog belongs to the window it opened in (the user, 2026-10-06).** It is stacked with that -window and no other: a dialog of the main window's sits over the main window and under the floats -kept above it, and a dialog opened in a float that is out opens over that float, as its child -window, and travels with it. The float's window moving, the dialog is put where it lies over the -float as before (`Popout.dialogsRide`) and the OS carries its window there; its float closing, -the dialog comes back over the main window (SDL destroys a window's children with it, so the -backend lets riders go first). Sized and kept on the display, not the float, since its window is -its own (`screens.dialogScreenFor`). - -**Open:** -- keyboard focus — whether the popup takes it (Wayland's grab does) or the parent keeps it and - forwards keys to the menu; -- dismissal when the app deactivates, and on a press in another of fizzy's windows; -- several displays; and menus opened from a float out over the desktop. - -## The backend as the layer between SDL and dvui - -**The split.** -- **SDL** does the portable plumbing: the event pump and input devices, the GPU device and - swapchains (SDL_GPU), clipboard, IME, displays and file dialogs. -- **Fizzy's backend** owns window policy and composition: - - which windows exist (main, floats, carry and overlay); - - how they're dressed (title bars, materials); - - native layers (glass shapes and groups); - - presentation transactions; - - hit testing and drag regions; - - bands and input routing; - - each OS's window monitor. -- **dvui** sees only its backend interface. - -**Native layers, as an interface of the backend.** Glass is declared each frame by id, and the -backend reconciles it with the platform, as dvui does with widgets: -- `layers.glass(id, window, shape, material, group)`; -- materials are `window` (vibrancy, Mica, Acrylic), `frost`, `lens` and `clear`; -- a group merges where the platform can, and `layers.supports(.merge, .lens, …)` tells the caller - whether to fall back to app-drawn glass. - -**The seam stays outside the SDK.** Shapes reach the backend through `core` (as `core.screens` -does, in dvui's data), so plugins drawing drop zones or dialogs reach native glass with no SDK -change. - -**One `WindowChrome` per OS window** (from the review). The main window, a float's window, the -carry window and the overlay each want a hit test, corners, a material, a shadow and a title bar -policy. Today the main window's state is file-level globals and a float's is `Viewport` fields; on -Windows they have two subclass procs, and two hit tests answer one question -(`titlebar.hitTest`, `viewport_map.hitTest`). One `platform.Chrome` per window holds all of it — -one SDL hit-test callback with the `Chrome` as its data, one Win32 subclass with it as -`dwRefData`, per-window Objective-C state as an associated object on the `NSWindow` — and the -native layers (above) are the `Chrome`'s too: a window's material and its glass are one thing. The -macOS half is partly done: #223 gave the main window and floats one monitor, one style and one skin. - -**Where SDL owns the code path, fix it in SDL; where it is policy, do it in fizzy over native -handles** (the review's rule, which this plan takes in place of its first draft). The first draft -had fizzy create its own `NSWindow` and view for SDL to adopt (SDL3 does take one: -`SDL_PROP_WINDOW_CREATE_COCOA_WINDOW_POINTER` / `_VIEW_POINTER`, `_WIN32_HWND_POINTER`, … — SDL -marks it `SDL_WINDOW_EXTERNAL` and adds its Metal view inside the view given). That stays the -fallback for a behaviour neither way can reach. The patches that earn their place: -- **Cocoa: resize and pixel-size events during Space and zoom animations.** SDL holds them back; - that is why `window_monitor.m` runs a 60 Hz pump and `macos_monitor.zig` calls two private SDL - functions (`SDL_SendWindowEvent`, `SDL_OnWindowLiveResizeUpdate` — a rename breaks the build, a - change of meaning breaks it silently). Reads as a bug fix upstream. -- **Cocoa: the window's hit test decides `-mouseDownCanMoveWindow`** for full-size-content windows, - in place of fizzy replacing that method on SDL's view class at runtime - (`fizzy_macos_titlebar_hit_test_install`). Today it answers from where the cursor is when AppKit - asks, which is where the reported "float dragged over the main title bar" misbehaviour lives. -- **Win32: a "client area is the whole window" creation property, with caption-button hit-test - results** (`HTMINBUTTON`, `HTMAXBUTTON`, `HTCLOSE`), and SDL leaving `WS_SYSMENU` alone. The - subclass shrinks to backdrop attributes and hover tracking. -- **Wayland and X11: double-click on a draggable region toggles maximize** (only SDL sees the - clicks). -- **Wayland: a blur-region property** (`ext-background-effect-v1`), next to the frame insets - already carried — once compositors support it. -Materials, glass views, vibrancy, DWM attributes stay fizzy's: stable OS API over native handles, -where a C patch would be rebase cost for nothing. - -**Keeping up with SDL.** -- **Patches only where SDL owns the path,** each proposed upstream (DEPENDENCIES.md rule 4). The - first to propose is patch 1 (a transparent window's claim): one moved check that every other - transparent-window patch sits on. -- **A standing check.** A scheduled CI job rebases fizzy's SDL patch stack onto SDL's latest - release and builds fizzy against it. A conflict or a break shows up the week it happens, not at - the next bump. Less often, the same job runs against SDL's `main`. -- **Each patch has an acceptance test.** DEPENDENCIES.md names, for each patch, how to see that it - still works (`scripts/live-resize`, a transparent window's claim, Acrylic through DComp). A bump - is done when those pass. -- **A cadence.** Fizzy takes each SDL point release within a few weeks, tagging each step - `fizzy--`, as now. - -## One material, one slider - -Everything fizzy draws as glass — the drag's bubble and drop zones, floats, dialogs, popovers, -menus, tooltips, and the main window itself — is one material, set by one slider (the window -opacity, the user's design, 2026-10-05). It runs from fully clear refracting glass, through blur -with the window's colour tinting in, to opaque in the window's colour. The app works at any point -on it, opaque included, and its glass still merges and moves at every point. - -**Two forms of every glass surface,** on the same slider, so a surface looks the same in either: -- **In-app:** fizzy's own glass shader, kept (the user: "I don't want to trash our in-app glass"), - for surfaces drawn inside a window, for the web, and for every platform without native window - glass. It is tuned to look like macOS's Liquid Glass on the same slider — a clear lens at the - bottom that magnifies toward a thin bright rim, frost rising with the window's colour, the shine - kept until the top — so a surface looks alike in either form. Its parameters are continuous: - refraction kept, blur radius from 0 up, tint mix toward the window's colour from 0 to opaque. -- **Native:** the OS's glass, for surfaces that are OS windows of their own — float windows, the - carry window, the drag's overlay, and dialogs, popovers and menus moved into windows — where the - OS has it (Liquid Glass, macOS 26). - -****Native where the OS has it, in the OS's own style.** Windows' glass is not macOS's, and fizzy -should look like a Windows app there, not imitate macOS: each platform maps the slider onto its own -materials (below), and the library that does it stays small — one interface, a short file per -platform, the in-app glass behind it all. - -**The native way along the slider (built for the drag's glass in #227, measured).**** Liquid Glass -has no blur to turn, only variants, so the way is a blend: -- the clear lens (variant 11) is drawn whole, and frost (Clear) comes in over it late in the - slider, faded out toward each piece's edge by a radial mask, so the rim stays the clear lens - with its bright light and only the middle frosts, as the app's own glass does (the user's ask); -- two layers of the same pieces do it, and stay one outline while they do; -- the window's colour is under the glass, as native shape layers beneath it with necks where - pieces run together, so the glass bends and lights it and keeps its shine; drawn over the glass, - it muted the shine all the way up (the user); -- only in the last tenth of the slider does the glass go, handing over to the colour drawn flat - and opaque over everything in the merged shape (`core.liquid_blob.fill`); -- a lit piece is lit in the colour under the glass, never with the glass's pressed look, which - stops it merging. - -`Popout.glassLook` holds that mapping today; it moves into `core` as the app's one material -description, read by both forms. - -**Native menus.** On macOS 26 AppKit's own `NSMenu` is already Liquid Glass, with the OS's keyboard -handling and accessibility. That is the native form of a context menu: right-click opens an -`NSMenu` built from the same menu model. Its material is the OS's, not on fizzy's slider. A menu -fizzy draws itself, in an OS window on the native material, is the other way, kept for menus -`NSMenu` cannot express. - -**Per platform**, in each platform's own style: see "The materials library" below. - -- **The main window takes the same material** where the OS has it: an `NSGlassEffectView` pair - behind SDL's view in place of vibrancy, the fill over it per the slider. Float windows, the carry - window and the overlay already sit beside SDL's view that way, so the main window moves to the - float's view structure (the review's advice too: no responder-chain repair). -- **Accessibility wins over looks.** macOS "Reduce transparency" - (`accessibilityDisplayShouldReduceTransparency`) and Windows "Transparency effects" off put the - slider at opaque. "Reduce motion" (`accessibilityDisplayShouldReduceMotion`, - `SPI_GETCLIENTAREAANIMATION`, the GTK setting) turns the glass's growth, merging and wobble into - fades. Natively `SDLBackend.prefersReducedMotion` returns false today (the review). -- **Capabilities, not platform names.** A backend declares what it can (`fizzy_ext`): `liquid_glass`, - `lens`, `merge`, `carry_windows`, which materials it offers. The app picks the best one offered - and falls back without breaking anything. - -## The materials library - -The user's brief (2026-10-05): a backend library that matches this look across operating systems -*in their native look* — "Windows has some glass, but it's not the same as macOS" — kept small -enough to maintain while each platform is supported on its own terms. Three studies (Windows, -Linux, the in-app glass against Apple's) shaped what follows; their findings are summarised per -platform below, with what to build and what to skip. - -### One vocabulary - -- **Inputs:** a surface's **role** — `window` (the main window, a float's window), `transient` - (menus, popovers, tooltips), `carry` (the dragged bubble), `drop` (drop zones), `scrim` (dimming - under a modal) — the **slider** `t` (0 clear … 1 opaque), and the **policy** each platform - probes: transparency allowed (macOS Reduce transparency, Windows Transparency effects / energy - saver / high contrast, Linux portal `contrast`), reduced motion. -- **Output:** each platform's **realization** of that role at that point: the OS material behind - the window (or none), the window colour's opacity, whether the OS draws glass shapes itself (the - overlay), and otherwise the in-app glass's parameters. -- **One pure module, `core/gfx/glass_look.zig`,** unit-tested: the slider's breakpoints, the native - mapping (today `Popout.glassLook`), the in-app mapping (`inAppLook`), and role adjustments — a - surface carrying text keeps a minimum of frost, so a dialog over a clear lens still reads. - Policy off means `t` = 1. -- **Capabilities, not platform names:** a backend declares what it can (`liquid_glass`, `lens`, - `merge_overlay`, `window_backdrop`, `blur_region`, `carry_windows`); the app picks the best one - offered and falls back without breaking. - -### Each platform's realization - -**macOS 26 — Liquid Glass (built for the drag in #227).** -- Every role on Liquid Glass: the lens crossfading into frost, the window colour under the glass. -- The drag's bubble and drop zones merge in the overlay. -- Float windows and the main window take a glass pair behind SDL's view. -- Context menus are popup windows of Liquid Glass with fizzy's menu in them ("Floating surfaces" - above) — chosen over `NSMenu`, which would be Liquid Glass already but not fizzy's menu. -- Before macOS 26: vibrancy plus the colour, no merging, the drag in the app's glass. - -**Windows 11 (22H2+) — DWM materials, Terminal-style.** Windows has no lens and no merging, and -Microsoft has said it will not get a Liquid Glass-style change. -- **Native vocabulary:** Mica for long-lived windows, Mica Alt with tabbed title bars, Acrylic - only for transient surfaces (Microsoft: "don't put desktop acrylic on large background - surfaces"), Smoke under modals. -- **The slider along DWM's backdrop types** (`DWMWA_SYSTEMBACKDROP_TYPE`), the way Windows - Terminal maps its opacity setting: - - low end: no backdrop, the desktop sharp through the window's alpha, the colour rising; - - middle: Acrylic (`TRANSIENTWINDOW`) under the colour; - - top and default: Mica (`MAINWINDOW`) under the colour, which is Windows' native opaque window - and its inactive-window cue. - There is a visible step where the backdrop type changes; only Windows.UI.Composition could - remove it. -- **Roles:** - - window and float: as above; - - float windows inactive while the main window has focus are shown solid by DWM, so Fizzy's - windows act as one activation group (`WM_NCACTIVATE(TRUE)` to the floats, as PowerToys does); - - transient: Acrylic (in-app acrylic-styled glass inside a window, a DWM Acrylic window - outside it); - - carry: a carry window with Acrylic and DWM's round corners; - - drop: the in-app glass styled as acrylic (blur, tint, noise, 8 px corners, no merging); - - scrim: Smoke, and dialogs as opaque 8 px cards. -- **Policy probe:** `EnableTransparency`, `SPI_GETHIGHCONTRAST`, power saving status, refreshed on - `WM_SETTINGCHANGE` and `WM_POWERBROADCAST`. DWM goes solid on its own under them, but Fizzy's - own alpha and glass do not, so Fizzy clamps `t` itself. -- **About 150 lines** in `win32_titlebar.zig`. Today's main window uses Acrylic for its - background, against Microsoft's guidance; it moves to Mica at the top of the slider. -- **Phase 2, only with a go-ahead:** per-shape glass over the desktop and a continuous blur - through Windows.UI.Composition (`SpriteVisual`s with a backdrop brush and geometric clips, on a - non-topmost target under SDL's DirectComposition swapchain). Roughly 1,000 lines of hand-written - WinRT interop, an untested layer rule, and a one-frame skew against SDL's presents. -- **Skip:** the undocumented `SetWindowCompositionAttribute`, the Windows App SDK, the 21H2 Mica - hack, OS blur on Windows 10 (alpha only there, or opaque), refraction and merging. - -**Linux — the compositor's blur, where it offers it.** -- **The protocol:** `ext-background-effect-v1`, in KDE Plasma 6.7+, GNOME 51 (Mutter), Hyprland - 0.56+, niri 26.04+ and COSMIC. Plasma 6.6 and older (Kubuntu 26.04 LTS, to 2029) use KDE's own - `org_kde_kwin_blur`. -- **Blur only:** no tint, no strength — strength is the user's compositor setting — so along the - slider "more blurred" becomes "more tinted": - - an empty region at the top, so the compositor does no work (and Hyprland's default blur of - translucent windows is cancelled); - - the window's rounded rect blurred below that, the colour's opacity following `t`. -- **Attached by Fizzy, no SDL patch,** in about 200–250 lines: - - borrow SDL's own libwayland (`dlopen` with `RTLD_NOLOAD`) and its `wl_display`; - - bind the protocol on a private event queue; - - set the region in the commit SDL's Vulkan present makes; - - check the capability bit's value (wayland-protocols 1.45 declared it wrong). -- **Where it cannot:** Sway, labwc and GNOME 50 and older have no blur, and the slider clamps to - opaque. On X11, check `_NET_WM_CM_S0`: with no compositor the window is opaque and has no shadow - margins. -- **Bubbles and drop zones are always the in-app glass:** there are no overlay windows on - Wayland, and nothing on Linux lets an app refract the desktop. -- **Policy:** the XDG settings portal's `reduced-motion` and `contrast` (through libdbus, as SDL - reads the colour scheme). There is no reduce-transparency key, so high contrast stands in. -- **Skip:** KDE's retired contrast protocol, X11 blur atoms (KWin X11 ends early 2027), and - per-compositor tuning. - -**Web — the in-app glass,** with `prefers-reduced-motion` and `prefers-reduced-transparency`. - -### The in-app glass, tuned to Apple's - -The app's glass stays, and is the form wherever no native glass exists. Measured against Apple's -lens, it differs mostly in one place: **Apple's lens pulls the backdrop inward from a band near the -rim** (about 29% of the radius), magnifying it and folding it into a mirrored, darker band just -inside the edge, with the middle sharp and unblurred. The app's glass pulls from outside the edge -instead (`liquid_glass.zig`: shrinking, never folding — an inward pull was once tried and rejected -for its mirror line, which is exactly Apple's look). - -The work: -- **Shader:** an inward bevel displacement, a band shade, a brighter rim line with a slight - colour fringe, and signed bevel shading that survives a full tint. That is four copies changed - together: GLSL (the web compiles it too), Metal, HLSL and the CPU `sample`. It fits the uniform - slots already there. -- **Cheaper:** an inward pull needs almost no capture margin (today about 2.2× a pane's pixels), - and a clear lens can skip the blur pyramid entirely (about five render-target switches per pane - today). -- **One slider:** the in-app glass reads `glass_look.inAppLook(t)` on the same breakpoints as the - native glass. The separate dialog glass settings become optional advanced overrides; saved - settings still load, since unknown keys are ignored. -- **Defaults move:** light and dark themes default to different slider values, so each starts at a - different point on the trip. - -### Keeping it small - -- **One file per platform's realization, under about 300 lines each,** behind one interface: - `apply(window, role, look)`, `probePolicy()`, `capabilities()`. The pure mapping and the role - tables are unit-tested. -- **No per-compositor or per-build tuning.** Platforms are told apart by capabilities. Nothing - needs an SDL patch. -- **Anything that grows past that** — Windows composition, X11 blur — is a separate phase with its - own go-ahead. - -## The first plugin consumer: pixi's dropper orb and floating buttons - -The user's candidate for strengthening the library (2026-10-05), because it is the first time a -plugin, not fizzy, draws glass of its own. - -**What it should be:** -- **One liquid layer.** pixi's round glass buttons floating at the canvas window's corner - (`widgets/glass_button.zig`) and its colour-dropper magnifier. -- **The orb.** While the dropper is active, the magnifier is an orb of glass floating round the - pointer: refracting at its rim, and in its middle pixi's own pixel-exact zoom of what is under the - pointer, with just the crosshair. -- **The animation.** The orb pinches off the magnifier button when the dropper starts, follows the - pointer on a spring, and runs back into the button when it ends. Wherever it overlaps the - buttons, they merge. -- **Everywhere.** It works in float windows too. Today the magnifier does not; pixi's fix for its - floating surfaces is on its unpushed branch `claude/popout-screens` (`3b0aecf`). -- **Its logic** (`FileWidget.drawSampleMagnifier*`) is the user's own, from earlier, and wants - redoing anyway. - -**What the library needs for it** — each piece general, not pixi's: -1. **Glass outside drags.** The native overlay opens whenever any glass is declared, not only for a - view drag, and closes when none is. Declarations carry a **group**: pieces in one group merge - (pixi's buttons and its orb), pieces in different groups do not (pixi's controls and fizzy's drop - zones). Natively that is one container per group; in the app, one `LiquidField` per group. -2. **Content over the glass, from any code.** Today only the carried view and the drop icons reach - it, through `core.screens.markCarried`. A small `core.native_glass` call for "draw this over the - glass" generalizes it: natively the overlay replays that layer above its glass, as it does now, - and in the app it is simply drawn after the glass. It is how the orb's zoom and crosshair sit - crisp inside the glass. -3. **A magnifier look.** The orb's middle shows pixi's zoom, not the lens's own magnification, and - the glass bends only its rim band: the in-app look with the frost at 0 and the band clear, and - natively the lens with pixi's picture over its middle (as the carried photo is over the - bubble's). -4. **Motion from core:** the orb on a `core.Spring` toward the pointer; the pinch-off and merge are - the group's own merging as the orb and the button come apart and together. -5. **Plugins reach all of it through `core`:** both the declarations and the over-glass layer - travel in dvui's shared data, as `core.screens` does, so a plugin dylib's glass reaches fizzy's - overlay with no new SDK call. A change to `core` still means rebuilding pixi against the matching - SDK, and a release to the store. - -## Screen-edge tiling for a carried view - -The user asked for a carried view dragged to a screen's side or top to tile as a window would. The -OS tiles only a window the window server itself is dragging — a title bar drag — and the carry -window is placed by fizzy each frame, so the OS never offers it. Handing the drag to the OS -(`performWindowDragWithEvent:` on a real window) would cost fizzy the pointer: no drop zones, no -coming back into a window. So fizzy tiles it itself, the way the OS does: -- **Zones at the screen's edges and corners** — left/right halves, top fill, the four quarters — - read against the display's usable frame (below the menu bar, beside the Dock), with the system's - "tiled windows have margins" setting on macOS and Snap's layout on Windows. -- **A preview in glass:** the zone's rect as one more piece of the overlay's glass, the carried - drop running into it as into a drop zone. -- **Let go there,** the float opens at that frame, growing out of the drop into it as now. From - then it is an ordinary window, and the OS's own tiling works on it. - -## Steps - -1. **Done in #224:** the carried bubble is a window of Liquid Glass on macOS 26, a lens on a fifth - of the base; a float's window grows out of it from its top left and shows only once the glass - is its size; the lifted place keeps its base; no move cursor over a float's traffic lights. -2. **Built (#227):** the drag's glass as one overlay of Liquid Glass (above), on by default on - macOS with floats as windows, and on the slider from clear to opaque. Next on it: several displays, measuring its frame cost and merging at - 120 Hz, matching Control Center's material, screen-edge tiling. -3. **Clean the backend first** (the review's steps 1–2, no behaviour change): delete - `SDLBackend.zig`'s dead SDL2 arms, `initWindowSecondary`, `WindowGeometry`; split it by job - (backend, events, viewports, app main, live-resize trace); break the `platform`/`backend` import - cycle and have `platform` take `*SDL_Window`; one `native.cocoa(window)` / `native.hwnd(window)`; - declare `fizzy_ext` in place of `@hasField` probing; `Viewport` methods with one null type in - place of the forwarders in `backend_native.viewports` (the prototype added six more); `zig fmt - --check` in CI. -4. **`WindowChrome`** for the main window, then floats, carry and overlay: one hit test (std-only, - unit-tested first), one Win32 subclass, no file-level state; materials and native layers on it. -5. **The materials library** (above), in this order: - - **built (#227):** `core/gfx/glass_look.zig` with the native and in-app mappings, and the - in-app glass tuned to Apple's (the inward lens in GLSL, Metal and HLSL), on the slider; the - SPIR-V and DXIL rebuilt with shadercross (#242), so Vulkan and D3D12 draw it too. Still to do: - skip the blur pyramid for a clear lens, and the outward capture margin once the old programs - are gone; the separate dialog glass settings as advanced overrides; - - macOS: **built (#227)** the main window and float windows on Liquid Glass (the colour under - the lens and frost, beside SDL's view, on the slider) with a compact toolbar's 20-point - corners (measured: none 17, compact 20.5, unified 27 — the user chose compact); still to do, - context menus as popup windows (below); - - Windows: the DWM mapping, roles and policy probe; - - Linux: the blur region and the portal. -6. **Floating surfaces as native popup windows that fizzy draws** (above): context menus first — - the popup viewport, the menu copied into it, input mapped back, the display as its screen — on - macOS, then Windows (Acrylic) and Linux (the blur region); then the menu bar's dropdowns, - tooltips and popovers. -7. **Native layers as the backend's interface,** from the overlay: the drag's glass, then every - native-form surface. -8. **The other platforms' path:** where native glass can't merge, the bubble is the app's glass over - a window and a carry window outside them. -9. **The SDL patches above,** then deleting the 60 Hz pump and the private symbols; the scheduled - rebase-and-build job; upstream PRs. -10. **Peer or palette** (open question), then window-local coordinates for settled viewports — which - also gives Wayland settled floats and mixed DPI a path. -11. **Packages** (the review's shape): `tape` alone, `replay`, `window` (backend, renderer, platform, - viewports), `app` (layout, floats, pop-out orchestration and the glass overlay — `Popout` moves - out of `src/editor/`), with an app's own `main`. - -## Open questions - -- **Private API: answered.** Acceptable while stable and built to fall back (the user, 2026-10-05): - gated on a measured macOS version and on each setter, with the public style behind it. -- **Peer or palette.** The review recommends floats as peer windows by default (the main window can - come in front, each has its own Dock/Alt-Tab entry, as VS Code's and browsers' tear-offs) with a - per-float "Keep on top" done natively. Today a float is kept over the main window (#224 restored - that after the user saw one behind it at launch). -- **Colour.** The Metal layer is untagged (`layer.colorspace = nil`), so on a P3 display sRGB values - stretch to the panel's gamut and colours differ from colour-managed apps — for a pixel-art host. - Tagging sRGB is a one-time visible shift. -- **Control Center's material:** which variant, subvariant or tint it is. -- **Adopting windows:** what SDL stops doing for an external window, and whether SDL_GPU claims one - as it does its own. -- **Several screens:** one overlay per screen, and the bubble crossing between them. - -## From the 2026-10-04 review - -Fable's review of the backend and windowing (at #220) is folded in above where it shapes this plan. -What else it found, and where each stands: - -**Done since (#222–#224):** floats' macOS windows titled like the main window (the OS's corners, -shadow, resizing, tiling, Window menu); float windows wait for vsync on macOS; full-screen -auxiliary collection behaviour; one window machinery for the main window and floats on macOS. - -**Correctness backlog** (the window layer, not glass, but on the way): -- no message when SDL_GPU finds no device (old GPUs, VMs, RDP) — show `SDL_ShowSimpleMessageBox` - before quitting; -- a failed pipeline recompiles every draw (cache the failure); programs are never released (a - reloaded plugin leaks its shader); -- Windows: DPI-blind resize border (`GetSystemMetricsForDpi`), no system menu (Alt+Space, - right-click caption), maximized under an auto-hide taskbar; -- X11's 250 ms blind spot after an app placement (`SDL_SyncWindow`); -- frame pacing with the main window hidden while a float animates (Windows/X11 present without - vsync); -- bands at 100 000 px a slot cost `f32` precision by slot 6: a 32 768 stride keeps eight slots at - 1/32 px (the 40 000 "is it banded" thresholds in `Popout`/`ViewDrag` move with it — one constant); -- small ones: UTF-8-safe title truncation, clearing the clipboard, the main window's subclass never - removed, `GCLP_HBRBACKGROUND` changing SDL's whole window class, the click-through hint flipping - mid-session. - -**Performance to profile, not guess:** pointer motion walking the view tree for the Metal layer -(cache per window), window-sized targets reallocated each live-resize step (bucket them), vertices -copied twice, the stream ring cycling, per-event display-mode reads. - -**Elsewhere:** dvui internals — `Popout` empties subwindows' `render_cmds` (now in four places); -dvui's per-subwindow target PR would end that. File drops carry no position; AccessKit sees only the -main window. Web: one shared host module for `index.html` and the worker, WebGL context-loss -handling, the loader's import check across every module, reclaiming plugin memory. Workflow: a -`zig build shaders` step and `fizzy.plugin.addProgram` for one shader source on every backend, -running the Windows backend on WARP in CI, tapes as the windowing regression suite in CI (the -sandbox tapes this work used), `docs/BACKEND.md` for the backend's contract. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 795fbe83c..7a8b4a4bc 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1297,7 +1297,7 @@ that suits a disk is a spent API quota on a cloud mount. `fizzyedit/drive` (Google Drive) is the worked example; `plugins/archive` mounts a `.zip` through `core.vfs.Mem` in a few dozen lines. The design and its remaining edges are in -`docs/CLOUD_FS_PLAN.md`. +`plans/CLOUD_FS_PLAN.md`. ### 3.13 Motion — `core.motion` diff --git a/docs/POPOUT_WINDOWS_PLAN.md b/docs/POPOUT_WINDOWS_PLAN.md deleted file mode 100644 index 331eb8926..000000000 --- a/docs/POPOUT_WINDOWS_PLAN.md +++ /dev/null @@ -1,655 +0,0 @@ -# Pop-out windows: floats that leave the main window - -Agreed design, built through its third phase behind a flag. Written down because the decisions here were -reached by discarding the more obvious design (dvui's `osWindow`), and the reasoning is the -expensive part to re-derive. - -## Where it stands - -**P6, floats are windows, is on by default on macOS (2026-10-05; `FIZZY_POPOUT=0` turns it off), -and behind `FIZZY_POPOUT=1` elsewhere.** There, a view drag's glass is the OS's Liquid Glass where -it exists (`docs/NATIVE_WINDOWS_PLAN.md`; `FIZZY_NATIVE_GLASS=0` turns it off). After testing the -P3 gesture, the user's call: "we want native os windows where we can have them" — a float that is a -window from the frame it is made in, as VS Code's floating windows are, rather than one that splits -out at the main window's edge. Nearly everything that went wrong in P3's polish lived in that -crossing (the held drag, the frame the float is in neither window, the glass's look changing as it -crosses), and a window that is a window from birth never makes it. See "P6: floats are windows" -below. In-window floats stay where there are no OS windows to have (the web, Wayland): "faked" -there. - -**Phases 2 and 3 are in, behind `FIZZY_POPOUT=1`, with no dvui change.** A float dragged past the -main window's edge splits out into an OS window of its own, and merges back when let go wholly -inside it. Its popups and a view drag follow it across. On macOS its window wears vibrancy behind -its glass, and on Windows Acrylic, so it looks the same out as in, and the OS moves, resizes and -snaps its window as any other. On X11 the same gesture runs over an opaque backing, and on -Wayland floats stay in the main window. What P2 established, and what it changes -below, is in "What P2 found" at the end. What P3 changed is in "What P3 found", after it. - -**Phase 1 is built: in-window floats.** A view dropped on the middle of its own place floats into a -glass window over the layout — a place of the framework's own (`Float N`), movable, resizable, -re-dockable, closed home, remembered in `layout.zon`. The rule is in `app/layout/SPLITS.md` -("Floating a view"), the code in `app/layout/Floats.zig` and `float_rules.zig`. Everything below is -about taking such a float **out** of the main window into an OS window of its own, the way Dear -ImGui's multi-viewports do, without losing fizzy's glass: - -- macOS: the window's material is vibrancy, as the main window's is. -- Windows: Acrylic. -- Linux: an opaque window wearing glass chrome fizzy draws (X11; Wayland, below). - -A popped-out window has rounded corners, minimizes and closes with the main window, and can be -minimized and maximized itself. - -## Decisions - -1. **ImGui-style viewports, not dvui's `osWindow`.** There stays one `dvui.Window`. A float that - leaves the main window keeps its subwindow there; its draw is replayed into a second SDL window's - swapchain on the shared GPU device. -2. **The main window moving does not move them.** Popped-out windows stay where they are on the - desktop. The link to the main window is z-order, minimize and close only. -3. **A popped-out window minimizes as an OS window**, with its own Dock / taskbar entry. -4. **The float is the unit.** A popped-out window *is* a float (`Float.viewport`), so docking back, - closing home, persistence and the drag's occlusion are the same code in either place. - -### Why not `osWindow` - -dvui's `osWindow` (`OsWindowWidget`) gives each OS window its own `dvui.Window`, and fizzy's backend -already has the matching `SDLBackend.initWindowSecondary` (sharing the primary's GPU device) — -nothing calls it. It is the wrong base for this: - -- **A dialog crossing the edge would lose its widget state.** Ids, data, focus, animations and - textures belong to a `dvui.Window`; moving a subtree between two of them is a remount — the very - thing a float exists to avoid (`SPLITS.md`, on why a self-split never moves the view). -- **Plugin dylibs are pinned to the main window's context.** Each dylib's dvui is injected with one - window (`sdk/src/dvui_context.zig`); a second `dvui.Window` is a second context every plugin - would have to be told about, per draw. -- **Upstream says subwindows "are not detachable"**, and `osWindowImpl` hard-codes a new window's - position (850, 150) and caps them at five. It is a way to open a second window, not to carry one - out of the first. - -ImGui keeps one context and adds *viewports*: extra platform windows and swapchains that receive a -window's draw lists. Fizzy's natural hook is the same — dvui's deferred subwindow replay -(`Window.endRendering`, called from `core/gfx/FrameTarget.zig`'s `end`) plus `RenderTarget.offset`. - -## Phases - -| Phase | What | Ships behind | -|---|---|---| -| **P1** (built) | In-window floats | — | -| **P2** (built, flag) | Viewport infrastructure: a float rendered into a second SDL window, input routed back. A debug command "Pop Out Float" | a flag | -| **P3** (built, flag) | The gesture: a float split out as it crosses the main window's edge, merged back when let go fully inside | the flag | -| **P4** | Per-OS dressing, parenting, minimize / maximize / close with the main window | — (flag off) | -| **P5** | Hybrid frost (built), mixed DPI, Wayland | — | -| **P6** (in progress, flag) | Floats are windows: born as native, titled OS windows; the edge crossing gone | the flag | - -## The viewport model - -The backend holds, per popped-out float: - -``` -Viewport { sdl_window, swapchain, origin_px, scale, state: hidden|shown|minimized|maximized } -``` - -and the float points at one: `Float.viewport: ?ViewportId`. Its rect is in **main-window natural -units while in-window** and in **screen units once popped out** (converted at the split and the -merge, never both live). - -### The dvui-dev hook - -During `endRendering`'s subwindow replay (dvui-dev `src/Window.zig`, the loop over -`subwindows.stack` replaying each `render_cmds`), a subwindow that carries a viewport: - -- renders into that viewport's target, with `RenderTarget.offset` set to the viewport's origin in - main-window pixels (dvui subtracts it from every vertex and clip); -- is skipped in the main target; -- has the window-rect clip and clamp lifted — fizzy's `FloatingWindowWidget` clips to - `windowRectPixels()` and holds itself on screen (`placeOnScreen`), both meaningless out there. - -Shape it so it could go upstream: a per-subwindow `target: ?RenderTarget` dvui honours in the -replay is all fizzy needs, and nothing about it is fizzy's. Pinned in exactly one place -(`sdk/build.zig.zon`, `docs/DEPENDENCIES.md`). - -Two facts the hook rests on, verified in dvui-dev: a subwindow not re-added in a frame is pruned at -the end of it (`Subwindows.reset`, from `Window.end`) and comes back as new, on top — so a -popped-out float must be drawn every frame, as in-window floats already are; and `focusSubwindow` -does not raise — only `raiseSubwindow` does (dvui calls it from a window's header, Phase 1 on any -press in a float). - -### The renderer - -Today `initWindowSecondary` gives each window a `GpuRenderer` of its own sharing the primary's -device (`createWindowRenderer(…, parent.gpu)`). The viewport model wants **one renderer driving N -swapchains on one command buffer**: one frame, one submission, every texture and custom program -(`core/gfx/programs.zig` ids, the liquid glass) valid in every viewport because there is one -`dvui.Window` and one device behind it. - -- **Pacing.** The main window paces with vsync; secondary windows present `MAILBOX` or `IMMEDIATE` - (`GpuRenderer` already picks between them, around its present-mode setup), so a second swapchain - never halves the frame rate. -- **Skip what nobody sees.** Minimized or fully occluded windows are not acquired: Metal's - `nextDrawable` stalls about a second on them. -- **Transparent first.** Each viewport's target is cleared to transparent, so its frost samples - transparent pixels and the OS material shows through — the same as the main window's over - vibrancy and Acrylic. - -This needs **fizzy's backend**. It is the default on macOS, and on Windows since "build: fizzy's own -backend is the default on Windows too" (seen on Windows 11 on Arm: D3D12 on WARP, the transparent -window over Acrylic through DirectComposition). `-Dnative-backend=sdl3` (dvui's SDL_Renderer backend) gets no viewports; its -floats stay in-window. - -## Splitting and merging - -- **Split** when the float's rect leaves the main window's client area while it is being moved. - **Merge** only on release, and only when it is fully inside, with hysteresis — a float dragged - along the edge never flickers between the two. -- **Show sequence:** create the window hidden, position and size it, claim the swapchain, render a - frame into it, then show it. OS show/hide animations off. The float never blinks. -- While split and still held, the window follows `SDL_GetGlobalMouseState`, not window-relative - events (the pointer is now over a window that did not see the press). -- **The own-centre drop can go straight out.** A float whose computed rect already lies past the - edge (a small main window) can land in its own window directly, growing out of the carried glass - across the boundary. - -## Input and focus - -- Route SDL events by window id to the one `dvui.Window`, translated into main-window physical - space by the viewport's origin (today `SDLBackend.addEventWinRecursive` routes to - `child_os_wins`; the viewport path replaces that, not extends it). -- `SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH`, so the first click on a popped-out window acts. Decide the - auto-capture policy: a drag that starts in one window and ends in another must keep its capture - (a view carried out of a popped-out float onto the main window is the common case). -- Text input and the IME rect follow the focused viewport. - -## DPI - -dvui has one `natural_scale`. First version: every viewport uses the main window's. Later: replay -a viewport's commands with a scale ratio when its display differs (mixed-DPI desktops), which the -hook's per-subwindow target can carry. - -## Per-OS dressing (P4) - -**macOS.** An unparented borderless `NSWindow` with an `NSVisualEffectView` behind SDL's view -(active, the main window's material — `platform/macos/visual_effect_view.m`) and a `maskImage` for -the rounded corners (`NSGlassEffectView` on macOS 26). No AppKit shadow: fizzy draws its own in a -clear margin. Both are built, as "What P3 found" describes. Still to do: a window level and collection -behaviour that keep it with the app across Spaces, `animationBehavior` none. Not -`addChildWindow`, which drags children along with the parent — decision 2. Minimize and close with -the main window through `platform/macos/window_monitor.m`. - -**Windows.** Keep `WS_CAPTION` in the borderless style so DWM draws the backdrop (as -`win32_titlebar.zig` does for the main window); `DWMSBT_TRANSIENTWINDOW`; -`DWMWA_WINDOW_CORNER_PREFERENCE = ROUND`. The owner is the main window (z-order, minimize with it) -plus `WS_EX_APPWINDOW` for its own taskbar entry. `win32_titlebar`'s state moves per HWND (through -`dwRefData`), and `caption_buttons.zig` draws its buttons. - -**Linux, X11.** `transient-for` the main window; an opaque window with glass chrome fizzy draws, -as the main window's (`linux_titlebar.zig`'s client decorations). - -**Linux, Wayland.** Stays in-window: a Wayland client cannot position a toplevel, which is why -ImGui's SDL3 backend turns viewports off there. The escape hatch to watch is SDL draft PR #16432, -"dockable windows" (xdg-toplevel-drag). The frame insets fizzyedit/SDL already carries -(`SDL_PROP_WINDOW_CREATE_WAYLAND_FRAME_INSET_*`) are what a popped-out window's shadow would use. - -## Lifecycle - -- Minimizing the main window hides the popped-out windows; restoring it shows them. -- Each can be OS-minimized with its own entry, and maximized to its display's work area. -- Closing one sends its views home, as an in-window float's close does. -- A display that disappears brings its windows back in-window (or onto the main display), as - `float_rules.reachable` does for a saved float on a smaller window. - -## Single-window globals to refactor - -`src/backend/native/platform/window.zig`, `win32_titlebar.zig`, `linux_titlebar.zig`, -`titlebar.zig`, `geometry.zig`, `gestures.zig`, `macos_monitor.zig`, the statics in -`platform/macos/window_monitor.m`, and `src/editor/caption_buttons.zig` all assume one window. The -published style values and the plugin context do not change: there is still one `dvui.Window`. - -## Frame pacing - -One loop that wakes on any window's events. Coordinate with the frame-drop and resize work in -flight and with `docs/MACOS_LIVE_RESIZE.md` (SDL drawing each live-resize step from -`displayLayer:`): a popped-out window being resized must not stall the main window's frames. - -**Every window waits for the display on macOS** (`GpuRenderer.claimViewport`). The first cut -presented viewports without display sync, as Dear ImGui's backends do so that a second -`SwapBuffers` never adds a second vsync wait. On macOS, with a ProMotion display, it did the -opposite: the viewport's pictures reached the window server at any moment, and the main window's -`WaitAndAcquireGPUSwapchainTexture` waited up to 20 ms for its next drawable. With a float window -up, 30–95 frames in every 2 s took 12–13 ms, against 0–8 with both synced, measured in the user's -own session. Synced, the wait moves into the viewport's acquire and the frame is paced once. -Windows and X11 keep IMMEDIATE/MAILBOX until measured there. - -## Persistence - -`SavedRegion.Floating` (`src/backend/layout_file.zig`) gains -`os: ?{ x, y, w, h, display, minimized, maximized }`. A file written by a fizzy without viewports -reads past it and the float comes back in-window, as an older fizzy already reads past `floating`. - -## Testing - -The rules — split and merge thresholds, the coordinate conversions, which display a window belongs -to — in std-only modules beside `float_rules.zig`, unit-tested from `build/app.zig`. The rest is a -manual QA checklist per OS (macOS, Windows 11 x64 and Arm, X11, Wayland's in-window fallback), run -in a sandboxed fizzy driven by a demo tape where it can be (`docs/AUTOMATION.md`). - -## Risks and open questions - -- **Fork divergence.** The replay hook is a dvui-dev patch until upstream takes something like it. -- **AccessKit** is per OS window; one `dvui.Window` across several needs a tree per viewport. -- **OS drag and drop** into a secondary window (a file dropped on a popped-out explorer). -- **Spaces and fullscreen** on macOS: a popped-out window on another Space, the main window going - fullscreen. (P6: a float's window goes full screen in a Space of its own through the main window's - monitor — `macos_monitor.watch`.) -- **Focus stealing** when a split creates a window under a held drag. -- **D3D12 on hardware GPUs**: fizzy's backend has run on Windows only on WARP so far. - -Phase 1 left these for later, independent of the OS windows: - -- **Workbench drags ignore floats.** Its own file and tab drags into document panes - (`plugins/workbench/src/Workspace.zig`) do not read float occlusion yet. -- **Drop bubbles under a float.** Where a float covers part of a main-window place's drop wheel, - those bubbles still draw but cannot be aimed at. - -## What P2 found - -The spike: with `FIZZY_POPOUT=1` on fizzy's backend, the command **Pop Out Float** -(`fizzy.popOutFloat`) takes the topmost float out into an OS window of its own, and brings it back -when run again or when the OS asks that window to close (`src/editor/Popout.zig`). Checked on macOS -in a sandboxed fizzy, the Explorer floated by a demo tape and then popped out: the main window shows -nothing of the float, the new window shows all of it, and input pushed at that window as SDL events -(in-process; no OS events) hovered and expanded a folder in its tree, scrolled it, and dragged the -float by its header — the window followed by exactly the 150×80 points the pointer moved, a frame -behind it while held. Closed, the float was back in the main window with the folder still -expanded: one `dvui.Window`, so widget state crosses the edge both ways untouched. - -### No dvui change is needed - -The replay hook proposed above for dvui-dev is not needed for this: - -- `Window.renderCommands` is public, and fizzy already runs dvui's end-of-frame replay itself - (`core.FrameTarget.end` calls `endRendering`). Just before it, `Popout.endFrame` takes the - float's subwindow's queued commands (`subwindows.get(id)`, its `render_cmds` and - `render_cmds_after`), leaves them empty so the replay into the main window draws nothing of it, - and replays them itself with the render target switched to a target of the viewport's own, - `offset` at its part of the frame. dvui offsets every vertex and clip at replay - (`renderTriangles`) and the frost reads the bound target through that offset - (`BlurBackdrop.deinitFromTarget`), so nothing the float draws knows where it is going. -- The clip and clamp to the main window are fizzy's own `FloatingWindowWidget`'s - (`core/widgets/`), so they are lifted there: `detached` (no `placeOnScreen`, clipped to itself). -- If it goes upstream, the shape is still a per-subwindow target honoured in the replay; for fizzy - it would only shorten `endFrame`. - -### Bands: routing without telling dvui anything - -A float out of the main window is drawn in a **band** of the frame far past its edge — the first at -x = 100 000 physical pixels, each viewport its own (`src/backend/native/viewport_map.zig`, unit -tested). dvui routes a pointer by what is drawn under it (`Subwindows.windowFor`), and nothing over -the main window is ever under a point in a band, so no main-window pointer reaches a float that has -left, and a pointer translated from the viewport's window lands on it — with no flag on the -subwindow and nothing patched into dvui's events. (Taking the float out of hit-testing instead, -`Subwindow.mouse_events`, and tagging the viewport's events by hand would not hold: at the start of -every frame dvui tags the pointer's resting place against the subwindow stack again, so a pointer -resting over the viewport would hover whatever of the main window lies at the same point.) - -Within its band the frame follows the desktop, `frame = band + (screen − anchor) · density`, the -anchor being where the main window's top left was when the viewport opened, kept for its life — so -the main window moving does not move the windows that left it (decision 2; checked: the main window -moved by 60×20 and the popped-out window stayed put), and dvui's own drag code moves and resizes a -float out there as in the main window, its window following it (`SDLBackend.viewportPlace`). A pointer over the window goes to -`band + (window position + event position − anchor) · density`, read against where the window is -at the time, as Dear ImGui's SDL3 backend reads its viewports. Two viewports' windows can overlap -on the desktop; their floats never meet in the frame. - -### One renderer, N swapchains, as planned — without a second renderer - -`GpuRenderer.claimViewport` claims the new window on the main window's device. Each frame the -float's part of the frame is replayed into its own target (above), and `presentInto` acquires the -viewport's drawable on the frame's own command buffer and copies the target into it -(`SDL_BlitGPUTexture`, transparent where the target is); the frame's one submission presents every -window (SDL's Metal backend keeps a per-command-buffer list of them). The viewport presents -IMMEDIATE (no display sync on Metal; MAILBOX where a driver has it), and its drawable is acquired -without waiting (`SDL_AcquireGPUSwapchainTexture`): a drawable not ready skips that window for a -frame rather than holding the main one. Minimized or covered, it is not acquired at all. Cost: one -target per viewport and one copy a frame — the main window's own `FrameTarget` blit, again. -Per-window renderers (`initWindowSecondary`) were not needed: they would share textures (one -device) but not pipelines or program ids (`core.gfx.programs`), and two command buffers a frame -would have to be ordered against each other's uploads. - -The window is created hidden, claimed, handed a frame, and shown after that frame is presented, -without taking the keyboard from the main window (`SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN`); its first -click acts (`SDL_HINT_MOUSE_FOCUS_CLICKTHROUGH` — every window's, while a viewport is open). Keys -and text from it go to the one dvui window, and text input starts in whichever window has the -keyboard, its IME rect moved into that window's points (written, not exercised in the sandbox). - -### Surprises - -- **Glass reads the desktop as nothing.** The viewport's target is transparent, so the float's - frost reads transparent pixels and its glass came out clear: the Explorer's text floated over - whatever was behind the window. Until P4 gives the window a material to show through, the - float's window is backed by the chrome's colour, opaque (`Popout.backing`), and the glass over it - reads as a panel of the main window does. The backing must cover the window's margin too — the - frost's blur and bevel read past the glass's edge, and a transparent ring there faded every edge - to see-through; the four corners still fade a little, outside the backing's curve. -- **The float's popups stayed in the main window.** Menus, tooltips, the picker, a dialog and a - view drag's drop zones are each a subwindow of their own, placed and clamped on the main window - and drawn there. Dear ImGui gives a popup its parent's viewport. Fixed since: the float's window - is a screen of its own (`core.screens`) that its popups are placed on and kept within. Every - subwindow whose middle is in the float's part of the frame is replayed into its window, in - stack order. -- **A demo tape owns the pointer while it plays.** The player swallows real pointer motion while a - tape plays and takes a real press as the user diverging (pause, then a re-seek from a snapshot on - resume), and input from a viewport is real input to it: a run that pushed SDL events at the - viewport mid-tape seeked back to before the float existed, and the viewport went with its float. - A test of viewport input lets its tape end first. -- `CGWindowListCopyWindowInfo` reports a sandbox's windows off screen while they are on another - Space; `screencapture -l` captures them anyway. - -### Not done - -- A view dragged out of a float that is out would not land in the main window: macOS keeps - sending a held pointer to the window it was pressed in, and its translation kept it in the band. - Fixed since: while a button is held, the pointer is placed by the window it is over - (`SDLBackend.heldPoint`, from `SDL_GetGlobalMouseState`), and a view drag's layer is drawn on - every screen (`core.screens.markEverywhere`). -- The window is moved and resized only by dvui (header, edges); the OS does neither, and there is - no OS shadow, rounded mask or material (P4). Tried on Windows 11 (hardware GPU): the right and - bottom edges resize steadily, but the left and top edges change the window's place and size in - two SDL calls, and it shows moved and not yet sized for a moment between them. Doing both in one - `SetWindowPos` behind SDL's back is worse: SDL never learns the new size, its swapchain stays the - old one (the picture cropped in a bigger window) and its idea of the size goes stale. The fix is - the OS resizing the window — its edges hit-tested (`WM_NCHITTEST`), as the main window's chrome - is (`win32_titlebar.zig`) — not the app moving it under SDL. -- A popped-out window's pointer is read from the desktop (`SDL_GetGlobalMouseState`), not from - the event plus where the window is: the window moves under an edge or header drag, and an event - queued before a move, read against the window after it, set the edge oscillating. -- On Windows the window is owned by the main window (`SDL_SetWindowParent`), so it stays over it - and hides and minimizes with it — but has no taskbar button of its own yet (P4). Not on macOS, - where SDL's child window would move with the main one (decision 2). -- Out, the float draws no frost, shadow or margin (they read and drew into the transparent - window's empty edges and corners): its panel alone on the opaque backing, until P4's material. - On macOS it has its shadow and frost since ("What P3 found"). Elsewhere the shadow is drawn, - and the frost waits for a material. -- One float out at a time in the spike (the backend holds eight). Out, a float is not remembered: - `layout.zon` keeps its in-window rect, and it comes back in on the next launch. -- One density, the main window's, fixed when the viewport opens (P5). -- macOS only so far; Windows, X11 and Wayland's in-window fallback not tried. - -## What P3 found - -With `FIZZY_POPOUT=1`, a float held past the main window's edge, being moved or resized, splits -out into a window of its own. So does one held with the pointer past the edge, since a float is -held on the main window by all but a strip of itself. Let go outside, it settles there. Let go -wholly inside, it merges back. A settled float let go wholly inside comes back too, as the command -does (`src/editor/Popout.zig`). There is no transition, as in Dear ImGui: its window shows it -exactly where it was drawn, before and after. Checked on macOS with demo tapes, which put input -straight into dvui. - -### Coordinates change only at rest - -The plan converted a float's rect "at the split and the merge". Converting under a held drag does -not work: dvui moves a float by the pointer's motion since the press (`FloatingWindowWidget`'s -drag), so a rect moved into a band mid-drag sent the next motion across the frame. - -- **Split.** A float split under a drag stays in the main window's frame, which runs on past its - edge across the desktop: `frame = (screen − main origin) · density` - (`viewport_map.mainFromScreen`, unit tested). Its window follows it there - (`SDLBackend.viewportPlaceMain`), and only its own subwindow is taken into the window, by id. - Everything else in that frame stays the main window's, and nothing opens from the float until - it settles (`core.screens` cleared). -- **Release.** Only when let go does the float move frames. Outside the main window, it goes into - its band at the same place on the desktop, its window unmoved (`viewportBandFromMain`). Wholly - inside, it goes nowhere: it merges where it is. -- **Pointer.** While the float out is being moved or resized, a held pointer is read in the frame - it is drawn in, from the desktop (`viewports.pinPointer`): the main window's while split, its - band once settled. It does not jump between frames as it crosses from one window into the - other. - -### No blink - -- A new window is created hidden and shown after the first frame it is handed (P2). Until it has - shown one, the float is copied into it and also left in the main window's replay - (`viewports.shown`), so it is never on screen in neither. -- Merging, or brought back by the command, the float's window is let go a frame later, once the - main window has drawn it (`Popout.closeAfterFrame`). - -### The macOS dressing - -- **One look in and out.** The window is clear and has no AppKit shadow. It is the float's rect - grown by a clear margin (`Floats.outReach`, its shadow's reach), and the float draws there the - shadow it draws in the main window (`FloatingWindowWidget.InitOptions.detached_reach`). -- **Vibrancy.** The main window's material sits beside SDL's view and under it, in the window's - frame view, rather than being made its content view as the main window's is. It is masked to - the glass's rounded rect with a stretchable image, so the margin stays clear through a resize. - SDL's view stays its content view, and SDL tears down the window it made. The float frosts over - it as it does in the main window (`Floats.Viewport.material`). Split under a drag, its frost - still reads the main window's frame, so what is over the main window reads the app and what is - past it reads the vibrancy. -- **Matching the main window's glass, then not (#222, P6).** For a while the pop-out imitated a - glass window in the main window: its frost read the main window's picture behind it (hybrid - frost), the main window cut a hole under its glass and its vibrancy was masked off the main window - so its colour matched, and the main window's place was read from the window server so all of that - kept up with the OS moving the windows. It never quite could: the OS moves a window faster than - fizzy draws it, so the blur trailed the window and its rim streaked a pixel at a time. Once floats - were windows from birth (P6), the user's call: "now that its real windows, we stop trying to - match it to in-app dialogs and just draw it the same as the main window". All of that is gone. -- **Autorelease pools.** Objective-C called from the frame loop needs an autorelease pool of its - own, because SDL wraps only its own calls. Without one, the subview arrays AppKit autoreleased - kept the window SDL closed alive in the window server after every pop-in. -- **No OS animation.** AppKit's show and close animations never finished under fizzy's frame loop. - The stand-in window they draw stayed on screen: shrunk while the float was out, and after the - pop-out window had gone, where it first opened. The animation is off - (`NSWindowAnimationBehaviorNone`). -- **Over the main window.** SDL orders a window it shows without activating it below the key - window, the main one. The pop-out is ordered back above it, and again whenever it has fallen - behind (a press on the main window, a document opened from Finder raising it): the behaviour of - an owned window on Windows, without the child window AppKit would move with the main one. -- **Drawn as the main window.** A float in a window the OS frames has no glass of its own: the - window's base stands under its content, its chrome at the window's opacity over the material - (`Popout.backing`), as the main window's content stands on its base, and the OS draws the shadow. - The OS's blur of what is behind the window keeps up with any move, as the main window's does. - -### Not checked - -- **A real pointer.** The tapes put input straight into dvui, so only a real drag exercises the - OS's routing of a held pointer across two windows and AppKit's tracking of it. -- **The vibrancy by eye.** The sandbox's windows were on another Space in these runs, and - `screencapture -l` could not take them. -- **`NSGlassEffectView`** (macOS 26). - -### Windows: the window is the glass - -A DWM backdrop fills the whole window, so the clear margin macOS keeps for fizzy's own shadow would -come out frosted. On Windows the window is exactly the float's glass (`viewports.os_frame`), and -DWM dresses it as it does the main window (`win32_titlebar.viewportChrome`): - -- **Material.** Acrylic, through the frame extended over the whole window. -- **Corners and shadow.** DWM rounds its corners to 8 points, fizzy's surface radius as designed, - and draws its shadow. -- **No border, no system menu.** With the frame extended, DWM drew the caption buttons of SDL's - `WS_SYSMENU` over the float's header. - -On either OS the material follows the app's light or dark theme, not the system's. - -### The OS moves and resizes the window - -A settled float's window is moved and resized by the OS, as any window is: Aero Snap, half the -screen, maximized at the top, Win+arrows, macOS tiling. - -- **The hit test.** Each frame the float says where its header, its header's close button and its - glass are (`viewports.hints`). SDL's hit test answers the OS from that (`viewport_map.hitTest`, - unit tested): the header moves the window, and the glass's edges resize it. The window is - created resizable, since only a window the OS may resize snaps or tiles. -- **macOS.** SDL there takes only the move from the hit test (AppKit's window-background drag); - a titled window (P6) resizes from its edges by itself. Having the app move the window instead, - in the transaction its picture is presented in, was tried: in step, but no tiling, and the window - lagged the pointer by the app's frame. -- **The float follows its window.** When the OS moves or resizes the window, the float's rect - follows it (`viewports.osPlaced`). The app's own placement, reported back, is told apart by - comparing it with where the app last put the window. On X11, where placing a window is - asynchronous, reports that come soon after the app's own placement are ignored too. -- **Merge after an OS move.** A move the OS made, let go wholly inside the main window, merges as - one of dvui's does (`viewports.osMoveEnded`). A resize does not, nor a move that snapped or - maximized the window as it was let go. On Windows the `WM_SYSCOMMAND` that starts the loop says - which it was: Windows sends `WM_SIZING` when a snapped window takes back its size as it is - dragged off its snap, though that is a move. Elsewhere the size before and after decides. -- **The handoff.** The drag that splits a float out began as dvui's, in the main window, before - its window existed. On Windows, once the new window has shown a frame, that drag is handed to - the OS (`viewports.dragMove`): dvui's capture ends, the float settles where it is, and the window - takes a caption press where the pointer is, so the OS's move loop carries on from there. Frames - go on during the loop: SDL runs them from the loop's timer (fizzy uses SDL's main callbacks on - Windows), and a frame in the loop does not wait for events (`SDLBackend.inLiveResize`). - Elsewhere the app moves the window until the release, and the OS from the next press. - -Checked on Windows 11 on Arm in a VM, with real input inside the VM (`SendInput`): - -- the Explorer's float dragged past the main window's edge split out, and its window went on - following the pointer under the OS; -- dragged on to the screen's left edge, Windows showed its snap preview, and let go there the - window took the left half, the float laid out to it; -- dragged off the snap, it took back its size, and let go over the main window it merged; -- pulled by its left edge, the OS resized it with its right edge still. - -### Linux - -- **X11.** The split, the settle and the merge run as on the other two. One difference: Vulkan - hands a window no swapchain image while it is hidden, so a viewport's window would never get the - first frame it waits for to show. There, the window is shown empty, which is clear, and drawn - into from the next frame (`Viewport.mapped`). The float stays in the main window's picture until - its window has shown a frame, so it never blinks. Checked on Ubuntu in a VM through XWayland, - Vulkan on llvmpipe, by the tapes, with the window's position sampled as it moved. -- **Wayland.** A client cannot place its windows, so viewports are off there - (`viewports.available`), and floats stay in the main window. - -### Hybrid frost: the same glass in and out - -*Removed in P6: a float in its own window is drawn as the main window is ("Matching the main -window's glass, then not", above). Kept as the record of what was tried.* - -A float's glass is made as see-through as what it reads, and frosts and refracts it. In the main -window it reads the app; out of it, its window's own pixels. With nothing there it drew nothing; -with the main window's base stood behind it (`Popout.backing`) it drew a tinted pane. That pane -was right over the desktop but not over the main window, where in-window the glass had frosted -the app under it, so the look changed the moment a float split out. (At a window opacity of 0 the -two looked nearly the same: a pop-out's vibrancy blurs whatever is behind its window, and over the -main window that is the main window, which is what the frost does in it.) - -Fizzy draws the main window every frame, so where a float's window lies over the main window it -has the picture behind it. The glass's frost reads that instead of its window's pixels: while the -float's drawing replays into its window's target, `BlurBackdrop.behind` names the float's pane, and -its capture comes from `Popout.behindGlass` rather than the target. That builds, over exactly the -rect the frost captures (the margin past the rim included), the main window's picture where the -main window lies under the float's window (`FrameTarget.frameTexture`: the frame drawn so far, -before the deferred subwindows replay; its place in the main window's frame is the window's part -of the frame while split under a drag, and where its window is, `viewports.inMain`, once settled), -and the main window's base past its edge, where the desktop is and nothing of fizzy's sees. The -picture is never shown. The glass then frosts, bends and lights exactly what it does in the main -window, its rim included: past the rim it reads the picture too, where its window holds only the -clear margin its shadow is drawn in. Read from the window's own pixels, the rim bent nothing in -and the glass looked a flat blur. - -Checked on macOS with frame dumps. On the frame a float splits out it is drawn twice, in the main -window and in its own, and the two pictures of its glass differ by under 1/255 on average: the same -blur, glows, rim light, and the same text bent at its left rim. Settled straddling the main -window's right edge, the panel's green "All" button shows blurred behind its glass and bent at its -rim exactly where the button is in the main window, and the base past the edge. - -What it does not do yet: - -- **Deferred subwindows under it.** The picture is the frame before dialogs and other floats - replay, so a float out over another float frosts the layout, not that float. - -## P6: floats are windows - -Where the platform has OS windows (`viewports.available`: macOS, Windows, X11), every float is one, -from the frame it is made in. - -**Built (first step, macOS):** - -- **Born as a window.** A float made by a drop on a view's own middle, or brought back from a saved - layout, goes into a window of its own before it is ever drawn in the main window - (`Popout.beginFrame`), at its whole rect (not the carried glass it lands from). One window per - float (`Popout.outs`, up to the backend's eight). The held drag, splitting at the main window's - edge and merging back are gone, with `Float.split` and the "Pop Out Float" command: a float's - views go back into the main window by being carried there, and an emptied float's window closes. -- **A window a view is carried out of goes to its ghost**, as a float in the main window does - (`ViewDrag.settleGhost`): the whole OS window fades to the ghost's alpha, material and shadow too - (`viewports.fade`, `SDL_SetWindowOpacity`), and while it is a ghost a held pointer over it is read - as over the main window beneath (`viewports.seeThrough`; `SDLBackend.heldPoint` skips it), so the - places it lies over can be seen and dropped on. The drag reads such a float in both frames - (`ViewDrag.Occluder.band_bounds`): its band, where it is drawn and the pointer is read while it is - firm, and where its window lies over the main window (`Floats.Viewport.main_delta`), where the - pointer is read while it is a ghost and where the main window's drops slide clear of it. It firms - by the same rules — at once with nothing under it, after a rest or on its header otherwise. Over - the ghost the carry window shows what is carried, which the main window's drawing would show only - faintly through it. -- **Titled, as the main window is (macOS).** Created titled, not borderless - (`SDLBackend.viewportOpen`), and dressed as the main window: content under a transparent title - bar, the title's text hidden (the float's header shows it), the OS's traffic lights, shadow, - corners and resizing from every edge (`fizzy_macos_viewport_glass`). The window is the glass - (`viewports.os_frame`): no clear margin, no shadow of fizzy's, and the float resizes nothing - itself. Its header draws no close button beside the traffic lights (`viewports.os_buttons`); the - red one closes the float, its views going home (`Floats.Viewport.close_asked`), at once, as any - window closes — no fly-shut. -- **Drawn as the main window.** No frost, rim or shadow of the float's own in a window the OS frames: - its content on the window's base over the OS material, as the main window's content is. The hybrid - frost, the hole and mask under it in the main window, and reading where windows are from the - window server are gone with it. - -- **Carrying a view outside every window (macOS).** While what a view is carried as lies past the - main window, and over no float's window, a carry window shows it there (`Popout.carryFrame`, - `viewports.openCarry`): a round window of its own, the shape of what is carried (a drop's head, - a card, a tab) — the main window's material masked to that shape and the OS's shadow round it - (`viewports.carryShape`, changed in the transaction its place and picture are) — the pointer - passing through it, at a pop-up menu's level above every window, in no window list. The drag's - drawing, recorded in the main window's frame past its edge (`core.screens.publishBeyond` lets it - reach there), is copied into it over the main window's base, as a float's window stands on it. - Once any of it is past the main window the carry window shows all of it, over the main window too. - A drop's tail stays in the drop's shape out there. Let go over no window of the app's, - the view floats where it was let go (`ViewDrag.floatAway`, `State.floats_windowed`): a float of - its size round the pointer, in a window of its own. An OS drag-and-drop session was the - alternative: its image glued to the cursor, but a still picture, no liquid glass. Not yet: - straddling a float window's edge it is cut at that edge. - -- **One window machinery for every window (macOS).** A float's own window is dressed and followed - as the main window is, not by a copy of it: one base colour (`Editor.windowBase`), one skin — its - background, the title bar AppKit draws, and its light or dark appearance (`platform.window.skin`, - re-applied to every float window when the theme changes) — and one monitor through Spaces, zooms - and live resizes (`macos/window_monitor.m`, `macos_monitor.zig`), its state per window instead - of one window's globals, with one pump for all of them. So a float's window goes full screen in - a Space of its own as the main window does, several at once: the monitor feeds its live size - and place into SDL through the animation (`macos_monitor.watch`), the float follows the window - there and back, nothing places it meanwhile (`osOwnsFrame`), it is drawn opaque there as the main - window is, and it is not ordered against the main window across Spaces. A plain un-zoom used to - leave the monitor's pump, and every frame of the app, running for good; its flag now ends with - its frames. -- **Growing out of the carried glass (macOS).** A float a drop makes opens as one in the main - window lands (`Floats.Landing`): its window shows nothing while a carry window — the drag's own - when it had one (`Popout.spare`), already at the drop — grows from the drop to the window's frame - and rounds to its corners (`viewports.windowRadius`, 16 points from macOS 26), the carried - photograph fading out in it (`Popout.growFrame`). Landed, the window shows under it and it fades - off in 120 ms (`viewports.carryShape`'s alpha, in the transaction with its shape). Growing the - titled window itself would have shown its traffic lights in a window the drop's size, square. - The float is drawn whole in its band all along, so the window has its picture the frame it shows. - -**Next:** - -1. **Windows and X11 chrome.** Windows: the float's header draws caption buttons, as the main - window's does (`caption_buttons.zig`). X11: client-side decorations, as the main window's. And - the carry window there, which the opening grows in too. -2. **Persistence of where the windows are** (`SavedRegion.Floating.os`): a saved float comes back - in its window where it was on the desktop, not where it would be over the main window. - -## Next steps - -1. **The look on Windows with a GPU.** The VM draws Acrylic as its solid fallback, and its rounded - corners only partly. -2. **macOS and X11 with a real pointer.** On macOS: the AppKit drag of the header, tiling, and - whether a handoff at the split can be done there (`performWindowDragWithEvent:` takes the press - that began the drag). On X11: the hit test's `_NET_WM_MOVERESIZE`, the clear margin with and - without a compositor (without one it would show black), and a handoff through it. -3. **Persistence** (`SavedRegion.Floating.os`). -4. **P4's lifecycle.** Minimize, maximize and close with the main window. Done of it: a pop-out - is called what its float's header says, has a taskbar button of its own on Windows (an owned - window with `WS_EX_APPWINDOW`, the main window's icon on it; checked in the VM, its thumbnail - beside the main window's), and is in the Window menu and the Dock's on macOS. On X11 it is - transient for the main window, which keeps it out of most taskbars. diff --git a/docs/REVIEW_2026-09.md b/docs/REVIEW_2026-09.md deleted file mode 100644 index afc111af7..000000000 --- a/docs/REVIEW_2026-09.md +++ /dev/null @@ -1,693 +0,0 @@ -# Architecture review, 2026-09-21 - -A read-only review of fizzy (working copy on top of bookmark `fizzy-lib`, SDK 0.2.0 unreleased), -the four out-of-tree plugins (`fizzyedit/{zig,ghostty,pixi,atlas}`), and the new cloud-fs work -(`fizzyedit/drive` + `plugins/{archive,drive}`), against the idea fizzy is meant to be: a -near-empty native host that installs and updates itself and its plugins, reloads them live, and -can develop plugins for itself. Every finding was read in the code; line numbers are from the -working copy on the review date and will drift. - -Gates on the review date: `zig build`, `zig build test`, `zig build check-web` (17/17), -`zig build test-sdk-version` all pass. `drive`: `zig build test` + `check-wasm` pass. All -four out-of-tree plugins build against `../../fizzy/sdk` (18–26 s each, fingerprint -`0xfce7273c34e25eab`). - -**Side effect of the review:** building the four plugins ran their default install step, which -copies the dylib into `~/Library/Application Support/fizzy/plugins//`. The installed copies -are now local-SDK builds, not store releases (see H-plugins §gaps, item 9). - ---- - -## 1. The verdict in one paragraph - -The essence is built and holds. The host owns no features; regions/keywords/surfaces made the -workbench's tab system a plugin-declared region without a special case; services are versioned -and multi-provider; the fingerprint gate, the stable-copy dylib loader, the store pipeline, the -watchers and the ZON settings surgery are each careful, documented answers to a real bug. What -is not finished is the *edges of the contract*: the fingerprint misses several by-slice types, -live unload can leave function pointers into a closed image, a handful of pixi/workbench-shaped -members still sit on `EditorAPI`, and the cloud-fs layer has a cluster of lifetime bugs in its -completion/cancel paths that the tests do not reach. None of it is structural. The strategic -question (web) has a better answer than expected: the desktop dylib model translates to wasm -dynamic linking almost unchanged, and it was demonstrated end-to-end under node during this -review. - ---- - -## 2. Web strategy (the user's questions, answered) - -### 2.1 Can a plugin be compiled to wasm and loaded at runtime? Yes, with today's toolchain. - -Measured, not assumed. In the scratchpad, Zig 0.16 produced an Emscripten-style **side module** -from `zig build-lib side.zig -target wasm32-freestanding -dynamic -fPIC -fno-entry`: a -`dylink.0` section, imports for `env.memory`/`__indirect_function_table`/`__stack_pointer`/ -`__memory_base`/`__table_base`, and an exported `__wasm_apply_data_relocs`. A main module built -with `--import-table` (the JS-owned table must be growable; `--export-table` emits `max == -initial` and `table.grow` throws) and `--export=__stack_pointer` loaded it, applied relocations, -and **called a plugin function through a function pointer read out of relocated plugin data**. -Constraints found: `-fPIC` is mandatory (without it wasm-ld refuses `R_WASM_MEMORY_ADDR_LEB`); -it goes through LLD, which prints `creating shared libraries, with -shared, is not yet stable`. - -Why the SDK boundary survives unchanged: the desktop design *already is* the MAIN/SIDE model — -host and plugin each compile their own `dvui`/`sdk`/`core`, the host injects pointers -(`sdk/src/dylib.zig:1-9`, `dvui_context.zig:16-33`), and the proxy dvui backend a plugin links -has **zero `env` function imports** (everything goes through `RenderBridge` fn pointers). On -wasm: vtable fn pointers become indices in the one shared table (`call_indirect` type-checks -them; same compiler ⇒ same signatures); data pointers and slices are offsets in shared linear -memory; `dvui.io`/`dvui.debug` copy by value with identical layout; the fingerprint is -target-invariant by design so it matches as long as the web host ships the store's -optimize class (ReleaseFast/Small); `threadlocal` collapses to a global because the web exe is -`single_threaded` (`build/web.zig:42`). - -What stays impossible on the web regardless: **unload** (no `dlclose`; disable = hide), threads -inside plugins (no SharedArrayBuffer without COOP/COEP), processes (LSP, terminal, git), libc -(`wasm32-freestanding`), OS file access beyond picker/download. - -### 2.2 Separate wasm instance per plugin (own memory)? No. - -Everything crosses by pointer: `*Host` and its ArrayLists mutated from the plugin image -(`Host.zig:136-253`), `EditorAPI`'s 70 fn pointers returning slices into host memory, -`DocHandle.ptr`, immediate-mode `Surface.draw`/`drawDocument` into the host's live -`dvui.Window`, `RenderBridge` vertex/pixel buffers, allocator vtables, `settings.Access` on -plugin-owned values, `ServiceEntry.ptr` shared plugin-to-plugin, `core.FileTable` called with no -vtable. An out-of-process model means turning dvui's immediate mode into a command stream. That -is a different product; not recommended. - -### 2.3 Hosting each plugin at its own URL? The store is ~80 % there. - -Releases are keyed per `os-arch` in `ShardRelease.downloads` and sharded per fingerprint -(`app/store/registry/registry.zig:20-22, 70-114`), so `"web-wasm32"` is an additive key. On -wasm the store already browses `summary.json`/`releases.json` through `web_fetch` -(`PluginStore.zig:35, 1254-1259`; `web/index.html:307-325`); install is gated off -(`uninstalledCatalog` → `&.{}` at `:192`, `queueInstall` `:213`, `startDownload*` `:1538,1552`), -`compat.hostKey()` yields `"unknown-unknown"` on wasm (`registry/compat.zig:14-27`), and -`web_fetch.max_bytes` is 2 MB (`web_fetch.zig:29`) — too small for a plugin `.wasm`. Release -CI cross-compiles with `-Dtarget=`; `wasm32-freestanding` is a seventh entry, and -`plugin_sdk.install`'s `pluginExt` needs `.freestanding → "wasm"`. - -### 2.4 Recommendation and order - -1. **First: make static web bundling a one-liner for authors** (§2.5). It is the production-grade - path today, the prerequisite for the "plugin app" the user wants, and it is how pixi gets a - web build this month. -2. **Then: runtime wasm side modules as a `web-wasm32` store target.** The mechanism works; ship - it behind the store so a bad wasm-ld build is "no web download for this release", not a broken - web build. Steps and cost: - - `sdk/plugin_sdk.zig create`: on wasm32 set `pic = true`, `entry = .disabled`, - `single_threaded = true`, `link_libc = false`, name `{id}.wasm`; keep `export_symbol_names`. - No fingerprint change. ~1 day. - - `build/web.zig`: `import_table = true`, export `__stack_pointer`, add - `FizzyWebPluginAlloc`-style exports; ReleaseFast/Small for the optimize class. ~½ day. - - JS loader in `web/index.html` (~150-200 lines): growable `WebAssembly.Table` before - `instantiateStreaming`; `fizzy_web_plugin_load(id, url)` → fetch → alloc `__memory_base` → - `table.grow` for `__table_base` → instantiate with the 5 `env` globals → - `__wasm_apply_data_relocs` → `table.grow(1); table.set(i, fn)` per `dylib_exports` entry → - `FizzyWebPluginReady(id, idx[10])`. ~2 days. - - `app/store/PluginLoader_web.zig`: a `WebDynLib` whose `lookup(T, name)` returns - `@ptrFromInt(table_index)`; `loadAndRegister`'s sequence (`PluginLoader.zig:332-460`) runs - unchanged; drop the wasm early-returns in `App.zig:942-958`; `Editor.loadUserPlugins` - (`Editor.zig:588`) becomes async. ~3 days. - - Store: `hostKey()` → `"web-wasm32"`; enable install on wasm via `web_fetch` (raise the cap or - stream); in-memory installs, optionally Cache-API by `id@version@fingerprint`; enabled set - in `localStorage` (settings.zon is in-memory on web, `Settings.zig:114`). ~3 days. - - `plugin-build-action`: add the target. ~1 day. - Realistic: 2–3 weeks plus a budgeted week for wasm-ld `-shared` surprises. -3. What "a flourishing web build" realistically is: **a per-plugin app, not a second full IDE.** - The web has no project folder, no processes, no persistent config, and one thread, so the - workbench-centric shell is permanently second-class there. A pixi-shaped or atlas-shaped app - (one document type, mounts for files, download or Drive to save) is fully within reach, and a - store of runtime wasm plugins then lets *that* app grow. - -### 2.5 "Plugin app for free": how far it is, what is missing, proposed API - -Native is already all the way there: `examples/minimal-app/build.zig` is 36 lines (`defer-app`, -`fizzy.buildApp(fizzy_dep, &.{ .{ .name, .module = dep.module("plugin") } })`, -`fizzy_dep.artifact(name)`); `plugin.create` exports the `"plugin"` module -(`plugin_sdk.zig` `PluginArtifact.static`) and `build/exe.zig:200-232` bundles it. - -Missing, native: -- The plugin zon depends on `sdk/` only (by design). The app needs the fizzy **root** package, so - the plugin needs a second `.lazy = true` dependency on it, resolved only by `zig build app`. - (The lazy-dep-in-global-cache trap from CLAUDE.md applies: the plugin's `zig-pkg` grows a - Velopack tree. Cosmetic.) -- A default shape. Add one shipped shape in the copy-don't-configure spirit, - `app/layout/shapes/PluginApp.zig`: one region per distinct keyword set in `host.surfaces` - (a plugin with `sidebar` + `main` surfaces gets a tray and a center; `main` only ⇒ minimal). -- Built-ins are bundled unconditionally (`build/exe.zig:170-179`) and `Editor` still holds - `workbench: Workbench`. A `builtins:` list on `buildApp` follows the LIB_CHECKPOINT item that - moves workbench state behind `Host`; do not gate the plugin-app on it. -- `build/package.zig` hardcodes `--packId fizzy` (`:105-106`), exe name (`:116-117`), - `assets/windows/fizzy.ico` (`:149-150`), plist + `.icns` (`:160-166`), entitlements (`:177`). - Parameterize from `AppInfo` (~1 day); a plugin-app can ship without installers first. - -Missing, web (the real gap): -- `build/web.zig` is entirely fizzy-shaped: root `src/web_main.zig` (`:37`), six built-ins listed - by hand (`:100-140`), `app_layout`/`app_plugins` never passed (`build/app.zig:324`), fixed - export list (`:55-70`) and `index.html` template. **A consumer with `app-layout` set cannot - compile the web exe at all**: `build_opts.has_app_layout` is true (`build/app.zig:197`) and - `Editor.zig:3400` does `@import("app_layout")`, which the web graph never wires. -- `web`/`check-web` are `b.step` on fizzy's *dependency* builder (`build/web.zig:158,193`); Zig - runs only the root package's steps, so `zig build web` in an example does not exist. Return a - step/LazyPath the consumer hangs its own step on. -- `build/exe.zig:171-178` still special-cases "markdown null on wasm"; stale, harmless. - -Proposed minimal API (all additive): - -```zig -// plugins//build.zig -const created = fizzy.plugin.create(b, .{ .target = target, .optimize = optimize }); -fizzy.plugin.install(b, created.lib, .{}); -// needs `.fizzy_app = .{ .url = , .lazy = true }` in build.zig.zon -fizzy.plugin.app(b, created, .{ - .name = "pixi", // exe name, bundle id, config dir - .layout = null, // null ⇒ shipped PluginApp shape; or b.path(...) - .builtins = &.{ "workbench", "text" }, // later, once workbench leaves Editor -}); -// registers `zig build app` (native exe) and `zig build web` (zig-out/web/), later `package` -``` - -Under it: `fizzy.buildWeb(fizzy_dep, plugins) -> *Step` beside `buildApp`, with `web.addSteps` -taking `app_name`, `app_layout`, `app_plugins`, `index_html`, `extra_exports`. About a week for -native + web without installers. - -Wasm readiness of what exists: all six built-ins compile for wasm (`check-web` green). Out of -tree: **pixi** yes today via `buildApp` (30+ `wasm32` gates, in-memory zip, frame-sliced -packing, `web_file_io`; nobody has tried since the extraction — add a `check-web` step to its -CI); **zig** highlighting plausible (grammar is C, comes through dvui's tree-sitter option), -LSP never; **atlas** plausible with the plan in §4 (sqlite → in-memory backend on web, walk via -the mount layer, threads → frame-sliced); **ghostty** no, short of a WebSocket PTY backend -(`Pty.zig`'s per-OS switch is the seam). - ---- - -## 3. Host findings (sdk/, app/, core/, src/, build/, bundled plugins) - -### High - -- **H1. The ABI fingerprint does not cover `Surface`** or `Host.Painter`/`Painter.Subject`, - `Host.FileKind`, `Host.ServiceEntry`, `Plugin.PathEvent`, `EditorAPI.SaveDialogFilter` - (`sdk/src/dylib.zig:86-169` lists boundary types; each of these is reached only through a - slice/ArrayList, which `hashTypeShape` never follows, `fingerprint.zig:114-121`). The - workbench dylib reads `Surface` fields off `regionMatching`'s slice - (`plugins/workbench/src/Workspace.zig:114`). Adding a field to `Surface` today silently shifts - the layout every installed plugin sees. Fix: list them, and add a comptime test that walks every - `Host` container's element type and asserts membership. -- **H2. `Host.selections` keeps a borrowed id slice that `unregisterSurface` frees.** - `setSelectionForKey` stores the registry entry's `s.id` (`Host.zig:1361-1367`); for document - surfaces that slice is gpa-owned and freed in `Editor.unregisterDocSurface` - (`Editor.zig:2171-2176`) right after `Host.unregisterSurface`, which leaves selections alone - (`Host.zig:1102-1109`). Next `selectionForKey` compares freed memory. Same family: - `orderedRemove` on a by-value `ArrayListUnmanaged(Surface)` (`Host.zig:243`) means every - `*Surface` handed out this frame aliases the next entry after a close (the `tabs` slice in - `Workspace.drawTabs`'s deferred `processTabsDrag`, `Workspace.zig:330-333`). Fix: intern - selection ids as `State.internName` already does, drop selections naming a removed id, and give - `Surface` stable storage (pool/segmented list). -- **H3. Live unload can leave function pointers into an unmapped image.** - `Editor.unloadPlugin` (`Editor.zig:1379-1432`) unregisters, `deinit`s, `lib.close()`. Not - swept: dvui `dataSetDeinitFunction` entries (text's `TextEntryWidget` sets one), open - dialogs/toasts whose `displayFn` lives in the image (`core.dialogs.dialog` is compiled into - every image; `Host.requestNewDocument` invokes it from whichever image called, - `Host.zig:1488-1503`), queued dialog-result callbacks (`Editor.zig:3125`), - `Host.pending_new_document_owner` (not cleared in `unregisterPlugin`, `Host.zig:738-773`), and - mounts (`Host.mount`, `Host.zig:563`, has no owner, so `FileTable` keeps a `vfs.Fs` vtable into - freed code). The exit path already avoids `dlclose` for the data-store hazard - (`Editor.zig:690-706`); the live path does not. Fix: owner-scoped sweep in `unregisterPlugin` - (give `mount` an owner), refuse unload while an image-owned dialog is open, document - `dataRemove` obligations in `deinit`. -- **H4. Partial registration + `err_register` leaves dangling contributions, then closes the - image.** Bundled `register`s call `registerPlugin` first, then several `try host.register*` - (`plugins/image/plugin.zig:66-70`). A later failure returns `err_register` - (`dylib.zig:380-387`); `loadAndRegister` maps it to `RegisterRejected` and `errdefer - lib.close()` (`PluginLoader.zig:358, 435-441`) while `Host.plugins`/`surfaces`/`services` - still point into it. Fix: on any non-ok status `unregisterPlugin(pluginById(expected_id))` - before closing; better, `exportEntry.register` does it plugin-side too. -- **H5. Store install joins the registry's plugin id into filesystem paths unvalidated.** - `PluginStore.buildJob` (`:1581-1590`) joins `/plugins//.` with `id` from - the catalog; `uninstallPlugin` `deleteTree`s `/plugins/` (`Editor.zig:1495-1499`). - `App.isValidPluginId` (`App.zig:353`) guards disk discovery but not the catalog path, and - `FIZZY_PLUGIN_REGISTRY_URL` (`PluginStore.zig:774-784`) makes the registry user-suppliable. - SHA-256 verification itself is sound. Fix: validate ids when the summary/shard is parsed and - again in `buildJob`/`applyUninstall`; bound download bodies (`download.zig:50-56` and - `fetchJson` are unlimited). -- **H6. `@errorName` on errors returned across the dylib boundary**, against the rule written at - `Editor.zig:4199-4203`: `Editor.zig:1277, 3116, 3447, 3457, 3568, 4526, 4614, 4620`, - `Keybinds.zig:940`, and inside the SDK `Plugin.zig:497-498`, `Host.zig:1468-1470`. Logs print - a wrong error name. Fix: normalize in the `Plugin` wrappers (`error.PluginFailed`) or the - shared SDK error enum the checkpoint proposes. - -### Medium - -- **H7. `EditorAPI`/`Host` still carry pixi- and workbench-specific members**: `paletteFolder` - (fizzy creates `/palettes` for every app, `Editor.zig:317, 524-530`), `explorerRect`/ - `explorerVirtualSize`/`explorerViewportWidth`/`explorerBranchIsOpen`/`setExplorerBranchOpen`/ - `setExplorerNewFilePath`/`drawWorkspaces`, `panZoomScheme`, `NewDocGrid` on `createDocument` - (`Host.zig:1593-1595` admits it), the quit/save cluster (`setPendingCloseDocId`, - `queueCloseAfterSave`, `trackQuitSaveInFlight`, `resumeSaveAllQuit`, `abortSaveAllQuit`, - `cancelPendingSaveDialog`, `requestWebSave`; `EditorAPI.zig:55-81, 211-215, 232-248`), and - `sdk.pane_layout` (26 lines that exist for pixi's takeover surface). Fix: explorer* → the - `workbench` service or region context; `paletteFolder` → `pluginInstallDir`; `NewDocGrid` → an - opaque plugin-defined blob; quit flow behind a versioned `save_flow` service or host-private. -- **H8. The SDK `Host` draws a dialog.** `showNewDocumentChooser`/`newDocumentChooserDisplay`/ - `newDocumentChooserButton` (`Host.zig:1473-1586`) is a dvui dialog compiled into every plugin - image, with its `displayFn` in whichever image called `requestNewDocument`. Move behind an - `EditorAPI` hook. -- **H9. The plugin vtable is wider than the contract needs** (~60 hooks, `Plugin.zig:72-305`). - The staging protocol takes eight where `open(path, grouping) !*anyopaque` would do; - `bindDocumentToPane(canvas_id, workspace_handle, center)` / `removeCanvasPane` leak workbench - concepts; `saveDocumentAs(..., *dvui.Window)`. Zero host call sites: `contributeMenu`, - `documentHasNativeExtension`, `documentByPath`. The simplest viewer needs 28 entries - (`plugins/image/plugin.zig:27-55`). Delete the dead three now; collapse the staging protocol - while the fingerprint is free. -- **H10. Two keybinding mechanisms; dispatched keys are never marked handled.** Commands + - `Keymap` (`Keybinds.zig:893-948`) coexist with `Plugin.VTable.tickKeybinds` per-frame - self-matching (`Editor.zig:3440-3449`); the keymap path leaves the event unhandled - (`Keybinds.zig:914-920, 933-936`) so the focused widget sees it too — the clipboard - double-fire. pixi writes `win.keybinds` with `win.gpa` directly (`pixi/plugin.zig:872-903`) - because plugins cannot register into `app/keymap`. Fix: one path — plugins register `Command`s - with default chords, the host dispatches and marks handled; drop `tickKeybinds`. -- **H11. Duplicated build glue.** Four near-identical `plugins/*/static/integration.zig`; - `plugins/shared/build/helpers.zig` hand-mirrors `sdk/plugin_sdk.zig` (`dylib_exports`, - `plugin_options_import`, `readManifestAt`, `generatedDylibRoot`); `build/exe.zig:39-58` - repeats the install chain per plugin; `build/app.zig:198-215` exposes `static_workbench/text/ - image` but no `static_markdown` while `App.bundledDylibEnabled` reads `static_` generically - (`App.zig:976-991`). Fix: `helpers.integration(comptime id, .{…})` and one comptime list - driving exe/web/app. Also `helpers.zig:51` relative-imports `../../../sdk/src/ - manifest_identity.zig`, the one-module trap CLAUDE.md forbids; `sdk/manifest_identity.zig` - exists for this. -- **H12. Dead, dangerous helpers in `sdk/src/menu.zig`**: `menuItem`/`menuItemWithChevron`/ - `submenu` have no callers and call `dvui.menuItem` directly (the per-dylib `MenuWidget` crash - `EditorAPI.drawMenuItem` warns about, `EditorAPI.zig:269-278`) and `dvui.icon`. Delete; - `menu.zig` vs `menus.zig` is a naming trap. -- **H13. `wikilink` service type lives in the SDK and in the fingerprint** - (`sdk/src/services/wikilink.zig`, 424 lines, listed at `dylib.zig:150-157`), so a change to one - plugin pair's API bumps the fingerprint for everyone, against "services are versioned, not - fingerprinted" (`Host.zig:997-1003`). Move it to the producing plugin's package; consumers - pin it; rely on `service_version`. -- **H14. Silent fallback hides a broken bundled dylib.** `postInit` does `loadBundledDylib(...) - catch { try m.register(host) }` with no log (`Editor.zig:1594-1600`); the comment says none of - the bundled plugins ship as dylibs, but `zig-out/arm64-macos/plugins/{image,markdown,text, - workbench}` exist. Log the error; fix the comment. -- **H15. `readPluginReservedField` re-parses all of `settings.zon` three times per field per - plugin** (`App.zig:671-682`), per plugin from `seedPluginFlags`/`reconcilePluginEnabled` and - per directory entry in `reconcileDiscoveredPlugins` (`Editor.zig:2925`) on every watcher - event. Parse once (Zoir) and walk. -- **H16. User plugin load order is filesystem iteration order** (`Editor.zig:614`) ⇒ - registration order ⇒ surface/menu/tab order varies by OS. Sort ids. -- **H17. `waitForPluginSaves` busy-spins the GUI thread** (`App.zig:966-970`); use the futex - pattern `cancelPluginLoadingJobs` already uses (`Editor.zig:1334-1377`). -- **H18. No test covers unload, hot reload, or store install.** `tests/integration.zig` has 60+ - tests, none touching `unloadPlugin`, `reconcileChangedPluginBinaries`, or a dylib load. A - headless test that loads `examples/hello-plugin`'s dylib, draws, unloads, draws again would - catch H2–H4. - -### Low - -- Stale SDK doc comments that mislead an author: `EditorAPI.zig:103-105` (orphaned `swapDocs` - doc over `allocDocId`), `:132-138` (orphaned `splitState` doc over `beginRegion`), `:281` - (`/.settings.zon`, merged by R10); `Host.zig:391-392, 1069-1070`; - `Plugin.zig:1-3` ("today plugins are compiled in…"), `:152-153`; `services/workbench.zig:8-11` - (create/rename/delete are the `files` service); `keywords.zig:33` (`src/editor/layout/ - studio.zig` → `examples/studio-app`); `DocHandle.zig:3-4`; `dylib.zig:41`; - `pane_layout.zig:19-21` ignores `content_color`. -- Fizzy-only knowledge in `app/`: `themeFilenameToName` ("Fizzy Dark", `App.zig:715-724`), - `palette_folder` (`App.zig:71`), `pluginLoadFailureReason` telling users to rebuild "from a - current root.zig template" (`App.zig:741`; there is no root.zig). -- `src/fizzy.zig:11-15` hardcodes `version = 0.2.0` (the SDK number) while `VERSION` is 0.1.7; - only `web_main.zig:25` keeps it alive. Delete. -- `src/editor/layout.zig` reaches `editor.sidebar/explorer/panel/infobar/Menu` and carries the - `FIZZY_BLUR_DEMO` harness (`:142-188`), against CLAUDE.md's "nothing in a shape may be - privileged". Move the harness out; the chrome widgets are the known `App.Hooks` work. -- Two env-var mechanisms: `std.c.getenv` in `auto_update.zig:125` and `PluginLoader.zig:463` - vs `Environ.getAlloc(core.platform.processEnviron())` everywhere else (the environ-poisoning - note in memory). -- `Editor.zig:3008` `const chrome = if (…) fill else fill;`. `dvui.Examples.demo(.full)` at - `:3480` runs every frame (cheap early return unless toggled, but it is production code). -- `keywords.groupKey` truncates keywords to 64 bytes (`keywords.zig:56-58`): two qualified - keywords sharing a 64-byte prefix share a selection. -- `Host.warn_on_service_mismatch` is a `pub var` (`Host.zig:1019`) — one copy per image. -- `core.FileTable` crosses the boundary by raw layout (`dylib.zig:158-167`); every field change - is an ABI break for all plugins, unlike versioned services. Worth a vtable façade before 0.2.0. - -### Doc accuracy (quote → what is true now) - -- `CLAUDE.md` bundled list "workbench / text / image / markdown" → also `archive`, `drive` - (`build/plugins.zig:11-12`). -- `plugins/image/plugin.zig.zon` and PLUGINS.md §7 "PNG/JPG/JPEG viewer" → `.gif/.bmp/.tga` too. -- PLUGINS.md §3.10: markdown "native only — absent on web" → bundled on wasm - (`build/web.zig:151`). §7 "`sdk/src/manifest.zig`" → `Manifest.zig`. "Built-ins register in - Editor.zig postInit via `try _mod.register(&editor.host)`" → generated `bundled_plugins` - loop (`Editor.zig:1594-1600`). §3.2 hook list omits `settingsChanged`, `documentBytes`/ - `documentWritten`, `saveDocumentAsync`, `isDocumentSaving`, the save-UI cluster. -- PLUGIN_MANIFEST_PLAN.md: "R7 — `sdk-v*` tag namespace | planned" → `.github/workflows/ - sdk-tag.yml` exists; two rows are both labelled R19 (lines 46, 52). -- `examples/endless-app/build.zig:3-5`: "Unlike minimal-app / studio-app … rather than picking a - shipped `-Dlayout=` preset" → all three own layouts; there is no `-Dlayout=`. -- RELEASING.md: "`vpkc_new_source_github(..., prerelease: false)` in `App.zig`" → - `app/update/auto_update.zig:99`. -- MODULARIZATION_RELEASE_NOTES.md "document-level hooks provided by the workbench … a center - provider" → hooks are the Host's contract; center providers became `Surface`s. `readme.md` - "only builtin plugins … edit markdown and regular text" → image, archive, drive ship too. -- CLOUD_FS_PLAN.md: see §5.4. - -### Design points where a third party still has to ask fizzy - -- **Native menu**: `NativeMenuItem` parents only to fizzy ids or a plugin's own contribution - (`menus.zig:62-65`); NSMenu built from a comptime `menu_model` with `fizzy.editor()` refs - (`backend_native.zig:59, 715-752`). Queued in the checkpoint. -- **Input/focus routing**: nothing tells a plugin who has keyboard focus - (`Editor.text_input_focused` is private); the keymap does not mark events handled (H10); - no "when" clauses. -- **Background work**: `sdk.refresh()` is the only primitive; plugins spawn raw threads with no - host `std.Io`/job service (the load worker is fizzy-internal). A `jobs` service with - cancel/await removes a class of unload races (H3). -- **Document identity is a path** (`document.zig:54-70`); a pathless document (DB record, - generated view) cannot be a tab; ownership is by extension only. - -### What is genuinely good and should not be touched - -Region/keyword model (`keywords.Fit`/`accepts`, automatic qualification, interned names, -`hide_when_empty`, by-name panes, takeover surfaces; tested in `tests/integration.zig:1647-2763`); -versioned multi-provider services with refuse-on-mismatch; the fingerprint *mechanism* -(target/mode invariance, `optimize_safety_class`, `test-sdk-version` lock — only the coverage -list is wrong); the loader's stable mapped copy + fresh-copy-on-reopen; `.part` + rename + -SHA-256 in the store; the watchers; comptime `Schema(T)` + Zoir byte-span settings surgery; one -dvui pin, `bundledPluginsModule`, `defer-app` + `buildApp`, three examples building as independent -packages; the habit of comments that name the bug that motivated the shape. - ---- - -## 4. The four out-of-tree plugins - -All four have **uncommitted working copies that are the 0.2.0 migration itself** (`.path = -"../../fizzy/sdk"`, `min_sdk_version = "0.2.0"`, `registerPluginIcon`→`registerPainter`, -`registerSidebarView/BottomView`→`registerSurface`, `isActiveBottomView`→`selectionFor`, dvui -colour-option shapes). Nothing on 0.2.0 is committed in any repo. `atlas`'s change also mixes in -unrelated feature work (link-rasterisation "air" mask in `src/ui/layout.zig`, +297) and `zig fmt` -churn across ~10 files — `jj split` into migration / air-mask / fmt before describing. - -### zig (0.2.20) -Clean shape. `src/Lsp.zig:73-179` re-implements `core.shell_env.path` plus PATH search -(`core/lsp/Client.zig:1311` has a third copy) using `std.process.spawn` with no timeout (a slow -`.zshrc` hangs LSP start); `Lsp.zig:36` duplicates `core/platform.zig:53`'s environ; -`Lsp.zig:186-196` a third hand-rolled spinlock; `Lsp.zig:22` `init_options` never freed. -`src/Highlight.zig:8` `extern fn tree_sitter_zig()` resolves only because **dvui pins -`tree_sitter_zig`** — a language plugin can only highlight grammars dvui happens to bundle, and -`Highlight.zig:23-56` hard-codes an RGB theme. LSP process lifetime is handled well. - -### ghostty (0.1.16) -Clean shape, no settings (shell, scrollback hard-coded), no commands. **PTY join can hang unload**: -`Pty_unix.zig:85-96` SIGHUPs and joins a reader blocked in `read(master)`; a child ignoring -SIGHUP hangs the UI thread — `close(master)` before the join. Fork child calls `setenv` -(`:47-62`); shell inherits the GUI-launch PATH (`core.shell_env` fixes it). Terminal is created on -first draw with `folder()` as cwd and never recreated (no `onFolderOpen`). `State.zig:80` -spinlock held across a full VT parse while the UI spins (`render.zig:59`). One `renderText` per -cell per frame (`render.zig:112-152`). **`input.zig:5`: dvui `.text` events are unreliable in -plugin dylibs**, so `input.zig:155-185` hard-codes US-QWERTY shift pairs and there is no paste; -`plugin.zig:96-101` + `input.zig:31-45` re-scan `dvui.events()` in `endFrame` to steal focus -because the later-drawn canvas swallows the click. Both are SDK gaps (below). - -### pixi (0.1.23) -Full document cluster, commands, `Schema` settings. Of the three checkpoint items: -`takeover_when` **done** (`plugin.zig:418-425`); `dvui.icon` → `core.icon.icon` **not done** -(20 sites, atlas 2 more); `core.Atlas`/`Sprite` → pixi **not done** (pixi already has a -file-format `src/Atlas.zig`, so the move needs a rename). `State.zig:159-171` bypasses -`Schema.load` with a legacy-JSON branch and passes the literal `"pixi"` (also `runtime.zig:26`); -`plugin.zig:872-903` writes `win.keybinds` with `win.gpa`; ~40 sites read `dvui.currentWindow()` -internals (`mouse_pt`, `natural_scale`, `animations`, `subwindows`…) the fingerprint does not -cover; `PackJob.zig:197-201` wakes with a captured `*dvui.Window` rather than `sdk.refresh()`; -four dialogs each re-implement the `core.dialogs.dialog` `IdMutex` dance. `CLAUDE.md` there is -stale (`docs/PLUGIN_ROUGH_EDGES.md`, `src/plugins/example/`, `sdk.settings.make`). pixi is the -only plugin with a real wasm story and has not been built for web since the extraction. - -### atlas (0.0.7 in flight) -Clean shape; `wikilink` service; every service lookup null-tolerant; `folderPathsChanged` -consumer (`src/index/Watcher.zig`) is the model example. `documentIsOpen` (`State.zig:220-229`) -re-walks all docs across the dylib boundary every frame while dirty. sqlite on the UI thread by -design (`wikilink_impl.zig:55-61, 90`; `Db.zig:132-151`); `openIndex` (`State.zig:154-161`) runs -synchronously in `register`. **Direct FS everywhere**: `Indexer` walks with `std.Io.Dir`, -`Watcher.tick` stats every open `.md` every 2 s, `requestNewDocumentDialog` probes -`accessAbsolute` — none via `host.files`, so a mounted zip or Drive vault is invisible even -natively. `plugin.zig:92,311` use `dvui.currentWindow().arena()` not `host.arena()`. -`plugin.zig:276` runs `text.format` via a global `force_convert` flag because a non-owner cannot -edit a buffer. Layout threads are joined in `pluginDeinit` before teardown — correct. - -**Atlas on the web, concretely:** (1) an index backend seam `union(enum){ sqlite, mem }` behind -`query.zig` (the only sqlite consumer the UI touches; graph consumes `Indexer.Snapshot`, resolve -consumes `CandidateSet`, both already sqlite-free) — no sqlite-to-wasm port; (2) walk through -the mount layer (`host.files.?.listDir` + `vfs.Fs.readFile` completions pumped from -`beginFrame`) — which also makes atlas work on zip/Drive mounts natively; (3) `Indexer` as a -`step(budget)` state machine on wasm (pixi's `runWasmWorkers` shape), threads natively; -(4) skip `cache_dir` on wasm; (5) static bundle via `buildApp` + a `check-web` target. - -### Absorb into SDK / core / shared (ranked by plugins helped) - -1. `scripts/fizzy-sdk.sh` + `scripts/release-plugin.sh` are byte-identical in all four repos — - ship them in the SDK tarball or expose as `zig build sdk-local`/`sdk-latest`/`release`. (4/4) -2. `ICON.png` painter (`zig/plugin.zig:42-62` ≡ `ghostty/plugin.zig:30-49`): `plugin.create` - already reads the zon; embed `ICON.png` into `fizzy_plugin_options`, default `.plugin_logo`. -3. `src/runtime.zig` (`adoptState/state()/host()` + `pluginById("")`) is a diff apart in pixi - and atlas: `sdk.runtime` should own the state pointer, killing the literal id strings. -4. `Schema.load(host, id, &v)` + `Schema.register(host, plugin, …)` → one `Schema.attach(host, - plugin, &value, .{ .title })` keyed off the calling plugin; also fixes ghostty's "no settings - because the ceremony is disproportionate". -5. Login-shell PATH + executable resolution: `core.shell_env` exists; `core/lsp/Client.zig:1311` - and `zig/src/Lsp.zig:73-179` re-do it. `Client.Config.resolve_executable = true`, and ghostty's - PTY should spawn with the same env. -6. `processEnviron()` (`core/platform.zig:53`, `zig/Lsp.zig:36`, `atlas/cache_dir.zig:83`). -7. `Host.pluginCacheDir(id)` beside `pluginInstallDir` (atlas's 130-line `cache_dir.zig` - including the "must not be under the watched config root" rule). -8. One `core.SpinLock`, or the documented rule `std.Io.Mutex + dvui.io` with `sdk.runtime` - exposing the `Io` (three plugins hand-roll it). -9. `core.Job`: spawn, cancel, join-on-deinit, `sdk.refresh` on completion, wasm fallback that - runs `step()` from `beginFrame` — pixi `PackJob`/`SaveQueue`, atlas `Indexer`, ghostty - `readLoop` each hand-roll it (~200 lines each). This is also the answer to H3's races. -10. `core.dialogs.open(Kind)` owning the `IdMutex` dance (pixi ×4). - -### SDK gaps (ranked by how much they block "build almost anything") - -1. **Text input / IME for non-TextEntry widgets** (`ghostty/input.zig:5`): a surface or core - widget needs `wantsTextInput(rect)`. Blocks terminals, custom editors, REPLs, node graphs. -2. **Focus ownership by a surface** (ghostty's late-focus hack). -3. **Keybinds are dvui internals** (H10): plugins cannot register into `app/keymap`, so a plugin - chord is not rebindable in the Shortcuts pane. -4. **Grammar bundling**: `fizzy.plugin.addTreeSitterGrammar(b, dep)` + a runtime-safe - `TreeSitterHighlight.language` handoff, and a theme-driven capture→style map. -5. **FileTable/vfs is reachable but undocumented**: `Host.files` is public but every plugin uses - `std.Io.Dir`. Document `listDir`/`readFile` + the "if null, fall back" idiom in PLUGINS.md; - make `IndexJob` a public `FileTable.walk(root, cb)`. -6. **Non-owner document edits**: a `document` service (`replaceRange(doc, range, bytes)` as one - undo step) for link-rewriters, formatters, snippets. -7. **Workbench furniture on `EditorAPI`** (H7): a surface's `draw` should receive its region - rect; the tree owns a `revealNewPath` service. -8. Plugin cache dir + plugin-scoped `Io` (absorb 7/8). -9. **`zig build` installs into the live plugins dir by default** (`plugin_sdk.zig:266`): - surprising for CI and reviewers (it happened during this review). Make the copy - `zig build install-dev`. -10. A `CHANGELOG` line in `sdk/` per boundary change (the `workbench.Api.openCount/openPathAt` - removal was found by atlas at load, not at compile). - ---- - -## 5. drive and the cloud-fs layer - -### 5.1 Correctness (ranked) - -- **D1. A Drive listing in flight panics when another op forgets its directory.** - `drive.zig:425` `forgetChildren(job.path)` runs synchronously in `start`; `:503` and `:568` - do `index.get(job.listing).?`. Any mutation → `FileTable.invalidateAll` → the tree re-lists - `/` → `/Notes` (still listing) is dropped → its response lands on `.?` null — UB in the - ReleaseFast web build. Fix: refresh a listing in place (update keys, remove only names absent - after the last page); replace every `.?` on `job.listing`/`job.path` with `orelse - error.NotFound` + re-resolve. -- **D2. `Client.cancel` during `pump` delivery is a use-after-free.** `drive.zig:199-218`: `pump` - takes the ready list and delivers one at a time; a callback that cancels a *later* job in the - same batch destroys it, then the loop delivers on freed memory. Reachable today: - `FileTable.Mutation.onDone` (`FileTable.zig:833`) → `invalidateAll` → `IndexJob.cancel` - (`:618-628`) while a search runs on Drive. The `Fs.zig:101` contract ("its callback will not - run") is also violated by `Mem`, `LocalFs` and both transports for a job already taken by - `pump`. Fix: a `delivering` set in `http.Completions`; `cancel` of a taken id marks it skipped. -- **D3. Refresh-failure request storm.** `plugins/drive/plugin.zig:194-196` re-arms every frame - while `mounted and pending == null and now > expires_at - 120s`; `fail` (`:416`) does not sign - out when mounted and `expires_at_ms` never moves → one token POST per frame (on web, one GIS - `requestAccessToken` per frame, popup-blocked outside a gesture). Fix: exponential backoff on - `expires_at_ms`; a 400/401 refresh while mounted → `signOut`. -- **D4. A failing mount listing is re-asked every frame, silently** (`FileTable.zig:376-395`, - `ListJob.onListed:423` `result catch return`). Nothing in fizzy handles `error.Unauthorized`. - Fix: install an empty listing with a TTL on error, log once, add `Env.failed(path, err)` so the - drive plugin can refresh + `invalidateListing`. -- **D5. Quit save-all discards edits when a mount write fails** (`Editor.zig:3927-3936` closes - once `docSaving` clears regardless of outcome; `MountIo.Save.onWritten` `:188-196` leaves the - doc dirty while the app exits). Re-check `isDirty` and `abortSaveAllQuit`. -- **D6. Edits made during a slow upload are marked clean.** `text/src/Document.zig:371-382` - sets `clean_op_id = topOpId()` at *completion*; `saveThroughMount` swallows `SaveInProgress` - (`Editor.zig:4566`). Type, ⌘S (2 s upload), keep typing → clean tab → quit without prompt. - Fix: `savedBytes` records the op id and `written` restores that; MountIo re-saves after - `SaveInProgress`. -- **D7. Save As / new file onto a mount fails `NotFound`**: `MountIo.zig:155` only calls - `writeFile`; `Mem.writeImpl` (`mem.zig:169`) and Drive `.write` (`drive.zig:436`) require the - node, `LocalFs.writeFile` creates. Define `writeFile` as create-or-replace. -- **D8. `unmount` never reaches `MountIo`**: `FileTable.unmount` (`:237-256`) cancels only its - ListJobs; sign out mid-open leaves `loads[path]` forever; mid-save leaves `docSaving` true → - quit hangs. Add `Env.unmounted(prefix)`. -- **D9. `NativeTransport.cancel` joins a blocking fetch on the UI thread** - (`NativeTransport.zig:198`; `std.http.Client.fetch` has no timeout) — clearing the search box, - sign-out, unmount and quit all stall for a round trip or forever. Transfer ownership to the - worker. Also `:156` reads `self.wake` after unlocking while `deinit` (`:77-83`) does not join. -- **D10. Loopback `stop()` double-closes the socket; the listener is one-shot; no deadline** - (`oauth.zig:188, 239-245`). Chrome's speculative idle connection can consume the single - `accept`; `.awaiting_code` + `needsContinuousRepaint` burns CPU until Disconnect. Loop until a - request carrying `state` (404 the rest) or 5 min; `sdk.refresh()` from the thread. -- **D11. zip reader: 32-bit offset arithmetic overflows on wasm32** (`zip.zig:46-48` - `data_off + hdr.compressed_size`, `:174`): a crafted `compressed_size` wraps below `data_off`, - the bounds check passes, the inflater reads past the archive. No archive-wide inflate budget - (`:193` trusts `uncompressed_size` up to 4 GiB; a zip bomb OOMs the tab). Compute in `u64`, - cap total inflate. Path traversal, `__MACOSX`, absolute names, zip64 and encryption are - handled correctly; CRC32 unchecked. -- **D12. `Mem.rename("/a", "/a/b")` orphans the subtree** (`mem.zig:193-218`). Refuse. -- **D13. `FileTable.isDir` on a mount root is false** (`:857-866`; `exists` special-cases it at - `:845`) → a drop onto `zip://demo` treats it as a file. -- **D14. Windows joins unaudited**: `std.fs.path.join` on possibly-mounted paths in - `FilesService.move` and `workbench/src/files.zig:512, 514, 764, 1288, 1311` produce - `gdrive://me\Notes\x`. Add a mount-aware `core.paths.join`. -- **D15. In-call `pump()` on a mount delivers *every* completion mid-draw** (`FileTable.zig:393`, - `MountIo.zig:79,156`, mutations `:775-811`): document opens/closes/writes run inside the tree - widget, against the header's "once per frame before anything draws". Pump inline only for the - disk, or add `Fs.pumpJob(job)`. -- **D16. Web GIS callback runs outside the frame** (`plugin.zig:465-472`). Park the token, apply - in `beginFrame`. -- **D17. Two archives with one stem share a prefix** (`archive/src/Document.zig:29`; - `FileTable.mount:222-227` replaces in place) → closing one unmounts the other. Uniquify. - -### 5.2 Security - -- Refresh token and desktop client secret in plaintext `settings.zon`, shown unmasked in the - settings pane (`drive/src/Settings.zig:15-33`, `plugin.zig:426-436`), default file perms. - Google treats an installed-app secret as non-confidential (Settings.zig says so; - CLOUD_FS_PLAN does not). The refresh token is a real credential; `drive.file` limits blast - radius. In order: `settings.Value` `.secret = true` (masked, excluded from export); - `chmod 0600` when a secret is present; a `Host.secrets` seam (`get/set(key)`) with - keychain/libsecret/DPAPI backends and settings.zon fallback — design the seam now. -- PKCE verifier/state from the OS CSPRNG, loopback on `127.0.0.1:0`, `state` checked — good. -- Header injection: `Name: value\n` lines (`WebTransport.zig:66-73`, `index.html:242-245`); a - CR/LF either injects or makes `Headers.append` throw synchronously inside the wasm import (no - try/catch at `:241-247`) and traps the frame. Reject CR/LF/`:` in Zig; try/catch in JS. -- TLS: `std.http.Client` verifies against the system bundle. No request timeout anywhere (D9). -- Web GIS silent re-request from `beginFrame` is not a gesture → blocked when consent is not - cached → nothing recovers (D3/D4). - -### 5.3 Design points - -- The async `Fs` shape fits: nobody blocks, the disk completes in-call, the web spins nothing. - The gaps are around it: no error channel from `FileTable` to the mounting plugin (the - `Unauthorized → refresh → retry` in `Fs.zig:32` is implemented nowhere), no single-job pump, - walkers return partial results silently on a slow mount. -- **`drive.file` scope reality** (`oauth.zig:12`): `files.list` returns only files this app - created or the user picked — a fresh sign-in shows an *empty* My Drive and `root_folder_id` - does not help. Either add Google Picker (trivial on web; none on native → needs - `drive.readonly`/`drive` + verification) or document the empty tree. This is the largest gap - between "built" and "usable". -- Cross-mount rename: refusing is right; a `FileTable.copyTree` chain over the same primitives - gives disk↔drive drag, which users will try first. -- No watcher: `changes.list` with a start token polled every few seconds → `Client.forget` + - `FileTable.invalidateListing`/`noteFileModified`. Zip mounts need nothing. -- Last write wins: no ETag precondition in Drive v3, but `modifiedTime`/`version` are in every - listing; GET metadata before PATCH, refuse on change, reuse the DocumentWatcher conflict UI. -- Owner migration (pixi/atlas): implement `documentBytes`/`documentWritten` (pixi already - serializes for the web download), then have the host prefer the bytes hooks *for the disk - too* so `saveDocument` becomes legacy and every owner has one path. -- Second backend: Google-specific in `drive.zig` = URL shapes, `parents`/duplicate-name - semantics (hence the path index), `trashed`, `google_app` mime, page tokens, RFC3339, 401/403 - mapping; generic = the Job state machine, `Completions`, query escaping. Dropbox/OneDrive are - natively path-addressed, so the index stays Drive-only. Move `oauth.zig` (PKCE, loopback, - exchange/refresh) out of `plugins/drive` before backend #2 — they need the identical flow. -- Layering: `core.vfs` re-exporting a dependency-free wasm-safe package is the `zf` shape — - fine. `drive.zig` (Google REST) does not belong under core: split into `zig-vfs` (core dep: - Fs, Mem, zip, `http.Transport`) and `zig-gdrive` (dep of `plugins/drive` only). **The path pins - (`build.zig.zon:66`, `sdk/build.zig.zon:47`) point outside the repo, so a fresh clone or CI - cannot build fizzy at all** — URL-pin now. -- Perf: one `std.http.Client` per request (`NativeTransport.zig:161`) = CA rescan + TLS - handshake per `files.list`. Share one client. - -### 5.4 CLOUD_FS_PLAN.md accuracy - -- Step 3 "write half covered by the LocalFs/Mem tests": `LocalFs` has no direct tests; `Mem` has - one. "Save As adopts the path" holds only for an existing file (D7). -- Step 4 "cancel joins the worker and leaks nothing": true and tested; the join is D9. -- Step 5 "tested (correct and forged state)": true; the never-returns/`stop()` path is untested - and buggy (D10). "Blocked on account setup" omits the `drive.file` empty-tree issue. -- Step 2b and step 3 in-app verifications are manual — label them so. -- `Fs.zig:101` cancel contract vs what every backend does (D2). - -### 5.5 Tests to add (by name) - -`drive`: ancestor listing while a descendant listing is in flight (D1); cancel from inside a -completion callback (D2); 404 on read drops the node; same-parent rename omits `addParents/ -removeParents` (Google rejects equal ids). `mem`: rename into own subtree refused; cancel of a -job already taken by pump. `zip`: central-directory offset / `filename_len` / `compressed_size` -/ local-header offset past end; the 32-bit case (`-Dtarget=wasm32-wasi` or `u64` + assert); -inflate budget exceeded; a `std.testing.fuzz` harness over `unpackInto`. `FileTable`: failing -mount listing is not re-asked next call (D4); mutation completion in the same pump as an index -request (D2); `exists`/`isDir` on a mount root; unmount with a walk in flight. `NativeTransport`: -cancel returns in <10 ms while the server holds the connection. `oauth`: `stop()` while accept -blocks closes once; an idle connection before the redirect does not fail sign-in; refresh -backoff (extract the phase logic from `beginFrame`). `text/Document`: `written()` after further -edits keeps the document dirty. `LocalFs`: direct `writeFile`/`createFile`/`readFile`/`stat`. - ---- - -## 6. Opportunities - -Cheap with today's SDK (surface + command + optional service, no ABI change): -- **More LSP languages** (clangd, rust-analyzer, gopls): copy `zig/`, ~300 lines; only PATH - resolution is friction. Highlighting waits on grammar bundling. -- **Git plugin**: `folderPathsChanged` + `workbench.registerBranchDecorator` + a diff panel; - `files` service for revert. Nothing new needed. -- **Task/build runner + problems panel**: spawn `zig build`, parse diagnostics, - `host.revealPosition`; ghostty's `Pty` could be the runner. -- **Outline / tags / daily notes**: atlas's `wikilink` service + `documentContentChanged`. -- **Workspace from a URL / templates**: `fizzy_web_request` + `zip.unpackInto` + `mount` is - ~30 lines — "Open project from URL", store-shipped templates as zips, share a workspace as a - zip link. -- **`mem://scratch` on the web** so "New File" has a home; persist as zip bytes into IndexedDB. -- **`http://` read-only mount** over a directory index; **`git://` read-only mount** (native, - `git ls-tree`/`cat-file` through `core.darwin_spawn`) to browse any ref without a checkout. -- **`FileTable.walk` as public API**: first step of atlas-on-a-mount and atlas-vault-as-a-zip - on the web. -- **Change feed → conflict UI**: `changes.list` gives "edited elsewhere" for free. - -Need one specific SDK addition each: terminal-as-document and debugger/REPL panes (text input + -focus gaps); snippets/linters/format-on-save from a non-owner (document edit service); -spreadsheet/node-graph/tracker editors (keymap registration + text input — pixi proves the -GPU-heavy custom widget path works); remote vaults for every plugin (documented FileTable path); -a web store (§2.4). - ---- - -## 7. Suggested order - -1. **Contract hardening before 0.2.0 ships** (the fingerprint is free to move now): H1 fingerprint - coverage + the comptime membership test; H2 stable `Surface` storage + interned selections; - H4 rollback on `err_register`; H9 delete the three dead hooks, collapse staging; H13 move - `wikilink` out; the `FileTable` vtable façade; H6 shared error enum. One pass, one bump. -2. **Unload correctness** (H3) with the H18 headless load/draw/unload test, and the `core.Job` - primitive (absorb 9) since it removes the thread races at their source. -3. **Cloud-fs lifetime fixes** D1–D2, D5–D9 and D11 together (they share the completion/cancel - contract), URL-pin drive, split `zig-gdrive` out of core's dependency, then the - `drive.file`/Picker decision before any more Drive work. -4. **Static web bundling for authors** (§2.5): `buildWeb`, the `app_layout` web wiring bug, the - `PluginApp` shape, `fizzy.plugin.app`; pixi `check-web` as the acceptance test. -5. **Rebuild the four plugins on 0.2.0** and commit the migrations (atlas: `jj split` first); - pixi's two remaining checkpoint items; ghostty PTY close-before-join; absorb items 1–6. -6. **SDK gaps 1–3** (text input, focus, keymap registration) — they unlock the terminal-as- - document and every custom-editor plugin, and H10 is the same change as gap 3. -7. **Runtime wasm store target** (§2.4 step 2) once 4 is in and pixi has a web build to load into. -8. Doc sweep (§3 doc accuracy, §5.4) as each item lands; `CLAUDE.md`'s bundled list now. - -## 8. Housekeeping done during this review - -- `~/dev/fizzyedit/brain` renamed to `~/dev/fizzyedit/atlas` (its remote was already - `fizzyedit/atlas`; jj resolves in the new location). The `fizzy` skill and its `references/` - now point at `atlas`, `sdk/src/`, `app/`, `plugins/`, and `sdk/sdk_version.zig`; - `LIB_CHECKPOINT.md`'s queue line says atlas. -- Nothing in any repo was edited beyond this file and that one checkpoint line. No `jj describe` - was run; the working copy holds this file undescribed. diff --git a/docs/REVIEW_NAYSAYER_2026-09-22.md b/docs/REVIEW_NAYSAYER_2026-09-22.md deleted file mode 100644 index 5631fdb30..000000000 --- a/docs/REVIEW_NAYSAYER_2026-09-22.md +++ /dev/null @@ -1,195 +0,0 @@ -# Naysayer review, 2026-09-22 - -Read-only review of bookmark `fizzy-lib` (parent `e542b18c`, "web: a plugin updates in the session — the old module stays, inert"), measured against the code on that commit. The September 21 review (`docs/REVIEW_2026-09.md`) is treated as already written; this one covers what landed after it, plus the claims in the docs that no longer match the tree. Line numbers are from this commit and will drift. - -The side-module idea itself is sound: one shared memory, table indices where the desktop uses function pointers, the same fingerprint and `register` sequence. The trouble is the session-update layered on top of a loader that cannot unlink, and a store tick that still assumes a plugins directory. - ---- - -## Verdict - -Do not treat in-session web update as done. The desktop update path, in the same file, refuses to discard unsaved documents and only drops the offer after the new build has loaded. The web path force-unloads first, reports success when the fetch has merely been *started*, skips the SHA-256 the registry already computed, and the page remembers the URL before the host has accepted the module. A refused build is then what the next visit loads. Disable, uninstall, and the update window never run on the web at all, because `PluginStore.tick` returns before the queue that applies them. - -The simpler design is the one the page comment already half-admits: an update changes which URL the next load fetches, then the page reloads. Keeping the old module resident is a reasonable constraint. Pretending the id has moved over inside the same session is what creates the data-loss and the stuck-URL bugs, and it duplicates a loader sequence the desktop already has. - ---- - -## 1. Web update throws away the running plugin, then calls that success - -`Editor.updateWebPlugin` unloads with `force = true` and only then asks the page to fetch: - -```1604:1608:src/editor/Editor.zig -pub fn updateWebPlugin(editor: *Editor, id: []const u8, url: []const u8) WebLoadError!void { - if (comptime builtin.target.cpu.arch != .wasm32) return error.NotUnloadable; - if (editor.app.host.pluginById(id) != null) try editor.unloadPlugin(id, true); - try editor.loadWebPlugin(id, url); -``` - -`force = true` skips the dirty-document check. Documents the plugin owns are closed. The desktop completion path, forty lines above the web early-return in `PluginStore.tick`, says the opposite on purpose: `app.update(job.id, false)`, and on `DirtyDocuments` the new file stays on disk so Retry can apply it after the user saves (`PluginStore.zig` around the `downloaded` arm). - -`loadWebPlugin` returns as soon as `PluginLoader.begin` has handed the URL to JavaScript. `applyWebUpdate` treats that return as the update having landed and removes the row: - -```946:952:app/store/PluginStore.zig -fn applyWebUpdate(id: []const u8, url: []const u8) void { - app.updateFromUrl(id, url) catch |err| { - reportError("could not update '{s}': {s}", .{ id, @errorName(err) }); - return; - }; - dropPendingUpdate(id); -``` - -If the fetch fails, the compile fails, or `loadAndRegister` rejects the fingerprint, the running plugin is already gone and the offer is already gone. The failure is a log line from `WebPluginRequest.arrived`. There is no Retry, because there is no row and there is no download job (`isInstalling` reads `jobs`, which the web install path never fills). - -A load that should be abandoned still completes. `arrived` has no generation: uninstall or a second update cannot cancel the request already in `pending`, and a late `FizzyWebPluginReady` will register whatever it linked. - ---- - -## 2. The page remembers a build the host has not accepted - -`PluginLoader_web.remember` and the comment in `WebPluginRequest.arrived` say the URL is stored only after `register` returns ok, so a refused build cannot greet the user on every later visit. `loadPlugin` in `web/index.html` writes `localStorage["fizzy.web_plugins"]` immediately after `FizzyWebPluginReady`, before that callback runs. Any module that instantiates is remembered, including one whose fingerprint, SDK version, or declared id the host is about to reject. - -The next visit calls `requestUrlPlugins`, which loads that map with no look at `.plugins..enabled`. `FizzyWebPluginRequest` does not consult `isPluginDisabled` either (the desktop scan at `Editor.zig` around line 622 does). So: - -- A rejected update becomes the URL every future visit tries, and fails the same way. -- Disable cannot survive a reload even if the queued action were applied, because nothing on the web load path reads the flag, and nothing forgets the URL on disable. `forget` runs only from `uninstallPlugin`. - -`?plugin=` is also unordered relative to `?open=`. The comment above the open loop says the file is fetched after the plugins so the owner may already be registered. Both loops start their fetches immediately. A zip can be opened by the fallback editor, or refused, because the side module that owns the extension is still compiling. - -`FizzyWebPluginRequest` interpolates the query id into `plugins/{id}/{id}.wasm` without `App.isValidPluginId`. An id is allowed to contain `..` and slashes. That is a same-origin path, so it cannot leave the site, but it can name any wasm the site happens to host. - ---- - -## 3. The store's buttons on the web queue work the frame never runs - -`PluginStore.tick` on wasm pumps the catalog, READMEs, and icons, then returns: - -```1275:1281:app/store/PluginStore.zig -pub fn tick() void { - if (comptime builtin.target.cpu.arch == .wasm32) { - if (catalog) |*c| c.pump(web_fetch); - Readme.pump(); - StoreIcon.pump(); - syncReadmeCenter(); - return; - } -``` - -Everything after that return is the desktop. In particular it never reaches: - -- `pending_actions` — Enable, Disable, Uninstall, and the auto-update toggle all append here (`queueSetEnabled`, `queueUninstall`, `queueSetAutoUpdate`) and are applied only in this loop. -- `autoUpdateTick` — the once-per-session update check and the "Plugin updates" window. - -The card's Update button calls `applyWebUpdate` directly, so that one control does start a fetch. Enable, Disable, and Uninstall do not. Re-enable would still be the wrong function if the queue were drained: `setPluginEnabled` calls `loadUserPluginById`, which returns `error.NotUnloadable` on wasm before it looks for a URL. The URL lives only in the page's `localStorage`, and Zig has no way to ask for it back. - -`uninstalledCatalog` still returns an empty slice on wasm, with a comment that the store is browse-only. `queueInstall` on wasm calls `installFromUrl`. The home-page offers and the comment describe a store that the Plugins tab has already left behind. - ---- - -## 4. The web install path does not check the hash the registry published - -Desktop `startDownloadUrl` passes `sha256` into `download.zig`, which refuses the bytes before they are written. Web `queueInstall` and `applyWebUpdate` pass only `dl.url`. `loadPlugin` does `fetch` + `WebAssembly.compile` on whatever that URL returns. TLS protects the transport. A registry entry whose URL was edited, or a `localStorage` map rewritten by any script on the origin, is executed as a plugin with the host's memory, the host's function table, and every host export copied into `env` (the loop over `host.exports` in `loadPlugin`). - -That trust model matches a desktop dylib, and it should be said in one place. It does not match "the store verifies SHA-256", which is still true only on the desktop. - -Two tighter holes in the same loader, before the module even runs: - -- `readDylinkNeeds` trusts `memorySize` and `memoryAlign`. `FizzyWebPluginAlloc` then `rawAlloc`s that size. A side module (or a file that merely has a `dylink.0` section) can ask for a huge reservation. `1 << needs.memoryAlign` is a JavaScript shift; an alignment above 31 becomes a nonsense value passed into `Alignment.fromByteUnits`. Cap the size, require the alignment to be a small power of two, and do it before allocating. -- `tableAlign` is parsed and ignored. `tableBase` is `functionTable.length` with no padding. If wasm-ld emits a non-zero table alignment, relocations land on the wrong slots. Either honor it or refuse the module when it is not 0 or 1. -- There is no byte cap on `?open=` either. The whole body is copied into the wasm heap via `FizzyWebPluginAlloc`. A link of the form `?open=` is enough; the plugin does not have to be malicious. - -`fizzy_web_request` is an arbitrary method, headers, and body, with no size cap on the response. Cookies are not included (`fetch` defaults to same-origin credentials), which is the right default. The function is still a proxy with the user's network position. Fine for a trusted plugin; worth not handing to a module that skipped the hash check. - -`fizzy_web_oauth_open_page` turns plugin-supplied HTML into a blob URL on this origin and accepts `postMessage` from that origin. A plugin that can already read linear memory gains script access to `localStorage`, including `fizzy.file:*` (settings, recents, layout, keybinds) and `fizzy.web_plugins`. Same trust boundary. Do not document it as a sandbox. - ---- - -## 5. `Secrets.set` on the web frees the value it just stored - -`Secrets` is documented as memory-only on the web, and `save` returns `error.Unsupported` there. `set` inserts the new buffer into the map and then calls `save`. The `errdefer` that frees that buffer is still armed: - -```50:64:app/Secrets.zig -pub fn set(self: *Secrets, key: []const u8, value: []const u8) !void { - if (value.len == 0) return self.remove(key); - self.load(); - const v = try self.gpa.dupe(u8, value); - errdefer self.gpa.free(v); - // ... put v into self.values ... - try self.save(); -} -``` - -On wasm `save` always errors, so every `set` returns `Unsupported` and frees `v` while the map still holds it. The next `get` hands the caller a dangling slice. A second `set` of the same key `memset`s and `free`s that dangling pointer. Drive-style OAuth (`WebOAuth`, landed the day before this review) is the caller this will hit as soon as a plugin stores a refresh token. - -`set` should return `error.Unsupported` before mutating the map, or keep the value and skip `save` when there is no file. The `errdefer` has the same shape on desktop if `save` fails after a successful `put`: the map retains a freed buffer there too. - ---- - -## 6. `core.fs` on the web reports writes that did not happen - -`core/fs.zig` `write` calls `fizzy_web_storage_set` and returns. The JavaScript implementation swallows `localStorage.setItem` failures (quota, private mode) with `console.warn`. Settings, recents, layout, and keybinds all go through this seam now, which is the right single seam, and a full `localStorage` will look like a successful save. `fizzy_web_storage_set` needs a status the Zig side can turn into `error.NoSpaceLeft`. - ---- - -## 7. Simplicity and reuse - -**One update story.** Remember the new URL only after the host accepts it, leave the running module alone, reload the page. Dirty documents get the same prompt the desktop already has, because the unload happens on the way out of the page rather than as a side effect of starting a fetch. The "old module stays resident" paragraph, the force flag, `updateFromUrl`, and the success-before-arrival drop all go away. In-session swap is worth it only after the new module has registered and the old one's contributions are the only thing still pointing at it — which is the desktop order, not a second one. - -**One loader sequence.** `PluginLoader_web.loadAndRegister` is a second copy of the fingerprint, version, id, inject, and `register` steps in `PluginLoader.zig`. They have already drifted in comments (who is allowed to remember). A `lookup(T, name)` is the whole difference; the sequence should be one function. `WebDynLib` already has that lookup. - -**`pluginEntryPoints` is listed twice**, in the same order, in `web/index.html` and `WebDynLib.entry_names`. A missing or reordered export is a silent mismatch: `FizzyWebPluginReady` copies indices by position. Generate one list, or compare names. - -**`Shape.apply` is the right size.** A static tree of `region` and `split`, and an explicit refusal to grow conditionals, matches the copy-don't-configure rule. The test named "a depth-first walk numbers every place once" does not call `apply` or `place`; it counts children with a second walk. It cannot catch two regions sharing an `id_extra`. The split's `id_extra` is the counter value the following child then consumes. That happens to be unique per call site today because `region` and `split` use different `@src()` lines. A test that runs `apply` against a headless `Layout` would lock it. - -**Dead branch still in the frame.** `Editor.tick` sets `chrome` to `fill` on both arms of the maximized check (`const chrome = if (...) fill else fill`). The September 21 review already noted it. It is still the value published into every dialog. - ---- - -## 8. Docs that are now wrong - -| Where it says | What the tree does | -|---|---| -| `WebPluginRequest.arrived` and `PluginLoader_web.remember`: the URL is stored only once the host accepts the build | `loadPlugin` calls `rememberPlugin` as soon as the module is linked | -| `updateWebPlugin`: a refused build leaves the next visit on the one that worked | The page has already overwritten `fizzy.web_plugins`, and the working module was force-unloaded before the refusal | -| `uninstalledCatalog`: wasm is browse-only | `queueInstall` links the release URL | -| `SDK_0.2.0_NOTES.md`: the web plugin "fetches and links at runtime — same registration path as a desktop dylib" | Registration is the same function shape. Install skips SHA-256, disable and uninstall never run, and update force-closes documents | -| `SDK_0.2.0_NOTES.md`: "six desktop targets plus best-effort web" | The host key `web-wasm32` exists (`registry/compat.zig`). Whether release CI emits that artifact is a different repo; the notes read as if the store path were finished | -| `Surface.zig` doc: keyword overrides live in `settings.zon` | Placement is `layout.zon` assignments. The settings sentence is the pre-assignment model | -| `LIB_CHECKPOINT.md` header: last updated 2026-09-16, bundled plugins "workbench, text, image, markdown, shared" | The bookmark has since gained the web loader, `archive`/`drive`, accounts, `core.work`, and `-Dapp-layout=.zon`. The phase narrative (regions, workbench panes) is still the right history. The header is a cold-start trap | -| `docs/REVIEW_2026-09.md` §2.3: wasm install is gated off, `hostKey()` is `unknown-unknown`, `web_fetch` is too small for a plugin | Install is on, the key is `web-wasm32`, and the plugin body is fetched by `loadPlugin`, not `web_fetch`. The fingerprint and unload findings in §3 were not re-closed by this work (see below) | -| `CLAUDE.md` bundled list | Still omits `archive` and `drive`. The checkpoint's "trust `ls plugins/`" rule is the one that is right | - -`PLUGIN_MANIFEST_PLAN.md` is a July log that still says "External plugin repos still need pin + reshape" in the banner, then records R7 as landed. Harmless as a log, misleading as a status page. It should not be the document a new session treats as the plan; `LIB_CHECKPOINT.md` is, and its date is the problem. - ---- - -## 9. Still open from the September 21 review (re-checked, not assumed) - -These were not the subject of the last day's commits. They are still true, and the new web path makes two of them sharper. - -- **The fingerprint still does not name `Surface`.** `sdk/src/dylib.zig` `sdk_boundary_types` lists `FileTable`, `Setting`, `CompletionItem`, and the wikilink types, each with a comment about slices `hashTypeShape` does not follow. `Surface` is reached the same way (`regionMatching`'s slice, workbench reading fields off it) and is absent. So are `Host.Painter`, `Host.FileKind`, `Host.ServiceEntry`. Adding a field still shifts every installed plugin's layout without moving the fingerprint. -- **Partial `register` still closes over a live registration on the desktop, and now on the web too.** `loadAndRegister` maps a non-ok status to `RegisterRejected` and returns. Contributions already pushed into `Host` stay. On the web there is not even a `dlclose` to make the subsequent crash obvious; the half-registered plugin keeps drawing. -- **Live unload still does not sweep image-owned callbacks.** `unregisterPlugin` does drop selections whose ids no longer resolve, and `setSelectionForKey` stores the registry's own `id` slice. That part of the old use-after-free is addressed. Mounts, dvui `dataSet` deinit functions, and dialog `displayFn`s were not. On the web those callbacks remain callable, because the module is never unmapped. "Inert" is true only for contributions `unregisterPlugin` actually removed. -- **`@errorName` on plugin failures** was cleaned up on the keymap rebuild path (the log now says "see the plugin's own log"). Other sites in `Editor.zig` still print `@errorName` for host-side errors, which is fine. Do not extend that cleanup into a shared error enum; the checkpoint's "not worth it" note is right. - ---- - -## 10. What is solid and should stay - -- Shared linear memory and a growable table, with entry points handed back as indices. That is the dylib model, and a second wasm instance per plugin would break every `*Host` and every slice the SDK passes today. -- Fingerprint check before `register`, and id check against the id the host asked for. -- `Shape` as data for the static case only, with the zig file as the escape hatch. The comment at the top of `app/layout/Shape.zig` is the design, and it is the right one. -- Secrets kept out of `settings.zon` on the desktop (0600 file, wiped on free). The web bug is the error path, not the split. -- One `core.fs` seam for the small config files, so plugin and app code do not grow `wasm32` branches. It needs a real error on quota, not a second storage API. -- The region/keyword model, versioned services, and the stable mapped copy on desktop. None of the findings above are a reason to reopen those. - ---- - -## Suggested order - -1. `Secrets.set`: fail before inserting on wasm, and do not `errdefer`-free a buffer the map owns. Small, and it is a use-after-free on a credential. -2. Stop remembering from `loadPlugin`. One writer, after `register` returns ok. On rejection, leave the previous URL in place. -3. Make web update "remember, then reload" until the new module can be registered before the old one is torn down. Pass `force = false` if any in-session unload remains. Drop the offer from `arrived` on success, and put it back on failure. -4. Drain `pending_actions` inside the wasm arm of `PluginStore.tick`. Wire enable to `loadWebPlugin` with the remembered URL, and consult `isPluginDisabled` in `FizzyWebPluginRequest`. -5. Hash the bytes in the page before `WebAssembly.compile`, with the `sha256` the row already carries. Cap `memorySize` and `?open=` bodies. -6. Add `Surface` (and the other by-slice host types) to `sdk_boundary_types`. The fingerprint may still move freely under unpublished 0.2.0. -7. Refresh the `LIB_CHECKPOINT.md` header and correct `SDK_0.2.0_NOTES.md` so a release note does not claim the web store path is the desktop path. diff --git a/docs/TRANSITIONS_HANDOFF.md b/docs/TRANSITIONS_HANDOFF.md deleted file mode 100644 index 09f19fe32..000000000 --- a/docs/TRANSITIONS_HANDOFF.md +++ /dev/null @@ -1,156 +0,0 @@ -# Handoff: generalized screen transitions - -**Goal.** One host-owned mechanism that cross-fades between any two "screens" in fizzy — center -providers, document tabs, store pages, sidebar views — using an offscreen render of the outgoing -screen. Plugins should get this for free: no plugin should have to know a transition is happening, -opt in, or draw anything differently. - -Status: the technique is **built and confirmed working** for center providers. The red-rect -artifact is fixed (capture ID isolation + blit rect = `pic.r`). The host-owned `transition()` -helper is in place and `Editor.drawActiveCenter` uses it. What remains is deciding where else to -apply it. - ---- - -## 1. Why transitions are needed at all - -dvui sizes a widget from the **previous frame's min-size cache**. A widget id that did not exist -last frame has no entry, lays out at zero, and snaps to its real size on the next frame. Any screen -swap therefore has one frame of visibly wrong layout. - -Two shapes of fix exist in the tree, and they are *not* interchangeable: - -| | `core.anim.reveal` | `core.anim.transition` / `CrossFade` | -|---|---|---| -| what it does | hides the incoming subtree one frame, then fades it up | records the outgoing screen to a texture and fades that out over the incoming one | -| needs | nothing | backend render-target support | -| correct when | the pane's chrome is drawn **outside** the swap and stays put | the background is **part of** what changes | -| used by | document tabs, store detail pages | center providers | - -`reveal` is not sufficient for center providers, and this is the whole reason `CrossFade` exists: -each provider paints its own pane. The workbench document canvas is square and full-bleed; the -homepage, the pixi pack-project window and the store detail page are rounded cards. Fading the -incoming one up from nothing exposes the window (and the explorer's fill) behind it, *and* the -corner shape changes mid-swap. An earlier attempt had the host hold a flat colour underneath -(`Reveal.fillBehind`, now removed) — rejected, because the host cannot know whether the screen it -is fading between has rounded corners. - -## 2. What exists now - -**`core/reveal.zig`** — pure phase machine (`hidden → fading → shown`, restart on key change). -4 unit tests, wired into `zig build test` as `fizzy-reveal-tests`. - -**`core.anim.reveal(id, key, opts) Reveal`** (`core/anim.zig`) — maps those phases onto -`dvui.alpha` + a dvui animation. State lives in dvui's data store under `id`, which must be a -*stable* widget id (source-derived, not parent-derived — see §5). - -**`core.anim.transition(state, opts) TransitionFrame`** — the host-owned entry point: - -```zig -var frame = core.anim.transition(®ion.transition, .{ - .key = current_key, - .rect = rs.r, - .draw_previous = drawOutgoing, // called only on the swap frame - .ctx = ctx, -}); -defer frame.deinit(); // blits the fading snapshot -drawIncoming(); -``` - -Swap detection, capture, ID isolation, and blit teardown all live inside. `CrossFade` remains the -low-level primitive `transition` drives. - -**`core.anim.CrossFade`** — offscreen capture via `dvui.Picture`. `endCapture` keeps the texture -(not `Picture.deinit`, which would draw-and-destroy). Blit rect is `pic.r` (pixel-enlarged), and -the target is cleared explicitly after create. Capture runs under an isolate parent so its widget -ids cannot collide with the incoming tree. - -**`Editor.drawActiveCenter`** — the one call site, now three lines around `transition`. Tracks -`center_prev_id` only so the outgoing provider can be looked up again by id (never cache the -pointer: a plugin can unload between frames). - -## 3. Red-rect artifact + empty capture — fixed - -Two related bugs showed up as "no fade, just a one-frame flash" plus (earlier) a red outline: - -**Red outline.** Confirmed in the log as: - -``` -error(dvui): layout.zig:24 rectFor() got child … after expanded child -``` - -The capture packed an `expand = .both` child into the parent, then the incoming screen packed -another. `BasicLayout` `errorOutline`s the parent stack in red. - -**Empty capture (no visible fade).** An isolate parent was tried to avoid that layout error. New -parent ⇒ new widget ids ⇒ the store page's `reveal` restarted at alpha 0 and min-size caches went -cold. The snapshot was transparent, so there was nothing to fade over the incoming settle frame. - -**Fix.** `Editor.drawActiveCenter` hosts a **stable slot box** every frame. Capture and live draw -both parent there, so ids match last frame (`reveal` stays shown, min-sizes stay warm). After -capture, `after_capture` resets that box's pack state so the incoming screen is once again its -first child. Blit uses `pic.r`; the target is cleared explicitly after `Picture.start`. - -## 4. Where else to apply - -- **Document tabs / store pages** already use `reveal` and look fine (chrome stays outside the - swap). Leave them unless a case shows the background changing mid-swap. -- **Sidebar view switches** and the **settings tree** are untouched. Apply `transition` only when - the region's background is part of what changes — each capture costs one full offscreen draw of - the region on the swap frame. -- **Plugin-facing surface.** Nothing is required of plugins today and that should stay true. The - only plugin-visible seam worth considering is letting a provider *opt out* (a provider that - animates its own entrance would fight the fade). - -## 5. Traps already hit — don't re-learn these - -- **Reveal/transition state must not be keyed to a parent-derived widget id.** `drawActiveCenter` - runs under two different parents (bottom panel shown vs hidden), and the workspace box's id moves - with layout. Keying to the parent restarts the fade on panel toggles and split changes — changes - that are not content swaps. Use `dvui.Id.extendId(null, @src(), )` or a content hash. -- **Do not key a single-instance layout container by its content.** The store's detail header used - `.id_extra = hashId(entry.id)`, which made it a new widget on every page switch → cold min-size - cache → one frame of zero-height header. That was the original bug this whole thread started - from; a stable id fixed it at the root. Per-item widgets in a *list* still want content ids - (`dvui.animate` re-triggers on new ids). -- **Never leave a per-frame `dvui.log` call in a draw path.** One left in a task-list marker cost - ~11 fps on pixi's README (per checkbox, per frame, allocating and appending to the Output panel). -- **Capture must keep last frame's widget ids.** Reparenting the capture (an isolate box) restarts - `reveal` at alpha 0 and freezes min-size caches — the snapshot is empty and there is no fade. - Use a stable slot parent, then reset that slot's pack state in `after_capture` so the incoming - screen is not a second expanded child (`rectFor() got child after expanded child` → red outline). -- **dvui's testing backend has no render targets** (`textureCreateTarget` returns an error), so the - capture path cannot be covered by `zig build test-integration`. The integration tests pin the - *fallback*: a clean instant swap, nothing retained, and no draw of a provider that has been - unregistered. The capture path itself needs on-screen verification (§6). - -## 6. How to verify on screen - -Synthetic clicks do not reach the app in this environment (raw `CGEvent` posts are ignored, and -`osascript`/System Events lacks assistive access), so a center swap cannot be driven by clicking -the store tab. What worked was a temporary self-driving hook in `Editor.drawActiveCenter`, gated on -an env var, that alternated `host.setActiveCenter` between `center_providers[0]` and `[1]` every -few seconds and called `dvui.refresh` each frame (an idle app renders no frames, so a frame-counted -toggle never fires). Lengthen `CrossFade.duration_ns` to ~1.5s while looking at it. **Remove the -hook before committing.** - -Run a sandboxed instance so the user's own fizzy is untouched: - -```bash -HOME=/tmp/fizzy-sandbox TMPDIR=/tmp/fzmd ./zig-out/arm64-macos/fizzy -``` - -`TMPDIR` must be short — the singleton's unix socket path has a 104-byte limit — and a distinct -`TMPDIR` is what gives the sandbox its own single-instance lock. Quit it with -`NSRunningApplication.terminate()` (fizzy ignores SIGTERM); screenshot with -`screencapture -o -x -l `, finding the id via `CGWindowListCopyWindowInfo` with -`.optionAll` (an unfocused window is absent from `.optionOnScreenOnly`). - -## 7. Related change in the same branch - -`sdk.pane_layout.emptyStateCard` and the store's detail pane lost their `.margin = .{ .y = 10 }`. -Measured: the center region, the workspace vbox and the tab bar all start at the same y (72 -physical / 36pt, directly under the titlebar), but every card sat 10pt lower — a visible step when -swapping between a document canvas and the homepage, and one more thing a transition cannot -compensate for. Cards are now flush at the top; their rounded top corners now meet the titlebar -directly. Confirmed working. diff --git a/docs/UNIFIED_DRAG_PLAN.md b/docs/UNIFIED_DRAG_PLAN.md deleted file mode 100644 index f69fd8448..000000000 --- a/docs/UNIFIED_DRAG_PLAN.md +++ /dev/null @@ -1,202 +0,0 @@ -# One drag: everything carried as the view drag is - -Status: proposed. Step 1 is #248. - -Fizzy has one drag that looks and behaves the way a drag should: the view drag (`ViewDrag`). -- It carries what you lifted in liquid glass. -- Its drop bubbles say where it will land. -- It crosses every window fizzy has open: the main window, floats popped out into windows of their - own, and the carry window between them. - -Everything else that drags (rows in the file tree, folders, pixi's layers, frames and animations) -uses a widget's own drag: a frosted row clipped to one window that can only land in the list it -came from. So a file tree in one window can't hand a folder, or several files, to a workspace in a -float's window, and a pixi layer drag doesn't look like any other drag in the app. - -This plan makes the view drag the only drag, with: -- a carried payload broad enough for files, folders, selections and a plugin's own items; -- a way for any widget, not only a place, to take a drop. - -The scope is fizzy's own windows. Drag and drop with other apps is out of scope; its seam is named -at the end. - -## Where it stands - -| Drag | Mechanism | What it draws | Lands | Crosses windows | -|---|---|---|---|---| -| A view, a tab, a rail icon, a picker card, a file opened from the tree | `ViewDrag` (`begin`, `beginLoose`; `Host.beginViewDrag`) | liquid glass card, bubble or tab face; carry window | places (`RegionSpec.on_drop`), strips (`offerChooser`) | yes | -| A selection of files, a folder (#248) | `ViewDrag` (`Host.beginViewDragMany`, held when no place takes it) | stacked rows in the glass | places, with `Drop.others`; back over the tree through `TreeWidget.carriedOver` | yes | -| A move inside the file tree | `TreeWidget`'s own drag (`drag_name = "workbench.file_row"`) | a `FloatingWidget` row in `dialogs.carriedGlass`, clipped to one screen | the same tree only | no | -| pixi's layers, frames, animations | `TreeWidget`'s own drag, gestures driven by pixi (`tools.zig`, `sprites.zig`) | the same frosted row; pixi's canvas previews the order | the same list only | no | -| pixi's ruler columns and rows | `ReorderWidget`, a named dvui drag (`CanvasData.zig`) | a collapsed floating widget; glass cells in the canvas | the same ruler | no | -| pixi's sprite cells, the sample button | raw dvui drags in the canvas | previews in the canvas | the same canvas | no | -| A dock tab (`"dvui_dock"`) | `DockingWidget` | a frosted zone | dormant: both dockspaces have no header | — | - -The same thing ends up in several places: -- **Starting a drag:** three calls (`ViewDrag.begin`, `beginLoose`, `Host.beginViewDrag`, plus - `beginViewDragMany` in #248). -- **Releasing one:** two sites (`Region.zig` and `Picker.zig`). -- **A widget saying where a carried thing would go in it:** two bridges (`Host.Region.offerChooser` - for strips, `TreeWidget.carriedOver` for the tree). -- **A ghost:** three different ones (`ViewDrag`'s glass, `TreeWidget`'s and `ReorderWidget`'s - floating widgets). - -Only the first of each crosses windows. - -## The design - -### 1. One carry - -`ViewDrag` stays the engine. It already owns everything crossing windows takes: -- the bands every OS window draws from; -- the held pointer read in whichever window it is over (`SDLBackend.heldPoint`); -- `followAcross`; -- the overlay drawn on every screen; -- the carry window. - -Nothing in this plan copies any of that into another widget. Every drag becomes a view drag, so -every drag gets it. - -### 2. What is carried - -`EditorAPI.Carried` (#248) grows a kind: - -```zig -pub const Carried = struct { - what: union(enum) { - /// A surface, or a document by the id it will have: what places take today. - surface: []const u8, - /// A file or folder, by path. The explorer's rows; places take files they can open. - path: struct { path: []const u8, folder: bool }, - /// A plugin's own thing: only targets that name its type take it. - item: struct { owner: []const u8, kind: []const u8, key: []const u8 }, - }, - label: []const u8, - icon: ?Icon = null, -}; -``` - -Several are carried at once, in order, and the first is the one in hand. #248's `held_id` becomes -the general rule: the drag is held, so no place's bubbles show, unless at least one place takes the -item in hand. A pixi layer carried over the canvas shows no bubbles. Carried back over its layer -list it reorders, as a folder carried back over the tree moves. - -### 3. One start call - -```zig -host.beginDrag(.{ .items = items, .from = rows_rect, .grabbed = p }); -``` - -This replaces `beginViewDrag` and `beginViewDragMany`, which stay as wrappers until step 7. -- The app's own `begin` (a place's view) and `beginLoose` (a picker card) call the same thing - inside `ViewDrag`. -- One release path, `ViewDrag.apply`, for every drag: `Picker.zig` and `Region.zig` stop - releasing drags of their own. -- The card's picture is still taken by the app (a pane's preview, a row stack, a pill), never - drawn by the widget that lifted. - -### 4. Who takes it: places, and offers - -**Places** keep taking drops as now, through keywords (`sdk.keywords.accepts`) and `on_drop`. - -**Offers** are new, and replace both bridges. While a drag is in the air, any widget can offer to -take it, each frame, where it is drawn: - -```zig -if (host.dragOffer(.{ - .id = tree_id, - .bounds = rows_rect, - .takes = &.{ .{ .path = .any }, .{ .item = .{ .kind = "pixi.layer" } } }, - .shape = .between(.vertical), // or .into, .point -})) |over| { - // `over.point`: draw the slot or highlight under it. - // `over.released`: take `over.items` now. -} -``` - -- `offerChooser` is an offer with `.between` along the strip. -- `TreeWidget.carriedOver` becomes the tree's own offer: `.between` rows, `.into` a folder. -- The app picks the target once a frame. Uncovered offers (an offer under a float is behind it) - come before places, and an offer's bounds are what the place's drop zones leave out, as a - chooser's are today. -- The release is delivered to that one target: an offer or a place, never both. - -### 5. `TreeWidget` on the carry - -The core tree stops drawing a ghost. -- **At lift**, it calls `beginDrag` with its selection, as `Carried` items the tree's owner names. - The workbench gives paths; pixi gives `.item`s of kind `pixi.layer`, `pixi.frame` or - `pixi.animation`. -- **While carrying**, it offers its rows. -- **On release**, its existing results (`removed()`, `insertBefore()`, `dropInto`) come out as they - do now. The callers' move code (`applyFileMove`, pixi's layer reorder and history) doesn't - change. - -This one change does the work of the plan. In-tree moves, a folder carried to a float's window, -and a layer reordered from a popped-out Layers panel all happen in the glass, across windows. -pixi gets it by repinning to the SDK release that carries the new core. - -### 6. Crossing windows - -This comes for free once everything is a view drag: -- An offer is registered where its widget is drawn, in the band of the window it is in. The app's - pick reads them all in frame coordinates, as it reads places now. -- The plan relies on the windowing foundation's typed coordinate spaces - (`WINDOWING_FOUNDATION_PLAN.md` §4) for keeping band and main-window points apart, rather than - adding conversions of its own. - -### 7. How it fits the windowing foundation - -`WINDOWING_FOUNDATION_PLAN.md` §5 makes the drag one model resolved once a frame: -`DragModel.resolve(targets, occluders, offers, aim, now)`. This plan supplies what that model reads: -- what is carried (§2); -- who offers to take it (§4); -- the one release (§3). - -The steps below that change how targets are picked land on that model, not beside it. Step 4 here -and step 7 there are written together. - -## Steps - -Each step is a PR of its own, under 800 lines outside tests and docs. The SDK steps (3, 4, 5) move -the fingerprint and go out as one SDK release, before pixi's step 6. - -1. **A selection and a folder carried out of the explorer, all landing.** #248. -2. **This plan.** -3. **`Carried` kinds and `beginDrag`.** - - `Carried.what`; `beginViewDrag` and `beginViewDragMany` become wrappers. - - One release path in `ViewDrag`. - - The rule that the drag is held when no place takes the item in hand. -4. **Offers.** - - `Host.dragOffer`; `offerChooser` and `TreeWidget.carriedOver` on it. - - The workbench's strips and tree offer. - - Tests for the pick: offers before places, uncovered first, one target per release. - - Written with the foundation plan's step 7. -5. **`TreeWidget` on the carry.** - - The tree lifts with `beginDrag`, offers its rows, and draws no ghost. - - `KeybindSettings` and the workbench adopt it. - - A file tree in the main window drops a selection or a folder into a float window's workspace. -6. **pixi.** Repinned to the SDK release; its layer, frame and animation trees carry `pixi.*` - items, so they reorder in the glass from wherever the panel is. -7. **What is left goes.** - - The wrappers. - - `TreeWidget`'s floating ghost. - - The dormant `"dvui_dock"` tab drag. - - `ReorderWidget`: pixi's rulers are its only user. It moves into pixi, or onto the carry if a - ruler column should ever leave its ruler. - -Tests: each step adds unit tests where its logic is pure (the pick, held drags, the order `others` -lands in). A tape that carries a selection from the tree into a float's window waits on the -foundation plan's fake backend (§9) and comes with it. - -## Not in this plan - -- **Drag and drop with other apps** (to Finder or Explorer, or from the desktop into the tree). - - Its seam is `Carried.what.path`, the one kind another app understands. An OS drag starts from - the same `beginDrag` when the pointer leaves every fizzy window with path items in hand, and - ends as an offer of the OS's. - - Each OS needs its own source and target (`NSDraggingSource`, OLE `IDropSource`, Wayland and X11 - data devices). That's a plan of its own. -- **pixi's canvas drags:** ruler columns, sprite cells and the sample button never leave the - canvas. They keep their in-canvas previews. -- **Gesture drags** that aren't drag and drop: sashes, float headers, canvas pans, marquees. diff --git a/docs/WINDOWING_FOUNDATION_PLAN.md b/docs/WINDOWING_FOUNDATION_PLAN.md deleted file mode 100644 index 1d34824cd..000000000 --- a/docs/WINDOWING_FOUNDATION_PLAN.md +++ /dev/null @@ -1,323 +0,0 @@ -# The windowing foundation: one model, decided once a frame, tested without a screen - -Status: proposed, #243. - -Fizzy's floats, menus, dialogs, view drags and drop zones work, natively, on macOS 26. Everything -else will be built on that machinery, so it needs to stay fast and easy to change. Today it is -neither: Popout re-decides every window by hand each frame, hit tests repeat once per region, and -little of it has a test. This plan sets out how to restructure it in steps. The steps keep the -behaviour as it is, but make each decision testable without a screen. They make the native calls -cheap, share one model across platforms, and leave the backend usable by any dvui app. - -**What it builds on, without repeating:** -- [`NATIVE_WINDOWS_PLAN.md`](NATIVE_WINDOWS_PLAN.md) decides which material, glass and window - chrome each platform uses: capabilities rather than platform names, one `WindowChrome` per OS - window, a native `layers` interface, one material on one slider, and what SDL should be - patched to do. -- [`WINDOWS_LINUX_GLASS_PLAN.md`](WINDOWS_LINUX_GLASS_PLAN.md) carries that plan's Windows and - Linux research. Its built steps are in the open PRs #237, #238 and #242. -- [`POPOUT_WINDOWS_PLAN.md`](POPOUT_WINDOWS_PLAN.md) covers bands, viewports and per-OS dressing. - -This plan is about the layer underneath those: which window shows what, how that is decided, and -how the decision is tested. Where the two overlap, those plans decide and this one only says where -the code should live. - -The findings come from a review on 2026-10-08. It covered the drop-zone and float core, the backend -windowing layer, glass on every platform, and how portable the backend is. Line numbers will drift. - -## Where it stands - -**What is already right, and the pattern to copy.** The decisions that are already pure are also -the ones that are tested: -- `viewport_map` (bands, placement, hit tests): 14 tests. -- `float_rules`: 13. -- `window_layout`, the AppKit maths moved into Zig and called from Objective-C: 19. -- `glass_look`: 12. -- `Drop.plan`. - -The single dvui frame with a band for each OS window keeps a drag across windows to one layout and -one frame. The GPU presents every window in one submission. Neither needs changing. - -**The windows.** -- `SDLBackend.Viewport` is one struct serving five kinds of window: float, menu, dialog, carry and - overlay. - - Four booleans encode the kind (`passive`, `overlay`, `menu`, `dialog`), and the code infers it - from them (`passive and !overlay` means carry). - - About 3 KB of overlay glass storage sits in every one of the eight slots. - - All kinds share the eight slots: up to eight floats, menus, the carry window, the overlay and - growing glass. Running out fails silently (`menuOut` returns null; `popOut` just returns). -- `Popout` has five near-copies of "make a target, set its offset, replay subwindows into it, - present": `windowFrame`, `carryBegin`, `overlayFrame`, `menuFrame` and `drawGrow`. Menu, overlay - and carry targets are recreated on every size change, so a menu sliding open makes a texture each - frame. -- Popout keeps its state in module globals (`outs`, `covers`, `spare`, `menus_left_behind`, …). - It hands things over across frames (the drop's glass lives in `spare` for exactly one frame, then - is gone), and it only works in the order `Entry` happens to call it. `core.dialogs.carry_windows` - is a plain `pub var`, which a plugin dylib's copy of `core` never sees. - -**The coordinate spaces.** There are seven: -1. main natural; -2. main physical; -3. band physical; -4. main frame extended past its edge; -5. desktop points; -6. window-local points; -7. render-target pixels. - -Conversions between them are scattered across Popout, ViewDrag, `core.screens` and SDLBackend. -Two unrelated thresholds decide whether something is in a band: `main + 40000` in Popout, and a -jump of `30000` in ViewDrag. A third constant, `screens.beyond` (16384 natural units, how far the -desktop reaches past the main window), has to stay clear of the bands without saying so. -`Floats.Viewport.rect` is documented as -being in the main window's frame but holds band units. - -**The drag.** -- Hit tests repeat with no cache. During a drag, every region's draw calls `tick` → `settleGhost` - and `drawZones` → `chooserAt`, `aimedAt` and `wheelOf`. Each of those reaches `zoneBounds`, and - each `zoneBounds` runs `DropZones.uncovered` twice. That is roughly O(regions × targets × - uncovered) per frame, over name lookups that are linear string scans. -- The release rules are split. `Drop.plan` covers the table; `apply` adds held, float-away, - chooser, plugin-chooser and empty-place branches outside it. -- Lifecycles are implied by flags: `fresh`, `closing`, `aside`, `ghost_firm`, `lifted`, - `spare_kept`, `held`, `been_drop`. The float lifecycle (fresh → landing → live → ghost → gone) - and the drag lifecycle (lift → carry → release) are never written down. - -**Native calls per frame (macOS, per float window).** -- No NSWindow pointer is cached. Each `cocoaWindow` or `windowX` call does - `SDL_GetPointerProperty(SDL_GetWindowProperties(…))`: about 15–20 lookups and about 20 - Objective-C messages per float per frame. -- `fizzy_macos_window_liquid_glass_look` opens a Core Animation transaction on every call, with no - "unchanged" check on the Zig side. It runs for the main window, each float and each menu. -- `setStyle` runs every frame and re-applies `styleTitled` to every window that isn't passive. That - includes menus and dialogs, which get `FullScreenPrimary` added over the `FullScreenAuxiliary` - their window was given. Fixed in its own PR (step 2, #245). -- Two owners open Core Animation transactions: the `macos_monitor` hooks and `renderPresent`. - -**Glass.** -- The liquid glass shader is three hand-synced copies (GLSL, Metal, HLSL) plus a CPU mirror - (`LiquidField.sample`) that doesn't model the lens branch. -- Each look is mirrored four deep: `glass_look`, the backend, the platform and Objective-C. Stub - copies sit in `backend_web` and the native fallback. That fallback's `GlassShape` has already - drifted (no `frost`); it is latent, because nothing reaches it today. -- Liquid Glass variant numbers live in three places. -- "Is the window translucent" is spelled five ways: `Settings`, `SettingsMigration`, `Editor` - twice, and `backend_native`. -- `core.liquid_blob`, the CPU mesh the shader replaced, is still filled on every overlay frame - (`Popout.glassBase`). - -**Tests.** -- 32 tests beside the layout code never ran, because no test root reached the `app` module. They - run now (step 1, #244). -- There are none for Popout, the backend, `screens` or `motion`. `DropZones`' geometry (`wheel`, - `at`, `atDisc`, `uncovered`) is tested, in `tests/integration.zig` (`drop:`), because `core` has no - test root of its own. -- The integration tests never set `Float.viewport`, so nothing popped out is ever tested. -- No demo tape drags, floats or pops anything out. - -**Dead API.** -- `viewports.bandFromMain`, `dragMove`, `orderAbove`, `shown` and `maximized` have no callers. -- `PointerPin.main` is never set. -- `osMoveEnded`'s `resized` is thrown away, so `os_moving`, `os_start` and - `ViewportLoop.resized/sizing` have no consumer. - -**Full-screen transitions.** -- Fizzy runs its own transition into and out of a Space, so the window can fade, rather than - AppKit's, which moves still pictures. Each step redraws the whole app at that step's size, so it - can only be as smooth as one app frame is fast. -- Measured: in Debug, a steady 19–22 ms a frame (about 50 fps). In release before the change - below, about 8.7 ms on average, with stalls of 30–60 ms. -- Most of the stalls came from resizing the window, and so its Metal drawable, on every step. - -## The design - -### 1. Decide, then apply - -Each frame, the windowing becomes two halves. - -**A pure plan.** -- *Input:* the floats and their rects, the drag, the open menus and dialogs, and the OS reports - gathered at `begin` (moved, resized, close asked, full-screen fraction). -- *Output:* the frame's OS windows, each `{ id, kind, rect, look, order, what it shows }`. -- No dvui, no SDL, no globals, so it is tested like `viewport_map`. - -**A reconcile.** The backend applies the plan against what it applied last frame, by id, as dvui -does with widgets. It creates and destroys windows, moves them, and dresses them only when -something changed. `NATIVE_WINDOWS_PLAN.md`'s `layers.glass(id, …)` is the glass half of the same -reconcile. - -What this buys: -- **Tests without a screen:** the plan is a value to assert on. -- **Speed:** Objective-C and Win32 run only for what changed, which removes most of the per-frame - native traffic above. -- **One seam per platform:** a backend implements reconcile, and nothing else needs to know the - platform. - -### 2. One window type, its kinds a union - -`Viewport` becomes `OsWindow { window, band, screen, frame, … kind: union(enum) { float, menu, -carry, overlay } }`. -- Each kind's state lives in its own arm, and a dialog is a menu with its own stacking. -- The native handle (NSWindow or HWND) is read once, when the window opens, and kept. This is the - `WindowChrome` of `NATIVE_WINDOWS_PLAN.md`. -- Slots are budgeted per kind, so menus can never starve floats. Running out is logged, not - silent. - -### 3. One way to fill a window - -The five near-copies in Popout become a single function: - -```zig -fill(window, which subwindows, target) -``` - -Targets come from a pool keyed by bucketed size, as `BlurBackdrop.captureSize` already buckets its -captures. A menu sliding open or a carried shape morphing then reuses a texture instead of making -one each frame. With dvui change 1 below, `fill` stops taking subwindows' render commands by hand -and only names a target. - -### 4. Typed coordinate spaces - -- `viewport_map` owns distinct types for the spaces (`BandPx`, `MainPx`, `ScreenPt`, `WindowPt`) - and every conversion between them. -- A single `viewport_map.bandOf(x)` replaces both band thresholds, and the desktop's reach - (`screens.beyond`) is defined next to the bands, as staying short of the first one. -- `Floats.Viewport.rect` says which space it holds, in its type. - -### 5. A drag model, computed once a frame - -- A pure `DragModel.resolve(targets, occluders, offers, aim, now) → { target, kind, chooser, - ghost }`, run once a frame. Every region reads the result. -- `under`, `chooserAt`, `targetAtAim`, `zoneBounds` and `settleGhost` (given the time and the - pointer as inputs) move into it, with tests, built on `DropZones.wheel`, `uncovered` and `atDisc`, - which are already tested. -- `Drop.plan` grows to cover held, float-away and chooser drops, so `apply` becomes one switch over - a plan. - -### 6. Lifecycles written down - -Each lifecycle becomes an explicit state with legal transitions, replacing the flags and one-frame -handoffs: -- **float:** fresh → landing → live → aside or ghost → closing → gone; -- **a float's window:** opening → growing → shown → (moving through a Space) → closing; -- **drag:** lift → carrying → over another window → released. - -`spare` becomes a state of the landing it belongs to, so a drop's glass can no longer be dropped -because a float didn't pop out on exactly the next frame. - -### 7. Glass: one look record, end to end - -This builds on `NATIVE_WINDOWS_PLAN.md` ("One vocabulary", "Capabilities"). -- The struct `glass_look` returns is the extern struct each platform reads. No more four-deep - mirrors, and the Liquid Glass variant numbers live only in `glass_look`. -- The backend publishes its capabilities once (`liquid_glass`, `lens`, `merge`, `window_backdrop`, - `blur_region`, `carry_windows`). They replace the five spellings of "translucent". -- The shader gets either one source that generates the other two, or a parity test that draws all - three against `LiquidField.sample` on a fixed grid. Whichever comes after #242 lands. -- Drop `liquid_blob` from the overlay once the native fill draws the same colour. - -### 8. Full-screen transitions - -**Stage one (built locally, unpushed).** -- A float's window stands still at its full-screen size from the first step. Only the float's - picture and its glass move inside it, so no drawable is resized and the float isn't laid out - again in a new window. -- Clicks in a full-screen float are fixed in the same stack: SDL's view now passes the left press - on itself, which the `mouseDown:` it inherits from AppKit didn't do in full screen. - -**Stage two, if release frames don't hold 8.3 ms.** -- Core Animation moves the picture and the glass views, with a `CABasicAnimation` on the frame of - SDL's view layer and on the glass views. The app draws only the first and last sizes. -- The window server then paces the animation as it does AppKit's, and fizzy keeps its glass. -- The main window moves to the same scheme. - -### 9. Testing - -- **Pure modules, each with a std-only test root**, as `viewport_map` has: - - the plan (§1); - - the drag model (§5); - - the coordinate spaces (§4); - - the transition easing and rect; - - `keepTarget`'s sizing; - - the pointer-routing decision: global point, window rects, flags and pin in; the frame point - out. This is the part tapes can't reach. -- **A fake backend:** a recording implementation of the `viewports` facade under dvui's testing - backend. Integration tests can then pop floats out, set `Float.viewport`, and assert the plan and - the operations applied. -- **Tapes:** - - add anchors for corner buttons and drop bubbles; - - bundle a drag → split → float → pop-out tape; - - a tape still can't reach the OS's input routing, which is what the routing function's tests - are for. -- **The shader parity test** (§7). - -### 10. Any dvui app - -The goal: dvui's own example app, built with `-Dbackend=custom` and fizzy's backend, shows its -floating windows and menus as OS windows. - -**On fizzy's side.** -- The backend becomes a package of its own: `SDLBackend`, `GpuRenderer`, `platform/**` (the - Objective-C included, which today is compiled into fizzy's executable), `viewport_map`, the - `viewports` facade and the SDL pin. Its C symbols lose the `fizzy_` prefix. -- Popout splits in two: - - the generic half moves into that package: subwindows into OS windows, carry and overlay - windows, menus and dialogs as windows; - - fizzy's policy stays in the app: `Floats`, `ViewDrag`, the looks. - -**On dvui's side, smallest and most valuable first.** Each change goes to the fork -(`DEPENDENCIES.md`) and is proposed upstream: -1. **`Window.drawRetained` (done, on `fizzy-dev`; upstream branch `window-draw-retained`).** - `endRendering` drew dialogs and toasts and then replayed the subwindows, with no point between - the two for an app to act. `drawRetained` is that first half on its own; `endRendering` calls it - if the app did not. Popout calls it before taking subwindows into their windows, which removed - `core.dialogs.drawEarly`. - - A render target per subwindow, first proposed here, was dropped: it cannot express a layer - drawn into every window (a view drag's drops), and taking a subwindow's commands with the - public `render_cmds` and `renderCommands` already works once the dialogs exist. -2. **`dvui.screensSet` and `dvui.screenFor` (done, on `fizzy-dev`; upstream branch `screens-set`).** - The app lists, each frame, the areas besides the window that are screens of their own (the - bands). The floating widgets place, size, clip and dim against the screen holding their - middle, where they used `windowRect`. Without a call nothing changes. - - Not yet adopted: fizzy's `core.screens` still publishes into dvui data, and its forks of the - floating widgets stay, for their looks. Adopting it is what lets a stock dvui menu work in a - band. - - The option to skip the clamp was dropped: a float whose band is its screen is already on it. - -Dropped after review (2026-10-09), for now: -3. **A kind and parent per subwindow.** Fizzy's own menus pass no parent either; the band holding - a subwindow's middle already says which window it belongs to. -4. **Input per OS window.** Sending `.leave` only when the pointer leaves every window of the app - is the backend's job, not dvui's. -5. **Frame hooks for `dvui.App` backends.** A backend with its own `main` runs `begin`, the frame - and `end` itself, so it can call `drawRetained` and take subwindows with no hook. -6. **An `os_window` policy flag.** Which subwindows become OS windows is the app's policy; nothing - in dvui needs to know. - -Later: a natural scale per subwindow (mixed-DPI displays), and an AccessKit tree per OS window. - -**The case to make upstream.** dvui's only multi-window model today is `OsWindowWidget`: one -`dvui.Window` per OS window, at most five. A drag across windows needs one frame and one layout, -which is what bands give. Of the patches fizzy carries on dvui, `deferRender` and precise targets -are for glass; `drawRetained` and `screensSet` are the windowing ones. - -## Steps - -Each step is a PR of its own, in this order. Steps 3 to 8 keep the behaviour as it is. - -1. **The tests that never ran:** a test root for the `app` module (32 tests). #244 -2. **Menus and dialogs stay auxiliary windows:** `setStyle` leaves them alone; they are dressed once - as they open. #245 -3. Typed coordinate spaces and `bandOf` in `viewport_map`, with tests, adopted by Popout, ViewDrag - and `screens`. After #237 and #238, which touch the same files. -4. `OsWindow` with a union of kinds, the native handle cached, dressing checked for changes on the - Zig side, per-kind budgets. Measured by Objective-C messages per frame, before and after. -5. Extract the plan (§1), add the fake backend (§9), and write the first integration tests that pop - a float out. -6. Unify `fill` and add the target pool (§3). -7. The drag model once a frame, with tests, and the release table (§5). -8. Write the lifecycles down (§6). -9. Glass: capabilities, one look record, shader parity (§7). After #242. -10. Full-screen stage two (§8), if release measurements call for it. -11. dvui changes 1–3 on the fork, the backend as a package, and dvui's example app as the - acceptance test. Then changes 4–6. -12. Remove the dead API, along with steps 3–5 as they touch it. diff --git a/docs/WINDOWS_LINUX_GLASS_PLAN.md b/docs/WINDOWS_LINUX_GLASS_PLAN.md deleted file mode 100644 index aa84e2747..000000000 --- a/docs/WINDOWS_LINUX_GLASS_PLAN.md +++ /dev/null @@ -1,435 +0,0 @@ -# Native windows and glass on Windows and Linux - -Status: in progress. Research under `docs/NATIVE_WINDOWS_PLAN.md`. Step 0 (the glass programs -compiled again) is done (#242). Steps 3 (Windows menus and dialogs as Acrylic popups) and 4 (the -Linux window material) are built in PRs of their own, each behind a flag, and step 5's spikes are -run (`spikes/windows-composition/`); nothing else here is. - -An investigation, not yet a design anyone has agreed to. macOS has floats as OS windows by default, -and on macOS 26 the OS draws a view drag's glass (`docs/POPOUT_WINDOWS_PLAN.md`, "P6"; -`core/native_glass.zig`). This asks what the same experience takes on Windows and Linux, what each -OS gives fizzy to build it from, and in what order to build it. The facts about each OS were -gathered in October 2026, with sources linked. Anything marked **(spike)** was not run, and is the -first thing to check. - -**The plan this works under is `docs/NATIVE_WINDOWS_PLAN.md`** (#226). Its decisions stand: on Windows, the slider runs along DWM's backdrop types as Windows Terminal maps its opacity -(no backdrop, then Acrylic, then Mica), fizzy's windows act as one activation group, a carry -window is DWM Acrylic, and the drop zones stay the app's glass, with no merging. Per-shape glass -over the desktop through Windows.UI.Composition is its "Phase 2, only with a go-ahead". This file -is the research for that phase (the overlay below) and for the Linux blur region, which its -"Linux — the compositor's blur" already designs. - -## What macOS does, taken apart - -The macOS work is one experience, but it rests on several separate things the OS provides. Each one -has a different answer on the other platforms, so they are listed separately: - -| # | Piece | macOS (built) | Windows today | Linux today | -|---|---|---|---|---| -| 1 | **Window material**: a blur of the desktop behind the main window and each float's window | vibrancy / Liquid Glass (`visual_effect_view.m`) | Acrylic through DWM (`win32_titlebar.zig`) | none: opaque (`LiquidField.publishOpaqueWindow`) | -| 2 | **OS frame**: corners, shadow, resizing from the edges, snapping | a titled `NSWindow` | DWM, with the app's hit test (built for the main window and floats) | client-side; Wayland frame insets for the shadow | -| 3 | **Window buttons on a float** | traffic lights (`viewports.os_buttons`) | none yet: the float's header needs caption buttons | the app's own | -| 4 | **Menus and dialogs in windows of their own** | `viewportOpenMenu` | not built | not built | -| 5 | **Carry window**: what a dragged view is carried as, shown past every window, in its own shape, with the material and a shadow, clicks passing through | `viewportOpenCarry` (`viewports.carries`) | not built: the drag is cut off at the main window's edge | not built | -| 6 | **The OS's glass**: drop bubbles and the carried drop merging like liquid, refracting what is behind them across every app | `NSGlassEffectContainerView` in the overlay (`viewports.liquidGlass`) | no OS equivalent | no OS equivalent | -| 7 | **One transaction**: a window's place, its glass and its picture reach the screen together | one Core Animation transaction a frame (`renderPresent`, SDL patches 3–5) | none: a composition commit and a swapchain present take separate routes | Wayland: the surface commit; X11: none | - -1–3 are mostly done on Windows. 4 and 5 are ordinary windowing work. 6 is where the platforms -really differ, and 7 decides whether whatever is built for 5 and 6 looks native or trails a frame -behind. - -## The split that makes it portable: the OS supplies the blur, fizzy supplies the shape - -On macOS 26 the OS does both jobs: it shapes the glass (merging the pieces, refracting) and fills -it with material. Nothing on Windows or Linux is like `NSGlassEffectContainerView`. Microsoft said -in August 2026 that there are "no immediate changes" planned to Windows' transparency -([Windows Latest](https://www.windowslatest.com/2026/08/10/microsoft-reveals-why-windows-11-wont-get-liquid-glass-style-ui-even-though-windows-vista-did-it-first-with-aero/)). -The Linux compositors that refract ([hyprglass](https://github.com/hyprnux/hyprglass)) do it as -compositor effects an app cannot ask for. What both platforms *can* do is blur what is behind a -window and clip that blur to a shape the app gives. Fizzy already computes the merged shape -itself: the smooth minimum in `LiquidField` (`shaders/liquid_glass.*`) and its CPU twin -`liquid_blob` are what the app's own glass draws, in every window, on every platform. - -So everywhere except macOS 26: - -- **the shape is fizzy's**: the merging, necks, rim light, lift, tint, icons and the carried - photograph are drawn by the app into a transparent window, as `LiquidField` already draws them - in-window; -- **the material is the OS's**: the OS blurs what is behind that window, inside an outline the app - hands it each frame (a clip path on Windows, a region on Wayland), traced from the same field. - -`core.native_glass` already holds the piece list this needs (rects, radii, `lit`, `alpha`, `frost`, -the merge distance), and `Popout.overlayFrame` already sizes and places a window around the glass. -What changes is the backend. Instead of handing the pieces to Liquid Glass, it draws them as the -app's glass with no frost of its own, and hands the OS the outline of the pieces that take frost. - -Three things are lost against macOS 26: - -- **No bending of what is behind.** The OS's blur is only a blur. No Windows or Linux API refracts - another app's pixels (Windows' composition effects take only built-in shaders, and displacement - is not among them; - [composition effects](https://learn.microsoft.com/en-us/windows/apps/develop/composition/composition-effects)). - Within fizzy's own windows that never mattered, because the app refracts its own frame. Over - the desktop, the clear lens at the bottom of the slider (`core/gfx/glass_look.zig`) can only be - clear: rim light, no bend. "Windows: real refraction" below is the way round it, at a cost. -- **The blur's strength is the OS's.** Windows' host backdrop arrives blurred: a Microsoft - engineer said it is blurred "for security reasons" - ([WindowsCompositionSamples #202](https://github.com/Microsoft/WindowsCompositionSamples/issues/202)). - Wayland compositors choose the blur radius themselves. The slider's frost (`rough_by`) can only - fade that blur in and out, not grow it. The user has already rejected that look once ("blurred - everything at every point"), so the mapping needs looking at again for these platforms. -- **The outline is a frame late, or stepped.** On Windows the clip and the picture travel by - different routes (below). On Wayland the region is in sync, but made of whole rectangles. - -## Step 0 (both platforms): the glass programs compiled again — done (#242) - -`core/gfx/shaders/compiled/{spv,dxil}` were last compiled in #206 (2026-10-03), and -`liquid_glass.fragment.hlsl` changed in #227, so Windows and Linux drew the earlier glass even -in-window: no lens, and not the one-slider look. #242 compiled them again with `shadercross` (the -commands are at the top of the HLSL), with the DXC SDL_shadercross vendors (libsdl-org at -2c84a1c5, built with clang: built with GCC 13 it corrupts its heap compiling DXIL). That DXC -compiles #206's HLSL to #206's SPIR-V and DXIL byte for byte. Every comparison below assumes the -programs are current. - -## Windows - -### What Windows gives - -- **DWM system backdrops**, `DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE)` (Windows 11 22H2+): - Mica, Mica Alt and Acrylic (`DWMSBT_TRANSIENTWINDOW`) behind the whole window, with the frame - extended over the client area. Corners come from `DWMWA_WINDOW_CORNER_PREFERENCE` (none, small - or round, which are the system's radii, not any radius), and the shadow is DWM's - ([enum](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwm_systembackdrop_type)). - DWM ignores a backdrop on a plain `WS_POPUP`. The window has to keep a caption style that - `WM_NCCALCSIZE` collapses away, which is already how fizzy's float windows are made. The material - falls back to a solid colour when the window is inactive, in Battery Saver, or with transparency - turned off. This is what fizzy uses today. It covers a whole window only, and its outline is one - of three corner radii. -- **`DwmEnableBlurBehindWindow`** has blurred nothing since Windows 8. Today it only turns on - per-pixel alpha, which is all SDL does with it for `SDL_WINDOW_TRANSPARENT` (an empty region at - creation) - ([docs](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/nf-dwmapi-dwmenableblurbehindwindow)). - The undocumented `SetWindowCompositionAttribute` Acrylic lags while dragging, and misbehaves with - per-pixel alpha from 22H2 on: a Windows 10 fallback at most - ([window-vibrancy](https://github.com/tauri-apps/window-vibrancy)). -- **DirectComposition**: fizzyedit/SDL already presents a transparent window's D3D12 swapchain - through it (`docs/DEPENDENCIES.md`, SDL patch 2), as the content of the root visual on a - **topmost** target (`D3D12_INTERNAL_SetCompositionContent`). It composites alpha correctly, but - has no public brush that reads what is behind the window. -- **Windows.UI.Composition** (the visual layer, usable from plain Win32: a `DispatcherQueue` on the - thread, then `ICompositorDesktopInterop::CreateDesktopWindowTarget(hwnd, topmost)`; - [visual layer with Win32](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/ui/using-the-visual-layer-with-win32)). - - `CreateHostBackdropBrush` paints a blur of what is behind the window: other apps, the - desktop, fizzy's own windows. Win32 windows may use it from Windows 11 (22000) once they set - `DWMWA_USE_HOSTBACKDROPBRUSH` - ([brush](https://learn.microsoft.com/en-us/uwp/api/windows.ui.composition.compositor.createhostbackdropbrush), - [attribute](https://learn.microsoft.com/en-us/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute)). - - The backdrop can be cut to any shape by a `CompositionGeometricClip` over a - `CompositionPathGeometry`, which can change every frame. `RectangleClip` has per-corner radii - (a pill is a rect rounded by half its height). - - The backdrop **cannot** be the source of a `CompositionMaskBrush`: it fails with "Unsupported - source brush type" - ([microsoft-ui-xaml #12063](https://github.com/microsoft/microsoft-ui-xaml/issues/12063), - [WindowsCompositionSamples #103](https://github.com/Microsoft/WindowsCompositionSamples/issues/103)). - An effect graph taking the backdrop and a mask surface together (as Acrylic itself mixes the - backdrop with a noise surface) is untried. - - It hosts a swapchain (`ICompositorInterop::CreateCompositionSurfaceForSwapChain`), and casts - shaped shadows (`DropShadow` with a mask). - - Its changes are committed on the dispatcher's own tick, separately from any swapchain present: - expect up to a frame between them - ([Makepad #1190](https://github.com/makepad/makepad/pull/1190); Avalonia applies its - properties on the render thread just before presenting, - [#22115](https://github.com/AvaloniaUI/Avalonia/pull/22115)). -- **Capture with self-exclusion**: `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` keeps a window - out of every capture, and Windows.Graphics.Capture or DXGI Desktop Duplication then gives the app - what is under it, as a texture. -- **SDL**: `SDL_SetWindowOpacity` makes the window layered (`WS_EX_LAYERED`). SDL's draft "dockable - windows" ([#16432](https://github.com/libsdl-org/SDL/pull/16432), milestone 3.6) moves a window - with the pointer while the button is held. - -Community attempts at Liquid Glass on Windows -([LiquidGlassWinUI3](https://github.com/JamesLinYJ/LiquidGlassWinUI3): host backdrop, blur, rounded -clip) confirm the pieces work. None of them merges shapes, and none refracts anything but its own -content. - -### The plan - -**Windows, menus and dialogs: DWM, as now.** Float windows already wear Acrylic, DWM's corners and -shadow, Snap and their own taskbar buttons. What's left is P4's list: caption buttons in the -float's header, `win32_titlebar` state per HWND, persistence, and checking the look on a real GPU -(so far the VM has only shown Acrylic's solid fallback). Menus and dialogs in windows of their own -are the cheapest native win on Windows. Each is a popup owned by the window it opened from and -never activated, framed as the floats are (a caption style collapsed in `WM_NCCALCSIZE`, which -DWM needs to draw a backdrop), with `DWMSBT_TRANSIENTWINDOW` and `DWMWCP_ROUND`: the material and -the corners of Windows 11's own menus and flyouts. `viewportOpenMenu` gains a Windows branch -through `win32_titlebar.viewportChrome`. - -**Carried views and the drag's glass: one overlay, never a moving window.** On macOS a carry -window moves under the pointer, and stays in step because its frame changes in the transaction its -picture is presented in. Windows has no such transaction between `SetWindowPos` and a present, and -the plan has already seen what that costs: a window moved in one step and sized in another showed -half-done (`POPOUT_WINDOWS_PLAN.md`, "What P2 found"). So on Windows, nothing that follows the -pointer is a window that moves: - -- **One overlay per display**, made when a drag starts. It is topmost, never activated, passes - clicks through, has no taskbar entry (`WS_EX_TOOLWINDOW`) and no DWM transitions. It uses - `WS_EX_NOREDIRECTIONBITMAP`, so a window the size of the display costs no bitmap of its own; - only its visuals cost memory. A host backdrop hit-tests as opaque, so clicks must pass through - by style (`WS_EX_TRANSPARENT | WS_EX_LAYERED`), not by an empty hit test. Whether that style - leaves the composition tree alone is **(spike)**. -- **Its picture is a swapchain the size of the glass's area**, not of the display. - `Popout.overlayArea` already keeps that area steady with room to spare, because presenting a - display-sized picture each frame halved a drag's frame rate on macOS. On Windows the area moves - as the visual's offset inside the overlay, not as the window's place. -- **Carry and native glass become one path.** The carried card or tab, the drop's head and tail, - and the bubbles are all pieces in the same overlay. They are drawn by the app's `LiquidField`, - so they merge exactly as they do in-window, over the OS's blur. macOS needs two paths because - Liquid Glass is only on macOS 26; Windows needs only this one. - -**The material under the glass: Windows.UI.Composition's host backdrop, clipped to the field.** -DWM's system backdrop can't draw a pill, a disc or a merging drop. The visual layer can. In the -overlay: - -1. Under SDL's topmost DirectComposition target, a `DesktopWindowTarget` that is **not** topmost - (`CreateDesktopWindowTarget(hwnd, FALSE)`). Whether a Windows.UI.Composition target and a - DirectComposition target can share an HWND like this is **(spike) #1**. If they can't, the SDL - fork gains a property that hands the swapchain to the app instead of making a target of its - own, and fizzy hosts it in its own tree (`CreateCompositionSurfaceForSwapChain`). -2. In it, a `SpriteVisual` filled with the host backdrop and clipped by a - `CompositionPathGeometry`: the outline of the field where it takes frost, traced each frame. - `liquid_blob` already samples that field and runs marching squares over it, so tracing its edge - into a path (a Direct2D path geometry, which `CompositionPath` takes) is a small step from code - that exists. A card or tab that merges with nothing is cheaper as a `RectangleClip` with corner - radii. A clip edge that turns out aliased **(spike)** sits under the rim light the app draws - over it, so the clip goes a pixel inside the rim. -3. The window sets `DWMWA_USE_HOSTBACKDROPBRUSH`. - -The clip is a composition property, and the glass drawn over it is a swapchain present. The two can -land a frame apart, so the blur trails the rim on a fast drag. Setting the clip immediately before -the present, as Avalonia does, makes them usually coincide. Whether "usually" is good enough is for -PresentMon and the user's eye to decide **(spike) #2**. If it isn't, there is one more thing to try, -an exact match: an effect graph that masks the backdrop with a second swapchain the app presents -alongside the picture. The shape would then change only through presents, as on macOS it changes -only in the transaction. The mask brush can't do this (above). Whether an effect graph can, and -whether it takes a swapchain-backed surface as an input, is **(spike) #3**. Effects are built from -`IGraphicsEffect` descriptions, which fizzy would implement by hand, in the same way the SDL patch -declares DirectComposition's interfaces by vtable slot. - -**Windows: real refraction, if the clear lens over other apps matters.** The only way to bend -another app's pixels on Windows is to have them, and Windows does allow that. Mark the overlay -`WDA_EXCLUDEFROMCAPTURE` (Windows 10 2004+) so it never appears in any capture, and capture the -display under it with Windows.Graphics.Capture, or DXGI Desktop Duplication on the same adapter. -The app's own `LiquidField` then frosts and refracts that capture exactly as it does its own frame -in-window, the same at every point of the slider, and close to macOS 26. - -It costs: -- a display-sized capture each frame while a drag is on (started at the press, and it takes a - few frames to start); -- what is behind the glass lagging it by about a frame; -- protected content showing black; -- Windows.Graphics.Capture's border or consent prompt; -- the perception of an editor capturing the screen. - -Worth a spike behind a flag. Not the default. - -## Linux - -### What Linux gives - -What a window can ask of the desktop depends on the compositor, and in 2026 the answer has changed: - -- **`ext-background-effect-v1`** is now the cross-desktop blur-behind protocol. KWin has it from - Plasma 6.7, which dropped KDE's own `org_kde_kwin_blur` on Wayland. Mutter has it from GNOME 51 - (September 2026), so GNOME now blurs behind apps that ask - ([Phoronix](https://www.phoronix.com/news/GNOME-Mutter-Background-Blur)). niri has it from 26.04; - Hyprland in its development builds; COSMIC is working on it; wlroots/sway do not - ([scenefx #136](https://github.com/wlrfx/scenefx/issues/136)). - - One object per surface. - - Its blur region is a `wl_region` (whole rectangles, so no radius and no anti-aliasing), - double-buffered and applied on the surface's next commit. A region changes in the same commit - as the picture. - - The compositor chooses the blur's strength (Mutter's defaults: radius 24, saturation 1.25, - a little noise; KWin: a global setting). - - Whether compositors honour it on popups and subsurfaces is unverified. -- **No placing toplevels**, still. What a Wayland client has instead: - - **`xdg_popup`**: placed relative to a parent and kept on screen by the compositor. Moving one - (`reposition`, v3) waits for a configure round trip, so it lags anything that follows the - pointer. - - **Synchronized subsurfaces**: their position is applied with the parent's commit. - - **The drag icon of a `wl_data_device` drag**: the compositor moves it with the pointer itself, - so it never lags, and it never takes input. - - **`xdg-toplevel-drag`**: drags a real toplevel. KWin has it; Mutter's is a merge request - ([!4107](https://gitlab.gnome.org/GNOME/mutter/-/merge_requests/4107)). SDL's draft - "dockable windows" ([#16432](https://github.com/libsdl-org/SDL/pull/16432), milestone 3.6) - uses it, and so works on KDE but not GNOME. - - **An empty `set_input_region`** passes clicks through. -- **X11 is going away.** GNOME 50 removed the X11 session, and Plasma 6.8 (due around 14 October - 2026) is Wayland-only - ([Phoronix](https://www.phoronix.com/news/KDE-Plasma-68-Wayland-Exclusive)). On X11 a window - can be placed anywhere and be override-redirect. ARGB windows are translucent only under a - compositing manager, and input shapes (XShape) pass clicks through. KWin blurs behind - `_KDE_NET_WM_BLUR_BEHIND_REGION`; picom blurs by its own rules. A property change reaches the - compositor asynchronously to the present. - -Others: Zed blurs on KDE and notes that rounded corners are impossible there. Ghostty and -Alacritty blur on KDE only, and Ghostty's broke when Plasma 6.7 dropped `org_kde_kwin_blur` -([ghostty #13041](https://github.com/ghostty-org/ghostty/discussions/13041)). -Qt has `KWindowEffects::enableBlurBehind`; GTK has nothing yet. - -### The plan - -Wayland comes first. It is the default on every major desktop, and now has a blur protocol that -both GNOME and KDE implement. It allows less than X11 in where windows go, but X11 sessions are -being removed. - -**The window material, wherever the compositor has a blur.** Today the Linux window is opaque and -the app's glass knows it (`LiquidField.publishOpaqueWindow`). Where `ext-background-effect-v1` is -advertised (with `org_kde_kwin_blur` as a fallback for older Plasma), the main window asks for a -blur over its frame, not over the shadow margin in its frame insets, and publishes itself as not -opaque. The window opacity slider then means on Linux what it means on macOS and Windows. The -rounded corners are stepped rectangles a pixel high along each corner's arc, which a blur hides. -The region is surface state, so the request goes out before the frame's present (whose Vulkan WSI -makes the `wl_surface.commit`, on the same connection). A resize then changes the blur and the -picture in the same commit. Where no compositor offers it (sway, GNOME before 51), nothing changes: -the window stays opaque. At the top of the slider the window sends an empty region rather than -none, so a compositor that blurs translucent windows by default (Hyprland) does not; on KDE's own -protocol, where an empty region blurs the whole window, it unsets the blur instead. - -**Wayland: popups yes, floats not yet.** A Wayland client still can't place a toplevel, so floats -stay in the main window, as decided. Menus, dialogs and tooltips, though, can leave the main -window as `xdg_popup`s (`SDL_CreatePopupWindow`), placed relative to their parent and kept on -screen by the compositor. That is the Wayland form of `viewportOpenMenu`, blurred behind where the -compositor allows it. They hold still, so the reposition lag doesn't matter. `viewportsAvailable` -stops being one switch: "can place windows" and "can open popups" become separate questions. - -**Wayland: carrying a view past the window, later.** A view dragged past the main window's edge is -cut off there today. Wayland's own answer to "a picture that follows the pointer anywhere" is the -**drag icon**: the compositor moves it with no lag, it passes clicks through by definition, and the -client can redraw it every frame, so it isn't the still picture an OS drag image is on macOS. -Letting go over nothing could then open a float through `xdg-toplevel-drag`, the protocol made for -tabs torn out of browsers, once floats as windows are possible on Wayland at all. SDL #16432 is -how SDL means to get there. Both need SDL changes or fizzy's own Wayland code on SDL's -`wl_display`: SDL has no drag source on Wayland, and `start_drag` needs the serial of the press. -This is the largest Linux item, and the last. - -**X11: only what is cheap.** Floats as windows already work there (behind `FIZZY_POPOUT=1`). -An overlay like Windows' would be possible: an override-redirect ARGB window with an empty input -shape, opened only when a compositing manager owns `_NET_WM_CM_S` (without one an ARGB -window is black). Its blur would be a property that lags the present. With X11 sessions going -away, this is not worth building beyond keeping what works working. - -## What changes in fizzy - -The seams are already in the right places. Most of the macOS work is gated on the OS at a handful -of capability flags and backend functions, and the plan fills those in rather than adding new -paths: - -- **`viewports.carries`** (`backend_native.zig`, macOS only today) turns on carry windows, carried - pictures without their place's background (`dialogs.carry_windows`), and drawing past the main - window (`screens.publishBeyond`). On Windows it becomes true with the overlay, which serves as - the carry window there. -- **`viewports.liquidGlass()`** is a yes/no answer today. It becomes a three-way capability: - `none` (the app's glass in-window, as now), `os_glass` (macOS 26: the OS shapes and fills it), - and `os_backdrop` (Windows: the app shapes it and the OS fills it). `Popout.nativeGlass` and - `overlayFrame` branch on it. Under `os_backdrop`, `glassBase` is replaced by the app's own glass - (`LiquidField` with no frost of its own), and the backend gets the frost's outline instead of - `GlassShape`s. -- **`core.native_glass.Shape.frost`** already says how much blur each piece takes: all of it for a - bubble with an icon, none for the carried view's clear lens. That decides which pieces the - outline includes. -- **`SDLBackend.renderPresent`** has a macOS-only block that opens a transaction, applies frames, - shapes and glass, and commits after the presents. Windows gets a block in the same place that - sets the overlay's clip and offset right before the present. Wayland sets the blur region in - the same place, and needs no commit of its own. -- **`viewportOpenMenu`** and `nativeMenus` / `nativeDialogs` are macOS only. Windows gets them as - Acrylic popups, and Wayland as `xdg_popup`s, even though Wayland can't have floats as windows. -- **`viewports.os_buttons`** stays macOS only. On Windows the float's header draws - `caption_buttons.zig`, and `win32_titlebar`'s hover and press state moves from globals to - per-HWND state (`dwRefData`), as `POPOUT_WINDOWS_PLAN.md` "Next" already lists. -- **The ghost fade** (`viewportFade`, `SDL_SetWindowOpacity`) makes the window layered on Windows - (`WS_EX_LAYERED`). That is untested with a DirectComposition swapchain and a DWM backdrop - **(spike)**. The composition tree's root opacity is the alternative. -- **Calling the visual layer from Zig.** Windows.UI.Composition is WinRT: activation factories, - `IInspectable`, `HSTRING`s. zig's MinGW headers carry no C++/WinRT, so fizzy declares the dozen - interfaces it calls by vtable slot. fizzyedit/SDL already does this for DirectComposition, and - `win32_titlebar.zig` already does it for DWM. The bulk is in the clip path - (`IGeometrySource2DInterop` over a Direct2D path) and, if spike 3 is tried, the effect - descriptions. - -## Order of work - -Each step is useful by itself, and the spikes come before anything that depends on their answer. - -1. **Recompile the glass programs** (SPIR-V and DXIL; step 0). **Done (#242):** Windows and Linux - draw the current lens and slider in-window. -2. **Windows: float chrome and lifecycle** (P4): caption buttons in the float header, per-HWND - `win32_titlebar` state, minimize and close with the main window, and the look checked on a - hardware GPU. This is what lets `float_windows` default to on for Windows, as it does on macOS. -3. **Windows: menus and dialogs as Acrylic popups** (`viewportOpenMenu`). Small, and the closest - thing on Windows to the OS's own UI. *Built, run in a Windows 11 ARM VM on WARP:* - `win32_titlebar.viewportMenuChrome` dresses the menu's window (Acrylic, DWM's corners and shadow, - no system menu, no transitions), owned by the window it opens from, and keeps it looking active - to DWM, which otherwise draws a never-activated window's backdrop as its solid fallback. On with - floats as windows (`FIZZY_POPOUT=1`), as on macOS. In the VM, menus and the unsaved-changes - dialog open in windows of their own, stacked just above the main window, which keeps the - keyboard; the first click on an item acts; a dialog's window hides and comes back with a - minimized and restored main window. The look itself (Acrylic rather than its fallback, DWM's - corners) and whether the kept-active trick holds need a hardware GPU **(check)**: the VM's DWM - draws every backdrop as its fallback. -4. **Linux: the window material** through `ext-background-effect-v1` (GNOME 51, Plasma 6.7, niri), - and **Wayland popups** for menus and dialogs. A protocol binding on SDL's `wl_display`, and the - region set before the present. *The material is built, behind `FIZZY_BLUR_BEHIND=1`, not yet - seen on a real desktop:* `platform/wayland_blur.zig` binds either protocol on SDL's display - (libwayland-client opened at runtime, its own event queue), `blur_region.zig` steps the rounded - frame into rows (unit tested), and the Editor asks for it while the window is translucent and - draws the base translucent where the compositor blurs. Run against a stub compositor speaking - both protocols: the region is applied with the surface commit, sent only when it changes, - cleared when the window goes opaque, and not asked for where the compositor reports no blur. - Run in fizzy on Ubuntu 26.04's GNOME 50, which has neither protocol: the probe binds once on a - queue of its own, logs what it found (`wayland_blur`), asks for nothing, and the window stays - opaque. KWin through Plasma 6.6 blurs a whole window whose blur object has an empty region - (`plugins/blur/blur.cpp`), so on KDE's protocol "no blur" is the object unset and released. - One trap: wayland-protocols 1.45, the release that added the protocol, numbered the `blur` - capability 0; later releases made it the bit 1. fizzy follows the current definition, so a - compositor built against 1.45 reads as having no blur. The Wayland popups are not built. -5. **Windows spikes**, in a scratch app before fizzy, on a hardware GPU and on WARP, Windows 11 - 23H2 and 24H2: - 1. a Windows.UI.Composition target that is not topmost, under SDL's topmost DirectComposition - target, on one HWND; - 2. a host backdrop clipped by a path rebuilt every frame: its edge, and how far it trails a - present (PresentMon); - 3. a host backdrop masked through an effect graph by a swapchain the app presents; - 4. the overlay's click-through and z-order with a drag in flight, and `WS_EX_LAYERED` on a - window with a composition swapchain and a DWM backdrop. - - *Run on WARP in the Windows 11 ARM VM* (`spikes/windows-composition/`; its README has the - numbers). All four hold: - 1. The two targets share the HWND, aligned to the pixel. - 2. The path clip is antialiased. It **leads** SDL's VSYNC present by its queue (2 frames), and - lands exactly with a 2-frame delay or with a present that doesn't wait. - 3. AlphaMask takes a presented swapchain as its mask. - 4. Only `WS_EX_TRANSPARENT | WS_EX_LAYERED` passes clicks to other processes, the tree still - showing; a drag keeps its capture, and `WS_EX_LAYERED` leaves both trees drawing. - - Still to see on a hardware GPU: the blur itself (the VM draws the host backdrop black) and the - present queue's depth at real refresh rates. -6. **Windows: the overlay** (`viewports.carries`, the `os_backdrop` glass), if spikes 1 and 2 - hold (they do on WARP, the clip delayed by the present queue's depth), and with the go-ahead - `NATIVE_WINDOWS_PLAN.md` asks for (its Phase 2). If they don't, the fallback is the overlay - with the app's glass and no OS blur under it. - That still carries a view past every window, which is the part users will notice most. -7. **Optional: real refraction on Windows** through capture, behind a flag, if the clear lens over - other apps turns out to matter. -8. **Wayland carry** through the drag icon, then `xdg-toplevel-drag` (or SDL #16432) for floats as - windows on KDE. - -## Still open - -- **What "native" should mean on Windows** is settled in `NATIVE_WINDOWS_PLAN.md`: Mica for - long-lived windows at the top of the slider (Windows Terminal's mapping), Acrylic for transient - surfaces (menus, carries, and the overlay if Phase 2 goes ahead). -- **The slider where the OS owns the blur.** On Windows and Wayland the frost's strength is fixed, - so the lower half of `glass_look`'s slider (clear lens to whole frost) needs its own mapping - there, or a decision that those platforms start at frost. -- **Mixed DPI.** Every viewport uses the main window's density (`POPOUT_WINDOWS_PLAN.md`, P5). A - per-display overlay makes this matter sooner, as soon as one spans a second display. diff --git a/docs/WORKSPACES_PLAN.md b/docs/WORKSPACES_PLAN.md deleted file mode 100644 index d80a3f1e4..000000000 --- a/docs/WORKSPACES_PLAN.md +++ /dev/null @@ -1,322 +0,0 @@ -# Workspaces: many tabbed areas of documents, placed as views - -Status: proposed. Steps tracked in #260, each its own PR. - -Decided with the user (2026-10-04/05): -- **Groups and workspaces are joined into workspaces.** A workspace is a tabbed area of documents, - and there can be any number of them. Each is a view placed by the app: in Main, in a split, or in - a float. -- **Documents are children of workspaces.** Only workspaces take documents, and Main holds one by - default. -- **Views and workspaces are peers.** A view (Explorer, Output, …) dropped on a workspace replaces - it, as views swap today, rather than becoming a tab among its documents. -- **When a float holding a workspace closes,** its documents move into the main window's - workspace. -- **Nothing more for the web.** Floats there stay in the window (`Floats`), as now. -- **A workspace is named by its selected document** wherever views are listed (the picker, a - float's title). -- **A document may be open in several workspaces at once,** as two views of one shared document, - as in VS Code. Edits show live in every view, there is one unsaved state, and one save. Only the - cursor, selection, scroll and zoom are per view. -- **No default workspace unless the layout asks for one:** there is none when no place's keywords - match a workspace's. Fizzy's Main does (`main`). -- **⌘N opens a new window; ⌘D a new document.** "New Window" opens a window whose place takes a - workspace, so it opens on an empty one: a new editor, one keystroke away. "New File" is renamed - "New Document" (`fizzy.newFile`'s title, and the home page). -- **The home page lists New Window first,** above New Document. -- **A dialog opens in the window it was asked from,** not always in the main window (below). - -## Why - -Fizzy has two of everything for tabs and splits: - -| | The app's layout | The workbench plugin's copy | -|---|---|---| -| Places | regions, `State.assignments` | `Pane N` regions (`Workspace.draw`, `Workspace.zig:117`) | -| Splits | `SplitTree` (user splits, `Region.zig` `initTree`) | one `DockLayout` tree (`Workbench.panes`, `Workbench.zig:39`) | -| Tabs | `Chooser.zig` | `Workspace.drawTabs` (`Workspace.zig:188`) | -| Drops | `ViewDrag.place` / `Drop.plan` | `paneDrop`, `paneBeside`, `seatPane` | -| Where things open | assignments by place name | groupings (`newGroupingID`, `open_workspace_grouping`) | -| Saved in | `layout.zon` | `panes.zon` | - -The workbench's one `Workspace` surface draws its whole pane tree in whatever place holds it. So -there is one workspace, and "floating" it moves every document at once. Documents are slotted into -its panes, so they cannot reach the app's places: -- **A float can't hold one.** A float's place is a plain one (`Layout.slotted`, `Layout.zig:419`; - `matchingWith` skips slotted surfaces, `:353`). -- **A tab torn off has nowhere to go.** `canFloat` and `floatAway` refuse loose and slotted drags - (`ViewDrag.zig:1806-1823`). -- **More workspaces would mean several workbench trees.** That is per-tree - `seatPane`/`paneBeside`/`solePane`, culling, persistence and closing, all inside the plugin. - -It also goes against fizzy's one idea: the framework owns layout, and plugins contribute surfaces -(CLAUDE.md, "The core idea"). - -## The model - -- **A workspace is a view fizzy provides**, one per workspace (surface ids `fizzy.workspace.`, - keywords `workspace`, `main`; `src/editor/Workspaces.zig`). Like any view, it sits in a place: Main - has one by default, a float can hold one, and the picker and the view drag move it. Its content is - the workbench's pane for that workspace (`Workspace.draw`): its document slot, its tabs, its - selected document. That code already speaks only the SDK (`host.region`, `host.docFromPath`, - `host.closeDocById`), so tabs, previews, placeholders and the close flow carry over as they are. - The workbench's own arrangement of panes (`Workbench.panes`, a `DockLayout`, and `panes.zon`) is - what goes: the app's places, splits and floats arrange workspaces instead. Documents are ordinary - surfaces their owners register (`.doc:`), drawn through the owner's vtable as now. -- **Splits are the app's.** Splitting a workspace's place (`SplitTree`) gives the new place a new - workspace. There is no tree inside a workspace. -- **Documents go only into workspaces.** A document dropped: - - **on a workspace's tabs** goes in there, where it is let go; - - **on a workspace's middle** joins that workspace; - - **on a workspace's edge** splits its place, and the new place gets a new workspace holding the - document; - - **over the desktop** (OS windows) opens a float holding a new workspace with the document in it - (`ViewDrag.floatAway`); - - **on its own workspace's middle** floats it the same way, as a view dropped on its own place - floats. This doesn't apply when it is its workspace's only document in an unsplit float. - - **on the edge of a place showing a view** splits it with a new workspace, as for a workspace's - edge. Its middle is no target: a document never replaces a view. -- **Views and workspaces are peers.** A view dropped on a workspace's middle replaces it: the - workspace goes where the view came from, as two views swap today. Its edges split as for any - place. -- **The active workspace** is the one that last had focus. "Open file", the explorer and document - commands use it, in place of the grouping. If no workspace is left anywhere, opening a document - brings back the default one, in the first place whose keywords take a workspace. A shape with no - such place has no workspaces, and documents have nowhere to open. -- **Two views of one document.** A document can be in several workspaces at once, and every view is - of the one document (as in VS Code), never an independent copy. Its edits show in every view, it - has one unsaved state, and saving it from any view saves it. Carrying a tab moves it; a command - ("Open in New Workspace", splitting with the document) opens a second view. - Each workspace draws its documents under its own id, so what dvui keeps (scroll position, a text - field's state) is per view. What an owner keeps on the document itself is shared unless it keys - it by view (see "SDK"). Closing one view leaves the document open in the others; closing its last - view closes the document, through its owner. -- **An emptied workspace** in Main, or in a window opened by New Window, stays, and shows the - workbench's home page (open folder, recent files) as its empty state. Elsewhere, an emptied - workspace closes its place: a split leaf collapses, and a float closes, as an emptied float does - now. -- **Closing a float** moves its workspace's documents into the main window's workspace: the active - one there, else Main's. -- **Two strips, two levels.** A place's tabs are views (`Chooser`); a workspace's tabs are its - documents, with close, unsaved and saving markers and preview italics. Documents are children of - workspaces and views are peers of them, so the two strips are two levels, not two copies. Both are - where the one tab drag starts (#207). -- **Backgrounds.** A workspace stands on the rounded card. Whether a view does is a `Surface` field - (the explorer does not). The place keeps the card's look (`placeCard`) and stops deciding whether - it appears. The carried bubble photographs a surface without its card, so the glass's blur shows - through. - -## New windows - -- **"New Window" (⌘N, `fizzy.newWindow`)** opens a float whose place is keyworded `workspace`, holding - a new, empty workspace. Where floats are OS windows (macOS today), that is a new OS window. On the - web and Wayland it is an in-window float. It opens offset from the window it was asked from, as a - new window does in any app, and its empty workspace shows the home page. -- **"New Document" (⌘D, `fizzy.newFile` renamed)** asks for a new document in the active workspace. - ⌘D is free in fizzy today. VS Code binds it to "add the next match to the selection", which the - text plugin's keybinds plan (VS Code parity) would want; that binding moves to another key. -- **The home page** (the workbench's, the empty state of a workspace) lists New Window, then New - Document, then Open Folder and Open Files. The workbench runs `fizzy.newWindow` by id and shows - the entry only when the command exists, so it imports nothing of fizzy's and stays the same in - an app without new windows. -- **A new window stays when it empties,** as the main window does: its last document closed or - carried out, its workspace shows the home page. It closes only when the user closes it, its - documents moving into the main window's workspace, as for any float. A float made by tearing a - document off still closes when it empties (above); the float remembers which it is. - -## Dialogs open in the window asking - -Today every dialog (New Document's, Save As, a close prompt, the command palette, plugin dialogs) is -centred on the main window, even when the request came from a float or a float's window. A dialog -belongs to the window it was asked from. - -- **The window asking** is the one with focus when the command ran: the OS's key window where floats - are windows (`viewports`), else the float that took the last press (or the main window). A button - inside a window (the home page's) asks from that window, whichever has focus. -- **Placing it there.** A float's window shows everything whose middle lies in its part of the frame - (`Popout.windowFrame` takes those subwindows whole), as menus and tooltips there already do - (`core.screens.screenFor`). So routing a dialog is centring it on the asking window's part of the - frame. `core.screens` publishes that rect each frame (`activeScreen`, beside `publishScreens`), and - `core.dialogs.dialogWindow` centres on it (`FloatingWindowWidget`'s `center_on`). No SDK change: - `core` is source shared, and a plugin's dialog follows once the plugin is rebuilt. -- **Modal dialogs** dim the window they are in, not the main window and its title bar - (`modal_dim_titlebar`). dvui's modal still takes the input of every window; blocking only the - asking window is a later step. -- **The OS's own panels** (open, save, alerts) are given the asking window as their parent - (`SDL_ShowOpenFileDialog`'s window), so on macOS they are sheets on that window. -- **Independent of workspaces:** it can land before them, as its own change on `main`. - -## What a workspace must do: the workbench today (step 1) - -Mapped from the code before anything changes, so the framework's workspace reproduces all of it. - -**Drawing.** Main shows `workbench.workspaces` (`plugin.zig:64`). Its draw goes through -`host.drawWorkspaces` and `Editor.fizzyDrawWorkspaces` (`Editor.zig:2460`) to the workbench's -`drawWorkspaces` (`workbench_layout.zig:125`): a dockspace over `wb.panes`, one `Workspace.draw` -per leaf. That declares the place `Pane N` (`host.region`, `kind_slot`, `on_drop = paneDrop`), -draws the tabs, and draws the selected surface through `region.drawContents` → -`Layout.drawPluginRegionContents` (warm-ups, the drag's photograph, the cross-fade) → `s.draw`. A -pane never calls an owner: a document's surface (`.doc:`, `registerDocSurface`, -`Editor.zig:2578`) draws through `Editor.drawDocSurface` (`:2634`): -- a canvas box keyed by the document's id; -- `owner.bindDocumentToPane(doc, canvas_id, handle, false)`. The image plugin keys zoom/pan by - `canvas_id`; pixi stores it, and keys its per-pane canvas state by `documentGrouping`; -- `owner.drawDocument(doc)`; -- the document's context menu (`documentContextMenu`, `host.drawMenuSections`), and toasts over the - canvas. - -`tickActiveDocument` is broadcast once a frame with the layout root's id; it has nothing to do with -panes. - -**Tabs** (`Workspace.drawTabs`, `:188-514`): -- **Look:** icon (`host.drawFileIcon`), title (italic for a preview), and the active-tab chrome only - in the active pane. -- **Status slot:** the saving spinner (`isDocumentSaving`, `timeSinceSaveCompleteNs`), else a close - button. The X shows when hovered, or when the tab is selected and clean; otherwise a dirty dot. -- **Close:** `host.closeDocById` → `Editor.closeFileID`, with the unsaved-changes dialog. A loading - placeholder's close removes its tab, which cancels the load. -- **Context menu:** Keep Open (preview), Close, Close Others / to the Right / to the Left, then - `fizzy.menu.tab` sections. -- **Press:** selects the tab and makes its pane active. -- **Drag:** past the threshold it un-previews the document and lifts it into the view drag - (`host.beginViewDrag`), keeping its width. -- **Drop target:** the strip offers itself (`offerChooser`, a gap the carried tab's width) and takes - a drop where it opens (`insertIndexAt`). -- **Anchor:** `workbench.tab:`, which demos use. -- **Not there:** middle-click and double-click. - -**Active document.** `Workbench.open_workspace_grouping` is the active pane (set by a click or a -tab press). `activeDoc` is what that pane showed last frame. It is read by: -- the infobar; -- `"."` commands; -- save, undo and menus; -- the keymap's owner scope; -- text's and pixi's own active document; -- save toasts. - -`setActiveDocIndex` focuses a document, seating it first if no pane holds it. - -**Groupings.** -- `setDocumentGroupingOnBuffer` is written before a load lands. `documentGrouping` is read by - seating, previews, menus, demos and pixi. -- `setDocumentGrouping` is used only by `openOrFocusFileAtGrouping`, and only stamps. -- **Drift:** moving a tab to another pane never updates the grouping. -- **Grouping 0:** documented as "the pane the user is in", but it is the literal `Pane 0`. -- `services.workbench` (`currentGrouping`, `newGrouping`) has no consumer. - -**Opening.** -- `Host.openFile` → `openPath`. If the file is already open it is focused; otherwise a load job - starts, with the grouping set on the buffer. -- `Openings.begin` registers a placeholder surface `fizzy.loading:` (keyword `document`). It - takes the slot of a preview it replaces, or the tab a restored session kept; otherwise it adds - itself to `pane(grouping)`. When the load lands, the placeholder's tab id is swapped for the - document's, which is selected or drawn once offscreen (`warmIn`). The placeholder lingers two - frames for the cross-fade. -- **Previews** are the app's (`Editor.preview_docs`): one per grouping, replaced by the next preview - open, kept by an edit, a drag or Keep Open. -- **Explorer:** Open uses the current grouping, Open to the Side a new one, and a single click - previews. Rows carried out are the view drag, and an unopened file dropped on a pane opens there - (`paneDrop` → `openHere`). - -**Closing.** `rawCloseFileID` first takes a snapshot of a pane its last document leaves, then -removes the tab from every pane, unregisters the surface and frees the document. An emptied pane -slides shut, drawn from that snapshot, and is culled (`rebuildWorkspaces`). The last pane never -goes. Culling calls every plugin's `removeCanvasPane(grouping)`, which pixi uses. - -**Empty.** The sole pane, empty, draws the home page: the logo, New File, Open Folder (not on web), -Open Files into the active pane. A pane in a split, empty, draws an empty card. - -**Persistence.** -- `panes.zon` (the workbench's install dir, native only) holds the DockLayout tree, one `Pane N` - per leaf, and nothing else. -- Each pane's documents are the app's: `layout.zon` assignments under `Pane N`. -- On launch `rebuildWorkspaces` reopens them into their panes, and the placeholders take the kept - slots. -- The selected tab per pane and the active pane are not saved. - -**Coupling.** `Editor.workbench` is referenced across `Editor.zig`, `Entry.zig`, `Openings.zig`, -`Keybinds.zig`, Demo, WebFileIo, DocumentIo, UnsavedClose, CommandPalette and the explorer. -`kind_slot` is what keeps documents in panes (`Layout.slotted`, `ViewDrag.mapTargets`, `place`, -`canFloat`). `pane_cards` gives each pane Main's card when there are two or more. There are no -split commands: splits come from Open to the Side, go-to-definition's side, and edge drops. - -**Tests that touch it** (`tests/integration.zig`): `:1897`, `:2075`, `:2392`, `:2671`, `:2921`, -`:3514`, `:3951`, `:3992`, `:4043`, `:5799`, and `:5823`, which imports the workbench's -`Workspace` (tab gap and insert index). Nothing tests `Openings`, previews, `rebuildWorkspaces` or -`panes.zon`. - -**What this settles for step 2:** -- **A workspace's id is a grouping.** `documentGrouping` keeps its meaning. A move now sets it, - which ends the drift. Grouping 0 means the active workspace, as documented. -- **Document drawing stays the surface's draw** (`drawDocSurface`) inside the workspace's canvas, - so owners see the same `bindDocumentToPane` and `drawDocument` calls as now. -- **A workspace that goes calls `removeCanvasPane`** for every plugin, as a culled pane does. -- **Placeholders, previews, `warmIn`, the close flow and the home page** move into the workspace - unchanged in behaviour. The tab strip, close, status slot, context menu, drag, gap and anchor - (`workbench.tab:`, kept for demos) are drawn by the app. -- **The active workspace is the app's** (`State`), and `activeDoc` reads it. -- **A workspace's selection is saved,** which the panes never did. - -## SDK - -**What the workspaces need is there already.** A workspace draws its tabs from what the host has -for every open document: -- the owner's `isDirty`, `isDocumentSaving` and `documentTitle` (`Plugin.VTable`); -- `Host.documentIsPreview`; -- the host's close flow, with its save prompt, through the owner's `closeDocument`. - -Which workspace a document opens in is the grouping it already carries (`documentGrouping`, -`setDocumentGrouping`, `Host.openFile(.grouping)`), now a workspace's id. So the app's workspaces -(steps 2 to 5) land with no SDK change. Retiring names that no longer fit (`grouping` → -`workspace`, `bindDocumentToPane`'s pane, `kind_slot`) waits for a bump that has a reason of its -own. - -**One bump, when two views of one document land:** -- **The view being drawn.** A document's draw is told which view it is drawing (its workspace's - id), so an owner can keep a cursor, a selection or a zoom per view. Those that don't show the same - state in both views. dvui's own state (scroll, a text field) is per view without it, since each - workspace draws under its own id. -- **The background field** on `Surface` (above). -- **The renames**, once the old names have no users. - -## Steps (each its own PR, `Part of #260`) - -1. **Map the workbench's document path** — how a pane draws a document today (`Workspace.drawCanvas`, - `bindDocumentToPane`, the canvas and its handle, the home page, previews, `Openings` - placeholders) — into the plan, so step 2 draws documents the same way with nothing lost. No SDK - change. -2. **Workspaces as views.** Behind `FIZZY_WORKSPACES=1` at first: - - fizzy registers `fizzy.workspace.` for each workbench pane, drawing that pane - (`Workbench.drawPane`), titled by its selected document; - - Main shows `fizzy.workspace.0` in place of `workbench.workspaces`; - - a new pane (Open to the Side, go-to-definition's side, an edge drop) is a new workspace in a - split of the place its anchor is in (`Region.splitOn`), not a `DockLayout` leaf; - - an emptied workspace other than the last is removed and its place closed; - - "the sole pane" becomes "the only workspace". -3. **Documents through the app.** `openFile` puts a document into the active workspace, and close - and select go through the app. The drop rules above apply. The workbench stops seating documents - in its panes (`rebuildWorkspaces`). Its home page becomes the empty state of Main's workspace. -4. **Saved layouts.** `layout.zon` keeps the workspaces (documents, selection) and which place holds - each. On first load, a `panes.zon` tree becomes the app's split of Main: one workspace per pane, - its documents in order. Then `panes.zon` is deleted. An older fizzy reads past the new fields - (`ignore_unknown_fields`), as it does floats. -5. **Floats.** A tab is torn off over the desktop, or dropped on its own workspace's middle, into a - new float's workspace. An emptied workspace closes its place. A float closing moves its documents - into the main window's workspace. -6. **Remove the copy.** Delete the workbench's `DockLayout` panes, `Workspace.zig`'s strip and drop - handling, the groupings, the `workbench.workspaces` surface and `kind_slot`. Update - `app/layout/SPLITS.md`, `docs/PLUGINS.md` and the example apps' shapes, which get workspaces from - the framework. -7. **Two views of one document, and the SDK bump** (above): "Open in New Workspace", the view a - document is drawn for, the background field, the renames. -8. **New windows** (above): `fizzy.newWindow` on ⌘N, New Document on ⌘D, and both on the home - page, New Window first. - -Dialogs routed to the window asking (above) don't depend on any of these, and can land first. - -## Answered - -- **The picker** lists a workspace by its selected document's title, as a float's title does. -- **Documents in two workspaces:** allowed, as two views of one document (above). -- **App shapes with no workspace place** (fizzyedit/example-app's) have no default workspace. It is placed only - where a place's keywords match a workspace's. diff --git a/plugins/markdown/plugin.zig b/plugins/markdown/plugin.zig index 6de008307..a5f558d6b 100644 --- a/plugins/markdown/plugin.zig +++ b/plugins/markdown/plugin.zig @@ -6,7 +6,7 @@ const md = @import("src/markdown.zig"); /// Re-exported for fizzy's own use of the preview renderer outside this plugin's own /// vtable/service surface (e.g. `src/editor/readme.zig` rendering a fetched plugin README) — -/// see `docs/PLUGIN_MANIFEST_PLAN.md`'s "static module root" decision. +/// see `plans/PLUGIN_MANIFEST_PLAN.md`'s "static module root" decision. pub const Preview = md.Preview; pub const drawPreview = md.drawPreview; pub const drawPreviewForDocument = md.drawPreviewForDocument; diff --git a/plugins/markdown/src/md/block_heights.zig b/plugins/markdown/src/md/block_heights.zig index a5ecb7df8..f9a8ed0a5 100644 --- a/plugins/markdown/src/md/block_heights.zig +++ b/plugins/markdown/src/md/block_heights.zig @@ -138,7 +138,7 @@ pub const Measurement = struct { /// /// Sized for the legitimate case, not the pathological one: a table converges by measuring a few /// KB of its rows per frame (`render_ast.table_measure_bytes`), so the 45KB table in -/// docs/PLUGIN_MANIFEST_PLAN.md needs a dozen-odd passes and a much larger one proportionally +/// plans/PLUGIN_MANIFEST_PLAN.md needs a dozen-odd passes and a much larger one proportionally /// more. At 60fps this cap is about four seconds — long enough that nothing real hits it, short /// enough that a block which will *never* settle stops holding the app awake. /// @@ -305,7 +305,7 @@ pub const Table = struct { // Code never wraps — one source line is one laid-out line — plus the panel's padding. .code => src_lines * m.line_h + 12, // A table's height is driven by how much its cells *wrap*, not by how many rows it - // has: the 45KB table in docs/PLUGIN_MANIFEST_PLAN.md is 25 source lines and 13,310pt + // has: the 45KB table in plans/PLUGIN_MANIFEST_PLAN.md is 25 source lines and 13,310pt // tall. Cell text wraps inside a column rather than across the whole width, so it // needs far more lines than the same bytes of prose — measured at roughly a third of // the full column width across the sample documents, which is what the divisor is. @@ -501,7 +501,7 @@ pub const Table = struct { /// text, in which case the caller falls back to the line. /// /// Duplicates are not an edge case — a source hash identifies *text*, and real documents - /// repeat themselves. docs/PLUGIN_MANIFEST_PLAN.md has seven top-level blocks sharing one + /// repeat themselves. plans/PLUGIN_MANIFEST_PLAN.md has seven top-level blocks sharing one /// hash. Taking the first match threw the reader to whichever copy came earliest in the /// document, which is why anchoring by hash alone sent them to the top. pub fn blockForHash(self: *const Table, hash: u64, near_line: u32) ?usize { diff --git a/plugins/markdown/src/md/render_ast.zig b/plugins/markdown/src/md/render_ast.zig index b908e8eb8..6bc2e8c17 100644 --- a/plugins/markdown/src/md/render_ast.zig +++ b/plugins/markdown/src/md/render_ast.zig @@ -202,7 +202,7 @@ const resettle_above_budget: usize = 4; /// Off-screen table text (in bytes of markdown) that may be measured per frame the first time a /// table is seen. Same idea as `resettle_budget`, one level down: a 45KB table -/// (docs/PLUGIN_MANIFEST_PLAN.md has one) costs several milliseconds to lay out in full, and +/// (plans/PLUGIN_MANIFEST_PLAN.md has one) costs several milliseconds to lay out in full, and /// doing that on the frame the document opens is the hitch this whole file is about. Spread over /// frames instead, the table's height is briefly short — by however many rows are still unmeasured /// — and settles within half a second. @@ -1758,7 +1758,7 @@ fn renderTopLevel(doc_node: ast.Node, ids: *IdGen, ctx: RenderContext) void { // a scrollbar that lies by thousands of pixels — which scrolling then "discovers" a screen at // a time. That was the reported instability, and the earlier distance gate caused it by // starving distant blocks outright (164 of 185 unmeasured after 600 frames on - // docs/PLUGIN_MANIFEST_PLAN.md). Both budgets being non-zero is what makes the sweep + // plans/PLUGIN_MANIFEST_PLAN.md). Both budgets being non-zero is what makes the sweep // terminate: once every block has been measured twice, nothing wants a re-measure, // `pending_measure` hits zero, and the refresh loop stops. A bounded warm-up, not a // permanent wake. @@ -2758,7 +2758,7 @@ fn renderBlock(n: ast.Node, ids: *IdGen, ctx: RenderContext) void { // same reason `renderTopLevel` zeroes its block budget: a row measured at this // frame's width is invalid at the next frame's, so a split drag would pay for the // whole table over and over and keep none of it (~5.4KB of text shaped per frame - // on docs/PLUGIN_MANIFEST_PLAN.md, discarded every time). Those rows stay owed — + // on plans/PLUGIN_MANIFEST_PLAN.md, discarded every time). Those rows stay owed — // `pending_measure` keeps frames coming — and get measured once the width holds. // // `first_sight_left` is deliberately *not* zeroed. It gates rows that are on @@ -2796,7 +2796,7 @@ fn renderBlock(n: ast.Node, ids: *IdGen, ctx: RenderContext) void { // *content-sized* grid — it scrolls with the page rather than inside // itself — so dvui's own row virtualization (`GridWidget.rowsVisible`) // can't help: as far as the grid is concerned its whole body is in view. - // Without this, one 45KB table (docs/PLUGIN_MANIFEST_PLAN.md has one) is + // Without this, one 45KB table (plans/PLUGIN_MANIFEST_PLAN.md has one) is // laid out in full on every frame it appears on, which costs more than // the rest of that document put together. // diff --git a/plugins/text/plugin.zig b/plugins/text/plugin.zig index 7417cfd37..71c7a91e5 100644 --- a/plugins/text/plugin.zig +++ b/plugins/text/plugin.zig @@ -95,7 +95,7 @@ pub fn register(host: *sdk.Host) !void { plugin.state = @ptrCast(st); try host.registerPlugin(&plugin); - // Loaded-only settings UI (see `docs/PLUGIN_MANIFEST_PLAN.md`): the schema lives in the + // Loaded-only settings UI (see `plans/PLUGIN_MANIFEST_PLAN.md`): the schema lives in the // Host's registry only while this plugin stays registered. Registered directly against // `&st.settings`, so fizzy's pane edits land straight on the live struct — no // `settingsChanged` sync hook needed. diff --git a/plugins/workbench/plugin.zig b/plugins/workbench/plugin.zig index 586952d43..b0f271436 100644 --- a/plugins/workbench/plugin.zig +++ b/plugins/workbench/plugin.zig @@ -5,7 +5,7 @@ //! //! Re-exports the handful of types/values fizzy still reaches through `@import("workbench")` //! directly (`Workspace`/`Workbench`/`FileLoadJob`/`runtime`/`files`/`has_file_tree`) — see -//! `docs/PLUGIN_MANIFEST_PLAN.md`'s "static module root" decision: the hub dies as an +//! `plans/PLUGIN_MANIFEST_PLAN.md`'s "static module root" decision: the hub dies as an //! intra-plugin import surface, but fizzy's `@import("workbench")` needs *something* to //! resolve `Workspace` etc. against, so those re-exports move onto the module root instead. const std = @import("std"); diff --git a/scripts/plugin-loop/bench.py b/scripts/plugin-loop/bench.py index 9e34daa1e..eb54e6ca4 100755 --- a/scripts/plugin-loop/bench.py +++ b/scripts/plugin-loop/bench.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Time each stage of a plugin's rebuild loop: from an edit saved to the plugin running again. -`docs/AGENTS_PLAN.md`, "Fast enough to watch": every stage is measured before it is called fast. +`plans/AGENTS_PLAN.md`, "Fast enough to watch": every stage is measured before it is called fast. This measures them for one plugin (default: `examples/hello-plugin`, the smallest third-party-shaped plugin, depending only on `sdk/`): diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index e42942530..883e2d9f4 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -12,7 +12,7 @@ //! //! `root.zig` is **not** part of the author's repo: `create` generates a tiny hidden dylib root //! (the `std_options` + `exportEntry` comptime block) in the cache, so the author's `plugin.zig` -//! never carries C-ABI export boilerplate — see `docs/PLUGIN_MANIFEST_PLAN.md`'s "Streamlining +//! never carries C-ABI export boilerplate — see `plans/PLUGIN_MANIFEST_PLAN.md`'s "Streamlining //! outcome" for why that generated root can't just be merged into `plugin.zig` itself. const std = @import("std"); @@ -220,7 +220,7 @@ pub const InstallOptions = struct { /// Wire `zig build install` for a plugin: emit `zig-out/{name}.{ext}` (a flat single-file /// artifact for packaging / store CI / release uploads) **and** drop a copy into this OS's fizzy /// plugins dir, under its own `{name}/{name}.{ext}` directory (see -/// docs/PLUGIN_MANIFEST_PLAN.md R10 — every installed plugin gets its own directory; only this +/// plans/PLUGIN_MANIFEST_PLAN.md R10 — every installed plugin gets its own directory; only this /// dev-convenience copy is nested, not the flat release artifact above), so the editor loads it /// on next launch. `{name}` must equal the plugin's manifest `id`. This is the canonical /// plugin-dev command — `zig build install` is all an author needs. There is no on-disk `.zon` @@ -369,7 +369,7 @@ fn fizzyPluginsDir(b: *std.Build) ![]const u8 { } /// Custom step: copy the built dylib into the host's fizzy plugins dir, under its own -/// `{id}/{id}.{ext}` directory (see docs/PLUGIN_MANIFEST_PLAN.md R10). No `.zon` sidecar — the +/// `{id}/{id}.{ext}` directory (see plans/PLUGIN_MANIFEST_PLAN.md R10). No `.zon` sidecar — the /// dylib's own exports (`fizzy_plugin_id`/`fizzy_plugin_manifest_zon`/…) are identity's only /// on-disk-adjacent copy. const DevInstall = struct { @@ -554,7 +554,7 @@ pub fn create(b: *std.Build, opts: CreateOptions) PluginArtifact { if (is_wasm) { // A wasm *side module* (Emscripten's term): position-independent, no entry, no // threads, loaded into the web host's memory and function table at runtime — the - // dylib model with table indices for function pointers. See `docs/REVIEW_2026-09.md` + // dylib model with table indices for function pointers. See `plans/REVIEW_2026-09.md` // §2 and `web/index.html`'s loader. lib.root_module.pic = true; lib.root_module.single_threaded = true; @@ -643,7 +643,7 @@ pub fn exportModules( } /// Install a built-in plugin dylib as `{name}/{name}.{ext}` under `plugins/` — each plugin gets -/// its own directory (see docs/PLUGIN_MANIFEST_PLAN.md R10), which it can also use at runtime for +/// its own directory (see plans/PLUGIN_MANIFEST_PLAN.md R10), which it can also use at runtime for /// its own assets/data (`Host.pluginInstallDir`). No `.zon` sidecar — the dylib's own exports /// carry identity (see `helpers.zig`'s `addDylib`/`generatedDylibRoot`). pub fn installBuiltinPlugin( diff --git a/sdk/replay/Input.zig b/sdk/replay/Input.zig index ab5698ca0..137b4579a 100644 --- a/sdk/replay/Input.zig +++ b/sdk/replay/Input.zig @@ -4,7 +4,7 @@ //! //! The half of a `Sequencer.Sink` that is dvui's and nothing else's. Whoever plays a tape owns one //! and adds what is its own around it: the `Player` (a demo) its stage's commands, keyframes and -//! idleness, its transport and its interruptions; a live driver (`docs/AGENTS_PLAN.md`, "Driving a +//! idleness, its transport and its interruptions; a live driver (`plans/AGENTS_PLAN.md`, "Driving a //! live tape") the same few calls with rules of its own. Each owns one, so a button one of them //! holds is that one's to let go of. const Input = @This(); diff --git a/sdk/replay/LiveDriver.zig b/sdk/replay/LiveDriver.zig index fd4c85b41..5c210bb4e 100644 --- a/sdk/replay/LiveDriver.zig +++ b/sdk/replay/LiveDriver.zig @@ -1,6 +1,6 @@ //! Plays a live tape: input played on the app as it is, a step at a time, each once the last has //! landed. The other way to play a tape besides a demo, and a driver of its own rather than a -//! mode of the `Player` (`docs/AGENTS_PLAN.md`, "Driving a live tape"): +//! mode of the `Player` (`plans/AGENTS_PLAN.md`, "Driving a live tape"): //! //! | | a demo (`Player`) | a live tape (this) | //! |-----------------------|-------------------------------------|-----------------------------------| diff --git a/sdk/replay/root.zig b/sdk/replay/root.zig index 12e5275ee..eaa4b25b7 100644 --- a/sdk/replay/root.zig +++ b/sdk/replay/root.zig @@ -12,7 +12,7 @@ //! - `Snapshot` says what is on screen, as text: roles, names, tags, rects. //! - `overlay` draws the tape's pointer and its clicks, plainly — an app may draw its own. //! -//! See `docs/AUTOMATION.md` and `docs/AUTOMATION_PLAN.md`. +//! See `docs/AUTOMATION.md` and `plans/AUTOMATION_PLAN.md`. pub const Player = @import("Player.zig"); pub const LiveDriver = @import("LiveDriver.zig"); pub const Input = @import("Input.zig"); diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 6d043578e..8c12db077 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -42,7 +42,7 @@ pub const Command = @import("Command.zig"); /// `takePendingPluginSettings`) composes each entry into fizzy's own `/settings.zon` /// under `.plugins..settings` (a real, human-editable nested ZON struct literal, not an /// escaped-string blob — see `src/editor/SettingsPluginsZon.zig` and -/// `docs/PLUGIN_MANIFEST_PLAN.md` R10/R12) and never interprets the contents. +/// `plans/PLUGIN_MANIFEST_PLAN.md` R10/R12) and never interprets the contents. pub const PluginSettings = std.StringArrayHashMapUnmanaged(?[]const u8); /// Optional tint for a workbench file-tree row background. `color_index` is the row's @@ -766,7 +766,7 @@ pub fn drawMenuItem(self: *Host, title: []const u8, command_id: ?[]const u8) boo // Every plugin's settings live as a real, hand-editable ZON struct literal keyed by plugin id, // nested inside fizzy's own `/settings.zon` under a // `.plugins = .{ . = .{...}, ... }` field — not an escaped-string blob, and not one file per -// plugin (see `docs/PLUGIN_MANIFEST_PLAN.md` R10, superseding R8's one-file-per-plugin design). +// plugin (see `plans/PLUGIN_MANIFEST_PLAN.md` R10, superseding R8's one-file-per-plugin design). // `SettingsPluginsZon` (in `src/editor/`) does the actual ZON text surgery; the Host only buffers // pending writes and routes reads through `fizzy_api` — see `loadPluginSettings`'s doc comment // for why that indirection is required. This is deliberately not cached across calls: @@ -835,7 +835,7 @@ pub fn takePendingPluginSettings(self: *Host) PluginSettings { // ---- plugin install directory ----------------------------------------------- /// `/` — the directory a plugin (built-in or third-party) is installed into, -/// beside its own `.{dylib,so,dll}` (see docs/PLUGIN_MANIFEST_PLAN.md R10: every plugin gets +/// beside its own `.{dylib,so,dll}` (see plans/PLUGIN_MANIFEST_PLAN.md R10: every plugin gets /// its own directory rather than sitting flat in `plugins/`). A plugin can use this for its own /// assets/data; the directory is not guaranteed to exist yet — create it (and any subpath) before /// writing into it. Null when `plugins_dir` itself is unset (wasm/headless). Pure path join, no diff --git a/sdk/src/Manifest.zig b/sdk/src/Manifest.zig index 4c6d5f98d..fac810a57 100644 --- a/sdk/src/Manifest.zig +++ b/sdk/src/Manifest.zig @@ -1,7 +1,7 @@ //! The declarative `plugin.zig.zon` manifest (`Manifest`): identity/version metadata a plugin //! author declares, and fizzy reads back out of a loaded dylib. //! -//! See `docs/PLUGIN_MANIFEST_PLAN.md`'s "Locked decisions": the author's `plugin.zig.zon` +//! See `plans/PLUGIN_MANIFEST_PLAN.md`'s "Locked decisions": the author's `plugin.zig.zon` //! declares `id`/`name`/`version`/`min_sdk_version`/`description`/`tags` and nothing else (R16 //! added `description` — the store's plugin detail page needs one for every plugin, not just ones //! with a registry entry; R17 added `tags` the same way, so browse-list search/categorization @@ -20,7 +20,7 @@ pub fn versionTriplet(v: std.SemanticVersion) [3]u32 { } /// The declarative `plugin.zig.zon` manifest. See the module doc comment and -/// `docs/PLUGIN_MANIFEST_PLAN.md`. +/// `plans/PLUGIN_MANIFEST_PLAN.md`. const Manifest = @This(); id: []const u8, diff --git a/sdk/src/Plugin.zig b/sdk/src/Plugin.zig index b98c3be40..ec8b68b89 100644 --- a/sdk/src/Plugin.zig +++ b/sdk/src/Plugin.zig @@ -397,7 +397,7 @@ pub const VTable = struct { /// not assume which one: (1) fizzy's settings pane edited it in-app (see /// `Host.storePluginSettings`), or (2) an external edit to `settings.zon` (hand edit, another /// tool) was picked up live by the settings watcher and reconciled (see R11 in - /// docs/PLUGIN_MANIFEST_PLAN.md) — in that case this fires with no settings-pane interaction + /// plans/PLUGIN_MANIFEST_PLAN.md) — in that case this fires with no settings-pane interaction /// having happened at all. A plugin that keeps its own in-memory copy of settings (e.g. to /// avoid re-parsing zon every draw) should re-parse and apply it here instead of only reading /// the blob once at `register`. Absent = the plugin only ever reads its settings at diff --git a/sdk/src/sdk.zig b/sdk/src/sdk.zig index 5c954d461..72295d712 100644 --- a/sdk/src/sdk.zig +++ b/sdk/src/sdk.zig @@ -48,7 +48,7 @@ pub const Host = @import("Host.zig"); pub const Plugin = @import("Plugin.zig"); pub const DocHandle = @import("DocHandle.zig"); -/// Comptime settings API (`sdk.settings.Schema(T)`) — see `docs/PLUGIN_MANIFEST_PLAN.md`. +/// Comptime settings API (`sdk.settings.Schema(T)`) — see `plans/PLUGIN_MANIFEST_PLAN.md`. pub const settings = @import("settings.zig"); pub const language = @import("language.zig"); @@ -97,7 +97,7 @@ pub fn refresh() void { /// Document staging helpers (`allocStaging`, `loadPathInto`, …). pub const document = @import("document.zig"); -/// The declarative `plugin.zig.zon` manifest types (see `docs/PLUGIN_MANIFEST_PLAN.md`). +/// The declarative `plugin.zig.zon` manifest types (see `plans/PLUGIN_MANIFEST_PLAN.md`). pub const Manifest = @import("Manifest.zig"); /// Services: capability offered by name and version rather than by the ABI. diff --git a/sdk/src/services/automation.zig b/sdk/src/services/automation.zig index 6a7e6fab9..0596a99eb 100644 --- a/sdk/src/services/automation.zig +++ b/sdk/src/services/automation.zig @@ -4,7 +4,7 @@ //! A **live tape** is input — clicks aimed at named widgets, keys, text, commands with arguments //! — played on whatever the person has open, a step at a time, each once the last has landed. It //! is the other kind of tape from a demo, which opens on a keyframe and sets the person's session -//! aside (`docs/AUTOMATION.md`; `docs/AGENTS_PLAN.md`, "Driving a live tape"). A plugin builds one +//! aside (`docs/AUTOMATION.md`; `plans/AGENTS_PLAN.md`, "Driving a live tape"). A plugin builds one //! with the SDK's `tape` module (`tape.Script`, with `check.live = true`) and hands it over as //! bytes, ZON (`Tape.write`) or binary (`tape.binary.encode`): the tape's layout never crosses the //! boundary, only its encoding. diff --git a/sdk/src/settings.zig b/sdk/src/settings.zig index 8225b7493..0f26b9708 100644 --- a/sdk/src/settings.zig +++ b/sdk/src/settings.zig @@ -286,7 +286,7 @@ pub const Access = struct { persist: *const fn (value: *anyopaque, owner: *Plugin) void, /// Parse `blob` into `value` and notify `owner.settingsChanged(blob)` — the reconciliation- /// path counterpart to `persist` (which goes the other direction: live value → disk). Used - /// only by external-change reconciliation (see R11 in docs/PLUGIN_MANIFEST_PLAN.md), never + /// only by external-change reconciliation (see R11 in plans/PLUGIN_MANIFEST_PLAN.md), never /// by an in-app edit through the settings pane. applyBlob: *const fn (value: *anyopaque, owner: *Plugin, blob: []const u8) void, }; @@ -309,7 +309,7 @@ pub const SettingsSchema = struct { /// Build a settings namespace for `T`, a struct whose every field is a `Value(...)` cell with a /// default (`= .init(…)`). The default is required to compute `default_value` below, which the -/// non-default-only persistence (see `diffSerialize`, R12 in docs/PLUGIN_MANIFEST_PLAN.md) diffs +/// non-default-only persistence (see `diffSerialize`, R12 in plans/PLUGIN_MANIFEST_PLAN.md) diffs /// every value against. pub fn Schema(comptime T: type) type { const struct_fields = std.meta.fields(T); diff --git a/spikes/windows-composition/README.md b/spikes/windows-composition/README.md index 031bd4642..9fbc6ca21 100644 --- a/spikes/windows-composition/README.md +++ b/spikes/windows-composition/README.md @@ -1,6 +1,6 @@ # Spike: the drag's glass on Windows, through Windows.UI.Composition -Answers the four questions `docs/WINDOWS_LINUX_GLASS_PLAN.md` ("Order of work", step 5) asks before +Answers the four questions `plans/WINDOWS_LINUX_GLASS_PLAN.md` ("Order of work", step 5) asks before fizzy builds the Windows overlay (`NATIVE_WINDOWS_PLAN.md`, Phase 2): 1. Can a Windows.UI.Composition target that is **not** topmost share an HWND with the topmost diff --git a/spikes/windows-composition/src/main.zig b/spikes/windows-composition/src/main.zig index f5d3befcc..712d38415 100644 --- a/spikes/windows-composition/src/main.zig +++ b/spikes/windows-composition/src/main.zig @@ -1,4 +1,4 @@ -//! Windows composition spike (docs/WINDOWS_LINUX_GLASS_PLAN.md, "Order of work" step 5). See +//! Windows composition spike (plans/WINDOWS_LINUX_GLASS_PLAN.md, "Order of work" step 5). See //! README.md for what each mode asks and how to read what it shows. //! //! An SDL window made transparent and claimed for SDL_GPU on D3D12, so fizzyedit/SDL presents it diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 252da54d0..cfbc41fe1 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -176,7 +176,7 @@ pub fn windowGlass(win: *dvui.Window, look: WindowGlassLook) bool { } /// OS windows besides the main one, each showing a part of the one frame — a float popped out -/// (`docs/POPOUT_WINDOWS_PLAN.md`, the backend's `Viewport`). Fizzy's own backend only: on dvui's +/// (`plans/POPOUT_WINDOWS_PLAN.md`, the backend's `Viewport`). Fizzy's own backend only: on dvui's /// SDL3 backend (`-Dnative-backend=sdl3`) there are none, as on the web, and floats stay in. pub const viewports = struct { const Impl = @import("backend"); diff --git a/src/backend/native/SDLBackend.zig b/src/backend/native/SDLBackend.zig index 4122609fc..4e47d86b4 100644 --- a/src/backend/native/SDLBackend.zig +++ b/src/backend/native/SDLBackend.zig @@ -115,7 +115,7 @@ const cursor_enum_count = @typeInfo(dvui.enums.Cursor).@"enum".fields.len; pub const max_viewports = 8; /// An OS window besides the main one that shows a part of the one frame: a float popped out of -/// the main window (`docs/POPOUT_WINDOWS_PLAN.md`). There stays one `dvui.Window`. The part this +/// the main window (`plans/POPOUT_WINDOWS_PLAN.md`). There stays one `dvui.Window`. The part this /// window shows is a band of the frame past the main window's edge (`viewport_map`): the app /// draws the float there, replays its drawing into a target of its own and hands that over /// (`viewportPresent`), and the pointer over this window goes back to dvui where the window @@ -1195,7 +1195,7 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // transient for it on X11). Without it, a press on the main window — or one the OS took as // its own while the viewport was being resized — put the main window over it. Not on macOS, // where SDL makes it a child window that moves with its parent, and the windows that left - // the main one stay where they are when it moves (`docs/POPOUT_WINDOWS_PLAN.md`, decision 2). + // the main one stay where they are when it moves (`plans/POPOUT_WINDOWS_PLAN.md`, decision 2). if (comptime builtin.os.tag != .macos) _ = c.SDL_SetWindowParent(window, self.window); self.viewports[slot] = .{ .window = window, .band = b, .anchor = anchor, .density = d, .screen = placed.screen, .frame = placed.frame, .passive = carry and !menu, .overlay = role == .overlay, .menu = menu }; const vp = &self.viewports[slot].?; @@ -1283,7 +1283,7 @@ pub fn viewportFade(_: *SDLBackend, vp: *Viewport, alpha: f32) void { /// Whether this run can open viewports at all: not on Wayland, where a client cannot put its /// windows anywhere — a window split out of the main one would open where the compositor likes, and -/// none could follow a drag (`docs/POPOUT_WINDOWS_PLAN.md`). Floats stay in the main window there. +/// none could follow a drag (`plans/POPOUT_WINDOWS_PLAN.md`). Floats stay in the main window there. pub fn viewportsAvailable() bool { if (comptime builtin.os.tag != .linux) return true; const driver = c.SDL_GetCurrentVideoDriver() orelse return false; @@ -1618,7 +1618,7 @@ fn mainOnScreen(self: *SDLBackend) viewport_map.Point { } /// The main window's pixels per point: every viewport draws at its scale for now (one -/// `natural_scale`, `docs/POPOUT_WINDOWS_PLAN.md` on DPI). +/// `natural_scale`, `plans/POPOUT_WINDOWS_PLAN.md` on DPI). fn density(self: *SDLBackend) f32 { // The screen's, from AppKit: SDL's is its pixel size over its size, as it last heard of each, // and the two arrive apart — the frames after a window jumps its size into or out of full screen diff --git a/src/backend/native/platform/wayland_blur.zig b/src/backend/native/platform/wayland_blur.zig index 9387e9f92..828f2cd63 100644 --- a/src/backend/native/platform/wayland_blur.zig +++ b/src/backend/native/platform/wayland_blur.zig @@ -1,6 +1,6 @@ //! The desktop's blur behind a Wayland window: `ext-background-effect-v1`, the protocol GNOME //! (Mutter 51) and KDE (Plasma 6.7) both have, and before it KDE's own `org_kde_kwin_blur` -//! (`docs/WINDOWS_LINUX_GLASS_PLAN.md`). The window asks for a region of its surface to be +//! (`plans/WINDOWS_LINUX_GLASS_PLAN.md`). The window asks for a region of its surface to be //! blurred behind; the compositor draws the blur, at a strength of its own. //! //! The region is surface state, double-buffered: set before a frame is presented, it changes diff --git a/src/backend/native/platform/win32_titlebar.zig b/src/backend/native/platform/win32_titlebar.zig index 775f23023..19fdf4f33 100644 --- a/src/backend/native/platform/win32_titlebar.zig +++ b/src/backend/native/platform/win32_titlebar.zig @@ -342,7 +342,7 @@ fn win32MicaSubclassProc( // ---- a float popped out of the main window ------------------------------------------------ // -// fizzy's viewports (`docs/POPOUT_WINDOWS_PLAN.md`): a float out of the main window, in a borderless +// fizzy's viewports (`plans/POPOUT_WINDOWS_PLAN.md`): a float out of the main window, in a borderless // window of its own. SDL styles a borderless window with a caption it takes away again // (`WM_NCCALCSIZE`), so DWM still treats it as a framed window — it rounds its corners and draws // its shadow — and the frame extended over it shows the backdrop behind its transparent pixels, as diff --git a/src/backend/native/viewport_map.zig b/src/backend/native/viewport_map.zig index 8b6e47eb5..ec9ca2a1a 100644 --- a/src/backend/native/viewport_map.zig +++ b/src/backend/native/viewport_map.zig @@ -1,5 +1,5 @@ //! Where a viewport's OS window is on the desktop and where its part of the frame lies — the one -//! mapping between the two (`docs/POPOUT_WINDOWS_PLAN.md`). std-only, its own `b.addTest` root in +//! mapping between the two (`plans/POPOUT_WINDOWS_PLAN.md`). std-only, its own `b.addTest` root in //! `build/app.zig`; `SDLBackend` applies it. //! //! There is one `dvui.Window` and one frame. The main window shows the part of it at its origin. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 5ebb2934a..08a865ed1 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -94,7 +94,7 @@ pub const Profiler = @import("Profiler.zig"); /// Demo automation: fizzy's stage for `app.automation`, and the demos it ships with. pub const Demo = @import("Demo.zig"); /// A float taken out of the main window into an OS window of its own: on by default on macOS, -/// `FIZZY_POPOUT=1` elsewhere (`docs/POPOUT_WINDOWS_PLAN.md`). +/// `FIZZY_POPOUT=1` elsewhere (`plans/POPOUT_WINDOWS_PLAN.md`). pub const Popout = @import("Popout.zig"); pub const Host = sdk.Host; @@ -1834,7 +1834,7 @@ pub fn updateWebPlugin(editor: *Editor, id: []const u8, url: []const u8, sha256: pub fn updatePlugin(editor: *Editor, id: []const u8, force: bool) !void { if (isBundledPluginId(id)) return error.NotUnloadable; // Each half timed: a rebuilt plugin's swap is a stage of the loop a plugin author waits on - // (`docs/AGENTS_PLAN.md`, "Fast enough to watch"), and `scripts/plugin-loop/bench.sh` reads it. + // (`plans/AGENTS_PLAN.md`, "Fast enough to watch"), and `scripts/plugin-loop/bench.sh` reads it. const start = std.Io.Clock.boot.now(dvui.io).nanoseconds; try editor.unloadPlugin(id, force); const unloaded = std.Io.Clock.boot.now(dvui.io).nanoseconds; @@ -2092,7 +2092,7 @@ pub fn postInit(editor: *Editor) !void { try editor.app.host.registerService(sdk.services.automation.Api, &editor.demo.service.api, null); // Live external-edit reconciliation for settings.zon + dropped-in plugin discovery (see - // R11/R12 in docs/PLUGIN_MANIFEST_PLAN.md). Must happen here, in `postInit`, not `init` — + // R11/R12 in plans/PLUGIN_MANIFEST_PLAN.md). Must happen here, in `postInit`, not `init` — // nightwatch retains `&editor.app.settings_watcher.handler`, so `editor` has to already be at // its final heap address (see `SettingsWatcher.start`'s doc comment). Best-effort // throughout: fizzy must never fail to launch just because the watcher couldn't start. @@ -3005,7 +3005,7 @@ fn activelyDrawing(editor: *const Editor) bool { /// Composes fizzy's own fields (`Settings.serialize`) together with every plugin's pending /// settings write into one `/settings.zon` and writes it in a single pass (see -/// `docs/PLUGIN_MANIFEST_PLAN.md` R10). This *must* stay one combined write: writing fizzy's +/// `plans/PLUGIN_MANIFEST_PLAN.md` R10). This *must* stay one combined write: writing fizzy's /// fields and the plugins' blobs independently would let whichever write ran second silently /// drop the other's data, since `Settings.serialize` only knows fizzy's own fields and has no /// notion of `.plugins` at all. `settings_last_saved_hash` dedupes over the *whole* composed @@ -3162,7 +3162,7 @@ fn writeMergedSettings(editor: *Editor, settings_path: []const u8) !void { /// Called from `SettingsWatcher.tick` when the background watcher noticed a change to /// `settings.zon` — reconciles that external change (hand edit, another tool) into fizzy's live /// state instead of letting the next autosave silently overwrite it. See R11 in -/// docs/PLUGIN_MANIFEST_PLAN.md for the full design; `SettingsWatcher` itself never touches file +/// plans/PLUGIN_MANIFEST_PLAN.md for the full design; `SettingsWatcher` itself never touches file /// content, only detects "something changed" — this is the half that actually reads and applies. pub fn reconcileExternalSettingsChange(editor: *Editor) void { if (comptime builtin.target.cpu.arch == .wasm32) return; @@ -3719,7 +3719,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // Linux: the desktop's blur behind the window's frame, where the compositor has one // (`backend.blurBehind`), asked for while the window is translucent at all, its corners the // frame's (square maximized, as `draw` draws it). Behind `FIZZY_BLUR_BEHIND=1` until it has been - // seen on GNOME and KDE (`docs/WINDOWS_LINUX_GLASS_PLAN.md`). + // seen on GNOME and KDE (`plans/WINDOWS_LINUX_GLASS_PLAN.md`). if (builtin.os.tag == .linux and Popout.envSwitch("FIZZY_BLUR_BEHIND") orelse false) { const win = dvui.currentWindow(); const radius: f32 = if (fizzy.backend.isMaximized(win)) 0 else Constants.linux_window_radius; @@ -3752,7 +3752,7 @@ pub fn tick(editor: *Editor) !dvui.App.Result { PluginStore.tick(); const hitch_watchers = fizzy.core.hitch.begin(.watchers); - // Pick up any external edit to settings.zon (see R11 in docs/PLUGIN_MANIFEST_PLAN.md). + // Pick up any external edit to settings.zon (see R11 in plans/PLUGIN_MANIFEST_PLAN.md). // Cheap no-op unless the watcher thread actually saw a change. if (editor.app.settings_watcher) |*w| w.tick(editor.configWatchSink()); diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index d4d4a61bd..e7c55c270 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -1,4 +1,4 @@ -//! The pop-out plan's viewports (`docs/POPOUT_WINDOWS_PLAN.md`), on by default on macOS and behind +//! The pop-out plan's viewports (`plans/POPOUT_WINDOWS_PLAN.md`), on by default on macOS and behind //! `FIZZY_POPOUT=1` elsewhere, on fizzy's native backend: where the platform has OS windows a float is one, from the frame it is //! made in — every float its own window (a viewport, `fizzy.backend.viewports`), titled and framed by //! the OS where it can be (macOS), moved, snapped and resized by the OS as any window. Its views go @@ -435,7 +435,7 @@ const carried_backing: f32 = 0.2; /// A view drag's glass — the carried view and the drop zones' bubbles — is one overlay of the OS's /// Liquid Glass over the display the main window is on, wherever the OS has it /// (`viewports.liquidGlass`, macOS 26): the OS's glass where it can be, the app's where it cannot -/// (`docs/NATIVE_WINDOWS_PLAN.md`) — as the setting says (`Settings.native_glass`, +/// (`plans/NATIVE_WINDOWS_PLAN.md`) — as the setting says (`Settings.native_glass`, /// `FIZZY_NATIVE_GLASS` over it). Off, the app's glass. fn nativeGlass() bool { if (comptime builtin.target.cpu.arch == .wasm32 or !viewports.carries) return false; diff --git a/tests/bench/bench_markdown.zig b/tests/bench/bench_markdown.zig index 451a6293b..e4c8b7a48 100644 --- a/tests/bench/bench_markdown.zig +++ b/tests/bench/bench_markdown.zig @@ -32,7 +32,7 @@ const sample_huge = @embedFile("sample_huge"); // docs/PLUGINS.md /// Same size as `sample_huge` but shaped completely differently: very long paragraphs (single /// blocks of several thousand characters) and several tables. It is the document that stayed slow /// after block virtualization, which is exactly why it is in here. -const sample_prose = @embedFile("sample_prose"); // docs/PLUGIN_MANIFEST_PLAN.md +const sample_prose = @embedFile("sample_prose"); // plans/PLUGIN_MANIFEST_PLAN.md const sample_medium = @embedFile("sample_medium"); // CLAUDE.md const sample_small = @embedFile("sample_small"); // docs/MODULARIZATION_RELEASE_NOTES.md @@ -283,7 +283,7 @@ test "bench: markdown preview frame cost" { const cases = [_]struct { name: []const u8, text: []const u8 }{ .{ .name = "huge (docs/PLUGINS.md)", .text = sample_huge }, - .{ .name = "prose (docs/PLUGIN_MANIFEST_PLAN.md)", .text = sample_prose }, + .{ .name = "prose (plans/PLUGIN_MANIFEST_PLAN.md)", .text = sample_prose }, .{ .name = "medium (CLAUDE.md)", .text = sample_medium }, .{ .name = "small (release notes)", .text = sample_small }, }; diff --git a/docs/PLUGIN_MANIFEST_PLAN.md b/tests/data/markdown_sample_tables.md similarity index 100% rename from docs/PLUGIN_MANIFEST_PLAN.md rename to tests/data/markdown_sample_tables.md diff --git a/tests/integration.zig b/tests/integration.zig index b48c364f2..a223b3447 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -618,8 +618,10 @@ const md_render_ast = markdown.render_ast; var md_preview: markdown.Preview = .{}; var md_doc: []const u8 = ""; const md_sample = @embedFile("markdown_sample"); -/// Table-heavy: one of its tables is 45KB on its own, which is what makes it the document that -/// exercises row culling inside a table rather than only block skipping around it. +/// Table-heavy (`tests/data/markdown_sample_tables.md`, a frozen copy of what was +/// `docs/PLUGIN_MANIFEST_PLAN.md` before the plans moved to `fizzyedit/plans`): one of its tables +/// is 45KB on its own, which is what makes it the document that exercises row culling inside a +/// table rather than only block skipping around it. const md_sample_tables = @embedFile("markdown_sample_tables"); /// Image-heavy. Both samples above are prose and tables, so without this one no test in this file /// ever laid out an image block — the block kind whose height nothing in the source predicts, and @@ -766,7 +768,7 @@ fn markdownScroll(ticks: f32, frames: usize) !void { for (0..frames) |_| _ = try dvui.testing.step(markdownFrame); } -// The user-visible complaint these two encode: on docs/PLUGIN_MANIFEST_PLAN.md, scrolling about +// The user-visible complaint these two encode: on plans/PLUGIN_MANIFEST_PLAN.md, scrolling about // three quarters of the way down went unstable — the document jumped under the reader and the // scrollbar jumped with it, and scrolling back up landed near the top of the document instead of // where they had been. @@ -1485,7 +1487,7 @@ test "markdown preview: an edit does not move the reader or lose the layout" { // warm-up sweep finishes, the scrollbar is the sum of those guesses. The guess used to ignore what // kind of block it was: an image is one line of source and hundreds of points tall, a table row is // a line of source and a line *plus* cell padding, a heading is a line in a much larger font. All -// the errors pointed the same way, and docs/PLUGIN_MANIFEST_PLAN.md estimated at 24% of its real +// the errors pointed the same way, and plans/PLUGIN_MANIFEST_PLAN.md estimated at 24% of its real // length — a scrollbar claiming the document was a quarter of its true size. // // A band, not a number: these are guesses and are meant to be. What matters is that they are the @@ -1524,7 +1526,7 @@ test "markdown preview: the estimated document length is in the right ballpark" // because they all parked somewhere and thrashed locally instead of traversing. // // The last one it caught: anchoring by block source hash, where the hash identifies *text* and -// documents repeat themselves. docs/PLUGIN_MANIFEST_PLAN.md has seven top-level blocks sharing a +// documents repeat themselves. plans/PLUGIN_MANIFEST_PLAN.md has seven top-level blocks sharing a // single hash, so an anchor on any of them resolved to whichever copy came first and the reader // was thrown to the top of the document. test "markdown preview: scrolling through the document never jumps past where it was asked" { @@ -6504,7 +6506,7 @@ fn expectButtonsNamed(frame: fn () anyerror!dvui.App.Result) !void { } // ── Headless ──────────────────────────────────────────────────────────────────────────────────── -// The spike in docs/AGENTS_PLAN.md ("Running with no display"): the whole editor, as `Entry` +// The spike in plans/AGENTS_PLAN.md ("Running with no display"): the whole editor, as `Entry` // brings it up, over dvui's testing backend in a profile of its own. /// The app's frame as `Entry` runs it: the demo player, the frame target, the editor.