Skip to content

layout: a place's corner is glass, and turns into its button - #555

Merged
foxnne merged 1 commit into
mainfrom
layout/corner-dot
Oct 18, 2026
Merged

foxnne merged 1 commit into
mainfrom
layout/corner-dot

Conversation

@foxnne

@foxnne foxnne commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Part of fizzyedit/plans#27 (option A, the corner).

What changes

A report from the phone web build: a focused chip in a place's top-right corner sat under the place's corner button. The draw-order half was #540. This is the trigger half: the button came up whenever the pointer was within 64pt of the corner. On touch the last touch is the pointer, so tapping a control in that corner put the button on top of it.

  • A filled place always shows its corner: a piece of glass filling the card's top-right corner (option A in the prototypes). Its top and right edges are the card's, its outer corner is the card's own curve, and its inner corner is rounded. It carries the button's grid as a small glyph. Its frost is read once (witness), since what is under it is the card's own padding.
    • A glass shape's corner is held to half its side, so the piece is at least twice the card's radius wide. That way its outer curve is always the card's, at any corner roundness.
  • Only the corner brings the button up, and the corner turns into it.
    • The piece of glass becomes the button: one glass id, its rect and corners eased from the corner's to the button's circle by the button's fade, and back as it goes.
    • With a mouse: the pointer on the corner, then anywhere on the button while it is up. It stays down under a modal, keeping layout: corner button stays dark under a modal; Output tabs on the left #535's rule.
    • With a finger: a tap on the corner brings the button up and goes no further, so the place never sees it. A second tap opens the picker, and holding the button lifts the view as before. It goes after 3s (a dvui.timer wakes the frame), or at the next touch anywhere else.
    • The finger's target is 44pt, centred on the card's outer corner, so half of it lies in the gutter.
  • Any material, not only glass. The piece and the morph draw through core.dialogs.glassBubbles, which now takes per-corner radii (each held to half the shorter side; the frosted-pane and plain-fill fallbacks take the same corners). Where there is no glass program or no blur, it is frosted, a transparent tint or an opaque fill, in the same shape, as plans#27's material says once that lands.
  • Empty places are unchanged: the hatch and the button, always.

Prototypes: https://claude.ai/artifact/MotGphK5UyeH9sQ6NHeYZk (option A). The piece's inner corner is rounded to half its side (the most a shape's corner can be), not a full quarter-circle, so it reads as the corner filled with glass rather than a quarter disc.

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
  • SDK release: bumps sdk_version, lists the sdk PRs since the last sdk-v* tag

