Skip to content

Commit 395c59f

Browse files
committed
splitPane: implement collapse state and onCollapse routing
- Apply collapsed indices in layout (collapse to minSizes) - Add double-click divider routing to invoke onCollapse - Fix divider hit testing/rendering to use panel boundaries
1 parent 05b486d commit 395c59f

6 files changed

Lines changed: 331 additions & 10 deletions

File tree

docs/widgets/split-pane.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ Dividers between panels can be dragged with the mouse to resize:
4444

4545
The hit area for dividers extends 1 cell on each side of the divider for easier grabbing.
4646

47+
### Collapse
48+
49+
When `collapsible: true`:
50+
51+
- A panel index in `collapsed` is laid out at its minimum size (`minSizes[index]` or `0`).
52+
- **Double-click** near a divider to toggle collapse:
53+
- Click just to the **left/top** of a divider to target the panel on that side
54+
- Click just to the **right/bottom** of a divider to target the other panel
55+
- Use `onCollapse(index, collapsed)` to update your `collapsed` list (controlled state).
56+
4757
## Notes
4858

4959
- `sizes` length should match the number of child panels.

packages/core/src/app/__tests__/widgetRenderer.integration.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ function keyEvent(key: number, mods = 0): ZrevEvent {
1818
return { kind: "key", timeMs: 0, key, mods, action: "down" };
1919
}
2020

