Skip to content

layout: a split of a carded place opens even halves - #465

Merged
foxnne merged 1 commit into
mainfrom
layout/split-reach
Oct 16, 2026
Merged

foxnne merged 1 commit into
mainfrom
layout/split-reach

Conversation

@foxnne

@foxnne foxnne commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Found while chasing the uneven nested split reported in #464's follow-ups.

What changes

A split of a carded place now opens two even halves. Before, the new half was short by the card's padding on both sides. That covers fizzy's Main and Panel, and every split of their halves. At 2× on macOS:

before after
Main → Split → Vertical Main 886 px, Main/r1 854 px 870, 870
then Main/r1 → Split → Horizontal Main/r1 494 px, Main/r1/b1 462 px 478, 478

Why. A split's extent is the new half's whole reach, border to border, padding included (Region.init). splitOn halved the place's recorded size, which was its content rect. State.setPlaceMetrics now records the border box, and it takes the box's WidgetData, so all three call sites measure it the same way. A split is the only thing that reads size (placeSize, and a drag's frozen Target.size).

The suspected cause was not it. #464 suspected the .branch arm: it sizes the branch by extent(b.origin), the same name the kept leaf has. But the kept origin is always the leftover half of its own branch (resize = false), so it never writes an extent under that name. Inside one tree, extent(name) always means the minted leaf's reach along its parent's axis, before and after that leaf is split again. Persistence round-trips too: the nested split came back at the same sizes after a restart.

The 1:3 in #464 comes from its own carry-over. I reproduced it on #464's head (f927a7f) with its recipe: a panes.zon of Pane 0 | (Pane 4 over Pane 5), relaunched with FIZZY_WORKSPACES=1. Main/r1 came out 248 px over Main/r1/b1 708 px. The stored extent for Main/r1/b1 was 354 pt, which is exactly (718 − 10) × 0.5. 718 pt is Main's content height without the 236 pt Panel under it. So settle measures Main on a frame before the Panel has its room, and hands that height to splitOnSized. That fix belongs in #464.

SDK impact

  • None (test-sdk-version passes; app/ only)

Verified

  • macOS:
    • new integration test a split of a carded place, and of its new half, opens even halves. It uses fizzy's shape: a carded Main with pane_cards, in a column, and compares the halves' drawn rects, not the size this PR redefines. Before the fix it failed at 187 vs 171.
    • sandbox fizzy (--profile, driven through the agent plugin): Main → Split → Vertical, then Main/r1 → Split → Horizontal, measured as in the table.
    • scripts/check-fmt.sh, zig build, zig build test (91/91 steps), zig build test-integration (53/53 steps), zig build check-tests-reached, zig build check-web, zig build test-sdk-version.
  • Windows:
  • Linux:
  • Web: builds

Follow-ups

🤖 Generated with Claude Code

@foxnne
foxnne marked this pull request as ready for review October 16, 2026 20:15
A place's recorded size was its content rect, but a split's extent is the
new half's whole reach, padding included. Every split of a carded place
(fizzy's Main and Panel, a float's places) gave the new half the card's
padding twice over: 886 | 854 px for Main, 494 / 462 px for its new half.
`State.setPlaceMetrics` now records the border box, which is all a split
reads it for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the layout/split-reach branch from 1af9263 to f39f221 Compare October 16, 2026 21:15
@foxnne
foxnne added this pull request to stack #471 October 16, 2026 21:33
@foxnne
foxnne merged commit ebada7e into main Oct 16, 2026
11 checks passed
@foxnne
foxnne deleted the layout/split-reach branch October 16, 2026 21:33
foxnne added a commit that referenced this pull request Oct 16, 2026
Stacked on #465 (it reuses that PR's `ColumnCardFrame`
test frame); review after it, or retarget to `main` once it merges. This
is the follow-up #465 named.

## What changes