Verified

  • macOS 26:
    • scripts/check-fmt.sh, zig build and zig build check-web all pass.
    • zig build test: 105/105 steps.
    • zig build test-integration: 54/54 steps, 594/594 tests.
    • On screen: a sandbox fizzy (--profile), captured at rest. The corner piece shows in the explorer, the main view and the panel, its outer curve on each card's, with the glyph.
    • Not seen: the morph into the button on hover, a tap, the 3s hide, or the non-glass materials (those need plans#27's material, app: every glass draws in the window material #549). The hover and tap need input I didn't drive, since no tape here hovers a corner.
  • Windows:
  • Linux:
  • Web: check-web only. The phone flow (tap, tap, hide) is what this is for, and needs seeing on a phone.

Follow-ups

  • Once core: tabs show the hand, and a tapped item is left unlit #563 merges, the corner reads dvui.pointerKind() == .touch in place of its own _chooser_touch flag, in a small PR of its own.

  • Cost not measured: every filled place now draws its corner glass each frame it draws, with its frost read once (witness). That is one small pane draw per place.

  • The hint beside the button on a first tap ("Tap to arrange · hold to move", in the prototypes) is not drawn here.

  • Option C, the button docked in a tab strip.

  • A demo tape that hovers and taps the corner, so this is covered by a test.

🤖 Generated with Claude Code

@foxnne
foxnne force-pushed the layout/corner-dot branch from 3d6be35 to b7cf4bf Compare October 18, 2026 15:46
@foxnne foxnne changed the title layout: a place's corner always shows; only it reveals the button layout: a place's corner is glass, and turns into its button Oct 18, 2026
Base automatically changed from web/tape-tabs-app-import to main October 18, 2026 15:48
@foxnne
foxnne force-pushed the layout/corner-dot branch from b7cf4bf to 1b44ef0 Compare October 18, 2026 15:48
foxnne added a commit that referenced this pull request Oct 18, 2026
Fixes #547. Serves #555 (plans#27), whose corner button needs to tell
touch from mouse.

## What changes

- **Hand cursor:** panel tabs and rail icons (everything drawn through
`core.widgets.Tabs`) and document tabs show the hand under a mouse.
- **Splits keep their arrow:** within a split's reach of its sash, the
split's resize arrow still wins over a tab's hand.
- **No hover after a tap:** on a touchscreen, a tapped tab, rail icon,
menu row or button no longer stays lit, and its tooltip no longer comes
up once the finger lifts.
- **macOS trackpad:** a trackpad's fingers no longer reach dvui as
`touch0` presses at the finger's position on the pad. They are mouse
input only, as they already were through SDL's mouse events.

**For plugins:** `dvui.pointerKind()` (`.touch` or `.mouse`) and
`dvui.cursorSetPriority(cursor, .normal | .resize | .drag)`, both from
dvui-dev. #555 should read `dvui.pointerKind() == .touch`.

## Design: the pointer kind and cursor priority seam

Two questions every hover look and cursor needs answered once a frame:
*is this pointer a finger or a mouse*, and *does a resize handle claim
the cursor here*.

**Pointer kind: from the event source, or inferred by fizzy?**

- **A. dvui's window says it (chosen).** dvui-dev's `Window` sets
`pointer_kind` as the backend adds events: a touch button
(`touch0`..`touch9`, from `addEventPointer` or `addEventTouchMotion`) is
`.touch`; mouse motion, a mouse button or the wheel is `.mouse`. Read
with `dvui.pointerKind()`. The window is the one every plugin dylib and
wasm side module already draws into, so they all read the same answer,
with no frame key and no module global. dvui also keeps which touch
buttons are down: after a touch with none down, the frame's `.position`
event goes off every window and to no widget. A lifted finger hovers
nothing, so dvui's buttons, menu rows, tooltips and
`core.widgets.hovered` all stop showing hover after a tap without a
check of their own.
The backend decides what a touch is. SDL reports a macOS trackpad's
fingers as touches (`SDL_TOUCH_DEVICE_INDIRECT_*`, at the pad's
normalized point), and fizzy's backend passed them on as `touch0`
presses at that point mapped into the window. Nothing in fizzy or the
store plugins read them as anything else: every touch consumer
(`CanvasWidget`, `Tabs`, `ContextWidget`, `viz`, pixi, atlas) reads
touch buttons as fingers on the screen. So the backend gives only
`SDL_TOUCH_DEVICE_DIRECT` fingers a touch button, one per finger
(`touch_slots.zig`), and the trackpad stays the mouse it already is.
- **B. fizzy infers it** (`core.input`, scanning each frame's events for
touch buttons). This is what #542 tried. State in a module is per image
(each dylib and wasm side module has its own), a per-frame key needs a
frame id that is unique on every backend, and a hover look in a dvui
widget (a menu row) would still need dvui changed. Putting it on `Host`
instead would add an SDK seam for something dvui already sees.

**Cursor: priority in dvui, or ordering in fizzy?**

- **A. `dvui.cursorSetPriority(cursor, priority)` (chosen).** The
highest asked in a frame wins, and between equals the first. `cursorSet`
is `.normal`, as before. A resize handle asks at `.resize` (fizzy's
`Split`, float edges; dvui's `PanedWidget`, `GridWidget` columns and
floating window edges), and the carried view at `.drag`. A tab's `.hand`
can't take a split's arrow, whichever is drawn first, and *which widget
takes a press doesn't change*.
- **B. The split claims ahead**: it handles the pointer before the
region beside it draws. #542 tried this and dropped it, because it took
presses from scrollbars near a sash and took the cursor during other
widgets' captures. A tab checking how close it is to a split instead
would tie the tab to the layout.

**For #555 (plans#27):** the read is `dvui.pointerKind() == .touch`. It
replaces the corner's `_chooser_touch` bool. A finger that lifted leaves
no `.position` hit, so the corner's mouse-hover reveal won't fire after
a tap.

### The pieces

- **dvui-dev** (`fizzy-dev`, tag `fizzy-sdk-0.2.30-5`,
`docs/DEPENDENCIES.md` items 11 and 12):
- `e04bccc` Window: the pointer kind, and a lifted finger hovers nothing
  - `20e56f1` cursorSet: a priority, so a resize handle's cursor wins
- **Backend:** `backend/src/touch_slots.zig` is std-only and
unit-tested. Each finger on the screen keeps its own touch button from
down to up, so two fingers are two buttons on native touchscreens too.
Before, every finger was `touch0`, and `FINGER_CANCELED` was dropped.
- **Users:**
  - `Tabs.Tab` asks for `.hand` on its `.position`.
  - The workbench's document tab asks for `.hand` when hovered.
  - `Split.grab` and the float's resize edges ask at `.resize`.
  - `Layout.init`'s view-drag cursor asks at `.drag`.

The cursor priority doesn't change routing. A press over the edge of a
tab inside a split's reach still goes to the tab, drawn first, as it did
before this PR (a test pins this). That mismatch, an arrow shown where
the press goes to the tab, already existed. Changing it means changing
routing, which #542 showed has costs. It's left as a follow-up.

## SDK impact

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

The dvui pin moves from `fizzy-sdk-0.2.30-4` to `fizzy-sdk-0.2.30-5`,
because `Window` gained `pointer_kind`, `touches_down` and
`cursor_priority`. The fingerprint is now `0x35bac8377071cd23`, on top
of #538's. For plugin authors:
- `dvui.pointerKind()` and `dvui.cursorSetPriority` are new.
- `cursorSet` behaves as before.
- A plugin's own hover looks lose their hover after a tap with no change
on the plugin's side.

## Verified

- [x] macOS:
- **Gates:** `scripts/check-fmt.sh`, `zig build`, `zig build test`
(107/107 steps), `zig build test-integration` (54/54 steps, 601/601),
`zig build check-web`, `zig build test-sdk-version`, `zig build
check-tests-reached`, `scripts/check-examples.sh` (all 8 ok).
- **dvui-dev:** `zig build test -Dbackend=testing`, 268/268. New tests:
    - "a finger that lifts hovers nothing, and a mouse hovers again"
    - "cursorSetPriority: the higher asked wins, whichever asked first"
- **New fizzy tests** (`core/widgets/Tabs.zig`, dvui testing backend,
under `fizzy-sizing-tests`):
    - "a tab shows the hand"
- "beside a split, the split's arrow wins over the hand, and a press
goes where it did". With `Split` set back to plain `cursorSet`, this one
fails with "expected .arrow_w_e, found .hand".
    - "a tapped tab is left without a hover look"
  - **Backend unit tests** (`backend-touch-slots-tests`):
    - "a trackpad's fingers get no slot"
    - "each finger on the screen keeps its own slot until it lifts"
    - "an eleventh finger is not seen"
- **Not seen on screen:** the hand and the arrow in a running fizzy, and
a trackpad in the sandbox.
- [ ] Windows: builds in CI only. The direct-touch path is untested on a
touchscreen.
- [ ] Linux:
- [ ] Web: `check-web` only. The phone tap that leaves no hover look
needs seeing on a phone.

## Follow-ups

- **Ad-hoc touch tracking** in `core/widgets/CanvasWidget.zig`
(`last_input_was_touch`) and `core/viz/viz.zig` (`touch_pointer`) could
read `dvui.pointerKind()` instead.
- **Arrow shown, press to the tab:** inside a split's reach, over the
tab drawn before it, the arrow shows but a press goes to the tab. This
was already the case before this PR (see above).
- **Stale comment:** `app/store/PluginStore.zig` says "last cursorSet
call wins". It is the first, so its `.arrow` override doesn't do what
the comment says.
- **Upstream:** propose both dvui-dev changes to upstream dvui.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A filled place's card wears a piece of glass in its top-right corner, cut to the
card's curve. Only it brings the button up, the piece itself turning into the
button and back: the pointer on it, or a tap. A second tap opens the picker; the
button goes after 3s or at the next touch elsewhere. A pointer or touch merely
near the corner no longer does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the layout/corner-dot branch from 1b44ef0 to a82af81 Compare October 18, 2026 18:39
@foxnne
foxnne marked this pull request as ready for review October 18, 2026 18:39
@foxnne
foxnne merged commit 9ecf7a2 into main Oct 18, 2026
11 checks passed
@foxnne
foxnne deleted the layout/corner-dot branch October 18, 2026 19:02
foxnne added a commit that referenced this pull request Oct 18, 2026
foxnne added a commit that referenced this pull request Oct 18, 2026
Part of fizzyedit/plans#5 (`plans/ZIG_0_17_PLAN.md`, step 2): fizzy, its
backend and its bundled plugins move to Zig 0.17. Every piece was
reviewed as its own PR into `zig/0.17`, and this PR lands that branch.
`main` is already merged in (through #555), so it carries no conflicts.

## What changes

**The move to 0.17** (`minimum_zig_version = "0.17.0"` everywhere), as
reviewed in:
- #437: the switch, scaffolded with marked stand-ins
- #438: the build's custom steps become helper programs
- #448: release packaging on 0.17, with Velopack 1.2.161 (+ #452's zon
arenas)
- #488: settings and command schemas read 0.17's `@typeInfo`, with a
test that the SDK fingerprint hashes the same facts as on 0.16
- #489: per-pointer capture through `captureMouseGet`
- #494: the Windows traps in `docs/PITFALLS.md`
- #496: `blitData` without an `extern struct` `@bitCast`
- #498: `fizzy.plugin.installBeside`, for a plugin's helper program
- #505: the 0.16 inflate workaround goes; the docs say 0.17
- #561: review fixes. A settings payload with pointers is a compile
error, and build reads go through `b.root`.
- #573: the plugin install helper compiles once, in Debug
- the catch-ups with `main` (#486, #503, #558, #574, and the last merge
here), each with `scripts/zig-0.17/rewrite.sh` re-run. That script is
deleted in the last commit.

Most of the diff is mechanical: `zig fmt`'s upgrades (`@backingInt`,
`std.lang`, …) and one-answer renames. The hand migrations are the build
(configure/make split, no custom steps), the zon arenas, `@typeInfo`,
translate-c for `@cImport`, and the `@bitCast` audit. A high-effort
review of the whole `main`…`zig/0.17` diff found five things; the three
correctness ones are fixed in #561, and one efficiency one in #573.

