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/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. 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; 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() } }),