fix(pane): 3-column grab zones with cursor offset

Splitter strips widen to border +/- 1 help-padded column so the thin
border is easy to target; mousedown records the cursor's offset from
the border column and drag subtracts it, so the border tracks the
cursor instead of jumping. Padded columns must never overlap
interactive content (rect-based hit grid). Dead one-off render
harness scripts/_hv.ts removed. Tests cover far/inner-edge grabs
and no-resize on padded-column clicks.
This commit is contained in:
2026-09-06 18:46:42 -04:00
parent b53d4add29
commit 132d2079f7
3 changed files with 96 additions and 39 deletions

View File

@@ -1,9 +0,0 @@
import { testRender } from "@opentui/solid";
const { ThemeProvider } = await import("../src/context/ThemeContext");
const { PaneRow } = await import("../src/components/PaneRow");
process.env.XDG_CONFIG_HOME = import.meta.dir + "/../.harness/config-home";
import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path";
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "hv-"));
const setup = (await testRender(
() => React.createElement...
));

View File

@@ -5,9 +5,9 @@
* CENTER (current) column are draggable and resize the neighboring panes.
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
* as fractions of the row width; this component resolves them to pixel
* columns, gives each column an explicit width (so the drag strips sit
* exactly on the drawn borders), and renders two invisible grab handles over
* the border cells.
* columns, gives each column an explicit width (so the grab zones sit
* exactly on the drawn borders), and renders two 3-column invisible grab
* zones over the borders.
*
* Column semantics (per the yazi depth model):
* parent — the previous-depth list. Renders a muted `—` placeholder and
@@ -160,15 +160,22 @@ function Pane(props: {
);
}
/** A 1-column invisible grab handle covering exactly one border of the
* current pane. `onBegin` is called on mousedown; subsequent drag/drag-end
* events bubble up the row and drive `usePaneLayout` there. On hover or
* while dragging it overdraws the border with a full-height accent `│`
* line (a bordered box would render as a blocky rectangle instead). */
/** A 3-column invisible grab zone centered on one border of the current
* pane: the border column plus one column of help padding on each side,
* so the thin border is easy to target with a mouse. `onBegin` is called
* on mousedown with the cursor's x; the row records that grab offset so
* the border stays glued to the cursor while dragging. On hover or while
* dragging it overdraws just the border column with a full-height accent
* `│` line (a bordered box would render as a blocky rectangle instead).
* The two padding columns are transparent; the hit grid is rect-based, so
* they capture clicks too — they must never overlap interactive content. */
function Splitter(props: {
/** Column of the border itself. The strip spans `left - 1` .. `left + 1`
* (the border plus one help-padded column each side); the highlight
* renders at `left`. */
left: number;
active: boolean;
onBegin: () => void;
onBegin: (x: number) => void;
}) {
const { theme } = useTheme();
const dims = useTerminalDimensions();
@@ -177,13 +184,13 @@ function Splitter(props: {
return (
<box
position="absolute"
left={props.left}
left={props.left - 1}
top={0}
width={1}
width={3}
height="100%"
onMouseDown={(e) => {
e.preventDefault?.();
props.onBegin();
props.onBegin(e.x);
}}
onMouseOver={() => setHovered(true)}
onMouseOut={() => setHovered(false)}
@@ -192,7 +199,7 @@ function Splitter(props: {
{/* Draw the accent edge down the full pane height; the box clips
* any excess rows below the row's bottom edge. */}
<text fg={theme.primary} selectable={false}>
{"│\n".repeat(dims().height)}
{" │\n".repeat(dims().height)}
</text>
</Show>
</box>
@@ -243,22 +250,34 @@ export function PaneRow(props: PaneRowProps) {
const previewWidth = () => width() - pixels().rightPx;
// ── Drag state ──────────────────────────────────────────────────────────
// onMouseDown on a Splitter records which border is being dragged; the
// row then lives-updates the split from the absolute drag x (bubbled up
// from whatever renderable the cursor captures) and commits on release.
// onMouseDown on a Splitter records which border is being dragged and
// the cursor's grab offset from that border's column; the row then
// lives-updates the split from the drag x (minus the offset, so the
// border stays glued to the cursor) and commits on release.
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
null,
);
const beginDrag = (which: "left" | "right") => () => setActiveSplit(which);
// Column of the border a strip centers on (the current pane's edge).
const borderCol = (which: "left" | "right") =>
which === "left" ? pixels().leftPx : pixels().rightPx - 1;
// Cursor x relative to the grabbed border column. Set on mousedown and
// subtracted from every drag x so the border tracks the cursor rather
// than jumping to it.
let grabOffset = 0;
const beginDrag = (which: "left" | "right") => (x: number) => {
grabOffset = x - borderCol(which);
setActiveSplit(which);
};
const handleDrag = (e: { x: number }) => {
const which = activeSplit();
if (!which) return;
if (which === "left") layout.setLeft(e.x, width());
else layout.setRight(e.x, width());
if (which === "left") layout.setLeft(e.x - grabOffset, width());
else layout.setRight(e.x - grabOffset, width());
};
const handleDragEnd = () => {
if (activeSplit()) layout.commit();
setActiveSplit(null);
grabOffset = 0;
};
return (
@@ -300,13 +319,13 @@ export function PaneRow(props: PaneRowProps) {
{/* ── drag handles over the current pane's borders ───────────────── */}
<Show when={hasRoom()}>
<Splitter
left={pixels().leftPx}
left={borderCol("left")}
active={activeSplit() === "left"}
onBegin={beginDrag("left")}
/>
<Show when={panes() === 3}>
<Splitter
left={pixels().rightPx - 1}
left={borderCol("right")}
active={activeSplit() === "right"}
onBegin={beginDrag("right")}
/>

View File

@@ -8,10 +8,11 @@
* content starts one column in — `leftPx + 1`. Hence
* `leftPx = firstC - 1`, `rightPx = firstV`.
*
* The drag strips overlay the border cells (left strip at [left, left+2),
* right strip at [right-2, right)). The test presses inside a strip and
* drags across the row — the drag bubbles to the row container which moves
* the split, so the panes must re-render at the new columns.
* The grab zones overlay each border: 3 columns wide, the border plus one
* help-padded column each side (left zone at [left-1, left+1], right zone
* at [right-2, right)). The test presses inside a zone and drags across
* the row — the drag bubbles to the row container which moves the split,
* so the panes must re-render at the new columns.
*/
import { test, expect, afterAll } from "bun:test";
import { testRender } from "@opentui/solid";
@@ -102,7 +103,7 @@ test("dragging the left border resizes parent vs current", async () => {
resetSplits();
await setup.renderOnce();
// Press on the left strip (border at 20 → strip covers 20) and drag
// Press on the left zone (border at 20 → zone covers 19-21) and drag
// toward the middle of the row.
await setup.mockMouse.drag(20, 5, 45, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
@@ -122,7 +123,7 @@ test("dragging the right border resizes current vs preview", async () => {
resetSplits();
await setup.renderOnce();
// Press on the right strip (border at 69 → strip covers 69) and drag
// Press on the right zone (border at 69 → zone covers 68-70) and drag
// toward the right edge of the row.
await setup.mockMouse.drag(69, 5, 90, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
@@ -132,6 +133,52 @@ test("dragging the right border resizes current vs preview", async () => {
expect(after.left).toBe(20);
});
test("grabbing the left zone from its far edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column LEFT of the border (x=19, border at 20 → offset -1)
// and drag to 37. The border must track the grab, landing at 38 (37 + 1),
// not at 37. Without the grab offset it would jump one column.
await setup.mockMouse.drag(19, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(38);
expect(after.right).toBe(70);
});
test("grabbing the left zone from its inner edge does not jump the border", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// Press one column RIGHT of the border (x=21, border at 20 → offset +1)
// and drag to 37. The border lands at 36 (37 - 1), not 37.
await setup.mockMouse.drag(21, 5, 37, 5);
for (let i = 0; i < 10; i++) await setup.renderOnce();
const after = readBounds(setup.captureSpans());
expect(after.left).toBe(36);
expect(after.right).toBe(70);
});
test("a click inside a padded grab zone (off the border) does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());
resetSplits();
await setup.renderOnce();
// A bare click (no drag) on the help-padded column beside the border
// must not move the split — only an actual drag does.
await setup.mockMouse.click(19, 5);
await setup.renderOnce();
const { left, right } = readBounds(setup.captureSpans());
expect(left).toBe(20);
expect(right).toBe(70);
});
test("a plain click away from the borders does not resize", async () => {
const setup = await renderRow(3);
cleanups.push(() => setup.renderer.destroy());