Skip to content

layout: the picker's buttons are named for tapes and agents - #468

Merged
foxnne merged 2 commits into
mainfrom
layout/picker-anchors
Oct 16, 2026
Merged

foxnne merged 2 commits into
mainfrom
layout/picker-anchors

Conversation

@foxnne

@foxnne foxnne commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

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

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

Verified

  • 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).
  • 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, expected 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

The picker's Single, Multiple, Split, Clear, Defaults and Remove, and the
Split menu's Vertical and Horizontal, now publish `picker.*` anchors. The
row's buttons come and go with the place (Remove sits where Split sat on a
place with nothing to Clear), so a click aimed by position removed places
while a split was being driven; a name cannot land on the wrong action.
`core.widgets.menuRow` takes an `anchor` for a row that runs no command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne force-pushed the layout/picker-anchors branch from 6599555 to d8dc931 Compare October 16, 2026 21:15
@foxnne
foxnne marked this pull request as ready for review October 16, 2026 21:15
Keeps #466's icons on the Split menu's rows beside their anchors, and the
picker test uses #466's `PickerFrame` instead of a second one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@foxnne
foxnne merged commit 68f6f97 into main Oct 16, 2026
11 checks passed
@foxnne
foxnne deleted the layout/picker-anchors 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>
@foxnne foxnne mentioned this pull request Oct 17, 2026
2 of 8 tasks
foxnne added a commit that referenced this pull request Oct 17, 2026
## What changes

`sdk_version` goes from 0.2.28 to 0.2.29. Merging this tags
`sdk-v0.2.29` and publishes its tarball.

**Why now:** authors (#474, #475) need a release before plugins can use
them:
- the agent acts as its connection around each command, and plays its
pointer tapes with `playAs`;
- chat acts around its writes;
- pixi's calls that wait out the person's stroke keep their author
(`plans/SHARED_HISTORY_PLAN.md` step 2's plugin PRs).

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

**`sdk` PRs since `sdk-v0.2.28`:**
- **#474:** who made each step.
- `sdk.Author`; `Host.act` / `endAct` / `acted` / `actAgain` / `author`
/ `setInputAuthor`.
  - The host keeps a per-document author map filled in `recordStep`.
- `stepAuthor`, `topStep`, `mayMerge`, `forgetDocumentAuthors`, and
`Step.top`.
- The fingerprint moved. Everything is additive (a defaulted field, new
functions).

**Reaching plugins, no fingerprint move:**
- **#475:** the automation service's `playAs(bytes, author)`
(`service_version` 5 → 6). Activity titles name an acting agent ("Claude
· Draw").
- **#468:** `core`: `MenuRowOptions.anchor`, a stable name for a menu
row's button.

## Verified

- [x] macOS:
- On this head, `zig build test-sdk-version` passes, and
`scripts/pack-sdk.sh` packs `fizzy-sdk-v0.2.29.tar.gz`.
- fizzyedit/pixi, fizzyedit/agent and fizzyedit/atlas `main` build
against this `sdk/` (`--fork`) with no changes.
- [ ] Windows:
- [ ] Linux: CI.
- [ ] Web:

## Follow-ups

- The repin automation opens `sdk: repin to fizzy SDK 0.2.29` PRs in the
store plugins whose fingerprint moved.
- The agent has no repin automation: its repin is a hand-made PR,
carrying the agent's `act` around `agentCall` and `playAs` for its
pointer tapes.
- Chat's repin carries its `act` around ACP writes.

🤖 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