From 31c7f544cf14d51cd1dd46f8be2bf3013a664624 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:39:53 -0600 Subject: [PATCH 1/3] docs(spec): define pointer hit testing by cell --- specs/renderer-spec.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 2b70afd..0f4bb89 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -1198,6 +1198,10 @@ events as part of the render result: - `pointerclick` — a pointer-up occurred over an element that was also under the pointer at pointer-down +Pointer coordinates are cell coordinates. The pointer is over an element when +its cell lies inside the element's bounding box, so a cell on an edge two +elements share belongs only to the element that draws it. + This surface is functional but should not be treated as stable contract. The calling convention was discovered through iteration, the event model has acknowledged gaps, and the approach may evolve. From 47e5e4e7baf66d5e68fa1ab28a893fb990339ef5 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:40:30 -0600 Subject: [PATCH 2/3] test: cover pointer hit testing on shared edges --- test/pointer-shape.test.ts | 9 ++++++++ test/term.test.ts | 47 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index c2b918e..9114570 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -155,6 +155,15 @@ describe("pointer shape", () => { ]); }); + it("takes the shape of the element that owns a cell on a shared edge", () => { + expect(sequences(term.render(layout(), over(10, 0)).output)).toEqual([ + osc22("pointer"), + ]); + expect(sequences(term.render(layout(), over(20, 2)).output)).toEqual([ + osc22("default"), + ]); + }); + it("follows a declared shape that changes under a still pointer", () => { term.render(layout(), LINK); expect( diff --git a/test/term.test.ts b/test/term.test.ts index 618e61e..bdaaa11 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -886,6 +886,53 @@ hi }); }); + describe("pointer events", () => { + let siblings: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("left", { layout: { width: fixed(20), height: grow() } }), + close(), + open("right", { layout: { width: fixed(20), height: grow() } }), + close(), + close(), + ]; + + let rows: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("row0", { layout: { width: grow(), height: fixed(1) } }), + close(), + open("row1", { layout: { width: grow(), height: fixed(1) } }), + close(), + close(), + ]; + + function click(frame: Op[], x: number, y: number): string[] { + term.render(frame, { pointer: { x, y, down: true } }); + return term.render(frame, { pointer: { x, y, down: false } }).events + .filter((event) => event.type === "pointerclick") + .map((event) => event.id); + } + + it("clicks only the sibling that owns the first cell past a shared edge", () => { + let ids = click(siblings, 20, 0); + expect(ids).toContain("right"); + expect(ids).not.toContain("left"); + }); + + it("clicks only the row that owns the first row past a shared edge", () => { + let ids = click(rows, 0, 1); + expect(ids).toContain("row1"); + expect(ids).not.toContain("row0"); + }); + + it("reports no element for a pointer past the last column", () => { + expect(click(siblings, 40, 0)).toEqual([]); + }); + }); + describe("synchronized output", () => { let frame: Op[] = [ open("root", { layout: { width: grow(), height: grow() } }), From 3646f682e423c92942829823938ae2b241637740 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:40:52 -0600 Subject: [PATCH 3/3] fix: hit-test the pointer at the cell center --- .changeset/neat-cases-decide.md | 5 +++++ src/clayterm.c | 6 ++++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 .changeset/neat-cases-decide.md diff --git a/.changeset/neat-cases-decide.md b/.changeset/neat-cases-decide.md new file mode 100644 index 0000000..c657944 --- /dev/null +++ b/.changeset/neat-cases-decide.md @@ -0,0 +1,5 @@ +--- +"@bomb.sh/tty": patch +--- + +Fixes pointer events and pointer shape on a cell where two elements meet. The pointer at that cell is now over only the element that draws it, so clicking a row in a list no longer also clicks the row above it. diff --git a/src/clayterm.c b/src/clayterm.c index dea1c84..6f4c9b2 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -999,10 +999,12 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); /* pointer: 0 = absent, 1 = up, 2 = down. The hit test runs against this - * frame's layout and fires cursor_hover for declaring elements. */ + * frame's layout and fires cursor_hover for declaring elements. Clay's + * point-in-rect test includes both edges, and a cell's corner sits on the + * edge of every element that ends there, so hit-test the cell's center. */ ct->hover_shape = 0; if (pointer) - Clay_SetPointerState((Clay_Vector2){px, py}, pointer == 2); + Clay_SetPointerState((Clay_Vector2){px + 0.5f, py + 0.5f}, pointer == 2); /* reset output state */ ct->out.length = 0;