Skip to content

Commit 077b535

Browse files
nedtwiggclaude
andcommitted
Last-pane kill: shrink pane + focus ring, fix missing respawn
Two fixes to last-pane kill: 1. Auto-spawn was silently dropped. api.addPanel called re-entrantly from inside the onDidRemovePanel handler was not taking effect. Always defer the spawn through setTimeout(spawn, delay), even when delay is 0, so the addPanel runs after the remove event fully settles. 2. The focus ring did not follow the shrinking pane. The shrink was using clip-path (doesn't affect getBoundingClientRect), so the SelectionOverlay never saw a change. Switched to transform: scale() with transform-origin: 100% 100%, and gave the overlay element a matching .ring-shrinking-to-br animation via a new ref so both collapse in lockstep toward their bottom-right corners. The ring class is removed in finalize() before the next overlay render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a08d415 commit 077b535

File tree

2 files changed

+57
-15
lines changed

2 files changed

+57
-15
lines changed

lib/src/components/Pond.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -840,11 +840,12 @@ export function MarchingAntsRect({ width, height, isDoor, color, paused }: {
840840
);
841841
}
842842

843-
function SelectionOverlay({ apiRef, selectedId, selectedType, mode }: {
843+
function SelectionOverlay({ apiRef, selectedId, selectedType, mode, overlayElRef }: {
844844
apiRef: React.RefObject<DockviewApi | null>;
845845
selectedId: string | null;
846846
selectedType: 'pane' | 'door';
847847
mode: PondMode;
848+
overlayElRef?: React.RefObject<HTMLDivElement | null>;
848849
}) {
849850
const { elements: panelElements, version: panelVersion } = useContext(PanelElementsContext);
850851
const { elements: doorElements, version: doorVersion } = useContext(DoorElementsContext);
@@ -907,11 +908,11 @@ function SelectionOverlay({ apiRef, selectedId, selectedType, mode }: {
907908
if (mode === 'passthrough') {
908909
style.borderRadius = isDoor ? '0.375rem 0.375rem 0 0' : '0.5rem';
909910
style.border = `1px solid ${selectionColor}`;
910-
return <div style={style} />;
911+
return <div ref={overlayElRef} style={style} />;
911912
}
912913

913914
return (
914-
<div style={style}>
915+
<div ref={overlayElRef} style={style}>
915916
<MarchingAntsRect
916917
width={rect.width}
917918
height={rect.height}
@@ -1007,6 +1008,7 @@ function orchestrateKill(
10071008
selectPanel: (id: string) => void,
10081009
setSelectedId: (id: string | null) => void,
10091010
killInProgressRef: { current: boolean },
1011+
overlayElRef: { current: HTMLElement | null },
10101012
): void {
10111013
const panel = api.getPanel(killedId);
10121014
if (!panel) return;
@@ -1029,8 +1031,19 @@ function orchestrateKill(
10291031
}
10301032

10311033
// Fade the killed pane in place. Block input on it during the fade.
1034+
// For a last-pane kill (auto-spawn will create a replacement), also shrink
1035+
// the pane toward the bottom-right so the disappearance is visible — a plain
1036+
// fade offers no visual cue since the pane's space is reclaimed by a new one
1037+
// appearing in exactly the same rect from the opposite corner. The focus
1038+
// ring (SelectionOverlay element) gets a matching shrink animation so it
1039+
// scales with the pane rather than sitting over empty space.
1040+
const isLastPane = api.panels.length === 1;
1041+
const fadeClass = isLastPane ? 'pane-fading-and-shrinking-to-br' : 'pane-fading-out';
1042+
const fadeAnimationName = isLastPane ? 'pane-fade-and-shrink-to-br' : 'pane-fade-out';
10321043
killedGroupEl.style.pointerEvents = 'none';
1033-
killedGroupEl.classList.add('pane-fading-out');
1044+
killedGroupEl.classList.add(fadeClass);
1045+
const overlayEl = isLastPane ? overlayElRef.current : null;
1046+
if (overlayEl) overlayEl.classList.add('ring-shrinking-to-br');
10341047

10351048
let finalized = false;
10361049
const finalize = () => {
@@ -1077,10 +1090,15 @@ function orchestrateKill(
10771090
pre.el.addEventListener('transitionend', cleanup, { once: true });
10781091
setTimeout(cleanup, 1000);
10791092
}
1093+
1094+
// Peel the ring-shrink class so the next selection's overlay renders at
1095+
// full scale. The element may have been reused by React for the next
1096+
// selected pane's overlay by the time the animation finishes.
1097+
if (overlayEl) overlayEl.classList.remove('ring-shrinking-to-br');
10801098
};
10811099

10821100
killedGroupEl.addEventListener('animationend', (ev) => {
1083-
if ((ev as AnimationEvent).animationName !== 'pane-fade-out') return;
1101+
if ((ev as AnimationEvent).animationName !== fadeAnimationName) return;
10841102
finalize();
10851103
});
10861104
// Safety: if animationend never fires, still finalize.
@@ -1129,6 +1147,10 @@ export function Pond({
11291147
// the in-place fade) so the auto-spawn shouldn't re-delay another 440ms.
11301148
const killInProgressRef = useRef(false);
11311149

1150+
// Ref to the SelectionOverlay's root element. orchestrateKill uses it to
1151+
// animate the focus ring in sync with the killed pane's shrink (last-pane case).
1152+
const overlayElRef = useRef<HTMLDivElement | null>(null);
1153+
11321154
// Consumed once in handleReady to restore existing sessions
11331155
const initialPaneIdsRef = useRef(initialPaneIds);
11341156
const restoredLayoutRef = useRef(restoredLayout);
@@ -1480,8 +1502,10 @@ export function Pond({
14801502
selectPanel(id);
14811503
}
14821504
};
1483-
if (delay === 0) spawn();
1484-
else setTimeout(spawn, delay);
1505+
// Always defer via setTimeout — even when delay is 0 — so api.addPanel is
1506+
// not called re-entrantly from inside the onDidRemovePanel handler (dockview
1507+
// silently drops the spawn in that case).
1508+
setTimeout(spawn, delay);
14851509
});
14861510

14871511
onApiReady?.(e.api);
@@ -1591,7 +1615,7 @@ export function Pond({
15911615
// survivors, handling selection updates itself (since the fade is
15921616
// async and selection must wait until the actual removal fires).
15931617
setConfirmKill(null);
1594-
orchestrateKill(api, ck.id, selectPanel, setSelectedId, killInProgressRef);
1618+
orchestrateKill(api, ck.id, selectPanel, setSelectedId, killInProgressRef, overlayElRef);
15951619
return;
15961620
}
15971621
// Wrong key — shake then dismiss
@@ -2010,7 +2034,7 @@ export function Pond({
20102034
theme={mousetermTheme}
20112035
singleTabMode="fullwidth"
20122036
/>
2013-
<SelectionOverlay apiRef={apiRef} selectedId={selectedId} selectedType={selectedType} mode={mode} />
2037+
<SelectionOverlay apiRef={apiRef} selectedId={selectedId} selectedType={selectedType} mode={mode} overlayElRef={overlayElRef} />
20142038
</div>
20152039
</div>
20162040

lib/src/theme.css

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,21 +167,39 @@ body.vscode-light {
167167
.pane-spawning-from-top { animation: pane-spawn-from-top 440ms cubic-bezier(0.22, 1, 0.36, 1); }
168168
.pane-spawning-from-top-left { animation: pane-spawn-from-top-left 440ms cubic-bezier(0.22, 1, 0.36, 1); }
169169

170-
/* Pane kill — fade the ghost overlay out. Directional crushing read as noisy
171-
* relative to the grower reveal already carrying the directional cue; a
172-
* uniform fade is cleaner and works for every case (edge kill, middle kill,
173-
* last-pane kill) with one rule. */
170+
/* Pane kill — fade the real pane element out. Opacity only is enough for edge
171+
* and middle kills: the grower reveal beside the fading pane already carries
172+
* the directional cue. For a last-pane kill there is no grower, so we also
173+
* shrink the pane toward its bottom-right corner — a visible disappearance
174+
* that also pairs diagonally with the auto-spawn's top-left reveal. */
174175

175176
@keyframes pane-fade-out {
176177
from { opacity: 1; }
177178
to { opacity: 0; }
178179
}
179180

180-
.pane-fading-out { animation: pane-fade-out 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
181+
@keyframes pane-fade-and-shrink-to-br {
182+
from { opacity: 1; transform: scale(1); }
183+
to { opacity: 0; transform: scale(0); }
184+
}
185+
186+
/* Matches the pane shrink so the focus ring stays visually glued to its pane. */
187+
@keyframes ring-shrink-to-br {
188+
from { transform: scale(1); }
189+
to { transform: scale(0); }
190+
}
191+
192+
.pane-fading-out { animation: pane-fade-out 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards; }
193+
.pane-fading-and-shrinking-to-br { animation: pane-fade-and-shrink-to-br 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
194+
transform-origin: 100% 100%; }
195+
.ring-shrinking-to-br { animation: ring-shrink-to-br 440ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
196+
transform-origin: 100% 100%; }
181197

182198
@media (prefers-reduced-motion: reduce) {
183199
.pane-spawning-from-left,
184200
.pane-spawning-from-top,
185201
.pane-spawning-from-top-left,
186-
.pane-fading-out { animation: none; }
202+
.pane-fading-out,
203+
.pane-fading-and-shrinking-to-br,
204+
.ring-shrinking-to-br { animation: none; }
187205
}

0 commit comments

Comments
 (0)