Compare commits

...

2 commits

6 changed files with 475 additions and 25 deletions

30
src-tauri/Cargo.lock generated
View file

@ -1984,6 +1984,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "keyring-core"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
dependencies = [
"log",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -3987,6 +3996,7 @@ version = "0.2.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64 0.22.1",
"keyring-core",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"portable-pty", "portable-pty",
@ -3999,6 +4009,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"windows-native-keyring-store",
] ]
[[package]] [[package]]
@ -4994,6 +5005,19 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]] [[package]]
name = "windows-numerics" name = "windows-numerics"
version = "0.2.0" version = "0.2.0"
@ -5574,6 +5598,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"

View file

@ -87,3 +87,12 @@
padding: 2px; padding: 2px;
box-sizing: border-box; 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;
}

View file

@ -18,6 +18,7 @@ import {
type Orientation, type Orientation,
type LeafNode, type LeafNode,
type LeafShellSpec, type LeafShellSpec,
type Box,
newLeaf, newLeaf,
splitLeaf, splitLeaf,
closeLeaf, closeLeaf,
@ -35,6 +36,7 @@ import {
updateSplitRatio, updateSplitRatio,
swapLeaves, swapLeaves,
findNeighborInDirection, findNeighborInDirection,
promoteFromGutter,
MIN_PANE_PX, MIN_PANE_PX,
type Direction, type Direction,
serialize, serialize,
@ -584,6 +586,18 @@ export default function App() {
setTree((t) => updateSplitRatio(t, splitId, ratio)); 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<Box | null>(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) ------------------------- // ---- global broadcast state (derived from tree) -------------------------
const broadcastStats = useMemo(() => { const broadcastStats = useMemo(() => {
let on = 0; let on = 0;
@ -728,8 +742,25 @@ export default function App() {
info={g} info={g}
containerRef={paneWrapRef} containerRef={paneWrapRef}
onRatioChange={onGutterRatio} onRatioChange={onGutterRatio}
onArmedChange={onGutterArmedChange}
onPromote={onGutterPromote}
/> />
))} ))}
{armedPromotionBox && (
<div
className="promote-preview"
style={{
position: "absolute",
top: `${armedPromotionBox.top * 100}%`,
left: `${armedPromotionBox.left * 100}%`,
width: `${armedPromotionBox.width * 100}%`,
height: `${armedPromotionBox.height * 100}%`,
pointerEvents: "none",
zIndex: 20,
}}
aria-hidden="true"
/>
)}
</OrchestrationProvider> </OrchestrationProvider>
)} )}
</div> </div>

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useState, type PointerEvent } from "react"; 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. * 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 * 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. * 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 HITBOX_PX = 8;
const PROMOTE_TRIGGER_FRAC = 0.75;
export default function Gutter({ export default function Gutter({
info, info,
containerRef, containerRef,
onRatioChange, onRatioChange,
onArmedChange,
onPromote,
}: { }: {
info: GutterInfo; info: GutterInfo;
containerRef: React.RefObject<HTMLDivElement | null>; containerRef: React.RefObject<HTMLDivElement | null>;
onRatioChange: (splitId: string, ratio: number) => void; 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 [dragging, setDragging] = useState(false);
const draggingRef = useRef(false); const draggingRef = useRef(false);
@ -29,6 +44,10 @@ export default function Gutter({
// and leaves artifacts). // and leaves artifacts).
const pendingRatioRef = useRef<number | null>(null); const pendingRatioRef = useRef<number | null>(null);
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(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(() => { const flushPending = useCallback(() => {
rafRef.current = null; rafRef.current = null;
@ -39,6 +58,15 @@ export default function Gutter({
} }
}, [info.splitId, onRatioChange]); }, [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<HTMLDivElement>) => { const onPointerDown = useCallback((e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId); (e.target as HTMLElement).setPointerCapture(e.pointerId);
setDragging(true); setDragging(true);
@ -72,25 +100,44 @@ export default function Gutter({
if (rafRef.current == null) { if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(flushPending); 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<HTMLDivElement>) => { const onPointerUp = useCallback(
if (!draggingRef.current) return; (e: PointerEvent<HTMLDivElement>) => {
(e.target as HTMLElement).releasePointerCapture(e.pointerId); if (!draggingRef.current) return;
draggingRef.current = false; (e.target as HTMLElement).releasePointerCapture(e.pointerId);
setDragging(false); draggingRef.current = false;
// Make sure the final ratio lands even if the rAF hadn't fired. setDragging(false);
if (rafRef.current != null) { // Make sure the final ratio lands even if the rAF hadn't fired.
cancelAnimationFrame(rafRef.current); if (rafRef.current != null) {
rafRef.current = null; cancelAnimationFrame(rafRef.current);
} rafRef.current = null;
if (pendingRatioRef.current != null) { }
onRatioChange(info.splitId, pendingRatioRef.current); const wasArmed = armedRef.current;
pendingRatioRef.current = null; setArmed(false);
} if (wasArmed) {
}, [info.splitId, onRatioChange]); // 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"; const isH = info.orientation === "h";
// Visible 4px line, but the draggable hitbox is wider for grabbability. // Visible 4px line, but the draggable hitbox is wider for grabbability.
@ -130,3 +177,27 @@ export default function Gutter({
/> />
); );
} }
function isArmed(
promote: NonNullable<GutterInfo["promote"]>,
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;
}
}

View file

@ -25,6 +25,8 @@ import {
presetThreeColumns, presetThreeColumns,
presetTwoRows, presetTwoRows,
presetTwoByTwo, presetTwoByTwo,
promoteFromGutter,
flattenLayout,
type TreeNode, type TreeNode,
type LeafNode, type LeafNode,
type SplitNode, 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", () => { describe("serialize / deserialize", () => {
it("roundtrips a complex tree", () => { it("roundtrips a complex tree", () => {
const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true }); const leaf1 = newLeaf({ distro: "Ubuntu", label: "left", broadcast: true });

View file

@ -396,6 +396,24 @@ export interface LeafSlot {
box: Box; 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 /** A draggable gutter at a split boundary. `box` is where to render the
* draggable strip; `parentBox` is the area the gutter divides (needed to * draggable strip; `parentBox` is the area the gutter divides (needed to
* convert pointer position ratio). */ * convert pointer position ratio). */
@ -405,6 +423,9 @@ export interface GutterInfo {
ratio: number; ratio: number;
box: Box; box: Box;
parentBox: 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. /** Walk the tree and produce a flat list of leaf slots + draggable gutters.
@ -415,11 +436,33 @@ export function flattenLayout(
root: TreeNode, root: TreeNode,
box: Box = { top: 0, left: 0, width: 1, height: 1 }, box: Box = { top: 0, left: 0, width: 1, height: 1 },
): { leaves: LeafSlot[]; gutters: GutterInfo[] } { ): { leaves: LeafSlot[]; gutters: GutterInfo[] } {
if (root.kind === "leaf") { return flattenInner(root, box, null);
return { leaves: [{ leaf: root, box }], gutters: [] }; }
/** 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 isH = node.orientation === "h";
const r = root.ratio; const r = node.ratio;
let boxA: Box; let boxA: Box;
let boxB: Box; let boxB: Box;
let gutter: GutterInfo; let gutter: GutterInfo;
@ -433,7 +476,7 @@ export function flattenLayout(
height: box.height, height: box.height,
}; };
gutter = { gutter = {
splitId: root.id, splitId: node.id,
orientation: "h", orientation: "h",
ratio: r, ratio: r,
box: { box: {
@ -454,7 +497,7 @@ export function flattenLayout(
height: box.height - splitPos, height: box.height - splitPos,
}; };
gutter = { gutter = {
splitId: root.id, splitId: node.id,
orientation: "v", orientation: "v",
ratio: r, ratio: r,
box: { box: {
@ -466,14 +509,89 @@ export function flattenLayout(
parentBox: box, 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 { return {
leaves: [...a.leaves, ...b.leaves], leaves: [...a.leaves, ...b.leaves],
gutters: [gutter, ...a.gutters, ...b.gutters], 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. */ /** Update a split's ratio by its id. */
export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode { export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode {
return replaceById(root, splitId, (node) => { 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"; export type Direction = "left" | "right" | "up" | "down";
/** Spatial pane navigation: given an active leaf, find the nearest neighbor /** Spatial pane navigation: given an active leaf, find the nearest neighbor