Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-geese-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changeset needs revision.

Changeset package scope does not match the affected packages confidently. Changeset is not sufficiently consumer-focused.

View the proposed replacement
---
'@bomb.sh/tty': patch
---

TODO: Write changeset message (inference failed: HTTP 401: {"error":{"message":"User not found.","code":401}})

"@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.
16 changes: 16 additions & 0 deletions patches/clay-half-open-hit-test.patch
Original file line number Diff line number Diff line change
@@ -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) {
12 changes: 10 additions & 2 deletions specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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:
Expand Down
100 changes: 100 additions & 0 deletions test/coverage.test.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
});
});
Loading