**Also on the branch, merged there by the maintainer:** the windowing
work.
- #480–#483: plain dvui apps on macOS
- #492, #501, #504, #510: OS windows for floating windows, full screen,
pointer routing
- #546: window materials

**Forks:** dvui-dev at its `zig-0.17` head `1b29a16`, which carries
fizzy-dev's patches on upstream's 0.17 `main`, including #540, #544 and
#563's dvui commits. Also assetpack, md4zig, sdl_zig and zig-objc on
their `zig-0.17` branches, and velopack-zig `3d553b7` (tagged
`fizzy-zig0.17`).

## Measured

macOS arm64, under load from other sessions, each step alternated
between the two. 0.16 is `main` #509; 0.17 is `zig/0.17` with #561.

| | Zig 0.16 | Zig 0.17 |
|---|---|---|
| App, cold Debug build | 94.4 s | 78.6 s |
| App, warm no-op rebuild | 0.7 s | 0.4 s |
| `test-integration` (compile + run) | 108.9 s | 90.4 s |
| App, ReleaseFast build | 142.8 s | 131.5 s |
| Image plugin, cold standalone build | 11.6 s | 14.0 s (with #573) |
| Startup, Debug (median of 5) | 565 ms | 466 ms |
| Startup, ReleaseFast (median of 5) | 175 ms | 177 ms |
| Text, ReleaseFast, µs/frame: idle / mid / scroll / typing | 305 / 424
/ 435 / 403 | 240 / 306 / 286 / 290 |
| Markdown, ReleaseFast, 4 docs, median of 5, µs/frame: idle top / mid /
scroll / resize | 1177 / 1735 / 2104 / 1319 | 1270 / 1699 / 1833 / 1192
|

The one slowdown is markdown idle at the top of a document, +8%. The
rest of the plugin build's gap is dvui's own translate-c on 0.17.

## The plan's gate

- **ZLS:** no 0.17 release yet (latest 0.16.0). zigtools/zls#3270 is on
ZLS's master. Completions across `fizzy_sdk` from a plugin are not
checked.
- **Minimum OS:** 0.17 needs Darwin 15+ (OS X 10.11); fizzy's macOS
minimum is 11.0, so no change. Windows 10+ and Linux 5.10+ are not
stated by fizzy.
- **Debug leak reports:** still print (`DebugAllocator`, deprecated,
unchanged). The move to `SafeAllocator` is a follow-up with its own
Debug frame-time check.
- **Velopack:** packaging proven by a test release on macOS (signed and
notarized), Windows and Linux. An update in place was not tested.
- **Every target builds:** in CI. Windows and Linux were not run by hand
on the VMs.

## SDK impact

- [x] Fingerprint moved (`0x5f9c818a6972efd4`, recorded in
`sdk/src/version.zig`); `sdk_version` untouched. `sdk: release 0.2.30`
follows this at once. Plugins need Zig 0.17 to build against it: there's
no overlap window.
- Plugin authors see `minimum_zig_version = "0.17.0"` and
`fizzy.plugin.installBeside`. A settings payload holding pointers other
than a string is now a compile error. `docs/PLUGINS.md` says 0.17.0.

## Right after this merges

1. fizzyedit/example-app#8 and fizzyedit/example-plugin#13 merge. Then a
one-line PR points `scripts/check-examples.sh` back at `main` (it pins
the examples' `zig-0.17` branches until then, so `main`'s CI is never
red).
2. `sdk: release 0.2.30`.
3. The store plugins' 0.17 PRs repin and release: fizzyedit/pixi#51,
zig#15, atlas#16, drive#13, ghostty#14, agent#23, chat#16.

## Verified

- [x] macOS (arm64, Zig 0.17.0), on this tree:
  - `scripts/check-fmt.sh`
  - `zig build`: 71/71
  - `zig build test`: 109/109 steps, 637/638 (1 skipped)
  - `zig build check-tests-reached`: 998 tests in 122 files
  - `zig build check-web`: 42/42
  - `zig build test-sdk-version`: 35/35
  - `zig build test-integration`: 78/78 steps, 608/608
- [x] All seven store plugins built against `zig/0.17`'s `sdk/` and
loaded together in a 0.17 fizzy (Debug, sandbox profile).
- [ ] Windows, Linux, the example repos: CI.

🤖 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 18, 2026
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 added a commit that referenced this pull request Oct 18, 2026
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 #523

## 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

#523 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

- [x] None

## Verified

- [x] 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 #523 describes is unconfirmed (no Linux
run); this PR does not address it, so #523 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 #523 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](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 18, 2026
Part of fizzyedit/plans#27 (the corner). Follows #555.

