diff --git a/AGENTS.md b/AGENTS.md index af86ef2..5685f3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,36 +2,34 @@ ## Spec-driven development -- Specs in `specs/` are the source of truth. Code conforms to specs, - not the other way around. +- Specs in `specs/` are the source of truth. Code conforms to specs, not the + other way around. -- Never add, remove, or change a public API in code without first - updating the relevant spec and getting explicit approval from the - user. This includes changes to `Term`, `createTerm`, `render()`, - directive constructors (`open`, `close`, `text`), sizing helpers - (`grow`, `fixed`, `fit`), and any future spec'd interfaces. +- Never add, remove, or change a public API in code without first updating the + relevant spec and getting explicit approval from the user. This includes + changes to `Term`, `createTerm`, `render()`, directive constructors (`open`, + `close`, `text`), sizing helpers (`grow`, `fixed`, `fit`), and any future + spec'd interfaces. -- The workflow is: propose the spec change, wait for approval, - then implement. Do not combine spec changes with implementation - in a single step. +- The workflow is: propose the spec change, wait for approval, then implement. + Do not combine spec changes with implementation in a single step. -- The renderer and input parser are specified separately - (`renderer-spec.md` and `input-spec.md`). They are architecturally - independent. Do not introduce dependencies between them. +- The renderer and input parser are specified separately (`renderer-spec.md` and + `input-spec.md`). They are architecturally independent. Do not introduce + dependencies between them. -- Each test file tests exactly one spec. Do not put tests for one - spec into another spec's test file. +- Each test file tests exactly one spec. Do not put tests for one spec into + another spec's test file. ## Rendering invariants -- The renderer MUST NOT perform IO. It produces bytes; the caller - writes them. +- The renderer MUST NOT perform IO. It produces bytes; the caller writes them. -- The renderer MUST NOT manage terminal state (alternate buffer, - cursor visibility, mouse reporting, keyboard protocol modes). +- The renderer MUST NOT manage terminal state (alternate buffer, cursor + visibility, mouse reporting, keyboard protocol modes). -- Each frame is a complete snapshot. The renderer carries no UI tree - state between frames — only cell buffers for diffing. +- Each frame is a complete snapshot. The renderer carries no UI tree state + between frames — only cell buffers for diffing. -- Directives are plain objects. No classes, no methods, no prototype - chains. The flat array pattern is normative. +- Directives are plain objects. No classes, no methods, no prototype chains. The + flat array pattern is normative. diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 74a6d49..7aae21d 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -639,7 +639,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: Map }`. The `output` field is the ANSI byte output specified normatively in Section 7.3 and Section 8.2. @@ -651,6 +651,18 @@ 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 is a `Map` keyed by element id (the `name` +parameter passed to `open()`). Each `RenderInfo` provides post-layout metadata: + +- **`dimensions`** — `{ x: number; y: number; width: number; height: number }` — + 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. + +The `info` map is populated for every element that has a non-empty id. Elements +opened with an empty-string id are excluded. When multiple elements share the +same id within a frame, only the first element's data appears in the map. + 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 a9bedf6..6d88200 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -32,6 +32,27 @@ #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 +/* ── Element info ─────────────────────────────────────────────────── */ + +#define MAX_ELEMENT_INFO 512 + +struct ElementEntry { + const char *name; + int name_len; + Clay_ElementId eid; +}; + +struct ElementInfo { + const char *name; + int name_len; + float x, y, w, h; +}; + +static struct ElementEntry g_entries[MAX_ELEMENT_INFO]; +static int g_entry_count; +static struct ElementInfo g_info[MAX_ELEMENT_INFO]; +static int g_info_count; + /* ── Instance state ───────────────────────────────────────────────── */ struct Clayterm { @@ -438,6 +459,8 @@ struct Clayterm *init(void *mem, int w, int h) { void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { int i = 0; uint32_t idx = 0; + g_entry_count = 0; + g_info_count = 0; Clay_BeginLayout(); @@ -456,6 +479,13 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { Clay_String str = {.length = (int32_t)id_len, .chars = id_chars}; Clay_ElementId eid = Clay__HashString(str, idx++); Clay__OpenElementWithId(eid); + + if (g_entry_count < MAX_ELEMENT_INFO) { + struct ElementEntry *entry = &g_entries[g_entry_count++]; + entry->name = id_chars; + entry->name_len = (int)id_len; + entry->eid = eid; + } } else { Clay__OpenElement(); } @@ -560,6 +590,20 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row) { Clay_RenderCommandArray cmds = Clay_EndLayout(); + /* resolve element bounding boxes */ + for (int k = 0; k < g_entry_count; k++) { + Clay_ElementData data = Clay_GetElementData(g_entries[k].eid); + if (data.found) { + struct ElementInfo *info = &g_info[g_info_count++]; + info->name = g_entries[k].name; + info->name_len = g_entries[k].name_len; + info->x = data.boundingBox.x; + info->y = data.boundingBox.y; + info->w = data.boundingBox.width; + info->h = data.boundingBox.height; + } + } + /* reset output state */ ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; @@ -612,6 +656,25 @@ char *output(struct Clayterm *ct) { return ct->out.data; } int length(struct Clayterm *ct) { return ct->out.length; } +int element_info_count(void) { return g_info_count; } + +int element_info_name_len(int index) { + if (index >= g_info_count) + return 0; + return g_info[index].name_len; +} + +int element_info_name_ptr(int index) { + if (index >= g_info_count) + return 0; + return (int)g_info[index].name; +} + +float element_info_x(int index) { return g_info[index].x; } +float element_info_y(int index) { return g_info[index].y; } +float element_info_w(int index) { return g_info[index].w; } +float element_info_h(int index) { return g_info[index].h; } + 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..ee6472e 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -17,6 +17,14 @@ char *output(struct Clayterm *ct); int length(struct Clayterm *ct); void measure(int ret, int txt); +int element_info_count(void); +int element_info_name_len(int index); +int element_info_name_ptr(int index); +float element_info_x(int index); +float element_info_y(int index); +float element_info_w(int index); +float element_info_h(int index); + 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..b027ddf 100644 --- a/term-native.ts +++ b/term-native.ts @@ -1,3 +1,11 @@ +export interface ElementInfo { + name: string; + x: number; + y: number; + width: number; + height: number; +} + export interface Native { memory: WebAssembly.Memory; statePtr: number; @@ -7,6 +15,7 @@ export interface Native { length(ct: number): number; setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; + getElementInfo(): ElementInfo[]; } import { compiled } from "./wasm.ts"; @@ -60,6 +69,13 @@ export async function createTermNative( pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; + element_info_count(): number; + element_info_name_len(index: number): number; + element_info_name_ptr(index: number): number; + element_info_x(index: number): number; + element_info_y(index: number): number; + element_info_w(index: number): number; + element_info_h(index: number): number; }; let heap = ct.__heap_base.value as number; @@ -101,5 +117,24 @@ export async function createTermNative( } return ids; }, + getElementInfo(): ElementInfo[] { + let decoder = new TextDecoder(); + let count = ct.element_info_count(); + let result: ElementInfo[] = []; + for (let i = 0; i < count; i++) { + let len = ct.element_info_name_len(i); + if (len === 0) continue; + let ptr = ct.element_info_name_ptr(i); + let name = decoder.decode(new Uint8Array(memory.buffer, ptr, len)); + result.push({ + name, + x: ct.element_info_x(i), + y: ct.element_info_y(i), + width: ct.element_info_w(i), + height: ct.element_info_h(i), + }); + } + return result; + }, }; } diff --git a/term.ts b/term.ts index 2b6e9c1..999cc53 100644 --- a/term.ts +++ b/term.ts @@ -32,9 +32,14 @@ export type PointerEvent = | { type: "pointerleave"; id: string } | { type: "pointerclick"; id: string }; +export interface RenderInfo { + dimensions: { x: number; y: number; width: number; height: number }; +} + export interface RenderResult { output: Uint8Array; events: PointerEvent[]; + info: Map; } export interface Term { @@ -103,7 +108,21 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; - return { output, events }; + let info = new Map(); + for (let el of native.getElementInfo()) { + if (!info.has(el.name)) { + info.set(el.name, { + dimensions: { + x: el.x, + y: el.y, + width: el.width, + height: el.height, + }, + }); + } + } + + return { output, events, info }; }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index 00a4b4e..3299cc6 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,45 @@ describe("term", () => { }); }); + describe("info", () => { + it("returns dimensions for each named element", 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!.dimensions).toEqual({ x: 0, y: 0, width: 40, height: 10 }); + + let child = result.info.get("child"); + expect(child).toBeDefined(); + expect(child!.dimensions).toEqual({ x: 0, y: 0, width: 20, height: 5 }); + }); + + it("excludes elements with empty id", async () => { + let term = await createTerm({ width: 20, height: 5 }); + let result = term.render([ + open("root", { + layout: { width: grow(), height: grow() }, + }), + open("", {}), + close(), + close(), + ]); + + expect(result.info.has("root")).toBe(true); + expect(result.info.has("")).toBe(false); + }); + }); + describe("row offset", () => { it("renders two frames at the offset position", async () => { let term = await createTerm({ width: 20, height: 5 });