Enforce minimum pane size (180px) on split and gutter drag
Spamming the ⇥/⇣ split buttons (or their Ctrl+Shift+E/O shortcuts) used to subdivide panes indefinitely, leaving toolbar-only slivers that were unusable. - tree.ts: MIN_PANE_PX = 180 constant. - App.tsx: the `split` orchestration callback now computes the active pane's pixel dimensions from its layout slot + the container rect, and refuses to split if either child would fall below MIN_PANE_PX. Surfaces the refusal via a `notify(...)` toast so the user knows why nothing happened. - Gutter.tsx: pointermove clamps the new ratio so the smaller child stays at least MIN_PANE_PX wide/tall. Falls back to the old 0.05 floor only if the parent is so small that two min-sized panes can't both fit (degraded but functional). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5209e08ae
commit
daf0d4e88a
3 changed files with 50 additions and 11 deletions
43
src/App.tsx
43
src/App.tsx
|
|
@ -27,6 +27,7 @@ import {
|
||||||
updateSplitRatio,
|
updateSplitRatio,
|
||||||
swapLeaves,
|
swapLeaves,
|
||||||
findNeighborInDirection,
|
findNeighborInDirection,
|
||||||
|
MIN_PANE_PX,
|
||||||
type Direction,
|
type Direction,
|
||||||
serialize,
|
serialize,
|
||||||
deserialize,
|
deserialize,
|
||||||
|
|
@ -145,9 +146,41 @@ export default function App() {
|
||||||
return () => clearInterval(interval);
|
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 --------------------------------------------
|
// ---- orchestration callbacks --------------------------------------------
|
||||||
const split = useCallback(
|
const split = useCallback(
|
||||||
(leafId: NodeId, orientation: Orientation) => {
|
(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) => {
|
setTree((t) => {
|
||||||
const parent = findLeaf(t, leafId);
|
const parent = findLeaf(t, leafId);
|
||||||
const inherit = parent
|
const inherit = parent
|
||||||
|
|
@ -156,7 +189,7 @@ export default function App() {
|
||||||
return splitLeaf(t, leafId, orientation, inherit);
|
return splitLeaf(t, leafId, orientation, inherit);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[defaultDistro],
|
[defaultDistro, notify],
|
||||||
);
|
);
|
||||||
|
|
||||||
const close = useCallback(
|
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) => {
|
const dismissNotification = useCallback((id: number) => {
|
||||||
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
setNotifications((ns) => ns.filter((n) => n.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useRef, useState, type PointerEvent } from "react";
|
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.
|
* A draggable gutter at a split boundary.
|
||||||
|
|
@ -59,7 +59,15 @@ export default function Gutter({
|
||||||
info.orientation === "h"
|
info.orientation === "h"
|
||||||
? (xFrac - pb.left) / pb.width
|
? (xFrac - pb.left) / pb.width
|
||||||
: (yFrac - pb.top) / pb.height;
|
: (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;
|
pendingRatioRef.current = ratio;
|
||||||
if (rafRef.current == null) {
|
if (rafRef.current == null) {
|
||||||
rafRef.current = requestAnimationFrame(flushPending);
|
rafRef.current = requestAnimationFrame(flushPending);
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,12 @@ export function setAllBroadcast(root: TreeNode, on: boolean): TreeNode {
|
||||||
return { ...root, a, b };
|
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) ------------------------
|
// --- flat layout (for absolute-positioned rendering) ------------------------
|
||||||
|
|
||||||
/** Normalised bounding box: top/left/width/height as fractions [0, 1]. */
|
/** Normalised bounding box: top/left/width/height as fractions [0, 1]. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue