Compare commits
No commits in common. "150e5f09cb5b812481903ccf93f2688fb5435cab" and "b462f9f3bfc971089387db818dc8e33b35e2918a" have entirely different histories.
150e5f09cb
...
b462f9f3bf
6 changed files with 25 additions and 475 deletions
30
src-tauri/Cargo.lock
generated
30
src-tauri/Cargo.lock
generated
|
|
@ -1984,15 +1984,6 @@ 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"
|
||||||
|
|
@ -3996,7 +3987,6 @@ 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",
|
||||||
|
|
@ -4009,7 +3999,6 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"windows-native-keyring-store",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5005,19 +4994,6 @@ 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"
|
||||||
|
|
@ -5598,12 +5574,6 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,3 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
31
src/App.tsx
31
src/App.tsx
|
|
@ -18,7 +18,6 @@ import {
|
||||||
type Orientation,
|
type Orientation,
|
||||||
type LeafNode,
|
type LeafNode,
|
||||||
type LeafShellSpec,
|
type LeafShellSpec,
|
||||||
type Box,
|
|
||||||
newLeaf,
|
newLeaf,
|
||||||
splitLeaf,
|
splitLeaf,
|
||||||
closeLeaf,
|
closeLeaf,
|
||||||
|
|
@ -36,7 +35,6 @@ import {
|
||||||
updateSplitRatio,
|
updateSplitRatio,
|
||||||
swapLeaves,
|
swapLeaves,
|
||||||
findNeighborInDirection,
|
findNeighborInDirection,
|
||||||
promoteFromGutter,
|
|
||||||
MIN_PANE_PX,
|
MIN_PANE_PX,
|
||||||
type Direction,
|
type Direction,
|
||||||
serialize,
|
serialize,
|
||||||
|
|
@ -586,18 +584,6 @@ 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;
|
||||||
|
|
@ -742,25 +728,8 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useRef, useState, type PointerEvent } from "react";
|
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.
|
* A draggable gutter at a split boundary.
|
||||||
|
|
@ -10,32 +10,17 @@ import { type Box, 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);
|
||||||
|
|
@ -44,10 +29,6 @@ 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;
|
||||||
|
|
@ -58,15 +39,6 @@ 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);
|
||||||
|
|
@ -100,18 +72,11 @@ 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, setArmed],
|
[containerRef, info, flushPending],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerUp = useCallback(
|
const onPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
||||||
(e: PointerEvent<HTMLDivElement>) => {
|
|
||||||
if (!draggingRef.current) return;
|
if (!draggingRef.current) return;
|
||||||
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
draggingRef.current = false;
|
draggingRef.current = false;
|
||||||
|
|
@ -121,23 +86,11 @@ export default function Gutter({
|
||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = null;
|
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) {
|
if (pendingRatioRef.current != null) {
|
||||||
onRatioChange(info.splitId, pendingRatioRef.current);
|
onRatioChange(info.splitId, pendingRatioRef.current);
|
||||||
pendingRatioRef.current = null;
|
pendingRatioRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
}, [info.splitId, onRatioChange]);
|
||||||
[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.
|
||||||
|
|
@ -177,27 +130,3 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ import {
|
||||||
presetThreeColumns,
|
presetThreeColumns,
|
||||||
presetTwoRows,
|
presetTwoRows,
|
||||||
presetTwoByTwo,
|
presetTwoByTwo,
|
||||||
promoteFromGutter,
|
|
||||||
flattenLayout,
|
|
||||||
type TreeNode,
|
type TreeNode,
|
||||||
type LeafNode,
|
type LeafNode,
|
||||||
type SplitNode,
|
type SplitNode,
|
||||||
|
|
@ -504,129 +502,6 @@ 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 });
|
||||||
|
|
|
||||||
|
|
@ -396,24 +396,6 @@ 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). */
|
||||||
|
|
@ -423,9 +405,6 @@ 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.
|
||||||
|
|
@ -436,33 +415,11 @@ 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[] } {
|
||||||
return flattenInner(root, box, null);
|
if (root.kind === "leaf") {
|
||||||
}
|
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 = node.orientation === "h";
|
const isH = root.orientation === "h";
|
||||||
const r = node.ratio;
|
const r = root.ratio;
|
||||||
let boxA: Box;
|
let boxA: Box;
|
||||||
let boxB: Box;
|
let boxB: Box;
|
||||||
let gutter: GutterInfo;
|
let gutter: GutterInfo;
|
||||||
|
|
@ -476,7 +433,7 @@ function flattenInner(
|
||||||
height: box.height,
|
height: box.height,
|
||||||
};
|
};
|
||||||
gutter = {
|
gutter = {
|
||||||
splitId: node.id,
|
splitId: root.id,
|
||||||
orientation: "h",
|
orientation: "h",
|
||||||
ratio: r,
|
ratio: r,
|
||||||
box: {
|
box: {
|
||||||
|
|
@ -497,7 +454,7 @@ function flattenInner(
|
||||||
height: box.height - splitPos,
|
height: box.height - splitPos,
|
||||||
};
|
};
|
||||||
gutter = {
|
gutter = {
|
||||||
splitId: node.id,
|
splitId: root.id,
|
||||||
orientation: "v",
|
orientation: "v",
|
||||||
ratio: r,
|
ratio: r,
|
||||||
box: {
|
box: {
|
||||||
|
|
@ -509,89 +466,14 @@ function flattenInner(
|
||||||
parentBox: box,
|
parentBox: box,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
const a = flattenLayout(root.a, boxA);
|
||||||
// Promote-gesture metadata: available when the parent split is
|
const b = flattenLayout(root.b, boxB);
|
||||||
// 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) => {
|
||||||
|
|
@ -600,72 +482,6 @@ 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue