Promote nested pane to full row/column by dragging gutter past sibling

This commit is contained in:
megaproxy 2026-05-25 20:24:47 +01:00
parent dbd6c163c3
commit 150e5f09cb
5 changed files with 445 additions and 25 deletions

View file

@ -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 });