Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/widgets/split-pane.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ Dividers between panels can be dragged with the mouse to resize:

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

### Collapse

When `collapsible: true`:

- A panel index in `collapsed` is laid out at its minimum size (`minSizes[index]` or `0`).
- **Double-click** near a divider to toggle collapse:
- Click just to the **left/top** of a divider to target the panel on that side
- Click just to the **right/bottom** of a divider to target the other panel
- Use `onCollapse(index, collapsed)` to update your `collapsed` list (controlled state).

## Notes

- `sizes` length should match the number of child panels.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ function mouseEvent(
x: number,
y: number,
mouseKind: 1 | 2 | 3 | 4 | 5,
opts: Readonly<{ wheelX?: number; wheelY?: number }> = {},
opts: Readonly<{ timeMs?: number; buttons?: number; wheelX?: number; wheelY?: number }> = {},
): ZrevEvent {
return {
kind: "mouse",
timeMs: 0,
timeMs: opts.timeMs ?? 0,
x,
y,
mouseKind,
mods: 0,
buttons: 0,
buttons: opts.buttons ?? 0,
wheelX: opts.wheelX ?? 0,
wheelY: opts.wheelY ?? 0,
};
Expand Down Expand Up @@ -520,6 +520,55 @@ describe("WidgetRenderer integration battery", () => {
assert.deepEqual(events, []);
});

test("splitPane double-click toggles collapse via onCollapse", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
backend,
requestRender: () => {},
});

const calls: string[] = [];

const vnode = ui.splitPane(
{
id: "sp",
direction: "horizontal",
sizes: [50, 50],
onResize: () => {},
collapsible: true,
collapsed: [],
onCollapse: (index, collapsed) => {
calls.push(`${String(index)}:${collapsed ? "1" : "0"}`);
},
},
[ui.text("A"), ui.text("B")],
);

const res = renderer.submitFrame(
() => vnode,
undefined,
{ cols: 40, rows: 10 },
defaultTheme,
noRenderHooks(),
);
assert.ok(res.ok);

// divider is at x=20; hit area includes x=19 (left) and x=21 (right)
// Left-side double click -> panel 0
renderer.routeEngineEvent(mouseEvent(19, 0, 3, { timeMs: 1, buttons: 1 }));
renderer.routeEngineEvent(mouseEvent(19, 0, 4, { timeMs: 2, buttons: 0 }));
renderer.routeEngineEvent(mouseEvent(19, 0, 3, { timeMs: 100, buttons: 1 }));
renderer.routeEngineEvent(mouseEvent(19, 0, 4, { timeMs: 101, buttons: 0 }));

// Right-side double click -> panel 1
renderer.routeEngineEvent(mouseEvent(21, 0, 3, { timeMs: 200, buttons: 1 }));
renderer.routeEngineEvent(mouseEvent(21, 0, 4, { timeMs: 201, buttons: 0 }));
renderer.routeEngineEvent(mouseEvent(21, 0, 3, { timeMs: 250, buttons: 1 }));
renderer.routeEngineEvent(mouseEvent(21, 0, 4, { timeMs: 251, buttons: 0 }));

assert.deepEqual(calls, ["0:1", "1:1"]);
});

test("virtualList routing updates selection and activates on Enter", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
Expand Down
96 changes: 93 additions & 3 deletions packages/core/src/app/widgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,13 @@ export class WidgetRenderer<S> {
startY: number;
startCellSizes: readonly number[];
availableCells: number;
didDrag: boolean;
}> | null = null;

private splitPaneLastDividerDown: Readonly<{
id: string;
dividerIndex: number;
timeMs: number;
}> | null = null;

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

if (this.splitPaneDrag) {
if (event.mouseKind === MOUSE_KIND_UP) {
if (this.splitPaneDrag.didDrag) {
this.splitPaneLastDividerDown = null;
}
this.splitPaneDrag = null;
return ROUTE_RENDER;
}
Expand All @@ -663,6 +673,13 @@ export class WidgetRenderer<S> {
if (pane) {
const delta =
drag.direction === "horizontal" ? event.x - drag.startX : event.y - drag.startY;
const didDrag = drag.didDrag || delta !== 0;
if (didDrag && !drag.didDrag) {
this.splitPaneDrag = Object.freeze({ ...drag, didDrag: true });
}
if (didDrag) {
this.splitPaneLastDividerDown = null;
}
const nextCellSizes = handleDividerDrag(
drag.startCellSizes,
drag.dividerIndex,
Expand All @@ -676,6 +693,7 @@ export class WidgetRenderer<S> {
return ROUTE_RENDER;
}
// SplitPane removed mid-drag.
this.splitPaneLastDividerDown = null;
this.splitPaneDrag = null;
return ROUTE_RENDER;
}
Expand Down Expand Up @@ -708,12 +726,48 @@ export class WidgetRenderer<S> {

for (let i = 0; i < childRects.length - 1; i++) {
const a = childRects[i];
if (!a) continue;
const b = childRects[i + 1];
if (!a || !b) continue;
if (direction === "horizontal") {
const x0 = a.x + a.w;
// Divider starts immediately before the next panel's x.
const x0 = b.x - dividerSize;
const hitX0 = x0 - expand;
const hitX1 = x0 + dividerSize + expand;
if (event.x >= hitX0 && event.x < hitX1) {
// Only allow primary-button drags/double-click collapse.
if ((event.buttons & 1) === 0) continue;

const prevDown = this.splitPaneLastDividerDown;
const DOUBLE_CLICK_MS = 500;
if (pane.collapsible === true && pane.onCollapse) {
if (
prevDown &&
prevDown.id === id &&
prevDown.dividerIndex === i &&
event.timeMs - prevDown.timeMs <= DOUBLE_CLICK_MS
) {
this.splitPaneLastDividerDown = null;

// Select which panel to toggle based on which side of the divider was clicked.
const targetIndex = event.x < x0 ? i : event.x >= x0 + dividerSize ? i + 1 : i;
const isCollapsed = pane.collapsed?.includes(targetIndex) ?? false;
try {
pane.onCollapse(targetIndex, !isCollapsed);
} catch {
// Swallow collapse callback errors to preserve routing determinism.
}
return ROUTE_RENDER;
}

this.splitPaneLastDividerDown = Object.freeze({
id,
dividerIndex: i,
timeMs: event.timeMs,
});
Comment on lines +762 to +766

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear divider double-click state before starting a drag

This records a double-click candidate on every divider mouse-down, but that state is not cleared when the interaction turns into a drag, so a quick second resize attempt on the same divider (within 500ms) is interpreted as a double-click and fires onCollapse unexpectedly. In collapsible panes this causes unintended panel collapse during rapid consecutive drag adjustments; the double-click tracker should be invalidated on drag/mouse-up or armed only for click-like interactions.

Useful? React with 👍 / 👎.

} else {
this.splitPaneLastDividerDown = null;
}

const availableCells = rect.w;
const startCellSizes = computePanelCellSizes(
childRects.length,
Expand All @@ -737,14 +791,49 @@ export class WidgetRenderer<S> {
startY: event.y,
startCellSizes,
availableCells,
didDrag: false,
});
return ROUTE_RENDER;
}
} else {
const y0 = a.y + a.h;
// Divider starts immediately before the next panel's y.
const y0 = b.y - dividerSize;
const hitY0 = y0 - expand;
const hitY1 = y0 + dividerSize + expand;
if (event.y >= hitY0 && event.y < hitY1) {
// Only allow primary-button drags/double-click collapse.
if ((event.buttons & 1) === 0) continue;

const prevDown = this.splitPaneLastDividerDown;
const DOUBLE_CLICK_MS = 500;
if (pane.collapsible === true && pane.onCollapse) {
if (
prevDown &&
prevDown.id === id &&
prevDown.dividerIndex === i &&
event.timeMs - prevDown.timeMs <= DOUBLE_CLICK_MS
) {
this.splitPaneLastDividerDown = null;

const targetIndex = event.y < y0 ? i : event.y >= y0 + dividerSize ? i + 1 : i;
const isCollapsed = pane.collapsed?.includes(targetIndex) ?? false;
try {
pane.onCollapse(targetIndex, !isCollapsed);
} catch {
// Swallow collapse callback errors to preserve routing determinism.
}
return ROUTE_RENDER;
}

this.splitPaneLastDividerDown = Object.freeze({
id,
dividerIndex: i,
timeMs: event.timeMs,
});
} else {
this.splitPaneLastDividerDown = null;
}

const availableCells = rect.h;
const startCellSizes = computePanelCellSizes(
childRects.length,
Expand All @@ -768,6 +857,7 @@ export class WidgetRenderer<S> {
startY: event.y,
startCellSizes,
availableCells,
didDrag: false,
});
return ROUTE_RENDER;
}
Expand Down
98 changes: 98 additions & 0 deletions packages/core/src/layout/__tests__/splitPaneCollapse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import type { VNode } from "../../index.js";
import { layout } from "../layout.js";

function mustLayout(vnode: VNode, maxW: number, maxH: number) {
const res = layout(vnode, 0, 0, maxW, maxH, "column");
if (!res.ok) {
assert.fail(`layout failed: ${res.fatal.code}: ${res.fatal.detail}`);
}
return res.value;
}

describe("splitPane collapse", () => {
test("collapsed index forces panel to minSize (default 0)", () => {
const sp = {
kind: "splitPane",
props: {
id: "sp",
direction: "horizontal",
sizes: Object.freeze([50, 50]),
onResize: () => {},
collapsible: true,
collapsed: Object.freeze([0]),
},
children: Object.freeze([
{ kind: "divider", props: {} },
{ kind: "divider", props: {} },
]),
} as unknown as VNode;

const tree = mustLayout(sp, 100, 10);
const c0 = tree.children[0];
const c1 = tree.children[1];
assert.ok(c0 !== undefined && c1 !== undefined);
if (!c0 || !c1) return;

// dividerSize default = 1, so panel widths must sum to 99.
assert.equal(c0.rect.w, 0);
assert.equal(c1.rect.x, 1);
assert.equal(c1.rect.w, 99);
});

test("collapsed index uses provided minSizes[index]", () => {
const sp = {
kind: "splitPane",
props: {
id: "sp",
direction: "horizontal",
sizes: Object.freeze([50, 50]),
minSizes: Object.freeze([10, 0]),
onResize: () => {},
collapsible: true,
collapsed: Object.freeze([0]),
},
children: Object.freeze([
{ kind: "divider", props: {} },
{ kind: "divider", props: {} },
]),
} as unknown as VNode;

const tree = mustLayout(sp, 100, 10);
const c0 = tree.children[0];
const c1 = tree.children[1];
assert.ok(c0 !== undefined && c1 !== undefined);
if (!c0 || !c1) return;

assert.equal(c0.rect.w, 10);
assert.equal(c1.rect.x, 11);
assert.equal(c1.rect.w, 89);
});

test("collapsed list is ignored when collapsible is false", () => {
const sp = {
kind: "splitPane",
props: {
id: "sp",
direction: "horizontal",
sizes: Object.freeze([50, 50]),
onResize: () => {},
collapsible: false,
collapsed: Object.freeze([0]),
},
children: Object.freeze([
{ kind: "divider", props: {} },
{ kind: "divider", props: {} },
]),
} as unknown as VNode;

const tree = mustLayout(sp, 100, 10);
const c0 = tree.children[0];
const c1 = tree.children[1];
assert.ok(c0 !== undefined && c1 !== undefined);
if (!c0 || !c1) return;

assert.ok(c0.rect.w > 0, "panel should not be collapsed when collapsible=false");
assert.ok(c1.rect.w > 0);
});
});
Loading
Loading