Replace drag-promote gesture with Ctrl+Shift+P keyboard shortcut

This commit is contained in:
megaproxy 2026-05-25 20:58:43 +01:00
parent 8e4a358aa8
commit 5085326cb1
6 changed files with 142 additions and 373 deletions

View file

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