diff --git a/.changeset/large-geese-fix.md b/.changeset/large-geese-fix.md new file mode 100644 index 0000000..6d56bea --- /dev/null +++ b/.changeset/large-geese-fix.md @@ -0,0 +1,5 @@ +--- +"@bomb.sh/tty": patch +--- + +Fixes how elements at fractional positions map to cells. An element now draws on, and receives the pointer for, exactly the cells whose centers its bounding box covers. Elements sliding in past the top edge no longer pile onto row 0, and two elements that meet halfway through a cell no longer both receive the pointer there. diff --git a/patches/clay-half-open-hit-test.patch b/patches/clay-half-open-hit-test.patch new file mode 100644 index 0000000..4b746f5 --- /dev/null +++ b/patches/clay-half-open-hit-test.patch @@ -0,0 +1,16 @@ +Count only the top and left edges of a bounding box as inside it, so a +point on an edge two elements share hits one of them (renderer-spec 7.11). + +diff --git a/clay.h b/clay.h +index 7c967bb..8a1dc56 100644 +--- a/clay.h ++++ b/clay.h +@@ -1776,7 +1776,7 @@ Clay__MeasureTextCacheItem *Clay__MeasureTextCached(Clay_String *text, Clay_Text + } + + bool Clay__PointIsInsideRect(Clay_Vector2 point, Clay_BoundingBox rect) { +- return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; ++ return point.x >= rect.x && point.x < rect.x + rect.width && point.y >= rect.y && point.y < rect.y + rect.height; + } + + Clay_LayoutElementHashMapItem* Clay__AddHashMapItem(Clay_ElementId elementId, Clay_LayoutElement* layoutElement) { diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 0f4bb89..dce3f31 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -570,6 +570,14 @@ To restore before exit, the caller renders a frame without `pointer`, or passes `{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and writes the result. +### 7.11 Cell coverage + +Layout positions and sizes may be fractional. An element covers a cell when the +cell's center lies inside the element's bounding box, counting its top and left +edges and not its bottom and right edges. The renderer draws an element, and +applies its clip rect, on exactly the cells it covers, and the pointer is over +an element when its cell is covered. + --- ## 8. Public Rendering API @@ -1199,8 +1207,8 @@ events as part of the render result: 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. +the element covers its cell (§7.11), 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 diff --git a/src/clayterm.c b/src/clayterm.c index e9a8a96..f8eaec6 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -773,6 +773,10 @@ void set_pointer_shape(struct Clayterm *ct, int shape) { ct->pointer_shape = shape; } +/* The first cell whose center lies at or past a layout edge (renderer-spec + * 7.11), so an element draws on exactly the cells the hit test gives it. */ +static int cell_edge(float v) { return (int)__builtin_ceilf(v - 0.5f); } + void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, float deltaTime, float px, float py, int pointer) { int i = 0; @@ -980,9 +984,9 @@ 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. 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. */ + * frame's layout and fires cursor_hover for declaring elements. It samples + * the cell's center, the point cell coverage is defined by (renderer-spec + * 7.11). */ ct->hover_shape = 0; if (pointer) Clay_SetPointerState((Clay_Vector2){px + 0.5f, py + 0.5f}, pointer == 2); @@ -1003,10 +1007,10 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, for (int32_t j = 0; j < cmds.length; j++) { Clay_RenderCommand *cmd = Clay_RenderCommandArray_Get(&cmds, j); Clay_BoundingBox box = cmd->boundingBox; - int x0 = (int)box.x; - int y0 = (int)box.y; - int x1 = (int)(box.x + box.width); - int y1 = (int)(box.y + box.height); + int x0 = cell_edge(box.x); + int y0 = cell_edge(box.y); + int x1 = cell_edge(box.x + box.width); + int y1 = cell_edge(box.y + box.height); switch (cmd->commandType) { case CLAY_RENDER_COMMAND_TYPE_RECTANGLE: diff --git a/test/coverage.test.ts b/test/coverage.test.ts new file mode 100644 index 0000000..da5ba52 --- /dev/null +++ b/test/coverage.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "./suite.ts"; +import { createTerm } from "../term.ts"; +import { close, fixed, grow, type Op, open, text } from "../ops.ts"; +import { print } from "./print.ts"; + +const decode = (b: Uint8Array) => new TextDecoder().decode(b); +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); + +const LABELS = ["zero", "one", "two-long", "three"]; + +// a 4-row panel floating at y over a 12×4 grid, one label per row +function panel(y: number): Op[] { + return [ + open("root", { layout: { width: grow(), height: grow() } }), + open("panel", { + layout: { width: fixed(10), height: fixed(4), direction: "ttb" }, + floating: { x: 0, y, attachTo: "root" }, + }), + ...LABELS.flatMap((label, i) => [ + open(`row${i}`, { layout: { width: grow(), height: fixed(1) } }), + text(label), + close(), + ]), + close(), + close(), + ]; +} + +async function draw(ops: Op[], width: number, height: number) { + let term = await createTerm({ width, height }); + return trim(print(decode(term.render(ops).output), width, height)); +} + +async function over( + ops: Op[], + [width, height]: [number, number], + [x, y]: [number, number], + ids: string[], +) { + let term = await createTerm({ width, height }); + return term.render(ops, { pointer: { x, y, down: false } }).events + .map((event) => event.id) + .filter((id) => ids.includes(id)); +} + +describe("cell coverage", () => { + it("clips the rows of a panel that sit above the top edge", async () => { + expect(await draw(panel(-2.5), 12, 4)).toEqual( + ["three", "", "", ""].join("\n"), + ); + }); + + it("draws an edge more than halfway into a cell from the next cell", async () => { + expect(await draw(panel(0.7), 12, 4)).toEqual( + ["", "zero", "one", "two-long"].join("\n"), + ); + }); + + it("draws an edge exactly halfway into a cell from that cell", async () => { + expect(await draw(panel(0.5), 12, 4)).toEqual( + ["zero", "one", "two-long", "three"].join("\n"), + ); + }); + + it("puts the pointer over the row drawn in each cell", async () => { + let rows = LABELS.map((_, i) => `row${i}`); + for (let step = 0; step <= 40; step++) { + let y = -3 + step / 10; + let drawn = (await draw(panel(y), 12, 4)).split("\n"); + for (let row = 0; row < 4; row++) { + let label = drawn[row].trim(); + let hit = (await over(panel(y), [12, 4], [1, row], rows)) + .map((id) => LABELS[rows.indexOf(id)]); + expect({ y, row, hit }).toEqual({ y, row, hit: label ? [label] : [] }); + } + } + }); + + it("gives a cell on a half-cell boundary to one element", async () => { + let split: Op[] = [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("left", { layout: { width: grow(), height: grow() } }), + text("L"), + close(), + open("right", { layout: { width: grow(), height: grow() } }), + text("R"), + close(), + close(), + ]; + expect((await draw(split, 41, 1)).trim()).toEqual(`L${" ".repeat(19)}R`); + expect(await over(split, [41, 1], [19, 0], ["left", "right"])).toEqual([ + "left", + ]); + expect(await over(split, [41, 1], [20, 0], ["left", "right"])).toEqual([ + "right", + ]); + }); +});