Skip to content

layout: opening the view picker no longer stalls a frame - #570

Merged
foxnne merged 3 commits into
mainfrom
layout/picker-stall
Oct 18, 2026
Merged

foxnne merged 3 commits into
mainfrom
layout/picker-stall

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Now on Zig 0.17: main (with #575, the 0.17 switch, and #555) is merged in, and the gates pass with 0.17.0.

Part of fizzyedit/work#29

What changes

Opening a place's view picker (its corner button, or Layout settings) no longer pays for every view's picture in one or two frames.

  • The picker chooses, one view at a time. As the picker draws its cards, it chooses the next view to draw offscreen when none is in flight (Picker.nextOffscreen, State.offscreen). The choice is a card the list laid out in view, whose view may be drawn offscreen (Layout.mayDrawOffscreen, the one rule the capture step checks too): capture works here, the view has no picture or failed capture, isn't hidden, and drew nowhere this frame. A card doesn't count on its own first frame, when every card packs into the top of a list without sizes. A view scrolled or filtered out of the list before it is chosen is never drawn for it.
  • The capture step stays where it was: after the shape, before the picker, as on main. captureUnplaced warms the chosen view and then captures it, one offscreen draw a frame. The warm-up is at least 10 frames and at least a reveal's fade at the interface's speed, so a fast display doesn't capture mid-fade.
    • Running before the picker means whatever the view opens (a popup, a float) stacks under the picker, and a picture taken there shows on its card that same frame, with no extra refresh.
    • The view is found by its registry index, checked against its id's hash, and rescanned only if the registry changed. Its offscreen box is keyed by the id.
    • It is dropped once that rule no longer holds, for example when it is drawn on screen this frame. It is never drawn on screen and offscreen in one frame.
    • If the view's card leaves the list early in the warm-up (filtered out or scrolled away), the view is dropped, to be chosen again if the card comes back. Later in the warm-up, it is finished. This is decided by frame stamps: the card's card_ns against Picker.list_ns, when the list last laid its cards out. A picker draw that returns before its list never drops a view.
  • Failures are recorded for the picker's session in State and reset when it reopens.
    • A view whose capture fails (its picture couldn't start, or gave no texture) goes in an unbounded set (recordCaptureFailed, captureFailed) and is never retried. Nothing else is affected.
    • Capture is given up for the session (State.capture_unavailable) only after three offscreen captures in a row couldn't start (noteOffscreenStart). Their target is small and fixed, so that means the backend has no texture targets. Once given up, nothing more is captured, on screen or off, and no frame is asked for one.
    • An on-screen view that couldn't start (a too-large target, say) is only that view's failure.
    • No timers, and no retry loop.
  • Frames are asked for while a view is in flight, and while a view on screen lacks its picture, for example one that drew before the picker opened. A failed view, or one in a place with no room, asks for none: its card shows an empty tile until it has size.
  • On-screen views are still captured where they draw, as on main, unless capture has failed for them or here at all. They render that frame anyway, and in the measurements below they never showed in a frame over 9 ms.
  • Profiler scopes: the picker's own work shows in the profiler (devtools dashboard, devtools.stats) as picker, picker: capture unplaced and picker: capture. They cost one branch each while the profiler is off.

Nothing changes while the picker is closed: captureUnplaced still returns on snapshots_wanted, and drawSolid adds two checks behind it.

What was measured

fizzyedit/work#29 was filed as a pixel flash: the on-screen views redrawn offscreen and blitted back for a frame. On macOS I could not reproduce that.

  • I read the frame target back for frames N-2..N+15 around a picker open, with drawCaptured on and with it skipped. The frame where the on-screen views were captured matched the frame before to within one level out of 255 (65k pixels at ±1, 6 at ±2; the rest identical).
  • Settings: default, and with float windows, native glass, menus and dialogs all off.
  • Content: a folder, a text document, and an image with its glass Fit button.

What was there is a stall. These are profiler numbers per frame over 9 ms, with a temporary probe opening the Sidebar's picker. The machine was shared and loaded (1-minute load 10–18), Debug build, two runs each, before and after alternated.

frame before (main) after (this branch, first version)
picker opens 19–23 ms: devtools dashboard, store and settings all drawn for the first time (pie 6 ms, store 2.5–3 ms, settings 1.7 ms) plus the picker's first draw (3 ms) 15–20 ms: one of them (the dashboard: pie 6–8.5 ms) plus the picker (3.7–5 ms)
~10 frames later 20–26 ms: all three captured at once 17–29 ms: the dashboard's capture alone; store and settings (2–4 ms each) on frames after it

The "after" column is an earlier version of this branch, so it is not this branch's final number. Since then:

  • The final form draws nothing for an unplaced view on the frame the picker opens, or on its list's first frame. It then draws one view a frame instead of warming several at once, so the dashboard's first draw and its capture each still land in a frame of their own.
  • The on-screen copy from the last frame was dropped: it bought little, since on-screen captures never showed in frames over 9 ms.
  • The review rounds: one view in flight chosen by the picker, failures remembered per session.

None of these were re-measured in the app: no visible sandbox runs.

Why a capture costs so much: it is the one draw of an offscreen view that renders anything. The warm-up draws at its offscreen position are clipped away and lay out only. So a capture costs a full render of the view: 8–12 ms each for the devtools dashboard's pie and table in Debug.

Tried and dropped: photographing the on-screen views from the last frame (core.FrameTarget.snapshot, as ViewDrag.photographFromFrame does) on the frame the picker opens. On macOS it removed no visible flash, and it needed per-frame state.

SDK impact

  • None

Verified

  • macOS: before/after above, profiler read through devtools.stats over the control socket from a sandbox fizzy (--profile), picker opened by a temporary probe, never committed. New integration test, which fails with the in-view check removed. It uses only views no place shows, so the first capture is offscreen.
    • Nothing is drawn for a card on the picker's opening frame or its list's first frame.
    • No frame draws two of the views.
    • The cards in view are a prefix of the list, some but not all of it, measured from the cards' tags rather than assumed from the window size.
    • The window is checked to be the shim's 600 by 400, so the fold is fixed.
    • The first three cards in view are drawn in turn. No offscreen capture can start in the testing backend, so after three capture is given up, and nothing more is drawn.
    • Reopened with the views in view already failed, none past the fold is drawn.
    • Taking those views out of the list (hidden; the testing backend's wheel and typed input don't reach the popup) brings the next card into view, and its view is drawn.
    • Gates, on the merge with main under Zig 0.17.0: scripts/check-fmt.sh, zig build, zig build test, zig build test-integration, zig build check-web, zig build check-tests-reached.
  • Windows:
  • Linux: the pixel flash fizzyedit/work#29 describes is unconfirmed (no Linux run); this PR does not address it, so fizzyedit/work#29 stays open.
  • Web: builds (check-web); not run.

Follow-ups

  • The devtools dashboard's capture is still one 10+ ms frame (Debug) while its card is in view: its pie and table cost that much to render once. Capturing at a smaller size, or the dashboard drawing less when not on screen, would cut it.
  • The explorer's filter box costs 6.6–9 ms on one frame shortly after the picker opens, before and after this change. The Output view spikes on that frame too, at about 3 ms. My guess, untested: dvui's font cache growing for glyphs the picker's text introduced, a one-time cost.
  • The pixel flash on Linux/X11 from fizzyedit/work#29 is unconfirmed either way.
  • A view lifted out of the picker before its capture lands has no drag picture. That is the trade-off of spreading the captures: its card is still an empty tile.
  • The testing backend can't exercise a successful capture: it has no render targets. So the capture path itself, one capture after another, is covered by reasoning. The test exercises failed views and "capture unavailable".
  • The test can't scroll or type into the picker: dvui's testing backend delivered neither a wheel nor typed text to the popup in PickerFrame, so it takes views out of the list instead. Worth a look on its own.

🤖 Generated with Claude Code

@foxnne
foxnne force-pushed the layout/picker-stall branch 4 times, most recently from 1b147eb to eddf3b4 Compare October 18, 2026 20:09
Opening a place's view picker drew every view that shows nowhere for the
first time in one frame, and about ten frames later captured all of them
in another: 19-26 ms of main-thread work each (Debug, loaded machine).
Now those views are drawn offscreen one at a time. As the picker draws
its cards it chooses the next: a card in view whose view drew nowhere
and has no picture (`State.offscreen`: its registry index, checked
against its id's hash). `captureUnplaced`, before the picker draws as
before, warms that one view (ten frames and a reveal's fade) and
captures it, one draw a frame, so what it opens stacks under the picker
and its picture shows on its card that frame. A view whose card leaves
the list early is dropped. A failed capture is remembered for the
picker's session (`State.recordCaptureFailed`); three in a row that
cannot start, or more failures than are remembered, end capture for the
session. The picker's work is in the profiler: "picker", "picker:
capture unplaced" and "picker: capture".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the layout/picker-stall branch from eddf3b4 to 63418c7 Compare October 18, 2026 20:22
foxnne and others added 2 commits October 18, 2026 15:29
Capture is given up for the picker's session only after three offscreen
captures in a row cannot start (their target is small and fixed); an
on-screen view whose capture fails is only marked failed. Failed views
go in a per-session set (no cap, no global disable). Whether the chosen
view's card left the list is decided from frame stamps (`card_ns`
against `Picker.list_ns`), so a picker draw that returns early never
drops it. A view on screen without its picture asks for a frame again,
unless it failed or has no room. One predicate, `mayDrawOffscreen`,
decides for both the picker and `captureUnplaced`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts, and nothing to port: the branch builds and passes its
gates with Zig 0.17.0 as merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne marked this pull request as ready for review October 18, 2026 20:36
@foxnne
foxnne enabled auto-merge (squash) October 18, 2026 20:50
@foxnne
foxnne merged commit 9b2a745 into main Oct 18, 2026
11 checks passed
@foxnne
foxnne deleted the layout/picker-stall branch October 18, 2026 20:50
@foxnne foxnne mentioned this pull request Oct 19, 2026
2 of 8 tasks
foxnne added a commit that referenced this pull request Oct 19, 2026
## What changes

`sdk_version` goes from 0.2.30 to 0.2.31. Merging this tags
`sdk-v0.2.31` and publishes its tarball. `sdk-repin` then asks each
store plugin to repin.

**Why now:** the SDK's shape has moved several times since 0.2.30. Store
plugins and apps built on fizzy, Graphl among them, only get the new
calls once they're released. Most of them come from Graphl's report.

## SDK impact

- [ ] None
- [ ] Core-only or additive: reaches plugins at the next SDK release
- [ ] Fingerprint moved: recorded in `sdk/src/version.zig`,
`sdk_version` untouched, PR labelled `sdk`
- [x] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

**`sdk` PRs since `sdk-v0.2.30`:**
- **#560: a plugin's own data folder.** `host.pluginDataFolder(plugin)`
gives `<config>/plugin-data/<id>`, made on first ask. It's the plugin's
own from `register` through `deinit`, and stays on disk after uninstall.
- **#587: the data folder goes with the plugin.** `Host.retirePlugin`
unregisters a plugin, runs its `deinit`, then tells the app, so a plugin
can still write out state in `deinit`. On `EditorAPI`,
`pluginUnregistered` became `pluginRetired`; that vtable is implemented
by the app, not by plugins.
- **#593: put a view away.** `host.hideSurface(id)` shuts the tray whose
shown view is `id`, or closes a float that holds only `id`. Anything
else returns false. Mid-frame it acts after the frame has drawn. A
toggle is `if (!host.hideSurface(id)) _ = host.revealSurface(id);`.
- **#603: times on recent folders.** `host.recentFolderOpened(i)`
returns when recent folder `i` was opened, in Unix seconds, or null if
unknown. `recentFolderAt` is unchanged.
- **#599: a home page.** `sdk.keywords.ide.home` is new. While the
workbench's pane is empty, it shows a surface keyworded `home` in place
of its own page. The fingerprint didn't move; a plugin on an older SDK
can write `&.{"home"}`.
- **#581: undo is announced.** Undoing someone else's step is announced
as `fizzy.history.undone`, with a payload type `events.StepTaken`.
Additive; the fingerprint didn't move.
- **#609: history restore.** An optional `restoreHistory` hook and
`sdk.Plugin.RestoredHistory`. Plugins without the hook are unaffected.

**Core change plugins pick up at this release** (no boundary change):
#550, a replayed backdrop captures inside a front-to-back block. (`app/`
changes since 0.2.30, such as #570 and #602, are not compiled into
plugins.)

## Verified

- [x] macOS: `scripts/check-fmt.sh` and `zig build test-sdk-version`
pass. The fingerprint is main's recorded value; only `sdk_version`
changed.
- [ ] Windows:
- [ ] Linux: CI
- [ ] Web:

## Follow-ups

- Repin and release the store plugins (pixi, zig, ghostty, drive, atlas;
then agent and chat) when `sdk-repin` opens their PRs.
- Make 0.2.31 live on fizzyed.it/app once the repinned plugins are in
the store: `gh workflow run web.yml --ref main -f go_live=0.2.31`.

🤖 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant