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());