From 767b67543a88cbe16675edb318ae10976c99ee46 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:49:58 -0600 Subject: [PATCH 1/4] docs(spec): define cell coverage for fractional layouts --- specs/renderer-spec.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 From 27bb5973c73e61b05a4ff265fea9431db16eeac3 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:51:02 -0600 Subject: [PATCH 2/4] test: cover cell coverage for fractional layouts --- test/coverage.test.ts | 100 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 test/coverage.test.ts 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", + ]); + }); +}); From b3186027b74b20d1f84a99ec2eef9e95ab65b5e8 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:51:10 -0600 Subject: [PATCH 3/4] fix: exclude the bottom and right edges from pointer hit testing --- patches/clay-half-open-hit-test.patch | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 patches/clay-half-open-hit-test.patch 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) { From 307b7d03d021eb4cf9db4e9181c8e77414563b79 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:01:13 -0600 Subject: [PATCH 4/4] fix: round fractional coordinates to the nearest cell --- .changeset/large-geese-fix.md | 5 +++++ src/clayterm.c | 18 +++++++++++------- 2 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 .changeset/large-geese-fix.md 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/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: