diff --git a/docs/widgets/split-pane.md b/docs/widgets/split-pane.md index 4eb57366..537df544 100644 --- a/docs/widgets/split-pane.md +++ b/docs/widgets/split-pane.md @@ -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. diff --git a/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts b/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts index 98b62f99..e091a2dd 100644 --- a/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts +++ b/packages/core/src/app/__tests__/widgetRenderer.integration.test.ts @@ -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, }; @@ -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({ + 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({ diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 2aa0eb06..2d6c9ec7 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -357,6 +357,13 @@ export class WidgetRenderer { 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) --- */ @@ -654,6 +661,9 @@ export class WidgetRenderer { if (this.splitPaneDrag) { if (event.mouseKind === MOUSE_KIND_UP) { + if (this.splitPaneDrag.didDrag) { + this.splitPaneLastDividerDown = null; + } this.splitPaneDrag = null; return ROUTE_RENDER; } @@ -663,6 +673,13 @@ export class WidgetRenderer { 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, @@ -676,6 +693,7 @@ export class WidgetRenderer { return ROUTE_RENDER; } // SplitPane removed mid-drag. + this.splitPaneLastDividerDown = null; this.splitPaneDrag = null; return ROUTE_RENDER; } @@ -708,12 +726,48 @@ export class WidgetRenderer { 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, + }); + } else { + this.splitPaneLastDividerDown = null; + } + const availableCells = rect.w; const startCellSizes = computePanelCellSizes( childRects.length, @@ -737,14 +791,49 @@ export class WidgetRenderer { 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, @@ -768,6 +857,7 @@ export class WidgetRenderer { startY: event.y, startCellSizes, availableCells, + didDrag: false, }); return ROUTE_RENDER; } diff --git a/packages/core/src/layout/__tests__/splitPaneCollapse.test.ts b/packages/core/src/layout/__tests__/splitPaneCollapse.test.ts new file mode 100644 index 00000000..6694ec66 --- /dev/null +++ b/packages/core/src/layout/__tests__/splitPaneCollapse.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/layout/kinds/splitPane.ts b/packages/core/src/layout/kinds/splitPane.ts index f5800b43..b05fce93 100644 --- a/packages/core/src/layout/kinds/splitPane.ts +++ b/packages/core/src/layout/kinds/splitPane.ts @@ -84,6 +84,69 @@ type LayoutNodeFn = ( forcedH?: number | null, ) => LayoutResult; +function applyCollapsedPanelSizes( + sizes: readonly number[], + collapsed: readonly number[] | undefined, + minSizes: readonly number[] | undefined, +): readonly number[] { + if (!collapsed || collapsed.length === 0) return sizes; + const panelCount = sizes.length; + if (panelCount === 0) return sizes; + + const collapsedSet = new Set(); + for (const raw of collapsed) { + const idx = Math.trunc(Number(raw)); + if (idx >= 0 && idx < panelCount) collapsedSet.add(idx); + } + if (collapsedSet.size === 0) return sizes; + + const out = sizes.slice(); + const totalBefore = out.reduce((a, b) => a + (b ?? 0), 0); + + const collapsedSorted = Array.from(collapsedSet).sort((a, b) => a - b); + for (const idx of collapsedSorted) { + const cur = out[idx] ?? 0; + const minSize = Math.max(0, Math.trunc(minSizes?.[idx] ?? 0)); + const delta = cur - minSize; + if (delta <= 0) { + // In tight layouts, initial sizes may already be below mins; forcing + // back to min would overflow the panel budget. + continue; + } + + out[idx] = minSize; + + let recipient = -1; + for (let j = idx - 1; j >= 0; j--) { + if (!collapsedSet.has(j)) { + recipient = j; + break; + } + } + if (recipient === -1) { + for (let j = idx + 1; j < panelCount; j++) { + if (!collapsedSet.has(j)) { + recipient = j; + break; + } + } + } + if (recipient !== -1) { + out[recipient] = (out[recipient] ?? 0) + delta; + } + } + + // If all panels were collapsed, the loop above won't find a recipient. Preserve + // fill by adding any remainder back into the first panel deterministically. + const totalAfter = out.reduce((a, b) => a + (b ?? 0), 0); + const remainder = totalBefore - totalAfter; + if (remainder > 0) { + out[0] = (out[0] ?? 0) + remainder; + } + + return Object.freeze(out); +} + export function measureSplitPaneKinds( vnode: VNode, maxW: number, @@ -138,7 +201,7 @@ export function layoutSplitPaneKinds( const sizeMode = vnode.props.sizeMode ?? "percent"; const available = direction === "horizontal" ? rectW : rectH; - const panelSizes = computePanelCellSizes( + const initialPanelSizes = computePanelCellSizes( childCount, sizes, available, @@ -147,6 +210,10 @@ export function layoutSplitPaneKinds( vnode.props.minSizes, vnode.props.maxSizes, ).sizes; + const panelSizes = + vnode.props.collapsible === true + ? applyCollapsedPanelSizes(initialPanelSizes, vnode.props.collapsed, vnode.props.minSizes) + : initialPanelSizes; const childTrees: LayoutTree[] = []; let offset = 0; diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 0d83ce3d..51fbcd18 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -358,10 +358,11 @@ export function renderContainerWidget( const childCount = Math.min(node.children.length, layoutNode.children.length); if (direction === "horizontal") { for (let i = 0; i < childCount - 1; i++) { - const childRect = layoutNode.children[i]?.rect; - if (!childRect) continue; + const nextRect = layoutNode.children[i + 1]?.rect; + if (!nextRect) continue; - const offset = childRect.x + childRect.w - rect.x; + // Divider starts immediately before the next panel's x. + const offset = nextRect.x - rect.x - dividerSize; if (offset < 0 || offset >= rect.w) continue; const width = Math.min(dividerSize, rect.w - offset); @@ -373,10 +374,11 @@ export function renderContainerWidget( } } else { for (let i = 0; i < childCount - 1; i++) { - const childRect = layoutNode.children[i]?.rect; - if (!childRect) continue; + const nextRect = layoutNode.children[i + 1]?.rect; + if (!nextRect) continue; - const offset = childRect.y + childRect.h - rect.y; + // Divider starts immediately before the next panel's y. + const offset = nextRect.y - rect.y - dividerSize; if (offset < 0 || offset >= rect.h) continue; const line = rect.w > 0 ? "─".repeat(rect.w) : "";