Skip to content

backend: tear-off across a title bar, and back in at once - #605

Merged
foxnne merged 3 commits into
mainfrom
backend/tear-title-and-back
Oct 19, 2026
Merged

foxnne merged 3 commits into
mainfrom
backend/tear-title-and-back

Conversation

@foxnne

@foxnne foxnne commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

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

  • 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, zig build test-integration all pass.
  • 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

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, and dvui
held its top below the title bar, away from the pointer: the window jumped
off the pointer as it crossed (the user). The tear-off now asks whose
content the pointer is over (`windowContentAtPointer`, new beside
`windowAtPointer`): over a title bar, none, and the window stays out until
the pointer reaches the content.
A torn-off window moved by the OS went back into the main window only once
all of it lay inside the main window, and only on a frame after the OS's move
let go, which waited for the next event; then it shrank back through a card
of its last picture, its title bar's place becoming dvui's header's, and
flashed a title on the way (the user: "takes more events", "pops a title").
Now it goes back in as the OS's move lets go with the pointer over the main
window's content: frames keep coming while the OS moves it, 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; and the window, over it, closes the
frame after (`closing`), so neither is missing for a frame. The card, its
drawing and `mainHolds` go.
Going back into the main window, a torn-off window snapped into its floating
window (the user). Its window now stays over the floating window for 150 ms
with its last picture, the pointer passing through it (`viewports.held`), and
fades out (`viewports.fade`): the OS's title bar dissolves into dvui's
header while the content, the same in both, stays still.
@foxnne
foxnne marked this pull request as ready for review October 19, 2026 00:49
@foxnne
foxnne merged commit 6b69cb1 into main Oct 19, 2026
11 checks passed
@foxnne
foxnne deleted the backend/tear-title-and-back branch October 19, 2026 01:06
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