From 132d2079f7fb3dab3f5f89d57e28b3e2a96b1e74 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 6 Sep 2026 18:46:42 -0400 Subject: [PATCH] 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. --- scripts/_hv.ts | 9 ----- src/components/PaneRow.tsx | 67 ++++++++++++++++++++++++-------------- tests/pane-resize.test.tsx | 59 +++++++++++++++++++++++++++++---- 3 files changed, 96 insertions(+), 39 deletions(-) delete mode 100644 scripts/_hv.ts diff --git a/scripts/_hv.ts b/scripts/_hv.ts deleted file mode 100644 index 1f5e6c2..0000000 --- a/scripts/_hv.ts +++ /dev/null @@ -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... -)); diff --git a/src/components/PaneRow.tsx b/src/components/PaneRow.tsx index 506249a..8143091 100644 --- a/src/components/PaneRow.tsx +++ b/src/components/PaneRow.tsx @@ -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,14 +184,14 @@ function Splitter(props: { return ( { - e.preventDefault?.(); - props.onBegin(); - }} + onMouseDown={(e) => { + e.preventDefault?.(); + 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. */} - {"│\n".repeat(dims().height)} + {" │\n".repeat(dims().height)} @@ -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 ───────────────── */} diff --git a/tests/pane-resize.test.tsx b/tests/pane-resize.test.tsx index 77676d9..0ccd5f4 100644 --- a/tests/pane-resize.test.tsx +++ b/tests/pane-resize.test.tsx @@ -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());