[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

✨ 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.

Charles Lowell (Apr 17, 2026, 4:40 PM -0500) a9eec481 f6cc880a

+151 -4
+29 -1
specs/renderer-spec.md
··· 631 631 ### 12.3 Render return type 632 632 633 633 The `render()` method currently returns a `RenderResult` object shaped as 634 - `{ output: Uint8Array, events: PointerEvent[] }`. 634 + `{ output: Uint8Array, events: PointerEvent[], info: RenderInfo }`. 635 635 636 636 The `output` field is the ANSI byte output specified normatively in Section 7.3 637 637 and Section 8.2. ··· 642 642 but has acknowledged gaps (no modifier keys on click events) and its interaction 643 643 protocol (passing pointer state via render options, then reading events from the 644 644 return value) was arrived at through iteration rather than upfront design. 645 + 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`. 645 673 646 674 The return type of `render()` has changed twice since the project's inception 647 675 (string, then `Uint8Array`, then `RenderResult`). While the ANSI bytes
+14
src/clayterm.c
··· 611 611 612 612 int length(struct Clayterm *ct) { return ct->out.length; } 613 613 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 + 614 628 int pointer_over_count(void) { return Clay_GetPointerOverIds().length; } 615 629 616 630 int pointer_over_id_string_length(int index) {
+2
src/clayterm.h
··· 17 17 int length(struct Clayterm *ct); 18 18 void measure(int ret, int txt); 19 19 20 + int get_element_bounds(const char *name, int name_len, float *out); 21 + 20 22 int pointer_over_count(void); 21 23 int pointer_over_id_string_length(int index); 22 24 int pointer_over_id_string_ptr(int index);
+37
term-native.ts
··· 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 + 1 19 export interface Native { 2 20 memory: WebAssembly.Memory; 3 21 statePtr: number; ··· 7 25 length(ct: number): number; 8 26 setPointer(x: number, y: number, down: boolean): void; 9 27 getPointerOverIds(): string[]; 28 + getElementBounds(id: string): BoundingBox | undefined; 10 29 } 11 30 12 31 import { compiled } from "./wasm.ts"; ··· 60 79 pointer_over_count(): number; 61 80 pointer_over_id_string_length(index: number): number; 62 81 pointer_over_id_string_ptr(index: number): number; 82 + get_element_bounds(name: number, len: number, out: number): number; 63 83 }; 64 84 65 85 let heap = ct.__heap_base.value as number; ··· 100 120 ids.push(decoder.decode(new Uint8Array(memory.buffer, ptr, len))); 101 121 } 102 122 return ids; 123 + }, 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 + }; 103 140 }, 104 141 }; 105 142 }
+23 -2
term.ts
··· 1 1 import { type Op, pack } from "./ops.ts"; 2 - import { createTermNative } from "./term-native.ts"; 2 + import { type BoundingBox, createTermNative } from "./term-native.ts"; 3 3 4 4 export interface TermOptions { 5 5 height: number; ··· 32 32 | { type: "pointerleave"; id: string } 33 33 | { type: "pointerclick"; id: string }; 34 34 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 + 35 45 export interface RenderResult { 36 46 output: Uint8Array; 37 47 events: PointerEvent[]; 48 + info: RenderInfo; 38 49 } 39 50 40 51 export interface Term { ··· 103 114 prev = current; 104 115 wasDown = down; 105 116 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 }; 107 128 }, 108 129 }; 109 130 }
+41 -1
test/term.test.ts
··· 1 1 import { beforeEach, describe, expect, it } from "./suite.ts"; 2 2 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"; 4 4 import { print } from "./print.ts"; 5 5 6 6 const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); ··· 148 148 └──────────────────┘`.trim()); 149 149 150 150 expect(second.length).toBeLessThan(first.length); 151 + }); 152 + }); 153 + 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(); 151 191 }); 152 192 }); 153 193
+5
typedef.ts
··· 56 56 }; 57 57 } 58 58 59 + export const f32 = (): TypeDef<number> => ({ 60 + type: "f32", 61 + byteLength: 4, 62 + byteAlign: 4, 63 + }); 59 64 export const int32 = (): TypeDef<number> => ({ 60 65 type: "int32", 61 66 byteLength: 4,