Skip to content

Commit a9eec48

Browse files
committed
✨ add RenderInfo with lazy element bounds lookup
Adds an `info: RenderInfo` field to RenderResult that provides on-demand access to element bounding boxes after layout. Callers query by element id via `info.get(id)`, which calls into WASM to hash the id and look up the computed bounds via Clay_GetElementData(). A single WASM export handles the lookup — no tables or globals. Uses the typedef struct infrastructure for BoundingBox field offsets.
1 parent f6cc880 commit a9eec48

7 files changed

Lines changed: 151 additions & 4 deletions

File tree

specs/renderer-spec.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ prevent overlap.
631631
### 12.3 Render return type
632632

633633
The `render()` method currently returns a `RenderResult` object shaped as
634-
`{ output: Uint8Array, events: PointerEvent[] }`.
634+
`{ output: Uint8Array, events: PointerEvent[], info: RenderInfo }`.
635635

636636
The `output` field is the ANSI byte output specified normatively in Section 7.3
637637
and Section 8.2.
@@ -643,6 +643,34 @@ but has acknowledged gaps (no modifier keys on click events) and its interaction
643643
protocol (passing pointer state via render options, then reading events from the
644644
return value) was arrived at through iteration rather than upfront design.
645645

646+
The `info` field implements `RenderInfo`, a read-only lookup keyed by element id
647+
(the `id` parameter passed to `open()`):
648+
649+
```
650+
interface RenderInfo {
651+
get(id: string): ElementInfo | undefined;
652+
}
653+
654+
interface ElementInfo {
655+
bounds: BoundingBox;
656+
}
657+
658+
interface BoundingBox {
659+
x: number;
660+
y: number;
661+
width: number;
662+
height: number;
663+
}
664+
```
665+
666+
Each `ElementInfo` provides post-layout metadata. The `bounds` field is the
667+
element's computed bounding box in character cells, as determined by the layout
668+
engine after the render transaction completes. `x` and `y` are zero-indexed from
669+
the top-left corner of the layout root.
670+
671+
Querying an element with an empty-string id or an id not present in the frame
672+
returns `undefined`.
673+
646674
The return type of `render()` has changed twice since the project's inception
647675
(string, then `Uint8Array`, then `RenderResult`). While the ANSI bytes
648676
commitment (Section 7.3) is stable, the wrapper shape around those bytes is not.

src/clayterm.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,20 @@ char *output(struct Clayterm *ct) { return ct->out.data; }
611611

612612
int length(struct Clayterm *ct) { return ct->out.length; }
613613

614+
int get_element_bounds(const char *name, int name_len, float *out) {
615+
Clay_String str = {.length = name_len, .chars = name};
616+
Clay_ElementId eid = Clay__HashString(str, 0);
617+
Clay_ElementData data = Clay_GetElementData(eid);
618+
if (!data.found) {
619+
return 0;
620+
}
621+
out[0] = data.boundingBox.x;
622+
out[1] = data.boundingBox.y;
623+
out[2] = data.boundingBox.width;
624+
out[3] = data.boundingBox.height;
625+
return 1;
626+
}
627+
614628
int pointer_over_count(void) { return Clay_GetPointerOverIds().length; }
615629

616630
int pointer_over_id_string_length(int index) {

src/clayterm.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ char *output(struct Clayterm *ct);
1717
int length(struct Clayterm *ct);
1818
void measure(int ret, int txt);
1919

20+
int get_element_bounds(const char *name, int name_len, float *out);
21+
2022
int pointer_over_count(void);
2123
int pointer_over_id_string_length(int index);
2224
int pointer_over_id_string_ptr(int index);

term-native.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
import { f32, offsets, struct } from "./typedef.ts";
2+
3+
export interface BoundingBox {
4+
x: number;
5+
y: number;
6+
width: number;
7+
height: number;
8+
}
9+
10+
const BoundingBoxStruct = struct<BoundingBox>({
11+
x: f32(),
12+
y: f32(),
13+
width: f32(),
14+
height: f32(),
15+
});
16+
17+
const BOUNDING_BOX = offsets(BoundingBoxStruct);
18+
119
export interface Native {
220
memory: WebAssembly.Memory;
321
statePtr: number;
@@ -7,6 +25,7 @@ export interface Native {
725
length(ct: number): number;
826
setPointer(x: number, y: number, down: boolean): void;
927
getPointerOverIds(): string[];
28+
getElementBounds(id: string): BoundingBox | undefined;
1029
}
1130

1231
import { compiled } from "./wasm.ts";
@@ -60,6 +79,7 @@ export async function createTermNative(
6079
pointer_over_count(): number;
6180
pointer_over_id_string_length(index: number): number;
6281
pointer_over_id_string_ptr(index: number): number;
82+
get_element_bounds(name: number, len: number, out: number): number;
6383
};
6484

6585
let heap = ct.__heap_base.value as number;
@@ -101,5 +121,22 @@ export async function createTermNative(
101121
}
102122
return ids;
103123
},
124+
getElementBounds(id: string): BoundingBox | undefined {
125+
let enc = new TextEncoder();
126+
let bytes = enc.encode(id);
127+
new Uint8Array(memory.buffer).set(bytes, opsBuf);
128+
let out = opsBuf + 256;
129+
let found = ct.get_element_bounds(opsBuf, bytes.length, out);
130+
if (!found) {
131+
return undefined;
132+
}
133+
let view = new DataView(memory.buffer);
134+
return {
135+
x: view.getFloat32(out + BOUNDING_BOX.x, true),
136+
y: view.getFloat32(out + BOUNDING_BOX.y, true),
137+
width: view.getFloat32(out + BOUNDING_BOX.width, true),
138+
height: view.getFloat32(out + BOUNDING_BOX.height, true),
139+
};
140+
},
104141
};
105142
}

