Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 21 additions & 23 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 13 additions & 1 deletion specs/renderer-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RenderInfo> }`.

The `output` field is the ANSI byte output specified normatively in Section 7.3
and Section 8.2.
Expand All @@ -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<string, RenderInfo>` 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.
Expand Down
63 changes: 63 additions & 0 deletions src/clayterm.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand All @@ -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();
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/clayterm.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions term-native.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
},
};
}
21 changes: 20 additions & 1 deletion term.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, RenderInfo>;
}

export interface Term {
Expand Down Expand Up @@ -103,7 +108,21 @@ export async function createTerm(options: TermOptions): Promise<Term> {
prev = current;
wasDown = down;

return { output, events };
let info = new Map<string, RenderInfo>();
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 };
},
};
}
41 changes: 40 additions & 1 deletion test/term.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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 });
Expand Down
Loading