diff --git a/src/App.css b/src/App.css index 087ef55..84e9d04 100644 --- a/src/App.css +++ b/src/App.css @@ -87,3 +87,12 @@ 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 ef347a0..af2ab04 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { type Orientation, type LeafNode, type LeafShellSpec, + type Box, newLeaf, splitLeaf, closeLeaf, @@ -35,6 +36,7 @@ import { updateSplitRatio, swapLeaves, findNeighborInDirection, + promoteFromGutter, MIN_PANE_PX, type Direction, serialize, @@ -584,6 +586,18 @@ 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; @@ -728,8 +742,25 @@ 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 66cd982..30e9e16 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 GutterInfo, MIN_PANE_PX } from "./tree"; +import { type Box, type GutterInfo, MIN_PANE_PX } from "./tree"; /** * A draggable gutter at a split boundary. @@ -10,17 +10,32 @@ import { type GutterInfo, MIN_PANE_PX } from "./tree"; * * 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. */ const HITBOX_PX = 8; +const PROMOTE_TRIGGER_FRAC = 0.75; 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); @@ -29,6 +44,10 @@ 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; @@ -39,6 +58,15 @@ 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); @@ -72,25 +100,44 @@ 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], + [containerRef, info, flushPending, setArmed], ); - const onPointerUp = useCallback((e: PointerEvent) => { - if (!draggingRef.current) return; - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - draggingRef.current = false; - setDragging(false); - // Make sure the final ratio lands even if the rAF hadn't fired. - if (rafRef.current != null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - if (pendingRatioRef.current != null) { - onRatioChange(info.splitId, pendingRatioRef.current); - pendingRatioRef.current = null; - } - }, [info.splitId, onRatioChange]); + const onPointerUp = useCallback( + (e: PointerEvent) => { + if (!draggingRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + draggingRef.current = false; + setDragging(false); + // Make sure the final ratio lands even if the rAF hadn't fired. + if (rafRef.current != null) { + 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], + ); const isH = info.orientation === "h"; // Visible 4px line, but the draggable hitbox is wider for grabbability. @@ -130,3 +177,27 @@ 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 0e028bf..779a37f 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -25,6 +25,8 @@ import { presetThreeColumns, presetTwoRows, presetTwoByTwo, + promoteFromGutter, + flattenLayout, type TreeNode, type LeafNode, type SplitNode, @@ -502,6 +504,129 @@ describe("presets", () => { }); }); +describe("promoteFromGutter", () => { + it("HSplit(a, VSplit(b, c)) → VSplit(HSplit(a, b), c) when promoting the inner VSplit", () => { + 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 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", () => { + 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; + + 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"); + }); + + 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 split has no parent (root)", () => { + const root = newSplit("h", newLeaf(), newLeaf()); + expect(promoteFromGutter(root, root.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("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 before = Array.from(walkLeaves(tree)) + .map((l) => l.id) + .sort(); + const after = Array.from(walkLeaves(promoteFromGutter(tree, inner.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 f2cb988..77aec9a 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -396,6 +396,24 @@ 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). */ @@ -405,6 +423,9 @@ 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. @@ -415,11 +436,33 @@ export function flattenLayout( root: TreeNode, box: Box = { top: 0, left: 0, width: 1, height: 1 }, ): { leaves: LeafSlot[]; gutters: GutterInfo[] } { - if (root.kind === "leaf") { - return { leaves: [{ leaf: root, box }], gutters: [] }; + 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: [] }; } - const isH = root.orientation === "h"; - const r = root.ratio; + const isH = node.orientation === "h"; + const r = node.ratio; let boxA: Box; let boxB: Box; let gutter: GutterInfo; @@ -433,7 +476,7 @@ export function flattenLayout( height: box.height, }; gutter = { - splitId: root.id, + splitId: node.id, orientation: "h", ratio: r, box: { @@ -454,7 +497,7 @@ export function flattenLayout( height: box.height - splitPos, }; gutter = { - splitId: root.id, + splitId: node.id, orientation: "v", ratio: r, box: { @@ -466,14 +509,89 @@ export function flattenLayout( parentBox: box, }; } - const a = flattenLayout(root.a, boxA); - const b = flattenLayout(root.b, boxB); + + // 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, + }); 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) => { @@ -482,6 +600,72 @@ 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: + * + * HSplit(a, VSplit(b, c)) ──> VSplit(HSplit(a, b), c) + * + * 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). + * + * Returns `null` if the gesture is not applicable (no parent, same + * orientation as parent, or splitId not found). + */ +export function promoteFromGutter(root: TreeNode, splitId: NodeId): TreeNode | null { + const found = findSplitWithParent(root, splitId); + if (!found) return null; + const { s, p, isFirstInP } = found; + if (s.orientation === p.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 combined: 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, + }; + + return replaceById(root, p.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( + root: TreeNode, + splitId: NodeId, +): { s: SplitNode; p: SplitNode; isFirstInP: 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 }; + } + return ( + findSplitWithParent(root.a, splitId) ?? findSplitWithParent(root.b, splitId) + ); +} + export type Direction = "left" | "right" | "up" | "down"; /** Spatial pane navigation: given an active leaf, find the nearest neighbor