diff --git a/README.md b/README.md index c6cc0f7..9d2c267 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil | `Ctrl+Shift+E` | split active pane to the right | | `Ctrl+Shift+O` | split active pane downward | | `Ctrl+Shift+W` | close active pane | +| `Ctrl+Shift+P` | promote active pane out one level — turns a nested pane into a full row/column (e.g. nested-right `c` becomes a full-width bottom row). Self-inverse. | | `Ctrl+Shift+B` | toggle broadcast on active pane | | `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (same as the titlebar 📡 button) | | `Ctrl+Shift+←` / `→` / `↑` / `↓` | focus neighbour pane in that direction | diff --git a/src/App.css b/src/App.css index 84e9d04..087ef55 100644 --- a/src/App.css +++ b/src/App.css @@ -87,12 +87,3 @@ padding: 2px; box-sizing: border-box; } - -/* Translucent preview shown while the "drag past sibling to promote" gesture - is armed — tells the user what releasing now will reshape into. */ -.promote-preview { - background: rgba(90, 140, 216, 0.18); - border: 2px dashed rgba(120, 170, 240, 0.8); - border-radius: 4px; - box-sizing: border-box; -} diff --git a/src/App.tsx b/src/App.tsx index af2ab04..25a71be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,6 @@ import { type Orientation, type LeafNode, type LeafShellSpec, - type Box, newLeaf, splitLeaf, closeLeaf, @@ -36,7 +35,7 @@ import { updateSplitRatio, swapLeaves, findNeighborInDirection, - promoteFromGutter, + promoteLeaf, MIN_PANE_PX, type Direction, serialize, @@ -260,6 +259,22 @@ export default function App() { setTree((t) => toggleBroadcastInTree(t, leafId)); }, []); + // Ctrl+Shift+P: pop the active leaf out one level. The keyboard + // replacement for the (removed) drag-past-sibling gesture. No-op with a + // toast if the leaf is at the root or its parent shares orientation + // with the grandparent — no perpendicular promotion available. + const promoteActive = useCallback( + (leafId: NodeId) => { + const next = promoteLeaf(treeRef.current, leafId); + if (next === null) { + notify("Pane can't be promoted (no perpendicular split above it)"); + return; + } + setTree(next); + }, + [notify], + ); + const setActive = useCallback((leafId: NodeId) => { setActiveLeafId(leafId); }, []); @@ -415,12 +430,16 @@ export default function App() { e.preventDefault(); e.stopPropagation(); toggleBroadcast(activeLeafId); + } else if (key === "p") { + e.preventDefault(); + e.stopPropagation(); + promoteActive(activeLeafId); } } window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [split, close, toggleBroadcast]); + }, [split, close, toggleBroadcast, promoteActive]); const registerPaneId = useCallback( (leafId: NodeId, paneId: PaneId | null) => { @@ -586,18 +605,6 @@ export default function App() { setTree((t) => updateSplitRatio(t, splitId, ratio)); }, []); - // ---- promote-out gesture state ----------------------------------------- - // armedPromotionBox is non-null while the user is mid-drag and past the - // 75% threshold on a sibling pane. We render a translucent preview at - // that position so the user knows what releasing will do. - const [armedPromotionBox, setArmedPromotionBox] = useState(null); - const onGutterArmedChange = useCallback((box: Box | null) => { - setArmedPromotionBox(box); - }, []); - const onGutterPromote = useCallback((splitId: NodeId) => { - setTree((t) => promoteFromGutter(t, splitId) ?? t); - }, []); - // ---- global broadcast state (derived from tree) ------------------------- const broadcastStats = useMemo(() => { let on = 0; @@ -742,25 +749,8 @@ export default function App() { info={g} containerRef={paneWrapRef} onRatioChange={onGutterRatio} - onArmedChange={onGutterArmedChange} - onPromote={onGutterPromote} /> ))} - {armedPromotionBox && ( - diff --git a/src/lib/layout/Gutter.tsx b/src/lib/layout/Gutter.tsx index e37866c..6420d32 100644 --- a/src/lib/layout/Gutter.tsx +++ b/src/lib/layout/Gutter.tsx @@ -1,5 +1,5 @@ import { useCallback, useRef, useState, type PointerEvent } from "react"; -import { type Box, type GutterInfo, MIN_PANE_PX } from "./tree"; +import { type GutterInfo, MIN_PANE_PX } from "./tree"; /** * A draggable gutter at a split boundary. @@ -8,36 +8,20 @@ import { type Box, type GutterInfo, MIN_PANE_PX } from "./tree"; * `info.parentBox` is the parent split's bounding box, used to convert * pointer position back into a 0–1 ratio. * - * The actual draggable hitbox is a few pixels thick (and centered on the - * boundary), but we render a thin visible line via CSS pseudo-elements. - * - * When `info.promote` is set, this gutter also supports the "drag past - * sibling to promote" gesture — while dragging, if the cursor crosses - * 75% of the sibling pane's extent in the exit direction, the parent's - * `onArmedChange` is called with the would-be box of the promoted pane. - * On release while armed, `onPromote` fires. + * The actual draggable hitbox is wider than the visible line (HITBOX_PX + * tall/wide) so the gutter stays easy to grab; CSS renders a thin + * centered line via a pseudo-element. */ const HITBOX_PX = 14; -/** Cursor must reach this fraction across the sibling pane (in the exit - * direction) to arm the promote gesture. 0.5 = middle of the sibling. */ -const PROMOTE_TRIGGER_FRAC = 0.5; export default function Gutter({ info, containerRef, onRatioChange, - onArmedChange, - onPromote, }: { info: GutterInfo; containerRef: React.RefObject; onRatioChange: (splitId: string, ratio: number) => void; - /** Called as the cursor moves: `box` when the promote gesture is armed, - * `null` when it's not (or has un-armed). Parent renders a translucent - * preview at `box`. */ - onArmedChange: (box: Box | null) => void; - /** Called on pointer-up while the gesture is currently armed. */ - onPromote: (splitId: string) => void; }) { const [dragging, setDragging] = useState(false); const draggingRef = useRef(false); @@ -46,10 +30,6 @@ export default function Gutter({ // and leaves artifacts). const pendingRatioRef = useRef(null); const rafRef = useRef(null); - // Whether the promote gesture is currently armed. Live ref because we - // need to know on pointerup without a re-render race; useState mirror - // is only used by parent via onArmedChange. - const armedRef = useRef(false); const flushPending = useCallback(() => { rafRef.current = null; @@ -60,15 +40,6 @@ export default function Gutter({ } }, [info.splitId, onRatioChange]); - const setArmed = useCallback( - (armed: boolean) => { - if (armedRef.current === armed) return; - armedRef.current = armed; - onArmedChange(armed && info.promote ? info.promote.promotedBox : null); - }, - [info.promote, onArmedChange], - ); - const onPointerDown = useCallback((e: PointerEvent) => { (e.target as HTMLElement).setPointerCapture(e.pointerId); setDragging(true); @@ -102,14 +73,8 @@ export default function Gutter({ if (rafRef.current == null) { rafRef.current = requestAnimationFrame(flushPending); } - - // Promote gesture: armed iff cursor is inside the sibling box AND - // past PROMOTE_TRIGGER_FRAC along the exit direction. - if (info.promote) { - setArmed(isArmed(info.promote, xFrac, yFrac)); - } }, - [containerRef, info, flushPending, setArmed], + [containerRef, info, flushPending], ); const onPointerUp = useCallback( @@ -123,26 +88,15 @@ export default function Gutter({ cancelAnimationFrame(rafRef.current); rafRef.current = null; } - const wasArmed = armedRef.current; - setArmed(false); - if (wasArmed) { - // Discard any pending ratio update — the tree is about to change - // shape; touching the old split's ratio would be wasted work and - // can race a re-render. - pendingRatioRef.current = null; - onPromote(info.splitId); - return; - } if (pendingRatioRef.current != null) { onRatioChange(info.splitId, pendingRatioRef.current); pendingRatioRef.current = null; } }, - [info.splitId, onRatioChange, onPromote, setArmed], + [info.splitId, onRatioChange], ); const isH = info.orientation === "h"; - // Visible 4px line, but the draggable hitbox is wider for grabbability. const halfHit = HITBOX_PX / 2; const style: React.CSSProperties = isH ? { @@ -179,27 +133,3 @@ export default function Gutter({ /> ); } - -function isArmed( - promote: NonNullable, - cursorX: number, - cursorY: number, -): boolean { - const sb = promote.siblingBox; - const inSlot = - cursorX >= sb.left && - cursorX <= sb.left + sb.width && - cursorY >= sb.top && - cursorY <= sb.top + sb.height; - if (!inSlot) return false; - switch (promote.exitDirection) { - case "left": - return cursorX < sb.left + (1 - PROMOTE_TRIGGER_FRAC) * sb.width; - case "right": - return cursorX > sb.left + PROMOTE_TRIGGER_FRAC * sb.width; - case "up": - return cursorY < sb.top + (1 - PROMOTE_TRIGGER_FRAC) * sb.height; - case "down": - return cursorY > sb.top + PROMOTE_TRIGGER_FRAC * sb.height; - } -} diff --git a/src/lib/layout/tree.test.ts b/src/lib/layout/tree.test.ts index 779a37f..542964c 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -25,8 +25,7 @@ import { presetThreeColumns, presetTwoRows, presetTwoByTwo, - promoteFromGutter, - flattenLayout, + promoteLeaf, type TreeNode, type LeafNode, type SplitNode, @@ -504,129 +503,86 @@ describe("presets", () => { }); }); -describe("promoteFromGutter", () => { - it("HSplit(a, VSplit(b, c)) → VSplit(HSplit(a, b), c) when promoting the inner VSplit", () => { +describe("promoteLeaf", () => { + it("HSplit(a, VSplit(b, c)) + promote c → VSplit(HSplit(a, b), c)", () => { const a = newLeaf({ label: "a" }); const b = newLeaf({ label: "b" }); const c = newLeaf({ label: "c" }); - const inner = newSplit("v", b, c, 0.5); - const tree = newSplit("h", a, inner, 0.5); - - const next = promoteFromGutter(tree, inner.id) as SplitNode; - expect(next.kind).toBe("split"); - expect(next.orientation).toBe("v"); // outer is now V (matches inner's old axis) - // Top: HSplit(a, b) + const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5); + const next = promoteLeaf(tree, c.id) as SplitNode; + expect(next.orientation).toBe("v"); const top = next.a as SplitNode; - expect(top.kind).toBe("split"); expect(top.orientation).toBe("h"); expect((top.a as LeafNode).label).toBe("a"); expect((top.b as LeafNode).label).toBe("b"); - // Bottom: c expect((next.b as LeafNode).label).toBe("c"); }); - it("is its own inverse — applying it twice (to the moved inner) returns the original shape", () => { + it("HSplit(a, VSplit(b, c)) + promote b → VSplit(b, HSplit(a, c))", () => { const a = newLeaf({ label: "a" }); const b = newLeaf({ label: "b" }); const c = newLeaf({ label: "c" }); - const inner = newSplit("v", b, c, 0.5); - const tree = newSplit("h", a, inner, 0.5); - - const promoted = promoteFromGutter(tree, inner.id) as SplitNode; - // The new inner split (combined a+b) has a fresh id; find it via walk. - const newInnerId = (promoted.a as SplitNode).id; - const restored = promoteFromGutter(promoted, newInnerId) as SplitNode; + const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5); + const next = promoteLeaf(tree, b.id) as SplitNode; + expect(next.orientation).toBe("v"); + expect((next.a as LeafNode).label).toBe("b"); + const bot = next.b as SplitNode; + expect(bot.orientation).toBe("h"); + expect((bot.a as LeafNode).label).toBe("a"); + expect((bot.b as LeafNode).label).toBe("c"); + }); + it("is self-inverse — promote c then promote a returns the original shape", () => { + const a = newLeaf({ label: "a" }); + const b = newLeaf({ label: "b" }); + const c = newLeaf({ label: "c" }); + const tree = newSplit("h", a, newSplit("v", b, c, 0.5), 0.5); + const promoted = promoteLeaf(tree, c.id)!; + const restored = promoteLeaf(promoted, a.id) as SplitNode; expect(restored.orientation).toBe("h"); expect((restored.a as LeafNode).label).toBe("a"); - const innerR = restored.b as SplitNode; - expect(innerR.orientation).toBe("v"); - expect((innerR.a as LeafNode).label).toBe("b"); - expect((innerR.b as LeafNode).label).toBe("c"); + const inner = restored.b as SplitNode; + expect(inner.orientation).toBe("v"); + expect((inner.a as LeafNode).label).toBe("b"); + expect((inner.b as LeafNode).label).toBe("c"); }); - it("mirror direction: VSplit(HSplit(a, b), c) → HSplit(a, VSplit(b, c))", () => { - // S=HSplit (first child of outer V). isFirstInP=true. - // Promoted = S.a = a. Sibling = c (P.b). Combined = VSplit(b, c). - const a = newLeaf({ label: "a" }); - const b = newLeaf({ label: "b" }); - const c = newLeaf({ label: "c" }); - const inner = newSplit("h", a, b, 0.5); - const tree = newSplit("v", inner, c, 0.5); - - const next = promoteFromGutter(tree, inner.id) as SplitNode; - expect(next.orientation).toBe("h"); - expect((next.a as LeafNode).label).toBe("a"); - const innerR = next.b as SplitNode; - expect(innerR.orientation).toBe("v"); - expect((innerR.a as LeafNode).label).toBe("b"); - expect((innerR.b as LeafNode).label).toBe("c"); + it("returns null when the leaf has no parent (single-leaf root)", () => { + const leaf = newLeaf(); + expect(promoteLeaf(leaf, leaf.id)).toBeNull(); }); - it("returns null when the split has no parent (root)", () => { - const root = newSplit("h", newLeaf(), newLeaf()); - expect(promoteFromGutter(root, root.id)).toBeNull(); + it("returns null when the leaf's parent is the root (no grandparent)", () => { + const a = newLeaf(); + const b = newLeaf(); + const root = newSplit("h", a, b); + expect(promoteLeaf(root, a.id)).toBeNull(); }); - it("returns null when parent has the same orientation (gesture undefined)", () => { - // Both axes H: there's no perpendicular promote. - const inner = newSplit("h", newLeaf(), newLeaf()); - const root = newSplit("h", newLeaf(), inner); - expect(promoteFromGutter(root, inner.id)).toBeNull(); + it("returns null when parent and grandparent share orientation", () => { + const a = newLeaf(); + const b = newLeaf(); + const c = newLeaf(); + const inner = newSplit("h", b, c); + const root = newSplit("h", a, inner); + expect(promoteLeaf(root, b.id)).toBeNull(); }); it("preserves all leaf ids (no PTYs respawn on promote)", () => { const a = newLeaf({ label: "a" }); const b = newLeaf({ label: "b" }); const c = newLeaf({ label: "c" }); - const inner = newSplit("v", b, c); - const tree = newSplit("h", a, inner); + const tree = newSplit("h", a, newSplit("v", b, c)); const before = Array.from(walkLeaves(tree)) .map((l) => l.id) .sort(); - const after = Array.from(walkLeaves(promoteFromGutter(tree, inner.id)!)) + const after = Array.from(walkLeaves(promoteLeaf(tree, c.id)!)) .map((l) => l.id) .sort(); expect(after).toEqual(before); }); }); -describe("flattenLayout — promote metadata", () => { - it("populates promote on the inner V-gutter of HSplit(a, VSplit(b, c))", () => { - const a = newLeaf(); - const b = newLeaf(); - const c = newLeaf(); - const inner = newSplit("v", b, c, 0.5); - const tree = newSplit("h", a, inner, 0.5); - - const { gutters } = flattenLayout(tree); - const innerGutter = gutters.find((g) => g.splitId === inner.id)!; - expect(innerGutter.promote).toBeDefined(); - expect(innerGutter.promote!.exitDirection).toBe("left"); - // Sibling box = left half (a's area) - expect(innerGutter.promote!.siblingBox.left).toBe(0); - expect(innerGutter.promote!.siblingBox.width).toBeCloseTo(0.5); - // Promoted box = bottom row, full width - expect(innerGutter.promote!.promotedBox.top).toBeCloseTo(0.5); - expect(innerGutter.promote!.promotedBox.height).toBeCloseTo(0.5); - expect(innerGutter.promote!.promotedBox.left).toBe(0); - expect(innerGutter.promote!.promotedBox.width).toBe(1); - }); - - it("does NOT populate promote on the root gutter or on same-axis nestings", () => { - const root = newSplit("h", newLeaf(), newLeaf()); - const { gutters: g1 } = flattenLayout(root); - expect(g1[0].promote).toBeUndefined(); - - // Same-axis parent: no promote. - const inner = newSplit("h", newLeaf(), newLeaf()); - const sameAxis = newSplit("h", newLeaf(), inner); - const { gutters: g2 } = flattenLayout(sameAxis); - const innerGutter = g2.find((g) => g.splitId === inner.id)!; - expect(innerGutter.promote).toBeUndefined(); - }); -}); - describe("serialize / deserialize", () => { it("roundtrips a complex tree", () => { const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true }); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 77aec9a..e068bf6 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -396,24 +396,6 @@ export interface LeafSlot { box: Box; } -/** Metadata that lets a gutter implement the "drag past sibling to - * promote" gesture. Present iff the gutter's split has an immediate - * parent split with **perpendicular** orientation (otherwise extending - * the gutter across the workspace would just be a noop rearrangement - * of the same axis). */ -export interface GutterPromoteContext { - /** Which side of the workspace the cursor must exit toward to arm - * the gesture — always perpendicular to the gutter's drag axis. */ - exitDirection: Direction; - /** Sibling pane's bounding box (in workspace fractions). The gesture - * arms when the cursor has crossed >75% of this box along - * {@link exitDirection}. */ - siblingBox: Box; - /** Where the promoted leaf will land in the new layout. App uses this - * to render a translucent preview while the gesture is armed. */ - promotedBox: Box; -} - /** A draggable gutter at a split boundary. `box` is where to render the * draggable strip; `parentBox` is the area the gutter divides (needed to * convert pointer position → ratio). */ @@ -423,9 +405,6 @@ export interface GutterInfo { ratio: number; box: Box; parentBox: Box; - /** Promote-gesture data, populated only when the gesture is available - * at this gutter. See {@link GutterPromoteContext}. */ - promote?: GutterPromoteContext; } /** Walk the tree and produce a flat list of leaf slots + draggable gutters. @@ -436,33 +415,11 @@ export function flattenLayout( root: TreeNode, box: Box = { top: 0, left: 0, width: 1, height: 1 }, ): { leaves: LeafSlot[]; gutters: GutterInfo[] } { - return flattenInner(root, box, null); -} - -/** Info passed down during recursion so each split can decide whether the - * promote gesture is available at its gutter. */ -interface ParentCtx { - /** The split whose child we are. */ - parent: SplitNode; - /** Box of the **parent** (not us). When the gesture fires we replace - * this entire region with the new outer split. */ - parentBox: Box; - /** True iff we are parent.a (so the sibling is parent.b). */ - isFirstChild: boolean; - /** Bounding box of our sibling pane in the parent. */ - siblingBox: Box; -} - -function flattenInner( - node: TreeNode, - box: Box, - parent: ParentCtx | null, -): { leaves: LeafSlot[]; gutters: GutterInfo[] } { - if (node.kind === "leaf") { - return { leaves: [{ leaf: node, box }], gutters: [] }; + if (root.kind === "leaf") { + return { leaves: [{ leaf: root, box }], gutters: [] }; } - const isH = node.orientation === "h"; - const r = node.ratio; + const isH = root.orientation === "h"; + const r = root.ratio; let boxA: Box; let boxB: Box; let gutter: GutterInfo; @@ -476,7 +433,7 @@ function flattenInner( height: box.height, }; gutter = { - splitId: node.id, + splitId: root.id, orientation: "h", ratio: r, box: { @@ -497,7 +454,7 @@ function flattenInner( height: box.height - splitPos, }; gutter = { - splitId: node.id, + splitId: root.id, orientation: "v", ratio: r, box: { @@ -509,89 +466,14 @@ function flattenInner( parentBox: box, }; } - - // Promote-gesture metadata: available when the parent split is - // perpendicular to us (otherwise extending the gutter across the - // workspace would not change orientation, and the gesture would have - // no semantic meaning). - if (parent && parent.parent.orientation !== node.orientation) { - gutter.promote = { - exitDirection: exitDirectionToward( - parent.parent.orientation, - parent.isFirstChild, - ), - siblingBox: parent.siblingBox, - promotedBox: computePromotedBox( - parent.parentBox, - node.orientation, - node.ratio, - parent.isFirstChild, - ), - }; - } - - // Recurse, passing each child its own parent context. - const a = flattenInner(node.a, boxA, { - parent: node, - parentBox: box, - isFirstChild: true, - siblingBox: boxB, - }); - const b = flattenInner(node.b, boxB, { - parent: node, - parentBox: box, - isFirstChild: false, - siblingBox: boxA, - }); + const a = flattenLayout(root.a, boxA); + const b = flattenLayout(root.b, boxB); return { leaves: [...a.leaves, ...b.leaves], gutters: [gutter, ...a.gutters, ...b.gutters], }; } -/** From the perspective of `parent.X` (X is a or b), which direction is the - * sibling, i.e., which way would the cursor have to leave our box to - * enter the sibling? */ -function exitDirectionToward(parentOrientation: Orientation, isFirstChild: boolean): Direction { - if (parentOrientation === "h") { - return isFirstChild ? "right" : "left"; - } - return isFirstChild ? "down" : "up"; -} - -/** Where the promoted leaf will land after the gesture commits. - * - `splitOrientation` is the inner split's orientation (becomes the new - * outer split's orientation). - * - `splitRatio` is the inner split's ratio (becomes the new outer's - * ratio so the gutter stays at the same absolute position). - * - `isFirstChild` says which side the promoted leaf ends up on: matches - * our side in the original parent. */ -function computePromotedBox( - parentBox: Box, - splitOrientation: Orientation, - splitRatio: number, - isFirstChild: boolean, -): Box { - if (splitOrientation === "h") { - const leftFrac = isFirstChild ? 0 : splitRatio; - const widthFrac = isFirstChild ? splitRatio : 1 - splitRatio; - return { - top: parentBox.top, - left: parentBox.left + leftFrac * parentBox.width, - width: widthFrac * parentBox.width, - height: parentBox.height, - }; - } - const topFrac = isFirstChild ? 0 : splitRatio; - const heightFrac = isFirstChild ? splitRatio : 1 - splitRatio; - return { - top: parentBox.top + topFrac * parentBox.height, - left: parentBox.left, - width: parentBox.width, - height: heightFrac * parentBox.height, - }; -} - /** Update a split's ratio by its id. */ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode { return replaceById(root, splitId, (node) => { @@ -601,68 +483,87 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number) } /** - * Promote-out gesture. Given a split S whose immediate parent P has - * **perpendicular** orientation, restructure the tree so the gutter S - * was nested inside extends out one level: + * Promote the given leaf out one level in the tree — the keyboard-driven + * equivalent of the "drag past sibling" gesture. Given: * - * HSplit(a, VSplit(b, c)) ──> VSplit(HSplit(a, b), c) + * L's parent split P, P's parent split G (must be perpendicular to P) * - * The promoted child of S is the one on the SAME side as S itself in P - * (so the gesture is symmetric: applying it to the result un-does it). - * The other S child joins P's sibling on the combined side, in P's - * orientation, preserving sibling's original P-side. Ratios are inherited - * — the gutter stays at the same absolute position (modulo parent box - * being the full workspace; in nested cases it shifts but stays sensible). + * restructure so L becomes a direct sibling of the combined (P's other + * child + G's other child) subtree: * - * Returns `null` if the gesture is not applicable (no parent, same - * orientation as parent, or splitId not found). + * HSplit(a, VSplit(b, c)) ──(promote c)──> VSplit(HSplit(a, b), c) + * HSplit(a, VSplit(b, c)) ──(promote b)──> VSplit(b, HSplit(a, c)) + * + * Self-inverse: promoting L, then promoting the leaf adjacent to L in the + * combined subtree, returns the original tree. Ratios from P and G carry + * across so the visible layout is approximately preserved. + * + * Returns `null` when the gesture can't apply: leaf not found, leaf is + * the root (no parent), parent is the root (no grandparent), or + * parent's orientation matches grandparent's (no perpendicular promotion + * available — same-axis nesting doesn't change the workspace shape). */ -export function promoteFromGutter(root: TreeNode, splitId: NodeId): TreeNode | null { - const found = findSplitWithParent(root, splitId); +export function promoteLeaf(root: TreeNode, leafId: NodeId): TreeNode | null { + const found = findLeafWithAncestors(root, leafId); if (!found) return null; - const { s, p, isFirstInP } = found; - if (s.orientation === p.orientation) return null; + const { l, p, g, isLFirstInP, isPFirstInG } = found; + if (p.orientation === g.orientation) return null; - const sibling = isFirstInP ? p.b : p.a; - // Promoted is S's child on the SAME side that S occupies in P. - const promoted = isFirstInP ? s.a : s.b; - const other = isFirstInP ? s.b : s.a; + const siblingOfL = isLFirstInP ? p.b : p.a; + const siblingOfP = isPFirstInG ? g.b : g.a; + // Combined keeps G's orientation; sibling-of-P stays on its original + // G-side so we don't accidentally mirror unrelated panes. const combined: SplitNode = { + kind: "split", + id: newId(), + orientation: g.orientation, + ratio: g.ratio, + a: isPFirstInG ? siblingOfL : siblingOfP, + b: isPFirstInG ? siblingOfP : siblingOfL, + }; + // New outer keeps P's orientation; L stays on its original P-side. + const newOuter: SplitNode = { kind: "split", id: newId(), orientation: p.orientation, ratio: p.ratio, - a: isFirstInP ? other : sibling, - b: isFirstInP ? sibling : other, - }; - const newOuter: SplitNode = { - kind: "split", - id: newId(), - orientation: s.orientation, - ratio: s.ratio, - a: isFirstInP ? promoted : combined, - b: isFirstInP ? combined : promoted, + a: isLFirstInP ? l : combined, + b: isLFirstInP ? combined : l, }; - return replaceById(root, p.id, () => newOuter); + return replaceById(root, g.id, () => newOuter); } -/** Locate a split node and its immediate parent split. Returns null if - * `splitId` is the root, doesn't exist, or refers to a leaf. */ -function findSplitWithParent( +/** Locate a leaf and its parent + grandparent splits. Returns null if + * the leaf doesn't exist or doesn't have two ancestor splits. */ +function findLeafWithAncestors( root: TreeNode, - splitId: NodeId, -): { s: SplitNode; p: SplitNode; isFirstInP: boolean } | null { + leafId: NodeId, +): { + l: LeafNode; + p: SplitNode; + g: SplitNode; + isLFirstInP: boolean; + isPFirstInG: boolean; +} | null { if (root.kind !== "split") return null; - if (root.a.kind === "split" && root.a.id === splitId) { - return { s: root.a, p: root, isFirstInP: true }; - } - if (root.b.kind === "split" && root.b.id === splitId) { - return { s: root.b, p: root, isFirstInP: false }; + // root is the grandparent candidate (G). Look at each direct child of + // root — if that child is a split (P), check P's children for the leaf. + for (const isPFirstInG of [true, false]) { + const p = isPFirstInG ? root.a : root.b; + if (p.kind !== "split") continue; + if (p.a.kind === "leaf" && p.a.id === leafId) { + return { l: p.a, p, g: root, isLFirstInP: true, isPFirstInG }; + } + if (p.b.kind === "leaf" && p.b.id === leafId) { + return { l: p.b, p, g: root, isLFirstInP: false, isPFirstInG }; + } } + // Recurse on root's children to find deeper L-P-G triples. return ( - findSplitWithParent(root.a, splitId) ?? findSplitWithParent(root.b, splitId) + findLeafWithAncestors(root.a, leafId) ?? + findLeafWithAncestors(root.b, leafId) ); }