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:
@@ -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...
|
|
||||||
));
|
|
||||||
@@ -5,9 +5,9 @@
|
|||||||
* CENTER (current) column are draggable and resize the neighboring panes.
|
* CENTER (current) column are draggable and resize the neighboring panes.
|
||||||
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
|
* Split positions live in the shared pane-layout store (`@/stores/pane-layout`)
|
||||||
* as fractions of the row width; this component resolves them to pixel
|
* as fractions of the row width; this component resolves them to pixel
|
||||||
* columns, gives each column an explicit width (so the drag strips sit
|
* columns, gives each column an explicit width (so the grab zones sit
|
||||||
* exactly on the drawn borders), and renders two invisible grab handles over
|
* exactly on the drawn borders), and renders two 3-column invisible grab
|
||||||
* the border cells.
|
* zones over the borders.
|
||||||
*
|
*
|
||||||
* Column semantics (per the yazi depth model):
|
* Column semantics (per the yazi depth model):
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
* 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
|
/** A 3-column invisible grab zone centered on one border of the current
|
||||||
* current pane. `onBegin` is called on mousedown; subsequent drag/drag-end
|
* pane: the border column plus one column of help padding on each side,
|
||||||
* events bubble up the row and drive `usePaneLayout` there. On hover or
|
* so the thin border is easy to target with a mouse. `onBegin` is called
|
||||||
* while dragging it overdraws the border with a full-height accent `│`
|
* on mousedown with the cursor's x; the row records that grab offset so
|
||||||
* line (a bordered box would render as a blocky rectangle instead). */
|
* 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: {
|
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;
|
left: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onBegin: () => void;
|
onBegin: (x: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const dims = useTerminalDimensions();
|
const dims = useTerminalDimensions();
|
||||||
@@ -177,14 +184,14 @@ function Splitter(props: {
|
|||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
left={props.left}
|
left={props.left - 1}
|
||||||
top={0}
|
top={0}
|
||||||
width={1}
|
width={3}
|
||||||
height="100%"
|
height="100%"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault?.();
|
e.preventDefault?.();
|
||||||
props.onBegin();
|
props.onBegin(e.x);
|
||||||
}}
|
}}
|
||||||
onMouseOver={() => setHovered(true)}
|
onMouseOver={() => setHovered(true)}
|
||||||
onMouseOut={() => setHovered(false)}
|
onMouseOut={() => setHovered(false)}
|
||||||
>
|
>
|
||||||
@@ -192,7 +199,7 @@ function Splitter(props: {
|
|||||||
{/* Draw the accent edge down the full pane height; the box clips
|
{/* Draw the accent edge down the full pane height; the box clips
|
||||||
* any excess rows below the row's bottom edge. */}
|
* any excess rows below the row's bottom edge. */}
|
||||||
<text fg={theme.primary} selectable={false}>
|
<text fg={theme.primary} selectable={false}>
|
||||||
{"│\n".repeat(dims().height)}
|
{" │\n".repeat(dims().height)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
@@ -243,22 +250,34 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
const previewWidth = () => width() - pixels().rightPx;
|
const previewWidth = () => width() - pixels().rightPx;
|
||||||
|
|
||||||
// ── Drag state ──────────────────────────────────────────────────────────
|
// ── Drag state ──────────────────────────────────────────────────────────
|
||||||
// onMouseDown on a Splitter records which border is being dragged; the
|
// onMouseDown on a Splitter records which border is being dragged and
|
||||||
// row then lives-updates the split from the absolute drag x (bubbled up
|
// the cursor's grab offset from that border's column; the row then
|
||||||
// from whatever renderable the cursor captures) and commits on release.
|
// 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>(
|
const [activeSplit, setActiveSplit] = createSignal<"left" | "right" | null>(
|
||||||
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 handleDrag = (e: { x: number }) => {
|
||||||
const which = activeSplit();
|
const which = activeSplit();
|
||||||
if (!which) return;
|
if (!which) return;
|
||||||
if (which === "left") layout.setLeft(e.x, width());
|
if (which === "left") layout.setLeft(e.x - grabOffset, width());
|
||||||
else layout.setRight(e.x, width());
|
else layout.setRight(e.x - grabOffset, width());
|
||||||
};
|
};
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
if (activeSplit()) layout.commit();
|
if (activeSplit()) layout.commit();
|
||||||
setActiveSplit(null);
|
setActiveSplit(null);
|
||||||
|
grabOffset = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -300,13 +319,13 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
{/* ── drag handles over the current pane's borders ───────────────── */}
|
{/* ── drag handles over the current pane's borders ───────────────── */}
|
||||||
<Show when={hasRoom()}>
|
<Show when={hasRoom()}>
|
||||||
<Splitter
|
<Splitter
|
||||||
left={pixels().leftPx}
|
left={borderCol("left")}
|
||||||
active={activeSplit() === "left"}
|
active={activeSplit() === "left"}
|
||||||
onBegin={beginDrag("left")}
|
onBegin={beginDrag("left")}
|
||||||
/>
|
/>
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<Splitter
|
<Splitter
|
||||||
left={pixels().rightPx - 1}
|
left={borderCol("right")}
|
||||||
active={activeSplit() === "right"}
|
active={activeSplit() === "right"}
|
||||||
onBegin={beginDrag("right")}
|
onBegin={beginDrag("right")}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
* content starts one column in — `leftPx + 1`. Hence
|
* content starts one column in — `leftPx + 1`. Hence
|
||||||
* `leftPx = firstC - 1`, `rightPx = firstV`.
|
* `leftPx = firstC - 1`, `rightPx = firstV`.
|
||||||
*
|
*
|
||||||
* The drag strips overlay the border cells (left strip at [left, left+2),
|
* The grab zones overlay each border: 3 columns wide, the border plus one
|
||||||
* right strip at [right-2, right)). The test presses inside a strip and
|
* help-padded column each side (left zone at [left-1, left+1], right zone
|
||||||
* drags across the row — the drag bubbles to the row container which moves
|
* at [right-2, right)). The test presses inside a zone and drags across
|
||||||
* the split, so the panes must re-render at the new columns.
|
* 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 { test, expect, afterAll } from "bun:test";
|
||||||
import { testRender } from "@opentui/solid";
|
import { testRender } from "@opentui/solid";
|
||||||
@@ -102,7 +103,7 @@ test("dragging the left border resizes parent vs current", async () => {
|
|||||||
resetSplits();
|
resetSplits();
|
||||||
await setup.renderOnce();
|
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.
|
// toward the middle of the row.
|
||||||
await setup.mockMouse.drag(20, 5, 45, 5);
|
await setup.mockMouse.drag(20, 5, 45, 5);
|
||||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
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();
|
resetSplits();
|
||||||
await setup.renderOnce();
|
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.
|
// toward the right edge of the row.
|
||||||
await setup.mockMouse.drag(69, 5, 90, 5);
|
await setup.mockMouse.drag(69, 5, 90, 5);
|
||||||
for (let i = 0; i < 10; i++) await setup.renderOnce();
|
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);
|
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 () => {
|
test("a plain click away from the borders does not resize", async () => {
|
||||||
const setup = await renderRow(3);
|
const setup = await renderRow(3);
|
||||||
cleanups.push(() => setup.renderer.destroy());
|
cleanups.push(() => setup.renderer.destroy());
|
||||||
|
|||||||
Reference in New Issue
Block a user