21+
function mouseEvent(
22+
x: number,
23+
y: number,
24+
mouseKind: 3 | 4,
25+
timeMs: number,
26+
buttons: number,
27+
): ZrevEvent {
28+
return {
29+
kind: "mouse",
30+
timeMs,
31+
x,
32+
y,
33+
mouseKind,
34+
mods: 0,
35+
buttons,
36+
wheelX: 0,
37+
wheelY: 0,
38+
};
39+
}
40+
2141
function createNoopBackend(): RuntimeBackend {
2242
return {
2343
start: async () => {},
@@ -208,6 +228,55 @@ describe("WidgetRenderer integration battery", () => {
208228
assert.equal(renderer.getFocusedId(), "z3");
209229
});
210230

231+
test("splitPane double-click toggles collapse via onCollapse", () => {
232+
const backend = createNoopBackend();
233+
const renderer = new WidgetRenderer<void>({
234+
backend,
235+
requestRender: () => {},
236+
});
237+
238+
const calls: string[] = [];
239+
240+
const vnode = ui.splitPane(
241+
{
242+
id: "sp",
243+
direction: "horizontal",
244+
sizes: [50, 50],
245+
onResize: () => {},
246+
collapsible: true,
247+
collapsed: [],
248+
onCollapse: (index, collapsed) => {
249+
calls.push(`${String(index)}:${collapsed ? "1" : "0"}`);
250+
},
251+
},
252+
[ui.text("A"), ui.text("B")],
253+
);
254+
255+
const res = renderer.submitFrame(
256+
() => vnode,
257+
undefined,
258+
{ cols: 40, rows: 10 },
259+
defaultTheme,
260+
noRenderHooks(),
261+
);
262+
assert.ok(res.ok);
263+
264+
// divider is at x=20; hit area includes x=19 (left) and x=21 (right)
265+
// Left-side double click → panel 0
266+
renderer.routeEngineEvent(mouseEvent(19, 0, 3, 1, 1));
267+
renderer.routeEngineEvent(mouseEvent(19, 0, 4, 2, 0));
268+
renderer.routeEngineEvent(mouseEvent(19, 0, 3, 100, 1));
269+
renderer.routeEngineEvent(mouseEvent(19, 0, 4, 101, 0));
270+
271+
// Right-side double click → panel 1
272+
renderer.routeEngineEvent(mouseEvent(21, 0, 3, 200, 1));
273+
renderer.routeEngineEvent(mouseEvent(21, 0, 4, 201, 0));
274+
renderer.routeEngineEvent(mouseEvent(21, 0, 3, 250, 1));
275+
renderer.routeEngineEvent(mouseEvent(21, 0, 4, 251, 0));
276+
277+
assert.deepEqual(calls, ["0:1", "1:1"]);
278+
});
279+
211280
test("virtualList routing updates selection and activates on Enter", () => {
212281
const backend = createNoopBackend();
213282
const renderer = new WidgetRenderer<void>({

packages/core/src/app/widgetRenderer.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,12 @@ export class WidgetRenderer<S> {
349349
availableCells: number;
350350
}> | null = null;
351351

352+
private splitPaneLastDividerDown: Readonly<{
353+
id: string;
354+
dividerIndex: number;
355+
timeMs: number;
356+
}> | null = null;
357+
352358
/* --- Overlay Routing State (rebuilt each commit) --- */
353359
private readonly layerRegistry = createLayerRegistry();
354360
private layerStack: readonly string[] = Object.freeze([]);
@@ -607,12 +613,48 @@ export class WidgetRenderer<S> {
607613

608614
for (let i = 0; i < childRects.length - 1; i++) {
609615
const a = childRects[i];
610-
if (!a) continue;
616+
const b = childRects[i + 1];
617+
if (!a || !b) continue;
611618
if (direction === "horizontal") {
612-
const x0 = a.x + a.w;
619+
// Divider starts immediately before the next panel's x.
620+
const x0 = b.x - dividerSize;
613621
const hitX0 = x0 - expand;
614622
const hitX1 = x0 + dividerSize + expand;
615623
if (event.x >= hitX0 && event.x < hitX1) {
624+
// Only allow primary-button drags/double-click collapse.
625+
if ((event.buttons & 1) === 0) continue;
626+
627+
const prevDown = this.splitPaneLastDividerDown;
628+
const DOUBLE_CLICK_MS = 500;
629+
if (pane.collapsible === true && pane.onCollapse) {
630+
if (
631+
prevDown &&
632+
prevDown.id === id &&
633+
prevDown.dividerIndex === i &&
634+
event.timeMs - prevDown.timeMs <= DOUBLE_CLICK_MS
635+
) {
636+
this.splitPaneLastDividerDown = null;
637+
638+
// Select which panel to toggle based on which side of the divider was clicked.
639+
const targetIndex = event.x < x0 ? i : event.x >= x0 + dividerSize ? i + 1 : i;
640+
const isCollapsed = pane.collapsed?.includes(targetIndex) ?? false;
641+
try {
642+
pane.onCollapse(targetIndex, !isCollapsed);
643+
} catch {
644+
// Swallow collapse callback errors to preserve routing determinism.
645+
}
646+
return ROUTE_RENDER;
647+
}
648+
649+
this.splitPaneLastDividerDown = Object.freeze({
650+
id,
651+
dividerIndex: i,
652+
timeMs: event.timeMs,
653+
});
654+
} else {
655+
this.splitPaneLastDividerDown = null;
656+
}
657+
616658
const availableCells = rect.w;
617659
const startCellSizes = computePanelCellSizes(
618660
childRects.length,
@@ -640,10 +682,44 @@ export class WidgetRenderer<S> {
640682
return ROUTE_RENDER;
641683
}
642684
} else {
643-
const y0 = a.y + a.h;
685+
// Divider starts immediately before the next panel's y.
686+
const y0 = b.y - dividerSize;
644687
const hitY0 = y0 - expand;
645688
const hitY1 = y0 + dividerSize + expand;
646689
if (event.y >= hitY0 && event.y < hitY1) {
690+
// Only allow primary-button drags/double-click collapse.
691+
if ((event.buttons & 1) === 0) continue;
692+
693+
const prevDown = this.splitPaneLastDividerDown;
694+
const DOUBLE_CLICK_MS = 500;
695+
if (pane.collapsible === true && pane.onCollapse) {
696+
if (
697+
prevDown &&
698+
prevDown.id === id &&
699+
prevDown.dividerIndex === i &&
700+
event.timeMs - prevDown.timeMs <= DOUBLE_CLICK_MS
701+
) {
702+
this.splitPaneLastDividerDown = null;
703+
704+
const targetIndex = event.y < y0 ? i : event.y >= y0 + dividerSize ? i + 1 : i;
705+
const isCollapsed = pane.collapsed?.includes(targetIndex) ?? false;
706+
try {
707+
pane.onCollapse(targetIndex, !isCollapsed);
708+
} catch {
709+
// Swallow collapse callback errors to preserve routing determinism.
710+
}
711+
return ROUTE_RENDER;
712+
}
713+
714+
this.splitPaneLastDividerDown = Object.freeze({
715+
id,
716+
dividerIndex: i,
717+
timeMs: event.timeMs,
718+
});
719+
} else {
720+
this.splitPaneLastDividerDown = null;
721+
}
722+
647723
const availableCells = rect.h;
648724
const startCellSizes = computePanelCellSizes(
649725
childRects.length,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { assert, describe, test } from "@rezi-ui/testkit";
2+
import type { VNode } from "../../index.js";
3+
import { layout } from "../layout.js";
4+
5+
function mustLayout(vnode: VNode, maxW: number, maxH: number) {
6+
const res = layout(vnode, 0, 0, maxW, maxH, "column");
7+
if (!res.ok) {
8+
assert.fail(`layout failed: ${res.fatal.code}: ${res.fatal.detail}`);
9+
}
10+
return res.value;
11+
}
12+
13+
describe("splitPane collapse", () => {
14+
test("collapsed index forces panel to minSize (default 0)", () => {
15+
const sp = {
16+
kind: "splitPane",
17+
props: {
18+
id: "sp",
19+
direction: "horizontal",
20+
sizes: Object.freeze([50, 50]),
21+
onResize: () => {},
22+
collapsible: true,
23+
collapsed: Object.freeze([0]),
24+
},
25+
children: Object.freeze([
26+
{ kind: "divider", props: {} },
27+
{ kind: "divider", props: {} },
28+
]),
29+
} as unknown as VNode;
30+
31+
const tree = mustLayout(sp, 100, 10);
32+
const c0 = tree.children[0];
33+
const c1 = tree.children[1];
34+
assert.ok(c0 !== undefined && c1 !== undefined);
35+
if (!c0 || !c1) return;
36+
37+
// dividerSize default = 1, so panel widths must sum to 99.
38+
assert.equal(c0.rect.w, 0);
39+
assert.equal(c1.rect.x, 1);
40+
assert.equal(c1.rect.w, 99);
41+
});
42+
43+
test("collapsed index uses provided minSizes[index]", () => {
44+
const sp = {
45+
kind: "splitPane",
46+
props: {
47+
id: "sp",
48+
direction: "horizontal",
49+
sizes: Object.freeze([50, 50]),
50+
minSizes: Object.freeze([10, 0]),
51+
onResize: () => {},
52+
collapsible: true,
53+
collapsed: Object.freeze([0]),
54+
},
55+
children: Object.freeze([
56+
{ kind: "divider", props: {} },
57+
{ kind: "divider", props: {} },
58+
]),
59+
} as unknown as VNode;
60+
61+
const tree = mustLayout(sp, 100, 10);
62+
const c0 = tree.children[0];
63+
const c1 = tree.children[1];
64+
assert.ok(c0 !== undefined && c1 !== undefined);
65+
if (!c0 || !c1) return;
66+
67+
assert.equal(c0.rect.w, 10);
68+
assert.equal(c1.rect.x, 11);
69+
assert.equal(c1.rect.w, 89);
70+
});
71+
72+
test("collapsed list is ignored when collapsible is false", () => {
73+
const sp = {
74+
kind: "splitPane",
75+
props: {
76+
id: "sp",
77+
direction: "horizontal",
78+
sizes: Object.freeze([50, 50]),
79+
onResize: () => {},
80+
collapsible: false,
81+
collapsed: Object.freeze([0]),
82+
},
83+
children: Object.freeze([
84+
{ kind: "divider", props: {} },
85+
{ kind: "divider", props: {} },
86+
]),
87+
} as unknown as VNode;
88+
89+
const tree = mustLayout(sp, 100, 10);
90+
const c0 = tree.children[0];
91+
const c1 = tree.children[1];
92+
assert.ok(c0 !== undefined && c1 !== undefined);
93+
if (!c0 || !c1) return;
94+
95+
assert.ok(c0.rect.w > 0, "panel should not be collapsed when collapsible=false");
96+
assert.ok(c1.rect.w > 0);
97+
});
98+
});

packages/core/src/layout/kinds/splitPane.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,68 @@ type LayoutNodeFn = (
1717
forcedH?: number | null,
1818
) => LayoutResult<LayoutTree>;
1919

20+
function applyCollapsedPanelSizes(
21+
sizes: readonly number[],
22+
collapsed: readonly number[] | undefined,
23+
minSizes: readonly number[] | undefined,
24+
): readonly number[] {
25+
if (!collapsed || collapsed.length === 0) return sizes;
26+
const panelCount = sizes.length;
27+
if (panelCount === 0) return sizes;
28+
29+
const collapsedSet = new Set<number>();
30+
for (const raw of collapsed) {
31+
const idx = Math.trunc(Number(raw));
32+
if (idx >= 0 && idx < panelCount) collapsedSet.add(idx);
33+
}
34+
if (collapsedSet.size === 0) return sizes;
35+
36+
const out = sizes.slice();
37+
const totalBefore = out.reduce((a, b) => a + (b ?? 0), 0);
38+
39+
const collapsedSorted = Array.from(collapsedSet).sort((a, b) => a - b);
40+
for (const idx of collapsedSorted) {
41+
const cur = out[idx] ?? 0;
42+
const minSize = Math.max(0, Math.trunc(minSizes?.[idx] ?? 0));
43+
const delta = cur - minSize;
44+
if (delta <= 0) {
45+
out[idx] = minSize;
46+
continue;
47+
}
48+
49+
out[idx] = minSize;
50+
51+
let recipient = -1;
52+
for (let j = idx - 1; j >= 0; j--) {
53+
if (!collapsedSet.has(j)) {
54+
recipient = j;
55+
break;
56+
}
57+
}
58+
if (recipient === -1) {
59+
for (let j = idx + 1; j < panelCount; j++) {
60+
if (!collapsedSet.has(j)) {
61+
recipient = j;
62+
break;
63+
}
64+
}
65+
}
66+
if (recipient !== -1) {
67+
out[recipient] = (out[recipient] ?? 0) + delta;
68+
}
69+
}
70+
71+
// If all panels were collapsed, the loop above won't find a recipient. Preserve
72+
// fill by adding any remainder back into the first panel deterministically.
73+
const totalAfter = out.reduce((a, b) => a + (b ?? 0), 0);
74+
const remainder = totalBefore - totalAfter;
75+
if (remainder > 0) {
76+
out[0] = (out[0] ?? 0) + remainder;
77+
}
78+
79+
return Object.freeze(out);
80+
}
81+
2082
export function measureSplitPaneKinds(
2183
vnode: VNode,
2284
maxW: number,
@@ -71,7 +133,7 @@ export function layoutSplitPaneKinds(
71133

72134
const sizeMode = vnode.props.sizeMode ?? "percent";
73135
const available = direction === "horizontal" ? rectW : rectH;
74-
const panelSizes = computePanelCellSizes(
136+
const initialPanelSizes = computePanelCellSizes(
75137
childCount,
76138
sizes,
77139
available,
@@ -80,6 +142,10 @@ export function layoutSplitPaneKinds(
80142
vnode.props.minSizes,
81143
vnode.props.maxSizes,
82144
).sizes;
145+
const panelSizes =
146+
vnode.props.collapsible === true
147+
? applyCollapsedPanelSizes(initialPanelSizes, vnode.props.collapsed, vnode.props.minSizes)
148+
: initialPanelSizes;
83149

84150
const childTrees: LayoutTree[] = [];
85151
let offset = 0;

0 commit comments

Comments
 (0)