[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 element dimensions to RenderResult via info map

Adds a `info: Map<string, RenderInfo>` field to RenderResult that
provides post-layout bounding boxes for every named element. Each
RenderInfo contains dimensions (x, y, width, height) zero-indexed
from the layout root.

On the C side, element names and Clay_ElementIds are recorded during
layout, then resolved via Clay_GetElementData() after Clay_EndLayout()
to extract computed bounding boxes. New WASM exports expose the
results to TypeScript.

Charles Lowell (Apr 17, 2026, 3:19 PM -0500) ae2023dc 9adb34b0

+200 -26
+21 -23
AGENTS.md
··· 2 2 3 3 ## Spec-driven development 4 4 5 - - Specs in `specs/` are the source of truth. Code conforms to specs, 6 - not the other way around. 5 + - Specs in `specs/` are the source of truth. Code conforms to specs, not the 6 + other way around. 7 7 8 - - Never add, remove, or change a public API in code without first 9 - updating the relevant spec and getting explicit approval from the 10 - user. This includes changes to `Term`, `createTerm`, `render()`, 11 - directive constructors (`open`, `close`, `text`), sizing helpers 12 - (`grow`, `fixed`, `fit`), and any future spec'd interfaces. 8 + - Never add, remove, or change a public API in code without first updating the 9 + relevant spec and getting explicit approval from the user. This includes 10 + changes to `Term`, `createTerm`, `render()`, directive constructors (`open`, 11 + `close`, `text`), sizing helpers (`grow`, `fixed`, `fit`), and any future 12 + spec'd interfaces. 13 13 14 - - The workflow is: propose the spec change, wait for approval, 15 - then implement. Do not combine spec changes with implementation 16 - in a single step. 14 + - The workflow is: propose the spec change, wait for approval, then implement. 15 + Do not combine spec changes with implementation in a single step. 17 16 18 - - The renderer and input parser are specified separately 19 - (`renderer-spec.md` and `input-spec.md`). They are architecturally 20 - independent. Do not introduce dependencies between them. 17 + - The renderer and input parser are specified separately (`renderer-spec.md` and 18 + `input-spec.md`). They are architecturally independent. Do not introduce 19 + dependencies between them. 21 20 22 - - Each test file tests exactly one spec. Do not put tests for one 23 - spec into another spec's test file. 21 + - Each test file tests exactly one spec. Do not put tests for one spec into 22 + another spec's test file. 24 23 25 24 ## Rendering invariants 26 25 27 - - The renderer MUST NOT perform IO. It produces bytes; the caller 28 - writes them. 26 + - The renderer MUST NOT perform IO. It produces bytes; the caller writes them. 29 27 30 - - The renderer MUST NOT manage terminal state (alternate buffer, 31 - cursor visibility, mouse reporting, keyboard protocol modes). 28 + - The renderer MUST NOT manage terminal state (alternate buffer, cursor 29 + visibility, mouse reporting, keyboard protocol modes). 32 30 33 - - Each frame is a complete snapshot. The renderer carries no UI tree 34 - state between frames — only cell buffers for diffing. 31 + - Each frame is a complete snapshot. The renderer carries no UI tree state 32 + between frames — only cell buffers for diffing. 35 33 36 - - Directives are plain objects. No classes, no methods, no prototype 37 - chains. The flat array pattern is normative. 34 + - Directives are plain objects. No classes, no methods, no prototype chains. The 35 + flat array pattern is normative.
+13 -1
specs/renderer-spec.md
··· 639 639 ### 12.3 Render return type 640 640 641 641 The `render()` method currently returns a `RenderResult` object shaped as 642 - `{ output: Uint8Array, events: PointerEvent[] }`. 642 + `{ output: Uint8Array, events: PointerEvent[], info: Map<string, RenderInfo> }`. 643 643 644 644 The `output` field is the ANSI byte output specified normatively in Section 7.3 645 645 and Section 8.2. ··· 650 650 but has acknowledged gaps (no modifier keys on click events) and its interaction 651 651 protocol (passing pointer state via render options, then reading events from the 652 652 return value) was arrived at through iteration rather than upfront design. 653 + 654 + The `info` field is a `Map<string, RenderInfo>` keyed by element id (the `name` 655 + parameter passed to `open()`). Each `RenderInfo` provides post-layout metadata: 656 + 657 + - **`dimensions`** — `{ x: number; y: number; width: number; height: number }` — 658 + the element's computed bounding box in character cells, as determined by the 659 + layout engine after the render transaction completes. `x` and `y` are 660 + zero-indexed from the top-left corner of the layout root. 661 + 662 + The `info` map is populated for every element that has a non-empty id. Elements 663 + opened with an empty-string id are excluded. When multiple elements share the 664 + same id within a frame, only the first element's data appears in the map. 653 665 654 666 The return type of `render()` has changed twice since the project's inception 655 667 (string, then `Uint8Array`, then `RenderResult`). While the ANSI bytes
+63
src/clayterm.c
··· 32 32 #define PROP_CLIP 0x10 33 33 #define PROP_FLOATING 0x20 34 34 35 + /* ── Element info ─────────────────────────────────────────────────── */ 36 + 37 + #define MAX_ELEMENT_INFO 512 38 + 39 + struct ElementEntry { 40 + const char *name; 41 + int name_len; 42 + Clay_ElementId eid; 43 + }; 44 + 45 + struct ElementInfo { 46 + const char *name; 47 + int name_len; 48 + float x, y, w, h; 49 + }; 50 + 51 + static struct ElementEntry g_entries[MAX_ELEMENT_INFO]; 52 + static int g_entry_count; 53 + static struct ElementInfo g_info[MAX_ELEMENT_INFO]; 54 + static int g_info_count; 55 + 35 56 /* ── Instance state ───────────────────────────────────────────────── */ 36 57 37 58 struct Clayterm { ··· 438 459 void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { 439 460 int i = 0; 440 461 uint32_t idx = 0; 462 + g_entry_count = 0; 463 + g_info_count = 0; 441 464 442 465 Clay_BeginLayout(); 443 466 ··· 456 479 Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; 457 480 Clay_ElementId eid = Clay__HashString(str, idx++); 458 481 Clay__OpenElementWithId(eid); 482 + 483 + if (g_entry_count < MAX_ELEMENT_INFO) { 484 + struct ElementEntry *entry = &g_entries[g_entry_count++]; 485 + entry->name = id_chars; 486 + entry->name_len = (int)id_len; 487 + entry->eid = eid; 488 + } 459 489 } else { 460 490 Clay__OpenElement(); 461 491 } ··· 560 590 561 591 Clay_RenderCommandArray cmds = Clay_EndLayout(); 562 592 593 + /* resolve element bounding boxes */ 594 + for (int k = 0; k < g_entry_count; k++) { 595 + Clay_ElementData data = Clay_GetElementData(g_entries[k].eid); 596 + if (data.found) { 597 + struct ElementInfo *info = &g_info[g_info_count++]; 598 + info->name = g_entries[k].name; 599 + info->name_len = g_entries[k].name_len; 600 + info->x = data.boundingBox.x; 601 + info->y = data.boundingBox.y; 602 + info->w = data.boundingBox.width; 603 + info->h = data.boundingBox.height; 604 + } 605 + } 606 + 563 607 /* reset output state */ 564 608 ct->out.length = 0; 565 609 ct->lastfg = ct->lastbg = 0xffffffff; ··· 611 655 char *output(struct Clayterm *ct) { return ct->out.data; } 612 656 613 657 int length(struct Clayterm *ct) { return ct->out.length; } 658 + 659 + int element_info_count(void) { return g_info_count; } 660 + 661 + int element_info_name_len(int index) { 662 + if (index >= g_info_count) 663 + return 0; 664 + return g_info[index].name_len; 665 + } 666 + 667 + int element_info_name_ptr(int index) { 668 + if (index >= g_info_count) 669 + return 0; 670 + return (int)g_info[index].name; 671 + } 672 + 673 + float element_info_x(int index) { return g_info[index].x; } 674 + float element_info_y(int index) { return g_info[index].y; } 675 + float element_info_w(int index) { return g_info[index].w; } 676 + float element_info_h(int index) { return g_info[index].h; } 614 677 615 678 int pointer_over_count(void) { return Clay_GetPointerOverIds().length; } 616 679
+8
src/clayterm.h
··· 17 17 int length(struct Clayterm *ct); 18 18 void measure(int ret, int txt); 19 19 20 + int element_info_count(void); 21 + int element_info_name_len(int index); 22 + int element_info_name_ptr(int index); 23 + float element_info_x(int index); 24 + float element_info_y(int index); 25 + float element_info_w(int index); 26 + float element_info_h(int index); 27 + 20 28 int pointer_over_count(void); 21 29 int pointer_over_id_string_length(int index); 22 30 int pointer_over_id_string_ptr(int index);
+35
term-native.ts
··· 1 + export interface ElementInfo { 2 + name: string; 3 + x: number; 4 + y: number; 5 + width: number; 6 + height: number; 7 + } 8 + 1 9 export interface Native { 2 10 memory: WebAssembly.Memory; 3 11 statePtr: number; ··· 7 15 length(ct: number): number; 8 16 setPointer(x: number, y: number, down: boolean): void; 9 17 getPointerOverIds(): string[]; 18 + getElementInfo(): ElementInfo[]; 10 19 } 11 20 12 21 import { compiled } from "./wasm.ts"; ··· 60 69 pointer_over_count(): number; 61 70 pointer_over_id_string_length(index: number): number; 62 71 pointer_over_id_string_ptr(index: number): number; 72 + element_info_count(): number; 73 + element_info_name_len(index: number): number; 74 + element_info_name_ptr(index: number): number; 75 + element_info_x(index: number): number; 76 + element_info_y(index: number): number; 77 + element_info_w(index: number): number; 78 + element_info_h(index: number): number; 63 79 }; 64 80 65 81 let heap = ct.__heap_base.value as number; ··· 100 116 ids.push(decoder.decode(new Uint8Array(memory.buffer, ptr, len))); 101 117 } 102 118 return ids; 119 + }, 120 + getElementInfo(): ElementInfo[] { 121 + let decoder = new TextDecoder(); 122 + let count = ct.element_info_count(); 123 + let result: ElementInfo[] = []; 124 + for (let i = 0; i < count; i++) { 125 + let len = ct.element_info_name_len(i); 126 + if (len === 0) continue; 127 + let ptr = ct.element_info_name_ptr(i); 128 + let name = decoder.decode(new Uint8Array(memory.buffer, ptr, len)); 129 + result.push({ 130 + name, 131 + x: ct.element_info_x(i), 132 + y: ct.element_info_y(i), 133 + width: ct.element_info_w(i), 134 + height: ct.element_info_h(i), 135 + }); 136 + } 137 + return result; 103 138 }, 104 139 }; 105 140 }
+20 -1
term.ts
··· 32 32 | { type: "pointerleave"; id: string } 33 33 | { type: "pointerclick"; id: string }; 34 34 35 + export interface RenderInfo { 36 + dimensions: { x: number; y: number; width: number; height: number }; 37 + } 38 + 35 39 export interface RenderResult { 36 40 output: Uint8Array; 37 41 events: PointerEvent[]; 42 + info: Map<string, RenderInfo>; 38 43 } 39 44 40 45 export interface Term { ··· 103 108 prev = current; 104 109 wasDown = down; 105 110 106 - return { output, events }; 111 + let info = new Map<string, RenderInfo>(); 112 + for (let el of native.getElementInfo()) { 113 + if (!info.has(el.name)) { 114 + info.set(el.name, { 115 + dimensions: { 116 + x: el.x, 117 + y: el.y, 118 + width: el.width, 119 + height: el.height, 120 + }, 121 + }); 122 + } 123 + } 124 + 125 + return { output, events, info }; 107 126 }, 108 127 }; 109 128 }
+40 -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 dimensions for each named element", 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!.dimensions).toEqual({ x: 0, y: 0, width: 40, height: 10 }); 171 + 172 + let child = result.info.get("child"); 173 + expect(child).toBeDefined(); 174 + expect(child!.dimensions).toEqual({ x: 0, y: 0, width: 20, height: 5 }); 175 + }); 176 + 177 + it("excludes elements with empty id", async () => { 178 + let term = await createTerm({ width: 20, height: 5 }); 179 + let result = term.render([ 180 + open("root", { 181 + layout: { width: grow(), height: grow() }, 182 + }), 183 + open("", {}), 184 + close(), 185 + close(), 186 + ]); 187 + 188 + expect(result.info.has("root")).toBe(true); 189 + expect(result.info.has("")).toBe(false); 151 190 }); 152 191 }); 153 192