Skip to content

Commit 61e8a08

Browse files
RtlZeroMemoryRezoR0xFF
authored andcommitted
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 fbe5d0e commit 61e8a08

6 files changed

Lines changed: 329 additions & 13 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: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ function mouseEvent(
2222
x: number,
2323
y: number,
2424
mouseKind: 1 | 2 | 3 | 4 | 5,
25-
opts: Readonly<{ wheelX?: number; wheelY?: number }> = {},
25+
opts: Readonly<{ timeMs?: number; buttons?: number; wheelX?: number; wheelY?: number }> = {},
2626
): ZrevEvent {
2727
return {
2828
kind: "mouse",
29-
timeMs: 0,
29+
timeMs: opts.timeMs ?? 0,
3030
x,
3131
y,
3232
mouseKind,
3333
mods: 0,
34-
buttons: 0,
34+
buttons: opts.buttons ?? 0,
3535
wheelX: opts.wheelX ?? 0,
3636
wheelY: opts.wheelY ?? 0,
3737
};
@@ -520,6 +520,55 @@ describe("WidgetRenderer integration battery", () => {
520520
assert.deepEqual(events, []);
521521
});
522522

523+
test("splitPane double-click toggles collapse via onCollapse", () => {
524+
const backend = createNoopBackend();
525+
const renderer = new WidgetRenderer<void>({
526+
backend,
527+
requestRender: () => {},
528+
});
529+
530+
const calls: string[] = [];
531+
532+
const vnode = ui.splitPane(
533+
{
534+
id: "sp",
535+
direction: "horizontal",
536+
sizes: [50, 50],
537+
onResize: () => {},
538+
collapsible: true,
539+
collapsed: [],
540+
onCollapse: (index, collapsed) => {
541+
calls.push(`${String(index)}:${collapsed ? "1" : "0"}`);
542+
},
543+
},
544+
[ui.text("A"), ui.text("B")],
545+
);
546+
547+
const res = renderer.submitFrame(
548+
() => vnode,
549+
undefined,
550+
{ cols: 40, rows: 10 },
551+
defaultTheme,
552+
noRenderHooks(),
553+
);
554+
assert.ok(res.ok);
555+
556+
// divider is at x=20; hit area includes x=19 (left) and x=21 (right)
557+
// Left-side double click -> panel 0
558+
renderer.routeEngineEvent(mouseEvent(19, 0, 3, { timeMs: 1, buttons: 1 }));
559+
renderer.routeEngineEvent(mouseEvent(19, 0, 4, { timeMs: 2, buttons: 0 }));
560+
renderer.routeEngineEvent(mouseEvent(19, 0, 3, { timeMs: 100, buttons: 1 }));
561+
renderer.routeEngineEvent(mouseEvent(19, 0, 4, { timeMs: 101, buttons: 0 }));
562+
563+
// Right-side double click -> panel 1
564+
renderer.routeEngineEvent(mouseEvent(21, 0, 3, { timeMs: 200, buttons: 1 }));
565+
renderer.routeEngineEvent(mouseEvent(21, 0, 4, { timeMs: 201, buttons: 0 }));
566+
renderer.routeEngineEvent(mouseEvent(21, 0, 3, { timeMs: 250, buttons: 1 }));
567+
renderer.routeEngineEvent(mouseEvent(21, 0, 4, { timeMs: 251, buttons: 0 }));
568+
569+
assert.deepEqual(calls, ["0:1", "1:1"]);
570+
});
571+
523572
test("virtualList routing updates selection and activates on Enter", () => {
524573
const backend = createNoopBackend();
525574
const renderer = new WidgetRenderer<void>({

packages/core/src/app/widgetRenderer.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,13 @@ export class WidgetRenderer<S> {
357357
startY: number;
358358
startCellSizes: readonly number[];
359359
availableCells: number;
360+
didDrag: boolean;
361+
}> | null = null;
362+
363+
private splitPaneLastDividerDown: Readonly<{
364+
id: string;
365+
dividerIndex: number;
366+
timeMs: number;
360367
}> | null = null;
361368

362369
/* --- Overlay Routing State (rebuilt each commit) --- */
@@ -654,6 +661,9 @@ export class WidgetRenderer<S> {
654661

655662
if (this.splitPaneDrag) {
656663
if (event.mouseKind === MOUSE_KIND_UP) {
664+
if (this.splitPaneDrag.didDrag) {
665+
this.splitPaneLastDividerDown = null;
666+
}
657667
this.splitPaneDrag = null;
658668
return ROUTE_RENDER;
659669
}
@@ -663,6 +673,13 @@ export class WidgetRenderer<S> {
663673
if (pane) {
664674
const delta =
665675
drag.direction === "horizontal" ? event.x - drag.startX : event.y - drag.startY;
676+
const didDrag = drag.didDrag || delta !== 0;
677+
if (didDrag && !drag.didDrag) {
678+
this.splitPaneDrag = Object.freeze({ ...drag, didDrag: true });
679+
}
680+
if (didDrag) {
681+
this.splitPaneLastDividerDown = null;
682+
}
666683
const nextCellSizes = handleDividerDrag(
667684
drag.startCellSizes,
668685
drag.dividerIndex,
@@ -676,6 +693,7 @@ export class WidgetRenderer<S> {
676693
return ROUTE_RENDER;
677694
}
678695
// SplitPane removed mid-drag.
696+
this.splitPaneLastDividerDown = null;
679697
this.splitPaneDrag = null;
680698
return ROUTE_RENDER;
681699
}
@@ -708,12 +726,48 @@ export class WidgetRenderer<S> {
708726

709727
for (let i = 0; i < childRects.length - 1; i++) {
710728
const a = childRects[i];
711-
if (!a) continue;
729+
const b = childRects[i + 1];
730+
if (!a || !b) continue;
712731
if (direction === "horizontal") {
713-
const x0 = a.x + a.w;
732+
// Divider starts immediately before the next panel's x.
733+
const x0 = b.x - dividerSize;
714734
const hitX0 = x0 - expand;
715735
const hitX1 = x0 + dividerSize + expand;
716736
if (event.x >= hitX0 && event.x < hitX1) {
737+
// Only allow primary-button drags/double-click collapse.
738+
if ((event.buttons & 1) === 0) continue;
739+
740+
const prevDown = this.splitPaneLastDividerDown;
741+
const DOUBLE_CLICK_MS = 500;
742+
if (pane.collapsible === true && pane.onCollapse) {
743+
if (
744+
prevDown &&
745+
prevDown.id === id &&
746+
prevDown.dividerIndex === i &&
747+
event.timeMs - prevDown.timeMs <= DOUBLE_CLICK_MS
748+
) {
749+
this.splitPaneLastDividerDown = null;
750+
751+
// Select which panel to toggle based on which side of the divider was clicked.
752+
const targetIndex = event.x < x0 ? i : event.x >= x0 + dividerSize ? i + 1 : i;
753+
const isCollapsed = pane.collapsed?.includes(targetIndex) ?? false;
754+
try {
755+
pane.onCollapse(targetIndex, !isCollapsed);
756+
} catch {
757+
// Swallow collapse callback errors to preserve routing determinism.
758+
}
759+
return ROUTE_RENDER;
760+
}
761+
762+
this.splitPaneLastDividerDown = Object.freeze({
763+
id,
764+
dividerIndex: i,
765+
timeMs: event.timeMs,
766+
});
767+
} else {
768+
this.splitPaneLastDividerDown = null;
769+
}
770+
717771
const availableCells = rect.w;
718772
const startCellSizes = computePanelCellSizes(
719773
childRects.length,
@@ -737,14 +791,49 @@ export class WidgetRenderer<S> {
737791
startY: event.y,
738792
startCellSizes,
739793
availableCells,
794+
didDrag: false,
740795
});
741796
return ROUTE_RENDER;
742797
}
743798
} else {
744-
const y0 = a.y + a.h;
799+
// Divider starts immediately before the next panel's y.
800+
const y0 = b.y - dividerSize;
745801
const hitY0 = y0 - expand;
746802
const hitY1 = y0 + dividerSize + expand;
747803
if (event.y >= hitY0 && event.y < hitY1) {
804+
// Only allow primary-button drags/double-click collapse.
805+
if ((event.buttons & 1) === 0) continue;
806+
807+
const prevDown = this.splitPaneLastDividerDown;
808+
const DOUBLE_CLICK_MS = 500;
809+
if (pane.collapsible === true && pane.onCollapse) {
810+
if (
811+
prevDown &&
812+
prevDown.id === id &&
813+
prevDown.dividerIndex === i &&
814+
event.timeMs - prevDown.timeMs <= DOUBLE_CLICK_MS
815+
) {
816+
this.splitPaneLastDividerDown = null;
817+
818+
const targetIndex = event.y < y0 ? i : event.y >= y0 + dividerSize ? i + 1 : i;
819+
const isCollapsed = pane.collapsed?.includes(targetIndex) ?? false;
820+
try {
821+
pane.onCollapse(targetIndex, !isCollapsed);
822+
} catch {
823+
// Swallow collapse callback errors to preserve routing determinism.
824+
}
825+
return ROUTE_RENDER;
826+
}
827+
828+
this.splitPaneLastDividerDown = Object.freeze({
829+
id,
830+
dividerIndex: i,
831+
timeMs: event.timeMs,
832+
});
833+
} else {
834+
this.splitPaneLastDividerDown = null;
835+
}
836+
748837
const availableCells = rect.h;
749838
const startCellSizes = computePanelCellSizes(
750839
childRects.length,
@@ -768,6 +857,7 @@ export class WidgetRenderer<S> {
768857
startY: event.y,
769858
startCellSizes,
770859
availableCells,
860+
didDrag: false,
771861
});
772862
return ROUTE_RENDER;
773863
}
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+
});

0 commit comments

Comments
 (0)