From a9eec481507fd463a0e7099da5656d44c4b53c11 Mon Sep 17 00:00:00 2001 From: Charles Lowell Date: Fri, 17 Apr 2026 16:35:57 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20RenderInfo=20with=20lazy=20el?= =?UTF-8?q?ement=20bounds=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- specs/renderer-spec.md | 30 +++++++++++++++++++++++++++++- src/clayterm.c | 14 ++++++++++++++ src/clayterm.h | 2 ++ term-native.ts | 37 +++++++++++++++++++++++++++++++++++++ term.ts | 25 +++++++++++++++++++++++-- test/term.test.ts | 42 +++++++++++++++++++++++++++++++++++++++++- typedef.ts | 5 +++++ 7 files changed, 151 insertions(+), 4 deletions(-) diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index e0efa55..ac9c5f3 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -631,7 +631,7 @@ prevent overlap. ### 12.3 Render return type The `render()` method currently returns a `RenderResult` object shaped as -`{ output: Uint8Array, events: PointerEvent[] }`. +`{ output: Uint8Array, events: PointerEvent[], info: RenderInfo }`. The `output` field is the ANSI byte output specified normatively in Section 7.3 and Section 8.2. @@ -643,6 +643,34 @@ but has acknowledged gaps (no modifier keys on click events) and its interaction protocol (passing pointer state via render options, then reading events from the return value) was arrived at through iteration rather than upfront design. +The `info` field implements `RenderInfo`, a read-only lookup keyed by element id +(the `id` parameter passed to `open()`): + +``` +interface RenderInfo { + get(id: string): ElementInfo | undefined; +} + +interface ElementInfo { + bounds: BoundingBox; +} + +interface BoundingBox { + x: number; + y: number; + width: number; + height: number; +} +``` + +Each `ElementInfo` provides post-layout metadata. The `bounds` field is the +element's computed bounding box in character cells, as determined by the layout +engine after the render transaction completes. `x` and `y` are zero-indexed from +the top-left corner of the layout root. + +Querying an element with an empty-string id or an id not present in the frame +returns `undefined`. + The return type of `render()` has changed twice since the project's inception (string, then `Uint8Array`, then `RenderResult`). While the ANSI bytes commitment (Section 7.3) is stable, the wrapper shape around those bytes is not. diff --git a/src/clayterm.c b/src/clayterm.c index 81f793c..fa1d4c0 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -611,6 +611,20 @@ char *output(struct Clayterm *ct) { return ct->out.data; } int length(struct Clayterm *ct) { return ct->out.length; } +int get_element_bounds(const char *name, int name_len, float *out) { + Clay_String str = {.length = name_len, .chars = name}; + Clay_ElementId eid = Clay__HashString(str, 0); + Clay_ElementData data = Clay_GetElementData(eid); + if (!data.found) { + return 0; + } + out[0] = data.boundingBox.x; + out[1] = data.boundingBox.y; + out[2] = data.boundingBox.width; + out[3] = data.boundingBox.height; + return 1; +} + int pointer_over_count(void) { return Clay_GetPointerOverIds().length; } int pointer_over_id_string_length(int index) { diff --git a/src/clayterm.h b/src/clayterm.h index c5d127a..5065ed5 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -17,6 +17,8 @@ char *output(struct Clayterm *ct); int length(struct Clayterm *ct); void measure(int ret, int txt); +int get_element_bounds(const char *name, int name_len, float *out); + int pointer_over_count(void); int pointer_over_id_string_length(int index); int pointer_over_id_string_ptr(int index); diff --git a/term-native.ts b/term-native.ts index 6e87598..84349c9 100644 --- a/term-native.ts +++ b/term-native.ts @@ -1,3 +1,21 @@ +import { f32, offsets, struct } from "./typedef.ts"; + +export interface BoundingBox { + x: number; + y: number; + width: number; + height: number; +} + +const BoundingBoxStruct = struct({ + x: f32(), + y: f32(), + width: f32(), + height: f32(), +}); + +const BOUNDING_BOX = offsets(BoundingBoxStruct); + export interface Native { memory: WebAssembly.Memory; statePtr: number; @@ -7,6 +25,7 @@ export interface Native { length(ct: number): number; setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; + getElementBounds(id: string): BoundingBox | undefined; } import { compiled } from "./wasm.ts"; @@ -60,6 +79,7 @@ export async function createTermNative( pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; + get_element_bounds(name: number, len: number, out: number): number; }; let heap = ct.__heap_base.value as number; @@ -101,5 +121,22 @@ export async function createTermNative( } return ids; }, + getElementBounds(id: string): BoundingBox | undefined { + let enc = new TextEncoder(); + let bytes = enc.encode(id); + new Uint8Array(memory.buffer).set(bytes, opsBuf); + let out = opsBuf + 256; + let found = ct.get_element_bounds(opsBuf, bytes.length, out); + if (!found) { + return undefined; + } + let view = new DataView(memory.buffer); + return { + x: view.getFloat32(out + BOUNDING_BOX.x, true), + y: view.getFloat32(out + BOUNDING_BOX.y, true), + width: view.getFloat32(out + BOUNDING_BOX.width, true), + height: view.getFloat32(out + BOUNDING_BOX.height, true), + }; + }, }; } diff --git a/term.ts b/term.ts index 2b6e9c1..3e2193c 100644 --- a/term.ts +++ b/term.ts @@ -1,5 +1,5 @@ import { type Op, pack } from "./ops.ts"; -import { createTermNative } from "./term-native.ts"; +import { type BoundingBox, createTermNative } from "./term-native.ts"; export interface TermOptions { height: number; @@ -32,9 +32,20 @@ export type PointerEvent = | { type: "pointerleave"; id: string } | { type: "pointerclick"; id: string }; +export type { BoundingBox }; + +export interface ElementInfo { + bounds: BoundingBox; +} + +export interface RenderInfo { + get(id: string): ElementInfo | undefined; +} + export interface RenderResult { output: Uint8Array; events: PointerEvent[]; + info: RenderInfo; } export interface Term { @@ -103,7 +114,17 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; - return { output, events }; + let info: RenderInfo = { + get(id: string): ElementInfo | undefined { + let bounds = native.getElementBounds(id); + if (bounds) { + return { bounds }; + } + return undefined; + }, + }; + + return { output, events, info }; }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index 00a4b4e..47d8c94 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; -import { close, grow, open, rgba, text } from "../ops.ts"; +import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import { print } from "./print.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); @@ -151,6 +151,46 @@ describe("term", () => { }); }); + describe("info", () => { + it("returns bounds for named elements", async () => { + let term = await createTerm({ width: 40, height: 10 }); + let result = term.render([ + open("root", { + layout: { width: grow(), height: grow(), direction: "ttb" }, + }), + open("child", { + layout: { width: fixed(20), height: fixed(5) }, + }), + close(), + close(), + ]); + + let root = result.info.get("root"); + expect(root).toBeDefined(); + expect(root!.bounds).toEqual({ x: 0, y: 0, width: 40, height: 10 }); + + let child = result.info.get("child"); + expect(child).toBeDefined(); + expect(child!.bounds).toEqual({ x: 0, y: 0, width: 20, height: 5 }); + }); + + it("returns undefined for unknown ids", async () => { + let term = await createTerm({ width: 20, height: 5 }); + term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + close(), + ]); + + let result = term.render([ + open("root", { layout: { width: grow(), height: grow() } }), + close(), + ]); + + expect(result.info.get("nonexistent")).toBeUndefined(); + expect(result.info.get("")).toBeUndefined(); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 }); diff --git a/typedef.ts b/typedef.ts index f851173..a34ecd4 100644 --- a/typedef.ts +++ b/typedef.ts @@ -56,6 +56,11 @@ export function array(element: TypeDef, length: number): Arr { }; } +export const f32 = (): TypeDef => ({ + type: "f32", + byteLength: 4, + byteAlign: 4, +}); export const int32 = (): TypeDef => ({ type: "int32", byteLength: 4,