Skip to content

sdk: a plugin can put a surface away, not only reveal it - #566

Closed
foxnne wants to merge 4 commits into
mainfrom
sdk/hide-surface
Closed

foxnne wants to merge 4 commits into
mainfrom
sdk/hide-surface

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

On Zig 0.17: main (with #575, the SDK 0.2.30 release #580, and #560) is merged in, and every gate passes on the 0.17 toolchain.

Part of fizzyedit/work#33

What changes

A plugin can put its view away, not only show it, so a button can toggle a panel:

pub fn hideSurface(self: *Host, id: []const u8) bool

if (!host.hideSurface(id)) _ = host.revealSurface(id); // a toggle

hideSurface undoes revealSurface the way the user would. It returns true when the view was in sight and is not now. It returns false, changing nothing, when the view was not in sight: another view is its place's selection, its place or the tray round it is shut or folded, or no place shows it.

  • In a tray (Region.tray: the shape's sidebar or bottom panel), or in a place the user split off one, the tray shuts with whatever else it holds, as its rail icon would shut it. revealSurface opens it again at the size the user left it.
  • Anywhere else (Main or a place split off it, a plugin's document pane, a dockspace cell), nothing happens and it returns false.
  • In a float, only this view goes back to the place it floated out of, as closing the float would send it (float_rules.goHome). The float's other views stay where the user put them, and the float closes only if this was its last view.
    • If the view lands in sight in a tray, the tray shuts.
    • If it lands in sight anywhere else, it is in sight, and the answer is false though it left the float.

Layout changes it needs

  • A hide or reveal asked mid-frame waits, in order. A button in a floating view calls hideSurface while the shape and the floats walk the very lists a hide changes. Mid-frame, hideSurface and revealSurface both queue (Layout.hideLater, revealLater, one queue), and the frame applies them in the order asked once the shape, floats, drag overlay and picker have drawn (Layout.applyLater). A hide then a reveal in one frame ends with the view shown.
    • Each answers as it would between frames. For a float view, hideLater also predicts where the view lands (ViewDrag.floatHomeFor): landing in sight where nothing shuts gives false, as hide does. Tested.
    • Between frames a hide applies at once.
    • markWindowRatiosDirty now reads extents_changed after all of this, which also closes the old gap where the picker's late changes were missed.
  • Floats. ViewDrag.floatViewHome is withdraw plus goHome, where goHome is sendHome's per-view step pulled out. It only goes ahead if canFloatViewHome holds: the float is there and not closing, it holds the view, it can let go of it (withdraw's closable rule), and somewhere in the main window will show it (homeLanding, float_rules.goHome asked before anything moves).
    • If nowhere would show the view (its home is gone and no place takes it), it stays in the float and the answer is false, so reveal still finds it. Tested on the endless shape.
    • A float that is already closing has nothing left to put away (locate(.open), used only by hide), but reveal still finds a view not home yet.
    • A home tray where the view lands as the selection is shut even when folded, so it doesn't come back as the window widens.
  • Two questions about the explorer, kept apart. Toggle Explorer, its menu title and a rail tap (Sidebar.pickAction) all use inSight, so label and action agree.
    • Explorer.isShut(editor): the user or a plugin shut the sidebar region; folding doesn't count, and no sidebar means not shut. It is what the menu title, the explorer_focused keybind context (true when there's no sidebar, as before) and demo snapshots and their fingerprint read, so none of them change with the window's width.
    • Explorer.inSight(editor): neither shut nor folded. Only Toggle Explorer uses it, so a folded explorer opens in one press. hide and reveal count folded as out of sight the same way.
    • Both are plain functions over the sidebar region. Explorer.closed is gone.
  • Split.close(id, fallback: ?f32), the root fix, for trays only. A tray that keeps no size (left empty and folded away) is shut as though it had been open at fallback, with nothing sliding. Without a fallback (anything that isn't a tray), an unsized region is left as it was, as before. Region.close passes a fallback only for trays.
    • Nothing is retained past a region's life. A tray declared but not drawn (the hide_when_empty path) reads its _size/_open/_shown each frame, so dvui keeps them exactly as long as the place is declared.
    • Every caller passes the size to reopen to: Region.close (rail, Toggle Explorer, hide), the picker, and ViewDrag.
    • A Split unit test covers both: with a fallback, shut and reopened at it; without one, nothing written.
  • Never reopening at 1pt. Region.reopen_extent is filled in State.registerRegion: the user's persisted extent, else default_extent. The tray's declared default, padding and border included, is worked out once in Region.init, before the hide_when_empty early return, and initTree uses the same value.
  • Region.tray is decided in one place: Region's register, as resize and !forget_when_empty and !State.isMinted(name). A place that forgets itself when empty couldn't be reopened once forgotten. A place a runtime split made is no tray, so hiding never collapses a user's split pane, and no extra option is needed. fillInBox (regionIn, dockspace cells) registers no tray, because the cell and its size belong to the dockspace. A test checks hide there does nothing.
  • Layout.locate: one pass finds the place; its tray is the place itself or, by name, the place it was split from (SplitTree.Forest.rootOf → ViewDrag.regionNamed). reveal opens that tray too.
  • One contract. docs/PLUGINS.md §3.1.2, "Showing and putting away", is the authoritative description. Host.hideSurface and Layout.hide point to it.
  • One helper for both calls between frames: Editor.onLayout runs reveal or hide on last frame's regions and forwards extents_changed.

Boundary: EditorAPI.VTable.hideSurface(ctx, id) plus the Host/EditorAPI calls. Docs: docs/PLUGINS.md §3.1.2, "Showing and putting away". Corner-button code (#555) is untouched, but Region.zig gains the tray/split_part lines, so a rebase may touch them.

SDK impact

  • None
  • Core-only or additive: reaches plugins at the next SDK release
  • Fingerprint moved: recorded in sdk/src/version.zig (0x5cf2c503df2d415d: the combined value, with main and sdk: a plugin has a data folder of its own #560 merged in), sdk_version untouched, PR labelled sdk
  • SDK release: bumps sdk_version, lists the sdk PRs since the last sdk-v* tag

Verified

  • macOS: all gates (scripts/check-fmt.sh, zig build, test, test-integration, check-web, test-sdk-version, check-tests-reached, scripts/check-examples.sh). Tests on the layout harnesses (ManyPanelCase, SplitCase), through shared helpers (hideView, revealView, isShut over ViewDrag.regionNamed):

    • a view in sight shuts its tray, reveal reopens it, a second hide is false, an unknown id is false, and Main doesn't shut;
    • a view behind another tab is not hidden, and the one showing is;
    • a floating view goes home and its tray shuts, and the toggle goes round: hide → reveal → hide → reveal;
    • one view of a two-view float goes home alone, the float keeps the other, and the tray shuts;
    • a place split off Main does not shut, and nothing changes;
    • a place in a dockspace cell (regionIn, endless shape) does not shut, and nothing changes;
    • a view in a place split off the panel shuts the panel, a second hide is false, and reveal reopens it;
    • a tray widened to 300, its only view floated out and then hidden, reopens at 300;
    • a view in a float that asks to be hidden from inside its own draw: the answer is true, nothing changes until the frame ends, then it is home and its tray shut;
    • a floating view nowhere in the main window would show (endless shape, home gone): hide is false, it stays in its float, and reveal finds it;
    • a hide and then a reveal asked in one frame end with the view shown;
    • a floating view landing in sight in Main: hideLater and hide both answer false.

    In the whole-editor headless test: Host.hideSurface on Output in the real bottom panel, and hiding the sidebar's view followed by a single Toggle Explorer press that opens it.

  • Windows:

  • Linux:

  • Web: builds (check-web)

Follow-ups

  • Workspaces.layoutOf builds a between-frames Layout too, and also drops extents_changed. Left as is here.
  • Shapes whose splits come from a seeded dock tree (State.dock) are not covered by trayOf, which reads the split forest.
  • devtools' "Show Profiler" could become a toggle with this.
  • The rest of fizzyedit/work#33: timed recents, a home surface, and the scrolls_itself warning.

🤖 Generated with Claude Code

@foxnne foxnne added the sdk Moves the plugin boundary's fingerprint; ships at the next SDK release label Oct 18, 2026
@foxnne
foxnne force-pushed the sdk/hide-surface branch 2 times, most recently from 721d8ea to bd40954 Compare October 18, 2026 18:54
@foxnne foxnne mentioned this pull request Oct 18, 2026
2 of 8 tasks
host.hideSurface(id) undoes revealSurface as the user would: the tray
showing the view, or the tray a place it is in was split off, shuts; a
place in no tray (Main, a place split off it, a document pane, a
dockspace cell) is left; a view in a float goes home alone, the float
closing only when it was its last, and the tray it lands selected in
shuts, folded or not. True when the view was in sight and is not now.
Asked mid-frame it is queued and applied once the shape, floats and
picker have drawn. A toggle is `if (!hideSurface(id)) revealSurface(id)`.

Region.tray is decided in one place (a runtime split's place is resize
but no tray); Split.close takes the size to reopen to, so a tray that
drew nothing last frame shuts instead of being seeded open, and
Region.reopen_extent (the user's persisted extent, else the shape's
default) is what it opens back to. The explorer reads the sidebar
region instead of a flag of its own. Fingerprint recorded.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the SDK 0.2.30 release (#580). The one conflict was the
recorded SDK shape fingerprint, re-recorded for the merged boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 18, 2026
On Zig 0.17: main (with #575 and the SDK 0.2.30 release, #580) is merged
in, and every gate passes on the 0.17 toolchain.

Part of #530

## What changes

A plugin gets a folder of its own for what it keeps between runs that is
not a setting (recents, an index, a cache):

```zig
pub fn pluginDataFolder(self: *Host, plugin: *const Plugin) ?[]const u8
```

- `<config>/plugin-data/<id>`, the id in lowercase. It follows the
config folder, so a `FIZZY_PROFILE` sandbox gets its own.
- **Plugin ids are unique ignoring ASCII case, enforced before the disk
is touched.**
- `Host.pluginById` stays exact, as do
`isPluginDisabled`/`Undecided`/`AutoUpdate`, because everything keyed by
a loaded id needs exact matching. `registerPlugin` refuses `Graphl`
beside `graphl` (`error.DuplicatePluginId`), logging both names, through
the new `Host.pluginByIdIgnoringCase`.
- `App.pluginCaseClash(id)` checks for a loaded plugin, or a folder
already in `plugins/`, whose id is `id` in another case. It runs before
anything keyed by `id` touches the disk:
- the store's install/update path (`PluginStore.startDownloadUrl`,
before `plugins/<id>` is created or written: on macOS and Windows that
folder *is* the other plugin's), through a new optional
`PluginManager.VTable.refuseCaseClash`, since on main the store talks to
the app only through `PluginManager`;
    - `loadUserPluginById`;
    - the startup scan.
A clash becomes a store failure card through `App.recordCaseClash`: "id
'Graphl' differs only in case from the plugin 'graphl'".
- **Uninstall from a clash card never deletes the loaded plugin's
folder.** Before deleting `plugins/<id>`, `uninstallPlugin` compares it
with the loaded plugin's folder by what each is on disk
(`App.sameFolder`, same inode), not by name. On a case-insensitive
filesystem they are one folder, so only the card goes. On a
case-sensitive one, only that exact-case folder is deleted.
- **The startup scan doesn't let byte order choose.** Two plugin folders
whose names differ only in case (possible on a case-sensitive
filesystem) are one id twice: neither loads, and one card names both and
asks for one to be removed. It's the simplest deterministic rule. Which
build was meant can't be told from the folders alone, and the declared
id would be the same either way once case is folded. A folder whose name
case-clashes with a loaded plugin gets the case-clash card, as before.
- **Thread safety without walking the plugin list, symmetric for any
app.** Two optional `EditorAPI.VTable` hooks, both on the main thread:
- `pluginRegistered(ctx, id)`, fired by `Host.registerPlugin`, where the
app sets up the plugin's entry;
- `pluginUnregistered(ctx, id)`, fired by `Host.unregisterPlugin` before
the plugin's `deinit`. The app marks the entry leaving: it is still
handed out for that `deinit`, and stops at the next frame's top
(`App.releaseRetiredFolders`, which every app built on `App` already
calls). An app without fizzy's `Editor` stays correct.
- `pluginDataFolder` only reads the table: under the lock, then the
folder is made outside it, then `registered` is checked again under the
lock. Entries stay after a plugin goes, so a path a worker holds stays
valid.
- If registration-time setup runs out of memory, the plugin's id is
remembered (`plugin_data_missed`, no allocation) and its entry is made
on its first ask. The out-of-memory is logged.
- One key function (`pluginDataKey`) and one wasm-aware lock pair
(`lockPluginData`/`unlockPluginData`) serve both sides.
- **Ids that aren't plain identifiers:** not all registered ids pass
`isValidPluginId`; the store page registers as `fizzy.store`. So
`registerPlugin` doesn't enforce it. Such a plugin gets null from
`pluginDataFolder`, logged once, and the docs say so.
- **No `mkdir` on every call.** The folder is made on the first ask and
checked again only on an ask a second or more after the last
(`App.plugin_data_recheck_ns`). The contract, documented: a plugin whose
write into a kept path fails with `FileNotFound` asks again, a second
later if it just asked.
- **Failure backoff:** `failed_at` is recorded after `createDirPath`
returns. Asks within 5 s of it return null (`App.plugin_data_retry_ns`),
and each run of failures is logged once. Running out of memory is
logged.
- **`App.deinit` frees what the App owns**: the data-folder table and
the disabled/undecided/auto-update id lists, moved there from
`Editor.deinit`. `Editor.deinit` calls it through a `defer` at its top.
- Not `plugins/<id>`, which an update replaces and an uninstall deletes;
the App, Host and PLUGINS.md docs agree. Always local, whether or not
the open folder is on a mount.
- **Web:** as with `paletteFolder`, the path names no directory. It is
the prefix under which `core.fs.read`/`readZ`/`write`/`remove` keep
files in the page's storage; those four calls do the same on the
desktop, and `std.Io.Dir` calls on the path fail on the web.
- **Uninstall leaves it**, as it leaves the plugin's `settings.zon`
block. The named seam for removing it too is `Editor.uninstallPlugin`
(see Follow-ups).

Graphl asked for it (#530): today it keeps its recents beside
`paletteFolder()`'s parent.

Boundary: `EditorAPI.VTable.pluginDataFolder(ctx, id)` and the optional
`pluginRegistered(ctx, id)`/`pluginUnregistered(ctx, id)`, the
`Host`/`EditorAPI` calls, and `Host.pluginByIdIgnoringCase`. Docs:
`docs/PLUGINS.md` §3.3.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0xba4664870675e13d` after merging main, which released SDK 0.2.30;
whichever of this and #566 merges second records the combined value),
`sdk_version` untouched, PR labelled `sdk`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS: `scripts/check-fmt.sh`, `zig build`, `zig build test`, `zig
build test-integration` (54/54 steps, 595/595 tests), `zig build
check-web`, `zig build test-sdk-version`, `zig build
check-tests-reached`, `scripts/check-examples.sh`. A new `sdk` unit test
covers `registerPlugin` refusing the same id and the same id in another
case. The headless whole-editor test (fresh profile) checks:
- `text`'s and `markdown`'s folders are `<profile>/plugin-data/<id>`,
exist, are stable across asks, and differ;
- `../escape` gets null and leaves no table entry, and `Text` finds
`text`'s folder;
- a test-registered `CaseTest` gets `plugin-data/casetest`, and
`casetest` is then refused registration;
- once `CaseTest` is unregistered its folder is still handed out (for
its `deinit`) until the next frame's top, then not;
- `loadUserPluginById("Text")` is refused before opening anything, and
its card shows failed with the case reason;
- uninstalling `Text` works, `text` stays loaded, and a file in
`plugins/text` survives (on macOS `plugins/Text` is that folder);
  - `pluginCaseClash("graphl")` finds a `plugins/Graphl` folder.

Not testable on this case-insensitive Mac: two folders differing only in
case (the startup-scan rule), and deleting the exact-case folder on a
case-sensitive filesystem.
  - `dotted.id` gets null;
- a deleted folder is made again by an ask once its trusted second is
wound past;
- a test-registered `blocked` plugin with a file in its folder's way
gets null, and null again right after the way clears (backoff); with the
backoff wound past, the folder is made and the failure cleared.

The warnings these tests cause are muted with `std.testing.log_level`.
The test's one remaining `failed command:` comes from its own warning
already on `main` (a tape needing `no-such-plugin`).
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds (`check-web`); not run in a browser

## Follow-ups

- Removing the data folder on uninstall is not built. It would be an
option of `Editor.uninstallPlugin`, probably ask-first in its dialog,
since the folder can hold work the user would miss.
- The other items in #530 (timed recents, a home surface, the
`scrolls_itself` warning) are left for their own PRs. Hiding a surface
is the next PR (`sdk/hide-surface`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne and others added 2 commits October 18, 2026 15:38
Sixth review of hideSurface. Mid-frame, revealSurface queues with
hideSurface and both apply in the order asked; hideLater answers as hide
would, a float view landing in sight where nothing shuts included.
Split.close shuts an unsized region only when given a fallback (trays),
and nothing is retained past a region's life: a declared but undrawn
tray reads its size keys each frame. forget_when_empty places are no
trays, initTree shares init's default extent, a view in a closing float
stays revealable, and the explorer's menu title and rail tap use
inSight like Toggle Explorer.

Part of #530

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the plugin data folder (#560). The one conflict was the
recorded SDK shape fingerprint, re-recorded for the combined boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne

foxnne commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favour of a narrowed rebuild from current main: hideSurface acts only on a tray's shown view or a float's only view, deferred mid-frame, and leaves the explorer state, Split.close and revealSurface unchanged. This PR grew into reworking those, and kept bringing regressions. The new PR will link here. Its branch is kept for reference.

@foxnne

foxnne commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #593: narrowed to trays and lone floats.

foxnne added a commit that referenced this pull request Oct 18, 2026
Part of #530

Supersedes #566, narrowed to trays and lone floats. #566 grew into
reworking the explorer's state, `Split.close` and a mid-frame reveal,
and kept regressing; this keeps to the one primitive and leaves
everything around it as it is on `main`.

## What changes

A plugin can put its view away, not only show it, so a button can toggle
it:

```zig
pub fn hideSurface(self: *Host, id: []const u8) bool

if (!host.hideSurface(id)) _ = host.revealSurface(id); // a toggle
```

It acts in two cases only, each the way the user's own gesture does:

- **The view is what a tray shows.** A tray is a place the shape
declared `resize` and not `forget_when_empty` (so not a place a split
made), in the main window. If it is open, not folded, and `id` is its
shown view, it shuts with whatever else it holds, through
`Region.close`, as its rail icon or close does. `revealSurface` opens it
again at the size the user left it.
- **The view is alone in a float.** The float closes through
`Layout.closeFloat`, as its close button does, and the view goes home.

Anything else returns false and changes nothing: Main, a split's half
(of a tray too), a plugin's pane, a view behind another tab, a view out
of sight, a float with other views.

How:

- `Layout.hideTarget(id)` is the one predicate. It finds the region
showing `id` the way `reveal` does, and says what a hide would do
(`Hide.tray` / `Hide.float`) or null. `Layout.hide` applies it at once
(between frames).
- **During a frame** (a button in a view's draw), the shape and the
floats are walking the lists a hide changes. `Layout.hideLater` answers
with the same `hideTarget` and queues the id; `Layout.applyHides` does
the queued hides once the shape, floats, drag overlay and picker have
drawn, each asking `hideTarget` again. `revealSurface` is unchanged
(immediate), so a hide asked first in a frame lands after a reveal asked
later in it: a toggling caller makes one call a frame. Documented.
- The registry entry of a declared place now carries its `resize` (one
line in `Region.init`'s registration), which is how `hideTarget` knows a
tray.
- `Editor` wires the vtable member. A hide that shut the sidebar then
calls the explorer's existing `Explorer.close`, so its `closed` flag
(Toggle Explorer, its menu title) agrees; nothing in `Explorer` changes.
- **A test fix this needs.** `markdown preview: skipping off-screen
blocks lays the document out identically` lays out `docs/PLUGINS.md`
itself and compared two f32 totals near 41000 pt to an absolute 0.01,
where one rounding step is 0.004. The section added here tipped it
(41430.58 vs 41430.59, locally and on CI's Linux run); it now compares
relatively, at 1e-6.
- The `extents_changed` check in fizzy's frame moved below the picker
and the applied hides, so a change either makes is saved too.

Contract: `docs/PLUGINS.md` §3.1.2, "Showing and putting away a view".

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [x] Fingerprint moved: recorded in `sdk/src/version.zig`
(`0x1dd3a67d781503a7`, combined with main's after #587), `sdk_version`
untouched, PR labelled `sdk`. New: `EditorAPI.VTable.hideSurface`,
`EditorAPI.hideSurface`, `Host.hideSurface`.
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS, on the merge with main: all gates pass
(`scripts/check-fmt.sh`, `zig build`, `test`, `test-integration`,
`check-web`, `test-sdk-version`, `check-tests-reached`,
`scripts/check-examples.sh`). New integration tests on the
`ManyPanelCase` harness (whose frame now applies hides at its end, as
fizzy's does):
- a tray's view shuts the tray; a second hide and an unknown id are
false; reveal reopens it; hide again shuts it (the toggle);
- a view in Main, and in a place split off Main, is false and nothing
changes;
  - a view in a tray the user split is false and nothing changes;
  - a view alone in a float closes the float and goes home to the panel;
- a view in a float with another is false for both views, and the float
stays;
- a floating view asking from its own draw: the answer is true, the
float is still open while it draws, and it is closing by the frame's
end, then gone.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds (`check-web`)

## Follow-ups

Deliberately left out, each a case `hideSurface` returns false for
today:

- views in a split tree of a tray (a panel the user split): which half
shuts, and whether the tray does;
- views in a float with other views (send one home, keep the float);
- folded trays (a sidebar folded away on a narrow window), which count
as out of sight;
- `revealSurface` reopening the sidebar leaves `Explorer.closed` as it
was (as on `main`); worth one source of truth for the explorer's state
in its own PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sdk Moves the plugin boundary's fingerprint; ships at the next SDK release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant