diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8e6d58e..13fe735 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1984,15 +1984,6 @@ dependencies = [ "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]] name = "lazy_static" version = "1.5.0" @@ -3996,7 +3987,6 @@ version = "0.2.3" dependencies = [ "anyhow", "base64 0.22.1", - "keyring-core", "once_cell", "parking_lot", "portable-pty", @@ -4009,7 +3999,6 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "windows-native-keyring-store", ] [[package]] @@ -5005,19 +4994,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-numerics" version = "0.2.0" @@ -5598,12 +5574,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src/App.css b/src/App.css index 84e9d04..087ef55 100644 --- a/src/App.css +++ b/src/App.css @@ -87,12 +87,3 @@ 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 af2ab04..ef347a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,6 @@ import { type Orientation, type LeafNode, type LeafShellSpec, - type Box, newLeaf, splitLeaf, closeLeaf, @@ -36,7 +35,6 @@ import { updateSplitRatio, swapLeaves, findNeighborInDirection, - promoteFromGutter, MIN_PANE_PX, type Direction, serialize, @@ -586,18 +584,6 @@ 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; @@ -742,25 +728,8 @@ 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 30e9e16..66cd982 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 Box, type GutterInfo, MIN_PANE_PX } from "./tree"; +import { type GutterInfo, MIN_PANE_PX } from "./tree"; /** * 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 * 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); @@ -44,10 +29,6 @@ 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; @@ -58,15 +39,6 @@ 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); @@ -100,44 +72,25 @@ 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, setArmed], + [containerRef, info, flushPending], ); - 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 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 isH = info.orientation === "h"; // Visible 4px line, but the draggable hitbox is wider for grabbability. @@ -177,27 +130,3 @@ 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 779a37f..0e028bf 100644 --- a/src/lib/layout/tree.test.ts +++ b/src/lib/layout/tree.test.ts @@ -25,8 +25,6 @@ import { presetThreeColumns, presetTwoRows, presetTwoByTwo, - promoteFromGutter, - flattenLayout, type TreeNode, type LeafNode, 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", () => { 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 77aec9a..f2cb988 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -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) => { @@ -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"; /** Spatial pane navigation: given an active leaf, find the nearest neighbor