Skip to content

backend: the viewports API is the backend package's, with a stand-in - #324

Merged
foxnne merged 2 commits into
mainfrom
backend/viewports-api
Oct 10, 2026
Merged

foxnne merged 2 commits into
mainfrom
backend/viewports-api

Conversation

@foxnne

@foxnne foxnne commented Oct 10, 2026 •

Copy link
Copy Markdown
Collaborator

Part of #281: step 1 of "Popout's generic half moves into the backend".

A dvui app on fizzy's backend can now open OS windows through @import("backend").viewports, the same API fizzy's Popout uses. Before this PR it lived in fizzy's src/backend/backend_native.zig, out of reach of any other app. That is what run-dvui in example-app will build on.

What moved

  • backend/src/viewports.zig is the viewports namespace from backend_native.zig, moved as it was. The if (comptime !supported) return … guards are gone: this backend always supports viewports, so supported is just true. Each function still calls dvui.currentWindow().backend.impl.viewport*.
  • WindowGlassLook moves into platform.window, beside the WindowGlass it converts to. Both the main window's glass and a viewport's take it.
  • SDLBackend.viewports exports the API. The backend's slot array, also named viewports, is renamed viewport_slots to free that name (a mechanical rename, 23 sites).
  • Fizzy re-exports it: pub const viewports = @import("backend").viewports;. Popout and Editor are unchanged.

A stand-in for backends without OS windows

backend/src/viewports_none.zig has every name viewports has, and each one reports that there are no OS windows: available() is false and open returns null. An app's floats, menus and dialogs then stay in the main window. It is the web's former stand-in, moved into the package, so these all share it:

  • fizzy on the web, on dvui's testing backend (integration tests) and on dvui's own SDL3 backend;
  • any dvui app built on either kind of backend.

That app imports it beside the backend (viewportsNoneModule in backend/build.zig, which needs only dvui) and picks the backend's own where it has one:

const viewports = if (@hasDecl(backend, "viewports")) backend.viewports else @import("viewports_none");

backend_native.zig does exactly that. backend_web.zig uses the stand-in, and its WindowGlassLook is the stand-in's. The stand-in's windowGlass takes any look: on dvui's SDL3 backend fizzy still dresses its main window with platform's.

With #328 (-Dnative-backend=sdl3 builds again, with a CI step): whichever lands second renames the two impl.viewports loops that #328 guards in platform/window.zig to impl.viewport_slots. I trial-merged the two locally with that rename. Both zig build and zig build -Dnative-backend=sdl3 succeed, and the SDL3 build starts and plays the soak tape (no verdict there, since verdicts read fizzy's backend's counters).

Verification

On macOS, on this branch: zig build, test (514/515, 1 skipped), test-integration (358/358), check-web, test-sdk-version, the backend's own tests, and the Windows and Linux cross-builds all pass, with and without the stand-in commit. No behaviour changes on fizzy's backend: the same functions run, reached by a different path.

🤖 Generated with Claude Code

A dvui app on fizzy's backend opens its OS windows through `@import("backend").viewports`,
the API fizzy's Popout used from `src/backend/backend_native.zig`, moved into
`backend/src/viewports.zig` as it was. Fizzy re-exports it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`viewports_none` (backend/src/viewports_none.zig) has every name `viewports` has, each saying
there are no OS windows: the web's stand-in moved into the package, for fizzy on the web, on
dvui's testing backend and on dvui's own SDL3 backend, and for any dvui app built on either kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 10, 2026
The stand-in gains Picture and sizedTarget, so it keeps every name viewports has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne foxnne changed the title backend: the viewports API is the backend package's backend: the viewports API is the backend package's, with a stand-in Oct 10, 2026
@foxnne
foxnne merged commit afe8538 into main Oct 10, 2026
10 checks passed
@foxnne
foxnne deleted the backend/viewports-api branch October 10, 2026 13:03
foxnne added a commit that referenced this pull request Oct 10, 2026
Part of #281: what example-app's `run-dvui` builds on. Stacked on #326
(on #324); review those first. The example-app side is
fizzyedit/example-app#4.

**`fizzy.addDvui(fizzy_dep, root_module, backend)`** gives an outside
app's module dvui on one of two backends:
- `.fizzy`: fizzy's own, where each floating window can be an OS window
of its own;
- `.sdl3`: dvui's own SDL3 backend, which has the main window only.

The module gets `dvui` (fizzy's pin), its `backend`, and
`viewports_none` (#324), so the same app code builds on either:

```zig
const backend = @import("backend");
const viewports = if (@hasDecl(backend, "viewports")) backend.viewports else @import("viewports_none");
```

On fizzy's backend it also gets `platform`, and on macOS the backend's
Objective-C compiled into it. `build/exe.zig`'s backend switch is now
`nativeDvui`, shared by fizzy's own executable and `addDvui`.

## Two fixes a plain dvui app needed

1. **The main window's drawable was acquired with a copy pass still
open** (`GpuRenderer.acquireSwapchain`). The frame's texture uploads had
left it open, and SDL asserted on every frame ("Cannot acquire a
swapchain texture during a pass"). Fizzy never hit this because it draws
its frame into a target first. Any dvui app draws straight into the
window. The fix ends the copy pass before acquiring, as `presentInto`
and `clearPass` already do.
2. **`platform`'s menu-bar exports weren't emitted** (`FizzyMenu*`).
`macos/menu_target.m` calls them and is compiled into every macOS app on
the backend, but Zig emits an `export fn` only when analysis reaches its
file. Fizzy reaches `platform/menu.zig` through its menu bar; a dvui app
with none failed to link. Now `platform/root.zig` references them, and
they're `pub`.

## Verification

- **Fizzy:** `zig build`, `test` (514/515, 1 skipped),
`test-integration` (358/358), `check-web`, `test-sdk-version`, the
backend's tests (46/46), and the Windows and Linux cross-builds pass on
macOS. The soak tape ends in a passing verdict (1,429 viewport presents,
no leaks, nothing logged as an error).
- **example-app's `run-dvui`, built against this branch, on macOS:**
- **On fizzy's backend:** it opens its main window and two floating
windows. Each is an OS window of its own, opened over its place in the
main window (window list: main at 540,332; floats at 652,476 and
684,500, each 280×212). No assertions or warnings, and it quits cleanly
on SIGTERM.
- **With `-Ddvui-backend=sdl3`:** the same app shows only its main
window, with the floating windows in it. No warnings.
- **Not seen:** I couldn't screenshot, since this session has no
screen-recording permission. Window positions and the logs are what I
checked, not how they look.

🤖 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 10, 2026
)

Stacked on #326 (on #324); review those first.

A demo or live tape's pointer was drawn only in the main window. Over a
float's OS window, a menu or a dialog, it vanished, so a viewer couldn't
see it reach a float's close button, a menu row or a dialog's buttons.
Reported by @foxnne.

## Why

- **The pointer's layer was the size of the main window.**
`replay.overlay` made it a subwindow covering the whole window. Popout
gives each OS window the subwindows whose middle is in its part of the
frame, so the pointer layer always counted as the main window's.
- **Even placed right, a float's window would have claimed it before a
menu or dialog over the float could.** Floats are drawn first
(`windowFrame` before `menuFrame`). Dialogs are made in `drawRetained`,
after the pointer was raised, so they stack above it.

## Changes

- **`sdk/replay/overlay.zig`:** the pointer's layer is a small rect
round its tip, so its middle is where the pointer is. Drawing is still
clipped to the screen the pointer is on (`dvui.screenFor`), not to that
rect. Any dvui app that splits its frame across windows by subwindow
middle gets the same behaviour.
- **`Popout.overlaysFrame`, new, last in `endFrame`:** each subwindow
that takes no input (`mouse_events` false: the pointer and its click
ripple, a tooltip) is drawn into the topmost window over its middle.
That's a menu's or dialog's window, else a float's. Over none of them,
it's left to the main window.
- `windowFrame` now leaves such subwindows for this pass, unless they
are drawn everywhere (a drag's layer).
  - Carried layers stay the carry window's.
- **`viewports.Picture.again`** (and its stand-in): draws more into a
picture already handed over this frame, keeping what's there. `present`
only names the target; the window copies it when the frame ends. `begin`
is now `clear` plus `again`.

## Verification

- `zig build`, `test` (514/515, 1 skipped), `test-integration`
(358/358), `check-web`, `test-sdk-version` and the Windows cross-build
pass on macOS.
- **Sandbox, macOS:**
- **Pointer in a float:** a tape floated the explorer out and moved the
pointer onto a file in it. A temporary trace in `overlaysFrame` (removed
before the commit) showed the pointer layer going into the float's
window while over it (middle at `200354,388`, the float's band). The
other input-less layer stayed with the main window.
- **Soak:** the soak tape still ends in a passing verdict, with no leaks
and nothing logged as an error.
- **Not seen:**
- The menu and dialog windows. The sandbox's menus close on the next
frame because it isn't the active app, and this session has no
screen-recording permission for screenshots.
- The drawing itself. The routing is what's verified, not what it looks
like.

🤖 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 10, 2026
#324 landed on main squashed; its files here are the same plus Picture, Picture.again and sizedTarget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 10, 2026
The two loops own_backend guards in platform/window.zig read viewport_slots, as #324 renamed them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
foxnne added a commit that referenced this pull request Oct 10, 2026
Part of #281: step 2 of "Popout's generic half moves into the backend".
Stacked on #324; review that first.

`viewports.Picture` is the one way to draw an OS window's picture. Begin
it on a target at the part of the frame the window shows, draw
subwindows' drawing into it (taken out of the frame, or copied), end it,
then `present`. Popout drew its four kinds of window (float, menu or
dialog, carry, overlay) with four near-copies of those steps. The
windowing plan's §3, "One way to fill a window", asks for one. It is
also what a plain dvui app on this backend needs to put a
`dvui.floatingWindow` into an OS window, which is what example-app's
`run-dvui` builds on next.

## Changes

- **`backend/src/viewports.zig`** adds:
- `Picture.begin(target, part)` clears the target and makes it dvui's
render target, offset to `part` of the frame.
- `Picture.subwindow(sw, take)` replays a subwindow's commands into it.
With `take`, it also empties them, so dvui's replay into the main window
draws none of it.
- `Picture.moveTo(at)` draws the same layer again from another part of
the frame (the overlay draws carried things at each window's part).
  - `Picture.end()` goes back to the frame's own target.
- `sizedTarget(slot, w, h)` keeps a target the size asked for, or makes
a new one.
- **`src/editor/Popout.zig`:** the four sites use them, with the same
order of drawing and the same take-or-copy choices. The float window
keeps its own `keepTarget`, which holds a bigger texture through a
full-screen transition.
- **`backend/src/viewports_none.zig`** (#324's stand-in): no-op
`Picture` and `sizedTarget`, so it keeps every name `viewports` has. The
web, the integration tests and dvui's SDL3 backend analyse Popout
against it.

## Verification

- `zig build`, `test`, `test-integration`, `check-web`,
`test-sdk-version`, the backend's tests, and the Windows and Linux
cross-builds pass on macOS: `test` 514/515 (1 skipped),
`test-integration` 358/358, backend 46/46.
- **Sandbox, macOS:**
- **Soak:** the soak tape (`tests/tapes/soak.zon`: floats out into OS
windows three times, full screen, dialogs) ends in a passing verdict,
with 1,426 viewport presents, OS windows back to one at the end, no
leaks, and nothing logged as an error.
- **Menu in a float:** a tape that floats the explorer out and
right-clicks a file in it passes too.
- **Not seen:** I couldn't screenshot the windows, because this session
has no screen-recording permission. So the pictures themselves weren't
looked at, only that each window got one every frame.

🤖 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 10, 2026
## What changes

`zig build -Dnative-backend=sdl3` (fizzy on dvui's own SDL3 backend)
compiles and runs again, and CI now builds it on macOS so it can't break
unnoticed again.

On `main` it stopped compiling. The backend package's `platform` module
reached into things only fizzy's own backend
(`backend/src/SDLBackend.zig`) has:

- `win.backend.impl.viewports`, in `platform/window.zig`: restyling and
reskinning each float's own window.
- `win.backend.impl.window_skin`, in `platform/window.zig`.
- `back.present_hook`, in `platform/macos_monitor.zig`.
- `fizzy_native_transaction_begin` / `fizzy_native_viewport_transact`,
in `platform/macos_monitor.zig`. These are defined in the backend's
`macos_monitor.m`, so the mode failed at link time once the first three
compiled.

All of these now sit behind one comptime flag, `platform.own_backend`,
which is true only when `@import("backend")` is the package's own
backend: that is, the one that declares `platform`. On dvui's backend
those parts do nothing. `SDLBackend` asserts the flag at comptime, so
fizzy's own build fails rather than silently losing them.

**Why one flag instead of a `@hasField` per field:** #324 renames
`viewports` to `viewport_slots`. With a per-field
`@hasField("viewports")`, that rename would turn the guard false on
fizzy's own backend, and floats would quietly stop being reskinned. With
the flag, a renamed field still breaks fizzy's build.

**The transaction calls aren't just a link fix.** `macosAppPreBeginSync`
opens a Core Animation transaction that only the present hook closes.
Without the guard, dvui's backend would have left that transaction open
through a full-screen transition.

### Full screen on dvui's backend: AppKit's own, with fizzy's chrome

Full screen was broken on this mode. The macOS monitor
(`platform/macos_monitor.zig` + `macos/window_monitor.m`) takes full
screen and zooms over from AppKit. It animates the window itself and
draws each step from the backend's begin and present hooks inside a Core
Animation transaction, which only fizzy's backend has. On dvui's backend
those steps were drawn out of time with the window.

`macos_monitor.install` now leaves that out on dvui's backend. The
window goes in and out as AppKit and SDL take it, and every query
(`in_fullscreen_space`, `zoomed`, titlebar inset) answers from AppKit's
own state.

One piece of the monitor is about fizzy's chrome, not its backend: the
`setStyleMask:` guard. SDL puts its own style on the window at
will-enter and did-exit, and `styleTitled` puts fizzy's back. Without
the guard, AppKit keeps the content size across each change. Measured
with the monitor left out entirely, the window came back from each round
trip 40 pt shorter: 800 → 760 → 720.
`fizzy_macos_window_keep_full_size_content` installs that guard alone,
and on dvui's backend it is all `install` does.

What stays on dvui's backend: the frosted or Liquid Glass window, the
transparent title-less titlebar and the titlebar hit test. They are
plain AppKit calls that work on any SDL window. What goes: floats as OS
windows, the window skin on float windows, custom GPU programs, fizzy's
own full-screen and zoom animation, live-resize sync and the health
counters.

### Option considered: drop the mode

The other way to fix this was to remove `-Dnative-backend=sdl3`
entirely: `NativeBackend`, the build option, and the
`supported`/`@hasDecl` fallbacks. Fizzy's own app depends on its
backend's viewports, glass and window skin, and nothing shipped on the
sdl3 mode. I built that version and it passed every gate.

The decision went to keeping the mode. A dvui app that uses fizzy's
backend package on the **stock** dvui backend is in the same position as
fizzy in this mode (example-app's coming `run-dvui`). Building fizzy
this way is the cheapest proof that the platform pieces still work for
such an app. It costs one guard per backend-only reach, plus one CI
step.

### CI

The macOS leg of the `test` matrix gets two steps: fetch with
`-Dnative-backend=sdl3`, then `zig build -Dnative-backend=sdl3
-Dno-emit`. It runs on macOS only because all four breakages were in
macOS-only branches, which a Linux build never analyses. Cost: dvui's
own SDL is compiled on that leg. It isn't in the leg's saved Zig cache
until the next `build.zig.zon` change re-keys it.

### Coordination

#324 landed first, carrying the stand-in `viewports` for dvui's backend
(`viewports_none`). Main is merged into this branch (`1dca6885`): the
two `own_backend`-guarded loops in `platform/window.zig` now read
`impl.viewport_slots`, and `zig build`, `-Dnative-backend=sdl3` and the
gates pass on the merge.

## SDK impact

- [x] None. The backend package and `platform` aren't part of the plugin
boundary, and `test-sdk-version` passes.

## Verified

- [x] macOS (arm64, macOS 26, notched display):
- Gates pass: `zig build`, `zig build test` (514/515, 1 skipped), `zig
build test-integration` (44/44 steps, 358/358), `zig build check-web`,
`zig build test-sdk-version`.
  - `zig build -Dnative-backend=sdl3` builds, links and runs.
- **Full screen, driven through the agent plugin** (`fizzyedit/agent`'s
`fizzy-mcp`, in an isolated `--profile`). `fizzy.toggleFullScreen` ran
four times; after each toggle, fizzy's layout (`fizzy_screen`) and
AppKit's window frames (CGWindowList) were sampled for 3.5 s. The start
frame is 300,204 1200×800 in every run:

    | build | after each exit | full screen |
    |---|---|---|
| sdl3, before this PR's full-screen change (monitor installed) | back
to 1200×800, fizzy's own animation drawn out of time | 0,39 1800×1130 |
| sdl3, monitor left out entirely | **800 → 760 → 720**, moving down 40
pt each time | 0,39 1800×1130 |
| sdl3, this PR | back to 300,204 1200×800 every time, AppKit's
animation | 0,39 1800×1130 |
| fizzy's own backend, this PR | back to 300,204 1200×800 every time,
its own animation (layout steps through 3140 → 3600 px) | 0,39 1800×1130
|

The 39 pt at the top in full screen is the area below the notch, the
same on both backends. Fizzy's layout follows each way: full width with
the titlebar strip collapsed in full screen, the strip back after.
- Screenshots after a round trip on the sdl3 build show the frosted
window with its transparent titlebar and traffic lights.
- The `own_backend` assert works: pointing the flag at a declaration
that doesn't exist fails fizzy's own build.
- [x] Windows: `zig build -Dtarget=x86_64-windows-gnu -Dno-emit`
cross-compiles. It compiles; whether it runs wasn't tested.
- [ ] Linux: CI. Only `linux_titlebar.zig` is relevant there, and it is
unchanged.
- [x] Web: `check-web`.

## Follow-ups

- Fizzy on dvui's backend on Windows and Linux isn't built by CI. The
platform's reach into the backend there is only `impl.window`, which
dvui's backend also has.
- `FIZZY_VERDICT` writes nothing on dvui's backend: verdicts read
fizzy's backend's health counters, which dvui's backend doesn't have.
The soak tape itself plays there (found by the "Floating glass windows"
session).
- A window-only capture of the sdl3 build in full screen shows the
titlebar strip with the traffic lights at the top. AppKit normally
reveals that strip on hover, and a window capture may draw it
regardless; not checked on screen.
- Zoom (double-clicking the titlebar) on dvui's backend is also AppKit's
own now, with the same guard. It hasn't been measured.

🤖 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