Skip to content

layout: removing a half that was split again keeps its column - #467

Merged
foxnne merged 1 commit into
layout/split-reachfrom
layout/nested-split-remove
Oct 16, 2026
Merged

foxnne merged 1 commit into
layout/split-reachfrom
layout/nested-split-remove

Conversation

@foxnne

@foxnne foxnne commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

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

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

Verified

  • 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: layout: the picker's buttons are named for tapes and agents #468.

🤖 Generated with Claude Code

@foxnne
foxnne marked this pull request as ready for review October 16, 2026 20:30
A minted half split again (Main/r1 in Main | (Main/r1 over Main/r1/b1)) is
two places under one name: the half Main's split made, and the half its own
split kept. Emptied or removed, it closed its whole column, and the forest
then collapsed the inner leaf, promoting Main/r1/b1 into Main's split sized
by its height and with no link left in layout.zon.

`Forest.collapse` now takes the outermost node carrying a name, a branch for
a place split again, never the leaf that kept the name. Emptying or removing
such a place goes the way an origin goes: its other half's views move into
it and that half closes; with that half split too, it stays, and the picker
offers no Remove there. A column counts as kept while any place in it holds
a view, and closing it forgets them all.

A place forgotten while its branch was being drawn went on drawing children
`collapse` had just freed; `persistExtent` now says when it dropped the
place, and the tree draws nothing more of it that frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the layout/nested-split-remove branch from a511e38 to 083793f Compare October 16, 2026 21:15
foxnne added a commit that referenced this pull request Oct 16, 2026
## What changes

A tape or an agent can aim at the picker's controls by name, not by
position:

| Name | What |
|---|---|
| `picker.mode:one`, `picker.mode:many` | Single / Multiple |
| `picker.action:split`, `:clear`, `:defaults`, `:remove` | the actions,
each only while it can be pressed (a dimmed Clear has no name) |
| `picker.split:vertical`, `picker.split:horizontal` | the rows of the
Split menu |

These sit beside the cards' existing `picker:<view id>`, which is now in
`docs/AUTOMATION.md`'s anchor table along with the new names.

**Why.** The row's buttons come and go with the place. Remove sits where
Split sat on a place with nothing to Clear, so a point read off one
place's picker can land on a different action in the next. While driving
splits through the agent plugin for #467, a click aimed
at Split hit Remove twice and closed the place.

**How.**
- `modeButton` and `actionButton` draw through `namedButton`:
`dvui.button`'s body with a `core.anchor.mark`, since `dvui.button`
doesn't hand back its widget.
- `core.widgets.menuRow` gets an `anchor` option for a row that runs no
command (a command row is already `fizzy.command:<id>`).
- A dimmed action (Clear with nothing to clear, Defaults with no list)
publishes no name: it swallows the click, so `wait_for` / `expect` on a
name means the action can run.
- `core.anchor.mark` costs nothing unless someone asked for anchors this
frame.

## SDK impact

- [x] Core-only or additive: `MenuRowOptions.anchor` reaches plugins at
the next SDK release (`test-sdk-version` passes; the fingerprint didn't
move).

## Verified

- [x] macOS: integration test `picker: its controls are named for a tape
or an agent to aim at, while they can be pressed` (anchors wanted, the
picker drawn over a place: Single, Multiple and Split are named; a
dimmed Clear and a shape place's absent Remove are not).
- [x] macOS: sandbox fizzy (`--profile`), driven through the agent
plugin's `fizzy_run` by name only:
- opened Main's picker: `fizzy_screen` lists `picker.mode:one`,
`picker.mode:many`, `picker.action:split`, `picker.action:clear` and
`picker.action:defaults`;
- clicked `picker.action:split`, and the menu listed
`picker.split:vertical` and `picker.split:horizontal`;
  - clicked `picker.split:vertical`, and Main split into Main | Main/r1;
- opened Main/r1's picker, `expect`ed `picker.action:remove`, clicked
it, and Main was whole again.
- `scripts/check-fmt.sh`, `zig build`, `zig build test` (93/93 steps),
`zig build test-integration` (53/53 steps), `zig build
check-tests-reached`, `zig build check-web`, `zig build
test-sdk-version`, `scripts/check-examples.sh`.
- [ ] Windows:
- [ ] Linux:
- [ ] Web: builds

## Follow-ups

- `namedButton` repeats `dvui.button`'s body to reach the widget's data,
because `dvui.button` doesn't hand it back. A `dvui.button` variant in
the dvui-dev fork that exposes it would keep one body
(`docs/DEPENDENCIES.md`).

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne added this pull request to stack #471 October 16, 2026 21:33
@foxnne
foxnne merged commit 8a8eac7 into main Oct 16, 2026
11 checks passed
@foxnne
foxnne deleted the layout/nested-split-remove branch October 16, 2026 21:33
foxnne added a commit that referenced this pull request Oct 16, 2026
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

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

## Verified

- [x] 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

- #464: measure Main once the layout under it has settled (or from the
window), not on the first frame `settle` runs. Its `splitOnSized` gets
the reach fix from this PR for free.
- Removing a half that was split again: fixed in #467,
stacked on this one.

🤖 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