Take Main | (Main/r1 over Main/r1/b1). Emptying **Main/r1** (carrying
its last view to the trash or elsewhere), or picking **Remove** on it,
now closes just that half. b1's views move into Main/r1, and the column
keeps its name, its width and its link in `layout.zon`. That's the same
rule as emptying the half that was split anywhere else (SPLITS.md,
"Removing a split"). If b1 is split again too, there's no single place
to merge into, and Main/r1 stays, empty.

**Before:** Main/r1 is two places under one name: the half Main's split
minted, and the half its own split kept. `closeEmptied` saw "minted" and
slid shut the branch box registered under that name, which is the whole
column. Then `Forest.collapse("Main/r1")` matched the inner *leaf*, so
b1 was promoted into Main's split. b1 took the column at an extent that
was its height (195 pt in the test). The only link left in `layout.zon`
still named the gone `Main/r1`, and b1 had no link, so it was lost on
reload.

**The model change:**
- **`SplitTree.Forest.collapse`** takes the outermost node that carries
the name: for a place split again, that's the branch its split made,
never the leaf that kept the name. New `isSplit` and `placeLeaves` read
the same rule.
- **`ViewDrag.closeEmptied`** treats a minted half that was split again
as an origin: it merges in its other half, or stays. The **Picker's
Remove** goes through it for such a place.
- **`Region.persistExtent`** counts a column as kept while *any* place
in it holds a view. When the column closes, it forgets every place in
it, not only the one whose name the column carries. Before, closing a
column with an empty Main/r1 and a full b1 would have left b1's views
assigned to a place that no longer existed. It walks the forest only
once the place has shut, not every frame.
- **The picker** offers no Remove on a half that was split again when
its other half is split too: there's nothing to merge, and Remove would
only have cleared it.
- **A use-after-free while drawing.** When a place was forgotten in the
middle of drawing its branch, the tree went on drawing children that
`collapse` had just freed. The old code had the same hazard on a rarer
path. `persistExtent` now returns whether it dropped the place, and
`initTree` and the `.branch` arm draw nothing more of it that frame (a
refresh is already queued). The new column-close test caught this: the
freed b1 leaf re-stored its extent after the column had cleared it.

A leaf that was never split again behaves exactly as before. Every
changed path checks `isSplit` first, and `placeLeaves` of a plain leaf
is that leaf alone.

## SDK impact

- [x] None (`app/` only; `test-sdk-version` passes)

## Verified

- [x] macOS:
  - integration tests (new) on fizzy's forest with a carded Main:
- `split: emptying the half that was split again closes only that half`.
It failed before the fix: Main/r1 was gone and b1 sat in Main's split.
- `split: emptying the half that was split again stays when its other
half is split too`.
- `split: a column shut by its sash goes only once every place in it is
empty`: shut with a view in b1, it stays; emptied, the whole column goes
and leaves no assignment or extent behind.
- unit tests in `SplitTree.zig`: collapsing a place split again drops
its whole branch, and collapsing the minted half of a nested split
leaves its origin, with one link.
  - sandbox fizzy (`--profile`, through the agent plugin, macOS 2×):
    - Main → Split → Vertical gives 870 \| 870 px.
- Main/r1 → Split → Horizontal gives 478 / 478 px, with the column's
extent unchanged at 435 pt.
- Output into b1, then Main/r1 → Remove gives Main 870 \| Main/r1 870
px. Main/r1 holds Output, and `layout.zon` has one link (`Main/r1` from
`Main`, right) and no b1.
    - Remove on b1 itself still closes just b1, as before.
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (91/91 steps),
`zig build test-integration` (53/53 steps), `zig build
check-tests-reached`, `zig build check-web`, `zig build
test-sdk-version`.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds

## Follow-ups

- **The deeper fix is a name of its own for a branch.** A branch and the
half that kept its name share one name, and this PR special-cases that
(`isSplit`) where it bites: `closeEmptied`, the picker's Remove and
`persistExtent`. Anything else that resolves a place by name
(`regionNamed`, which finds the branch box first; `placeSize`) still
sees two places. Naming branches separately would need a `layout.zon`
migration for saved links, so it's its own change.

- Picker buttons named for drivers: #468.

🤖 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