Skip to content

backend: dvui's Debug window tears off too - #594

Merged
foxnne merged 1 commit into
mainfrom
backend/tear-retained
Oct 18, 2026
Merged

foxnne merged 1 commit into
mainfrom
backend/tear-retained

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the multi-window plan (fizzyedit/plans#26): the tear-off (#577, #591).

The bug (the user, on run-dvui): dvui's Debug window, opened from the demo window, never tore off; the demo window itself did. Diagnosed by the session working on #584.

Why:

  • dvui draws its Debug window, dialogs and toasts late, in Window.drawRetained.
  • The backend called that in os_windows.afterFrame only after the tear-off had looked for the floating window being dragged (tearFrame), and only while OS windows were open.
  • So when the tear-off looked, the Debug window hadn't been drawn this frame (its subwindow not used), and hadn't been noted as able to close (closable, which floatingWindowOs notes as dvui begins it). It was never the dragged window, and never tore off. Anything else dvui draws in drawRetained was the same.

The fix: with tear-off on (viewports.tears), afterFrame draws the retained layer first. drawRetained keeps to once a frame, and Window.end would have drawn it at this point anyway, so dialogs and toasts are drawn no differently.

SDK impact

  • None

Verified

  • macOS (arm64, Zig 0.17.0): scripts/check-fmt.sh, zig build, zig build test, zig build check-tests-reached, zig build check-web, zig build test-sdk-version pass. zig build test-integration passes. Its first run aborted in automation service: tape bytes in, a ticket back, the outcome by ticket ("replaying to 4860 ms did not reach what playing did"); a rerun with no change passed. That test runs on dvui's testing backend, which never reaches this code (viewports.tears is false there): flaky under load, not this change.
  • By hand on run-dvui: open the Debug window from the demo and drag it out.

🤖 Generated with Claude Code

dvui draws its Debug window, dialogs and toasts late, in drawRetained, which
the backend ran after the tear-off looked for the floating window being
dragged, and only with OS windows open. So the Debug window was not yet drawn
this frame (`used`) nor noted as closable when the tear-off looked, and never
tore off (the user; diagnosed by the session on #584). With
tear-off on, the retained layer is now drawn first; drawRetained keeps to
once a frame, and Window.end would have drawn it at this point anyway.
@foxnne
foxnne merged commit 74dbd81 into main Oct 18, 2026
11 checks passed
@foxnne
foxnne deleted the backend/tear-retained branch October 18, 2026 22:43
foxnne added a commit that referenced this pull request Oct 19, 2026
Part of the multi-window plan (fizzyedit/plans#26): the tear-off (#577,
#591, #594). Three commits, all from the user's testing on `run-dvui`.

**1. A window dragged over a title bar stays out of it.**
- A floating window torn off and dragged over another of the app's
windows goes back into it.
- Over that window's title bar it went in too. Then dvui held its top
below the title bar, away from the pointer, so the window jumped off the
pointer as it crossed.
- The tear-off now asks whose *content* the pointer is over
(`windowContentAtPointer`, new beside `windowAtPointer`). Over a title
bar the answer is none, and the window stays out until the pointer
reaches the content.

**2. A torn-off window let go over the app goes back in at once, fading
into the floating window.**
Before, a torn-off window moved by the OS:
- went back into the main window only once *all* of it lay inside the
main window;
- only on a frame after the OS's move let go, which waited for the next
event ("takes more events");
- then shrank back through a card of its last picture, its title bar's
place becoming dvui's header's, flashing a title on the way ("pops a
title").

Now:
- It goes back in when the OS's move lets go with the pointer over the
main window's content (`pointerOverMainContent`).
- Frames keep coming while the OS moves a window (`osMoving`), so the
let-go is seen at once.
- The same frame draws the floating window where the window was, dvui's
header where its title bar was. The OS window, over it, closes the frame
after (`closing`), so neither is missing for a frame.
- Rather than snapping, the OS window stays over the floating window for
150 ms with its last picture, the pointer passing through it
(`viewports.held`), and fades out (`viewports.fade`): its title bar
dissolves into dvui's header while the content stays still (a third
commit, from the user's "we need some transition").
- The card, its drawing (`Merge`, `drawCard`) and `mainHolds` are gone.

While there: `viewportInMain` gets its doc comment back. It had sat
above `viewportMainHolds` since that was added.

## SDK impact

- [x] None

## Verified

- [x] macOS (arm64, Zig 0.17.0): `scripts/check-fmt.sh`, `zig build`,
`zig build test`, `zig build check-tests-reached`, `zig build
check-web`, `zig build test-sdk-version`, `zig build test-integration`
all pass.
- [x] By hand on `run-dvui` (the user, macOS 26): drag a torn-off window
across the main window's title bar; let one go over the main window,
partly outside it; the fade back in.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
foxnne added a commit that referenced this pull request Oct 19, 2026
…584)

Part of #260 (step 5 of `plans/WORKSPACES_PLAN.md`: floats and
tear-off).

## What changes

Behind `FIZZY_WORKSPACES=1`, a document tab can float. It floats inside
a workspace of its own, so the new window is a tabbed area of documents
holding just that one.

- **Torn off over the desktop** (where floats are OS windows, macOS
today): a tab let go over no window of the app's opens a float there,
holding a new workspace with that document. Before, nothing happened: a
document could not float.
- **Dropped on its own pane's middle:** the same, the float opening over
the pane. The drop's middle shows the float icon there, as it does for a
view over its own place.
- **A workspace's only tab carries the workspace**
(`State.Documents.alone`, from #462): the workspace itself floats,
rather than a new one being made round the document and the old one
closing behind it. Already alone in a float nobody split, it doesn't
float again; the pane takes the drop.
- **Its float closes when the workspace empties** (its last tab closed
or carried out), as any float its last view leaves.
- **Closing the float gives its documents to the main window's
workspace**: the one the person was in, when that is in the main window,
else the first there. They go in after its own tabs, in order, and the
one the float showed is shown. The emptied workspace then goes. With no
workspace in the main window, it goes home as a view, as before.

How: `State.Documents` gets one more member, `fold(id) -> bool`, which
`ViewDrag.sendHome` asks before sending a closing float's view home.
`ViewDrag.floatDocument` floats the view a document carries alone
(`carriedView`), or a new one (`houseView`). A new workspace floats out
of the pane its photograph was taken in, at that pane's size.

With the flag off, `documents` is null, and nothing changes.

## SDK impact

- [x] None. `State.Documents` is the app framework's (`app/layout`), not
the plugin boundary.

## Verified

- [x] `zig build`, `zig build test`, `zig build test-integration`, `zig
build check-web`, `zig build test-sdk-version`, `zig build
check-tests-reached`, `scripts/check-fmt.sh`.
- [x] New integration test, "float: a document tab dropped on its own
pane's middle floats in a view holding it". It covers one tab of two
floating in a new view, a lone tab floating its workspace, no second
float for one already alone, and closing the float folding instead of
going home.
- [x] macOS, a sandbox profile run with `FIZZY_WORKSPACES=1`, driven
through the agent plugin (`fizzy_act`), each checked by `fizzy_screen`
and a capture:
- `tear-c.txt`, one of two tabs, dropped on its own pane's middle:
`Float 1` opens over the pane in an OS window. It holds a new workspace
(`Pane 3`) with only `tear-c.txt`, and the pane keeps `tt-b.pixi`.
- Closing `tear-c.txt` in the float: the float closes, and the main
window is unchanged.
- `tt-a.pixi`, its workspace's only tab, dropped on its own pane's
middle: that workspace (`Pane 4`) floats, and the emptied half of Main
closes into the other.
- [x] macOS, with the tear-out fixes since merged into a local build
(#591, #592, #594): a tab carried past the window's edge opens its own
window, holding a new workspace with just that document (`Float 1`,
`Pane 6`, `tear-d.txt`).
- [x] Review: `fold` copied the workspace's tab list but not the id
strings in it, then emptied the workspace, and `State.setAssignment`
frees those strings. It now copies the ids too. The gates pass on the
branch merged with current `main`.
- [ ] macOS, still to see with a real mouse: a tab torn off over the
desktop (my run was interrupted by a person's input), and a float closed
from its OS close button folding into the main workspace (the
integration test covers the layout side).
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds; the flag is never on there.

## Follow-ups

- A window from ⌘N (step 8) stays open when it empties. The float needs
to remember which kind it is then.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 19, 2026
Part of the window material (fizzyedit/plans#27): step 2. A plain dvui
app's frame draws through `FrameTarget`, so glass drawn in the frame (a
frost or blur under an in-app floating window) can read what is under
it. Stacked on #586.

## What changes

A plain dvui app on fizzy's backend (one that doesn't dress its own
windows) now draws its frame into a window-sized texture,
`glass.FrameTarget`, which is blitted onto the window at the end of the
frame. Fizzy has always done this for its own frame in `src/Entry.zig`.
The app's code doesn't change and the window looks the same.

## Why

A floating window drawn in the window's material has to read what is
under it. When the frame is already in a texture, `BlurBackdrop` copies
from a texture the GPU holds. When the frame goes straight to the
swapchain, the only way to get those pixels is to read the window back
through the CPU, which costs a GPU sync and a copy each way. The next
step builds the material on top of this one.

## How

- `backend/build.zig`: `backendModule` imports `glass` as
`glassModule(dep, dvui_mod)`. That's the same module instance per dvui
that the app's `core` takes, so no file belongs to two modules. Glass
imports dvui and `glass_look` and never the backend, so there's no new
cycle.
- `SDLBackend.zig`: `plainFrameBegin` and `plainFrameEnd` hold a
`plain_frame: glass.FrameTarget` on the backend. Both frame loops call
them: the blocking run loop and `appIterate`. Both return at once when
`platform.window.app_dresses_windows` is set. Fizzy sets it in `AppInit`
on every OS, so fizzy is never wrapped twice.
- The order in each loop is: `Window.begin` → events →
`os_windows.beforeFrame` → **`plainFrameBegin`** → `app.frameFn` →
`os_windows.afterFrame` → `dressPlainWindows` →
(`live_resize_trace.overlay`) → **`plainFrameEnd`** → `Window.end`. This
mirrors Entry, which does `Popout.beginFrame` → `FrameTarget.begin` →
tick → `Popout.endFrame` → `FrameTarget.end`.
- `afterFrame` runs while the target is bound. Its `drawRetained` only
queues the dialogs and toasts. Every picture it draws (the OS windows'
parts of the frame, the tear-off cards, the live picture) goes through
`viewports.Picture`, which binds its own target and gives the frame's
target back at `end`. Subwindows taken into an OS window are taken with
`.take = true`, so `FrameTarget.end` never draws them a second time.
- `FrameTarget.end` runs `Window.endRendering` (the deferred subwindows
still in the main window: floating windows, dialogs, toasts, dvui's
debug window) into the target, then blits. `Window.end` sees that
rendering is done and skips it.
- The live-resize trace overlay queues into the main subwindow, so it
goes before `plainFrameEnd`.
- `FrameTarget.init` runs once, on the first frame. It turns off
`clear_window_on_begin`, which on the GPU renderer only decides whether
an empty frame presents a cleared window.
- On macOS a plain app's frame is laid below the title band
(`places_main_picture`). `FrameTarget.end` already places the blit at
`pictureInWindow().at`, so on a frame where the target bound,
`plainFrameBegin` zeroes `gpu.window_offset`. Otherwise the blit would
be offset twice. On a frame that draws straight to the window (no render
targets, or the target couldn't be made), the backend still applies the
offset.
- I added a rule to `backend/AGENTS.md` (never both wraps; anything
drawn into the main frame goes before `plainFrameEnd`).

## Measured cost

Setup: dvui's demo (`fizzyedit/example-app`, `zig build dvui`, Debug,
macOS, 120 Hz). For the "animating" case I forced a redraw every frame.
I alternated runs with the wrap on and off through a temporary
environment switch and a per-frame `CLOCK_THREAD_CPUTIME_ID` timer,
neither committed. Other sessions had the machine heavily loaded (load
average 17–46).

| | frames/s | main-thread CPU / frame |
|---|---|---|
| wrap on (5 runs) | 94 avg (113–120 in quiet stretches) | 2.22 ms avg |
| wrap off (5 runs) | 99 avg (110–120 in quiet stretches) | 1.95 ms avg
|

The spread inside a single run (1.3–2.8 ms) is larger than the gap
between on and off. The fps dips in both columns came with *lower* CPU
per frame, so they were waits on the GPU or display under contention,
not frame work. `sample` (5 s, wrap on, about 600 frames) attributes
about 3 samples to `FrameTarget.begin` and about 3 to the blit's own
drawing. The window bind in `FrameTarget.end` (61 samples) is mostly the
flush that encodes the frame's own pass, work that otherwise happens at
present. Most of the main thread's time was the `nextDrawable` wait,
which is headroom. Idle, no frames run either way. The new GPU cost is
one full-window textured quad plus the target's clear pass per frame.
Fps held at 120 both ways when the machine was quiet.

## SDK impact

None. `zig build test-sdk-version` passes and the fingerprint didn't
move: the plugin boundary isn't touched, and the backend isn't part of
the SDK.

## Verified

- [x] `scripts/check-fmt.sh`
- [x] `zig build`
- [x] `zig build test`
- [x] `zig build check-tests-reached`
- [x] `zig build check-web`
- [x] `zig build test-sdk-version`
- [x] `zig build test-integration` (rc 0)
- [x] `fizzyedit/example-app`'s dvui demo, built against this tree on
macOS: a window capture shows the frame drawn correctly through the
target, with dvui's in-app floating demo window (a deferred subwindow)
and the frame below the title band.
- [x] By hand on `run-dvui` (the user, macOS 26), with the wrap on:
OS-window floats, tear-off and back in (with #591, #594, #605), and
in-app windows in each material (#589).
- [x] Gates on #589's head, which holds this: `scripts/check-fmt.sh`,
`zig build`, `test`, `check-tests-reached`, `check-web`,
`test-sdk-version`, `test-integration` all pass.
- [ ] Not exercised: a full-screen transition, Linux and Windows.

🤖 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