Skip to content

Commit a6e36b1

Browse files
nedtwiggclaude
andcommitted
Preserve door focus across auto-spawn; document animations
api.addPanel auto-activates the new panel, firing onDidActivePanelChange, which was calling setSelectedId(panel.id) unconditionally. When the current selection was a door (just-detached last pane), that flipped selectedId to the new pane's id while selectedType stayed 'door' — desyncing the door highlight and stranding SelectionOverlay on a stale door rect. Early-return from the listener when selectedType === 'door'. Also fleshes out docs/specs/layout.md with a new Animations section (spawn / kill crush / FLIP reclaim / auto-spawn delay) and adds a corner case entry for the door-focus preservation above. Updates corner case #10 to reference the 440ms auto-spawn delay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0e25f6d commit a6e36b1

File tree

2 files changed

+45
-1
lines changed

2 files changed

+45
-1
lines changed

docs/specs/layout.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,43 @@ Colors use a two-layer CSS variable strategy: `@theme --color-*` tokens → `var
283283

284284
Dockview's separator borders, sash handles, and groupview borders are all set to transparent/none — the 6px gap is the only visual separator between panes. All dockview container backgrounds are flattened to `var(--color-surface)`.
285285

286+
## Animations
287+
288+
All pane-related motion is 440ms with `cubic-bezier(0.22, 1, 0.36, 1)` and uses `clip-path` (not `transform`) so `getBoundingClientRect` remains accurate during animation — the selection overlay measures the real post-animation bounds without lag. Reduced-motion users skip every animation described below.
289+
290+
### Spawn (new pane reveal)
291+
292+
When a pane is added, its dockview group element gets a directional `.pane-spawning-from-{left,top,top-left}` class. The clip-path starts fully closed from the opposite edge(s) and reveals to `inset(0)`. Direction is chosen by how the pane was born:
293+
294+
- **Horizontal split** (new pane on the right) → reveal from the left edge.
295+
- **Vertical split** (new pane below) → reveal from the top edge.
296+
- **Auto-spawn after last-pane kill/detach** → reveal from the top-left corner.
297+
298+
The direction is carried via `FreshlySpawnedContext` — a `Map<paneId, SpawnDirection>` written by the spawn call site and consumed once by `TerminalPanel`'s `useLayoutEffect` on first mount.
299+
300+
### Kill (ghost crush + FLIP reclaim)
301+
302+
`orchestrateKill(api, killedId)` in `Pond.tsx` runs on kill confirmation:
303+
304+
1. Snapshot `getBoundingClientRect` for every other panel's group element.
305+
2. `destroyTerminal` + `api.removePanel`; dockview snaps the layout.
306+
3. Measure post-rects. Any panel whose rect grew is a "grower."
307+
4. Pick a crush direction by comparing growers' post-rect centers against the killed rect's center:
308+
- Growers to one side only (horizontal axis) → `.pane-crushing-to-{left,right}`.
309+
- Growers to one side only (vertical axis) → `.pane-crushing-{up,down}`.
310+
- Growers on both sides of the killed pane's center (horizontal or vertical) → `.pane-crushing-to-{hcenter,vcenter}` (middle-kill crush).
311+
- No growers (last-pane kill) → `.pane-crushing-to-br` (crushes toward bottom-right corner, opposite of where the auto-spawned replacement reveals from).
312+
5. Mount a solid `var(--color-surface)` ghost overlay at the killed rect (`position: fixed`, `z-index: 55`) with that class; it removes itself on `animationend` with a 1s timeout safety net.
313+
6. For each grower, apply an inline `clip-path: inset(...)` with the newly-claimed territory clipped off, force a reflow, then transition to `inset(0)`. This reveals the grower into the vacated space without affecting `getBoundingClientRect`. Clears on `transitionend`.
314+
315+
Case detection is purely rect-based (measure before and after removal), so 2-pane splits, linear 3+ rows/columns, and nested splits all fall through the same code path.
316+
317+
### Auto-spawn delay
318+
319+
When `onDidRemovePanel` triggers the "always keep one pane visible" auto-spawn (see corner case #10), the `api.addPanel` call is deferred by 440ms. This lets the outgoing animation (kill ghost crush, or detach's selection-overlay slide to the door) complete before the replacement's reveal starts — they play sequentially in the same screen region instead of fighting each other. The deferred spawn re-checks `totalPanels` at fire time and becomes a no-op if anything repopulated the pane area during the delay (e.g. a door reattach).
320+
321+
The deferred spawn also only calls `selectPanel` if selection is null. The kill handler clears selection to null, so the new pane takes focus. The detach flow sets selection to the just-created door; preserving that door focus across the delay is the point.
322+
286323
## Corner cases
287324

288325
1. **Dual React instance**: dockview bundles its own React. Fixed with `resolve.dedupe: ['react', 'react-dom']` in Vite config.
@@ -294,7 +331,8 @@ Dockview's separator borders, sash handles, and groupview borders are all set to
294331
7. **Asymmetric back-navigation**: breadcrumb tracks last direction + origin for opposite-direction return.
295332
8. **Center drop merges panels**: intercepted at group-level `model.onWillDrop` and converted to a swap.
296333
9. **Group drag has null panelId**: falls back to `api.getGroup(groupId).activePanel.id`.
297-
10. **Auto-spawn on empty**: `onDidRemovePanel` creates a new session whenever the last visible pane is removed, whether or not doors exist — there is always a pane visible. The new pane receives the just-removed pane's id as `paneToCopy` for future cwd/terminal-kind inheritance.
334+
10. **Auto-spawn on empty**: `onDidRemovePanel` creates a new session whenever the last visible pane is removed, whether or not doors exist — there is always a pane visible. The new pane receives the just-removed pane's id as `paneToCopy` for future cwd/terminal-kind inheritance. The `addPanel` call is delayed 440ms (see "Auto-spawn delay" under Animations) so the outgoing kill/detach animation finishes first.
335+
11. **Door focus survives auto-spawn**: `api.addPanel` auto-activates the new panel, firing `onDidActivePanelChange`. When the current selection is a door (e.g., just-detached last pane), that listener must not flip `selectedId` to the new pane — otherwise `selectedType === 'door'` + `selectedId === newPaneId` desyncs and the door loses its highlight while the SelectionOverlay is stuck on the stale door rect. The listener early-returns when `selectedType === 'door'`.
298336

299337
## Files
300338

lib/src/components/Pond.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,12 @@ export function Pond({
14621462
// Sync our selection when dockview activates a panel (e.g. after DnD rearrangement)
14631463
e.api.onDidActivePanelChange((panel) => {
14641464
if (panel) {
1465+
// Dockview auto-activates a panel on addPanel. Don't let that steal
1466+
// selection away from a currently-selected door (happens when the last
1467+
// pane is detached: selectDoor runs, then the delayed auto-spawn's
1468+
// addPanel would otherwise flip selectedId to the new pane's id while
1469+
// selectedType is still 'door', desyncing the door's highlight).
1470+
if (selectedTypeRef.current === 'door') return;
14651471
if (modeRef.current === 'passthrough' && selectedIdRef.current !== panel.id) {
14661472
enterTerminalModeRef.current(panel.id);
14671473
return;

0 commit comments

Comments
 (0)