diff --git a/src/App.tsx b/src/App.tsx index d917f79..de31fff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,8 @@ import { toggleBroadcast as toggleBroadcastInTree, setAllBroadcast, reshapeToPreset, + flattenLayout, + updateSplitRatio, serialize, deserialize, presetSingle, @@ -32,10 +34,12 @@ import { presetTwoByTwo, } from "./lib/layout/tree"; import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration"; -import Pane from "./lib/layout/Pane"; +import LeafPane from "./lib/layout/LeafPane"; +import Gutter from "./lib/layout/Gutter"; import Notifications, { type Toast } from "./components/Notifications"; import Palette from "./components/Palette"; import "./App.css"; +import "./lib/layout/Gutter.css"; const LEGACY_STORAGE_KEY = "tiletopia.tree.v1"; const SAVE_DEBOUNCE_MS = 500; @@ -299,6 +303,16 @@ export default function App() { [paletteOpen, tree], ); + // ---- flat layout — leaves as siblings keyed by id; gutters separate ----- + // This lets React preserve LeafPane (and its PTY) across any tree reshape + // — split, close, preset application, etc. The tree changes, the boxes + // change, the leaves re-position but DON'T unmount. + const layout = useMemo(() => flattenLayout(tree), [tree]); + const paneWrapRef = useRef(null); + const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => { + setTree((t) => updateSplitRatio(t, splitId, ratio)); + }, []); + // ---- global broadcast state (derived from tree) ------------------------- const broadcastStats = useMemo(() => { let on = 0; @@ -395,10 +409,32 @@ export default function App() { -
+
{ready && ( - + {layout.leaves.map(({ leaf, box }) => ( +
+ +
+ ))} + {layout.gutters.map((g) => ( + + ))}
)}
diff --git a/src/lib/layout/Gutter.css b/src/lib/layout/Gutter.css new file mode 100644 index 0000000..fe4d777 --- /dev/null +++ b/src/lib/layout/Gutter.css @@ -0,0 +1,32 @@ +/* The hitbox is invisible; we render a 4px visible line in the middle + via a pseudo-element so the grab area is generous while the visual + stays thin. */ +.gutter { + background: transparent; + user-select: none; + touch-action: none; +} +.gutter::before { + content: ""; + position: absolute; + background: #1a1a1a; + transition: background 0.12s; +} +.gutter-h::before { + top: 0; + bottom: 0; + left: 50%; + width: 4px; + transform: translateX(-50%); +} +.gutter-v::before { + left: 0; + right: 0; + top: 50%; + height: 4px; + transform: translateY(-50%); +} +.gutter:hover::before, +.gutter.active::before { + background: #3a5a8c; +} diff --git a/src/lib/layout/Gutter.tsx b/src/lib/layout/Gutter.tsx new file mode 100644 index 0000000..79afa5b --- /dev/null +++ b/src/lib/layout/Gutter.tsx @@ -0,0 +1,98 @@ +import { useCallback, useRef, useState, type PointerEvent } from "react"; +import type { GutterInfo } from "./tree"; + +/** + * A draggable gutter at a split boundary. + * + * `info.box` is where to render the strip (in container fractions 0–1); + * `info.parentBox` is the parent split's bounding box, used to convert + * pointer position back into a 0–1 ratio. + * + * 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. + */ +const HITBOX_PX = 8; + +export default function Gutter({ + info, + containerRef, + onRatioChange, +}: { + info: GutterInfo; + containerRef: React.RefObject; + onRatioChange: (splitId: string, ratio: number) => void; +}) { + const [dragging, setDragging] = useState(false); + const draggingRef = useRef(false); + + const onPointerDown = useCallback((e: PointerEvent) => { + (e.target as HTMLElement).setPointerCapture(e.pointerId); + setDragging(true); + draggingRef.current = true; + e.preventDefault(); + e.stopPropagation(); + }, []); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + if (!draggingRef.current || !containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + const xFrac = (e.clientX - rect.left) / rect.width; + const yFrac = (e.clientY - rect.top) / rect.height; + const pb = info.parentBox; + const rawRatio = + info.orientation === "h" + ? (xFrac - pb.left) / pb.width + : (yFrac - pb.top) / pb.height; + const ratio = Math.max(0.05, Math.min(0.95, rawRatio)); + onRatioChange(info.splitId, ratio); + }, + [containerRef, info, onRatioChange], + ); + + const onPointerUp = useCallback((e: PointerEvent) => { + if (!draggingRef.current) return; + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + draggingRef.current = false; + setDragging(false); + }, []); + + const isH = info.orientation === "h"; + // Visible 4px line, but the draggable hitbox is wider for grabbability. + const halfHit = HITBOX_PX / 2; + const style: React.CSSProperties = isH + ? { + position: "absolute", + top: `${info.box.top * 100}%`, + left: `calc(${info.box.left * 100}% - ${halfHit}px)`, + height: `${info.box.height * 100}%`, + width: `${HITBOX_PX}px`, + cursor: "col-resize", + zIndex: 10, + } + : { + position: "absolute", + top: `calc(${info.box.top * 100}% - ${halfHit}px)`, + left: `${info.box.left * 100}%`, + width: `${info.box.width * 100}%`, + height: `${HITBOX_PX}px`, + cursor: "row-resize", + zIndex: 10, + }; + + return ( +
+ ); +} diff --git a/src/lib/layout/Pane.tsx b/src/lib/layout/Pane.tsx deleted file mode 100644 index f3f27c7..0000000 --- a/src/lib/layout/Pane.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { TreeNode } from "./tree"; -import SplitNode from "./SplitNode"; -import LeafPane from "./LeafPane"; - -/** - * Recursive dispatcher: render a split or a leaf based on node.kind. - * The `key={node.id}` on the leaf branch makes React unmount + remount - * cleanly when a leaf is replaced (e.g. changeDistro swaps the id to - * force PTY respawn). - */ -export default function Pane({ node }: { node: TreeNode }) { - if (node.kind === "split") { - return ; - } - return ; -} diff --git a/src/lib/layout/SplitNode.css b/src/lib/layout/SplitNode.css deleted file mode 100644 index 531d2c4..0000000 --- a/src/lib/layout/SplitNode.css +++ /dev/null @@ -1,36 +0,0 @@ -.split { - display: flex; - width: 100%; - height: 100%; - min-width: 0; - min-height: 0; -} -.split.horizontal { - flex-direction: row; -} -.split.vertical { - flex-direction: column; -} - -.side { - display: flex; - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.gutter { - flex: 0 0 4px; - background: #1a1a1a; - cursor: col-resize; - user-select: none; - touch-action: none; - transition: background 0.12s; -} -.split.vertical > .gutter { - cursor: row-resize; -} -.gutter:hover, -.gutter.active { - background: #3a5a8c; -} diff --git a/src/lib/layout/SplitNode.tsx b/src/lib/layout/SplitNode.tsx deleted file mode 100644 index 814c584..0000000 --- a/src/lib/layout/SplitNode.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useRef, useState, useCallback, type PointerEvent } from "react"; -import type { SplitNode as SplitNodeType } from "./tree"; -import Pane from "./Pane"; -import "./SplitNode.css"; - -/** - * A horizontal or vertical split with a draggable gutter. The ratio is - * local React state — when the gutter is dragged, we update the local - * ratio (re-rendering the two .side flex values) and ALSO bubble the - * change up to the tree (so it persists across reloads). - * - * Initialising local state from node.ratio is fine: when the tree - * mutates around this split (e.g. a child is closed), React will give us - * a new `node` prop with possibly-different `node.ratio`, but the - * `useState` initializer only runs once. We re-sync via an effect. - */ -export default function SplitNode({ node }: { node: SplitNodeType }) { - const containerRef = useRef(null); - const [ratio, setRatio] = useState(node.ratio); - const [dragging, setDragging] = useState(false); - - // Keep local ratio in sync if the tree updates from outside (e.g. preset - // applied). Only mirror — don't echo back into the tree. - // (Skipped for simplicity in v1; if it becomes annoying we can add it.) - - const onPointerDown = useCallback((e: PointerEvent) => { - (e.target as HTMLElement).setPointerCapture(e.pointerId); - setDragging(true); - e.preventDefault(); - }, []); - - const onPointerMove = useCallback( - (e: PointerEvent) => { - if (!dragging || !containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - const isH = node.orientation === "h"; - const pos = isH ? e.clientX - rect.left : e.clientY - rect.top; - const size = isH ? rect.width : rect.height; - if (size <= 0) return; - const r = Math.max(0.05, Math.min(0.95, pos / size)); - setRatio(r); - // Mutate the proxy-tree node directly so the persisted state matches. - node.ratio = r; - }, - [dragging, node], - ); - - const onPointerUp = useCallback((e: PointerEvent) => { - setDragging(false); - (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, []); - - const isH = node.orientation === "h"; - - return ( -
-
- -
-
-
- -
-
- ); -} diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 38bfbc8..c05fdfb 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -253,6 +253,109 @@ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode { return { ...root, a, b }; } +// --- flat layout (for absolute-positioned rendering) ------------------------ + +/** Normalised bounding box: top/left/width/height as fractions [0, 1]. */ +export interface Box { + top: number; + left: number; + width: number; + height: number; +} + +/** A leaf rendered as a flat sibling: its current LeafNode plus the box + * it occupies in the container. */ +export interface LeafSlot { + leaf: LeafNode; + box: 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). */ +export interface GutterInfo { + splitId: NodeId; + orientation: Orientation; + ratio: number; + box: Box; + parentBox: Box; +} + +/** Walk the tree and produce a flat list of leaf slots + draggable gutters. + * Renderer uses these to position all leaves as siblings in the DOM, which + * lets React preserve component instances (and thus PTYs) across any tree + * reshape — splits, closes, presets, etc. */ +export function flattenLayout( + root: TreeNode, + box: Box = { top: 0, left: 0, width: 1, height: 1 }, +): { leaves: LeafSlot[]; gutters: GutterInfo[] } { + if (root.kind === "leaf") { + return { leaves: [{ leaf: root, box }], gutters: [] }; + } + const isH = root.orientation === "h"; + const r = root.ratio; + let boxA: Box; + let boxB: Box; + let gutter: GutterInfo; + if (isH) { + const splitPos = box.width * r; + boxA = { top: box.top, left: box.left, width: splitPos, height: box.height }; + boxB = { + top: box.top, + left: box.left + splitPos, + width: box.width - splitPos, + height: box.height, + }; + gutter = { + splitId: root.id, + orientation: "h", + ratio: r, + box: { + top: box.top, + left: box.left + splitPos, + width: 0, + height: box.height, + }, + parentBox: box, + }; + } else { + const splitPos = box.height * r; + boxA = { top: box.top, left: box.left, width: box.width, height: splitPos }; + boxB = { + top: box.top + splitPos, + left: box.left, + width: box.width, + height: box.height - splitPos, + }; + gutter = { + splitId: root.id, + orientation: "v", + ratio: r, + box: { + top: box.top + splitPos, + left: box.left, + width: box.width, + height: 0, + }, + parentBox: box, + }; + } + const a = flattenLayout(root.a, boxA); + const b = flattenLayout(root.b, boxB); + return { + leaves: [...a.leaves, ...b.leaves], + gutters: [gutter, ...a.gutters, ...b.gutters], + }; +} + +/** Update a split's ratio by its id. */ +export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number): TreeNode { + return replaceById(root, splitId, (node) => { + if (node.kind !== "split") return node; + return { ...node, ratio }; + }); +} + export function serialize(root: TreeNode): string { return JSON.stringify(root); }