term.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type Op, pack } from "./ops.ts";
2-
import { createTermNative } from "./term-native.ts";
2+
import { type BoundingBox, createTermNative } from "./term-native.ts";
33

44
export interface TermOptions {
55
height: number;
@@ -32,9 +32,20 @@ export type PointerEvent =
3232
| { type: "pointerleave"; id: string }
3333
| { type: "pointerclick"; id: string };
3434

35+
export type { BoundingBox };
36+
37+
export interface ElementInfo {
38+
bounds: BoundingBox;
39+
}
40+
41+
export interface RenderInfo {
42+
get(id: string): ElementInfo | undefined;
43+
}
44+
3545
export interface RenderResult {
3646
output: Uint8Array;
3747
events: PointerEvent[];
48+
info: RenderInfo;
3849
}
3950

4051
export interface Term {
@@ -103,7 +114,17 @@ export async function createTerm(options: TermOptions): Promise<Term> {
103114
prev = current;
104115
wasDown = down;
105116

106-
return { output, events };
117+
let info: RenderInfo = {
118+
get(id: string): ElementInfo | undefined {
119+
let bounds = native.getElementBounds(id);
120+
if (bounds) {
121+
return { bounds };
122+
}
123+
return undefined;
124+
},
125+
};
126+
127+
return { output, events, info };
107128
},
108129
};
109130
}

test/term.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, describe, expect, it } from "./suite.ts";
22
import { createTerm, type Term } from "../term.ts";
3-
import { close, grow, open, rgba, text } from "../ops.ts";
3+
import { close, fixed, grow, open, rgba, text } from "../ops.ts";
44
import { print } from "./print.ts";
55

66
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
@@ -151,6 +151,46 @@ describe("term", () => {
151151
});
152152
});
153153

154+
describe("info", () => {
155+
it("returns bounds for named elements", async () => {
156+
let term = await createTerm({ width: 40, height: 10 });
157+
let result = term.render([
158+
open("root", {
159+
layout: { width: grow(), height: grow(), direction: "ttb" },
160+
}),
161+
open("child", {
162+
layout: { width: fixed(20), height: fixed(5) },
163+
}),
164+
close(),
165+
close(),
166+
]);
167+
168+
let root = result.info.get("root");
169+
expect(root).toBeDefined();
170+
expect(root!.bounds).toEqual({ x: 0, y: 0, width: 40, height: 10 });
171+
172+
let child = result.info.get("child");
173+
expect(child).toBeDefined();
174+
expect(child!.bounds).toEqual({ x: 0, y: 0, width: 20, height: 5 });
175+
});
176+
177+
it("returns undefined for unknown ids", async () => {
178+
let term = await createTerm({ width: 20, height: 5 });
179+
term.render([
180+
open("root", { layout: { width: grow(), height: grow() } }),
181+
close(),
182+
]);
183+
184+
let result = term.render([
185+
open("root", { layout: { width: grow(), height: grow() } }),
186+
close(),
187+
]);
188+
189+
expect(result.info.get("nonexistent")).toBeUndefined();
190+
expect(result.info.get("")).toBeUndefined();
191+
});
192+
});
193+
154194
describe("row offset", () => {
155195
it("renders two frames at the offset position", async () => {
156196
let term = await createTerm({ width: 20, height: 5 });

typedef.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export function array<T>(element: TypeDef<T>, length: number): Arr<T[]> {
5656
};
5757
}
5858

59+
export const f32 = (): TypeDef<number> => ({
60+
type: "f32",
61+
byteLength: 4,
62+
byteAlign: 4,
63+
});
5964
export const int32 = (): TypeDef<number> => ({
6065
type: "int32",
6166
byteLength: 4,

0 commit comments

Comments
 (0)