## What changes

The corner #555 put on every filled place looked wrong on `main` (seen
by the user): "doesn't look like a bubble filling the top right corner,
it's got sharp edges, doesn't cover the corner area, and has a weird
single corner cut".

**The cause:** `cornerButton` runs inside the place's *content* clip,
the card inset by its padding (8 pt). The resting piece and its morph
into the button were drawn under that clip, so their top and right 8 pt
were cut off. The piece sat inset from the card's edge, and its outer
curve, the card's own, never showed. The ring beside it already drew
under the card's rect (`clipSet(rs.r)`) for the same reason. #555's
screenshot showed the cut, and it was misread as the shape.

**The corner is now what the user asked for after seeing the options:**
a small round bubble that "moves behind/under the region's rounded
corner, peeking out".
- **The bubble:** 16 pt round, its centre halfway round the card's
top-right curve, so half lies over the card and half peeks past the
curve.
- **Square cards:** with no curve, its centre is the corner point
itself.
- **Clipping:** it is drawn, and morphed into the button, under the
card's rect outset by half the bubble, not the content clip. It is kept
whole inside the window, for a card that runs to the window's edge.
- **Hover:** the pointer target is the bubble plus 8 pt round it
(`CornerPiece.hit`). The finger's target is unchanged: 44 pt, centred on
the card's outer corner.
- **Glyph:** centred in the bubble, smaller.
- **Why not a flush piece:** a shape with the card's top and right edges
can't be small. Its outer corner has to match the card's radius (24 pt),
and a glass corner is held to half its side, so it would be at least 48
pt across.

Prototype by the session working on the history transport (it measured
the clip), taken over, checked and finished here.

## SDK impact

- [x] 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`
- [ ] SDK release: bumps `sdk_version`, lists the `sdk` PRs since the
last `sdk-v*` tag

## Verified

- [x] macOS:
- **Gates:** `scripts/check-fmt.sh`, `zig build`, `zig build check-web`
all pass. `zig build test`: 107/107 steps. `zig build test-integration`:
54/54 steps, 605/605 tests.
  - **On screen** (sandbox fizzy, `--profile`, captured):
- default rounding: the bubble on the main view's and the panel's curve,
peeking out;
- `corner_roundness = 0`: the bubble whole, centred on the square corner
at the window's edge;
- hovered, by the prototype's author: the bubble turning into the
button.
- **Explorer:** the explorer has no card of its own, so its bubble sits
on the place's corner over the window's chrome. It marks the place, but
reads more detached than on a card.
  - **Not seen:** a tap on a touchscreen.
- **Unchanged by this PR, not re-checked:** the empty-place path (hatch
and button), which this doesn't touch (`resting` is false there); and
the 44 pt touch target.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: `check-web` only

## Follow-ups

- The corner reads `dvui.pointerKind()` once #563 merges (it already has
a follow-up note on #555).
- Whether the explorer, which has no card, should have a corner at all,
or one placed differently.

🤖 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