diff --git a/src/App.tsx b/src/App.tsx index 5bd84e2..6cab616 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ import { updateSplitRatio, swapLeaves, findNeighborInDirection, + MIN_PANE_PX, type Direction, serialize, deserialize, @@ -145,9 +146,41 @@ export default function App() { return () => clearInterval(interval); }, []); + // notify is defined up here (and not next to dismissNotification) because + // the split callback below uses it to warn when a pane is too small to + // subdivide further. Build-mode tsc balks on use-before-declaration. + const notify = useCallback((message: string) => { + const id = nextNotifIdRef.current++; + setNotifications((ns) => [...ns, { id, message }]); + window.setTimeout(() => { + setNotifications((ns) => ns.filter((n) => n.id !== id)); + }, 5000); + }, []); + // ---- orchestration callbacks -------------------------------------------- const split = useCallback( (leafId: NodeId, orientation: Orientation) => { + // Refuse the split if it would produce a child pane below MIN_PANE_PX + // — otherwise spamming the split buttons shrinks panes into nothing. + const container = paneWrapRef.current; + if (container) { + const slot = flattenLayout(treeRef.current).leaves.find( + (s) => s.leaf.id === leafId, + ); + if (slot) { + const rect = container.getBoundingClientRect(); + const paneW = slot.box.width * rect.width; + const paneH = slot.box.height * rect.height; + const childW = orientation === "h" ? paneW / 2 : paneW; + const childH = orientation === "v" ? paneH / 2 : paneH; + if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) { + notify( + `Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`, + ); + return; + } + } + } setTree((t) => { const parent = findLeaf(t, leafId); const inherit = parent @@ -156,7 +189,7 @@ export default function App() { return splitLeaf(t, leafId, orientation, inherit); }); }, - [defaultDistro], + [defaultDistro, notify], ); const close = useCallback( @@ -317,14 +350,6 @@ export default function App() { [], ); - const notify = useCallback((message: string) => { - const id = nextNotifIdRef.current++; - setNotifications((ns) => [...ns, { id, message }]); - window.setTimeout(() => { - setNotifications((ns) => ns.filter((n) => n.id !== id)); - }, 5000); - }, []); - const dismissNotification = useCallback((id: number) => { setNotifications((ns) => ns.filter((n) => n.id !== id)); }, []); diff --git a/src/lib/layout/Gutter.tsx b/src/lib/layout/Gutter.tsx index 8cb85e3..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 { GutterInfo } from "./tree"; +import { type GutterInfo, MIN_PANE_PX } from "./tree"; /** * A draggable gutter at a split boundary. @@ -59,7 +59,15 @@ export default function Gutter({ info.orientation === "h" ? (xFrac - pb.left) / pb.width : (yFrac - pb.top) / pb.height; - const ratio = Math.max(0.05, Math.min(0.95, rawRatio)); + // Clamp so neither child shrinks below MIN_PANE_PX. If the parent + // box is so small that two min-sized panes don't fit, fall back to + // 0.05 / 0.95 (ugly but functional). + const parentPx = + info.orientation === "h" ? rect.width * pb.width : rect.height * pb.height; + const minByPx = parentPx > 0 ? MIN_PANE_PX / parentPx : 0.05; + const minRatio = Math.min(0.45, Math.max(0.05, minByPx)); + const maxRatio = 1 - minRatio; + const ratio = Math.max(minRatio, Math.min(maxRatio, rawRatio)); pendingRatioRef.current = ratio; if (rafRef.current == null) { rafRef.current = requestAnimationFrame(flushPending); diff --git a/src/lib/layout/tree.ts b/src/lib/layout/tree.ts index 59a4836..4d60116 100644 --- a/src/lib/layout/tree.ts +++ b/src/lib/layout/tree.ts @@ -253,6 +253,12 @@ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode { return { ...root, a, b }; } +/** Minimum width/height (px) we allow a pane to shrink to. Just enough for + * the toolbar + a few cols/rows of usable terminal. Used by the split + * handler (to refuse subdividing a pane that's already too small) and by + * the gutter drag (to clamp ratios so neither child drops below this). */ +export const MIN_PANE_PX = 180; + // --- flat layout (for absolute-positioned rendering) ------------------------ /** Normalised bounding box: top/left/width/height as fractions [0, 1]. */