Skip to content

Commit d363633

Browse files
committed
✨ add pointer events and interactive hover to Clay render pipeline
Clay's pointer hit-testing is now wired through render() via RenderOptions, returning pointerenter/pointerleave/pointerclick events by diffing Clay_GetPointerOverIds() across frames. The demo keyboard shows hover highlights on keys, semantic element IDs, and configurable event log filters.
1 parent cfcbca7 commit d363633

9 files changed

Lines changed: 595 additions & 191 deletions

File tree

demo/keyboard.ts

Lines changed: 317 additions & 173 deletions
Large diffs are not rendered by default.

input.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,16 @@ export interface ResizeEvent {
265265
height: number;
266266
}
267267

268+
import type { PointerEvent } from "./term.ts";
269+
268270
export type InputEvent =
269271
| KeyEvent
270272
| MouseDownEvent
271273
| MouseUpEvent
272274
| MouseMoveEvent
273275
| WheelEvent
274-
| ResizeEvent;
276+
| ResizeEvent
277+
| PointerEvent;
275278

276279
/**
277280
* Result of a single scan() call.

src/clayterm.c

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,22 @@ char *output(struct Clayterm *ct) { return ct->out.data; }
557557

558558
int length(struct Clayterm *ct) { return ct->out.length; }
559559

560+
int pointer_over_count(void) {
561+
return Clay_GetPointerOverIds().length;
562+
}
563+
564+
int pointer_over_id_string_length(int index) {
565+
Clay_ElementIdArray ids = Clay_GetPointerOverIds();
566+
if (index >= ids.length) return 0;
567+
return ids.internalArray[index].stringId.length;
568+
}
569+
570+
int pointer_over_id_string_ptr(int index) {
571+
Clay_ElementIdArray ids = Clay_GetPointerOverIds();
572+
if (index >= ids.length) return 0;
573+
return (int)ids.internalArray[index].stringId.chars;
574+
}
575+
560576
void measure(int ret, int txt) {
561577
/* Read Clay_StringSlice from txt address.
562578
* Clay_StringSlice layout: { int32_t length, const char *chars, ... }

src/clayterm.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,8 @@ char *output(struct Clayterm *ct);
1717
int length(struct Clayterm *ct);
1818
void measure(int ret, int txt);
1919

20+
int pointer_over_count(void);
21+
int pointer_over_id_string_length(int index);
22+
int pointer_over_id_string_ptr(int index);
23+
2024
#endif

term-native.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export interface Native {
55
reduce(ct: number, buf: number, len: number): void;
66
output(ct: number): number;
77
length(ct: number): number;
8+
setPointer(x: number, y: number, down: boolean): void;
9+
getPointerOverIds(): string[];
810
}
911

1012
import { compiled } from "./wasm.ts";
@@ -45,6 +47,10 @@ export async function createTermNative(w: number, h: number): Promise<Native> {
4547
reduce(ct: number, buf: number, len: number): void;
4648
output(ct: number): number;
4749
length(ct: number): number;
50+
Clay_SetPointerState(vec: number, down: number): void;
51+
pointer_over_count(): number;
52+
pointer_over_id_string_length(index: number): number;
53+
pointer_over_id_string_ptr(index: number): number;
4854
};
4955

5056
let heap = ct.__heap_base.value as number;
@@ -68,5 +74,23 @@ export async function createTermNative(w: number, h: number): Promise<Native> {
6874
reduce: ct.reduce,
6975
output: ct.output,
7076
length: ct.length,
77+
setPointer(x: number, y: number, down: boolean) {
78+
let view = new DataView(memory.buffer);
79+
view.setFloat32(opsBuf, x, true);
80+
view.setFloat32(opsBuf + 4, y, true);
81+
ct.Clay_SetPointerState(opsBuf, down ? 1 : 0);
82+
},
83+
getPointerOverIds(): string[] {
84+
let decoder = new TextDecoder();
85+
let count = ct.pointer_over_count();
86+
let ids: string[] = [];
87+
for (let i = 0; i < count; i++) {
88+
let len = ct.pointer_over_id_string_length(i);
89+
if (len === 0) continue;
90+
let ptr = ct.pointer_over_id_string_ptr(i);
91+
ids.push(decoder.decode(new Uint8Array(memory.buffer, ptr, len)));
92+
}
93+
return ids;
94+
},
7195
};
7296
}

term.ts

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,89 @@ export interface TermOptions {
66
width: number;
77
}
88

9+
export interface RenderOptions {
10+
pointer?: {
11+
x: number;
12+
y: number;
13+
down: boolean;
14+
};
15+
}
16+
17+
export type PointerEvent =
18+
| { type: "pointerenter"; id: string }
19+
| { type: "pointerleave"; id: string }
20+
| { type: "pointerclick"; id: string };
21+
22+
export interface RenderResult {
23+
output: Uint8Array;
24+
events: PointerEvent[];
25+
}
26+
927
export interface Term {
10-
render(ops: Op[]): Uint8Array;
28+
render(ops: Op[], options?: RenderOptions): RenderResult;
1129
}
1230

1331
export async function createTerm(options: TermOptions): Promise<Term> {
1432
let { width, height } = options;
15-
let { memory, statePtr, opsBuf, reduce, output, length } =
16-
await createTermNative(
17-
width,
18-
height,
19-
);
33+
let native = await createTermNative(width, height);
34+
let { memory, statePtr, opsBuf } = native;
35+
36+
let prev = new Set<string>();
37+
let pressed = new Set<string>();
38+
let wasDown = false;
2039

2140
return {
22-
render(ops: Op[]): Uint8Array {
41+
render(ops: Op[], options?: RenderOptions): RenderResult {
2342
let len = pack(ops, memory.buffer, opsBuf, memory.buffer.byteLength);
24-
reduce(statePtr, opsBuf, len);
25-
return new Uint8Array(
43+
native.reduce(statePtr, opsBuf, len);
44+
45+
if (options?.pointer) {
46+
let { x, y, down } = options.pointer;
47+
native.setPointer(x, y, down);
48+
}
49+
50+
let output = new Uint8Array(
2651
memory.buffer,
27-
output(statePtr),
28-
length(statePtr),
52+
native.output(statePtr),
53+
native.length(statePtr),
2954
);
55+
56+
let current = new Set(
57+
options?.pointer ? native.getPointerOverIds() : [],
58+
);
59+
let down = options?.pointer?.down ?? false;
60+
let events: PointerEvent[] = [];
61+
62+
for (let id of current) {
63+
if (!prev.has(id)) {
64+
events.push({ type: "pointerenter", id });
65+
}
66+
}
67+
68+
for (let id of prev) {
69+
if (!current.has(id)) {
70+
events.push({ type: "pointerleave", id });
71+
}
72+
}
73+
74+
if (wasDown && !down) {
75+
for (let id of pressed) {
76+
if (current.has(id)) {
77+
events.push({ type: "pointerclick", id });
78+
}
79+
}
80+
}
81+
82+
if (down && !wasDown) {
83+
pressed = new Set(current);
84+
} else if (!down) {
85+
pressed.clear();
86+
}
87+
88+
prev = current;
89+
wasDown = down;
90+
91+
return { output, events };
3092
},
3193
};
3294
}

test/pointer.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { beforeEach, describe, expect, it } from "./suite.ts";
2+
import { createTerm, type Term } from "../term.ts";
3+
import { close, fixed, grow, open, text } from "../ops.ts";
4+
5+
function box(id: string, width: number, height: number) {
6+
return open(id, {
7+
layout: { width: fixed(width), height: fixed(height) },
8+
});
9+
}
10+
11+
// ┌─root (40x10, ltr)──────────────────┐
12+
// │┌─left (20x10)─┐┌─right (20x10)──┐│
13+
// ││L ││R ││
14+
// ││ ││ ││
15+
// ││ ││ ││
16+
// ││ ││ ││
17+
// ││ ││ ││
18+
// ││ ││ ││
19+
// ││ ││ ││
20+
// │└───────────────┘└────────────────┘│
21+
// └───────────────────────────────────┘
22+
function layout() {
23+
return [
24+
open("root", {
25+
layout: { width: grow(), height: grow(), direction: "ltr" },
26+
}),
27+
box("left", 20, 10),
28+
text("L"),
29+
close(),
30+
box("right", 20, 10),
31+
text("R"),
32+
close(),
33+
close(),
34+
];
35+
}
36+
37+
describe("pointer events", () => {
38+
let term: Term;
39+
40+
beforeEach(async () => {
41+
term = await createTerm({ width: 40, height: 10 });
42+
});
43+
44+
it("returns no events when pointer is not provided", () => {
45+
let result = term.render(layout());
46+
expect(result.events).toEqual([]);
47+
});
48+
49+
it("returns no events when pointer is outside all elements", () => {
50+
let result = term.render(layout(), {
51+
pointer: { x: 100, y: 100, down: false },
52+
});
53+
expect(result.events).toEqual([]);
54+
});
55+
56+
it("fires pointerenter when pointer moves over an element", () => {
57+
let result = term.render(layout(), {
58+
pointer: { x: 5, y: 5, down: false },
59+
});
60+
let enters = result.events.filter((e) => e.type === "pointerenter");
61+
expect(enters.some((e) => e.id === "left")).toBe(true);
62+
});
63+
64+
it("does not fire pointerenter again on subsequent frames", () => {
65+
term.render(layout(), {
66+
pointer: { x: 5, y: 5, down: false },
67+
});
68+
let result = term.render(layout(), {
69+
pointer: { x: 5, y: 5, down: false },
70+
});
71+
let enters = result.events.filter((e) => e.type === "pointerenter");
72+
expect(enters.some((e) => e.id === "left")).toBe(false);
73+
});
74+
75+
it("fires pointerleave when pointer moves off an element", () => {
76+
term.render(layout(), {
77+
pointer: { x: 5, y: 5, down: false },
78+
});
79+
let result = term.render(layout(), {
80+
pointer: { x: 25, y: 5, down: false },
81+
});
82+
let leaves = result.events.filter((e) => e.type === "pointerleave");
83+
expect(leaves.some((e) => e.id === "left")).toBe(true);
84+
});
85+
86+
it("fires pointerenter and pointerleave when moving between elements", () => {
87+
term.render(layout(), {
88+
pointer: { x: 5, y: 5, down: false },
89+
});
90+
let result = term.render(layout(), {
91+
pointer: { x: 25, y: 5, down: false },
92+
});
93+
expect(result.events).toContainEqual({ type: "pointerleave", id: "left" });
94+
expect(result.events).toContainEqual({ type: "pointerenter", id: "right" });
95+
});
96+
97+
it("fires pointerclick on press then release over same element", () => {
98+
term.render(layout(), {
99+
pointer: { x: 5, y: 5, down: true },
100+
});
101+
let result = term.render(layout(), {
102+
pointer: { x: 5, y: 5, down: false },
103+
});
104+
expect(result.events).toContainEqual({ type: "pointerclick", id: "left" });
105+
});
106+
107+
it("does not fire pointerclick if released over a different element", () => {
108+
term.render(layout(), {
109+
pointer: { x: 5, y: 5, down: true },
110+
});
111+
let result = term.render(layout(), {
112+
pointer: { x: 25, y: 5, down: false },
113+
});
114+
let clicks = result.events.filter((e) => e.type === "pointerclick");
115+
expect(clicks.some((e) => e.id === "left")).toBe(false);
116+
expect(clicks.some((e) => e.id === "right")).toBe(false);
117+
});
118+
119+
it("fires pointerleave for all hovered elements when pointer is removed", () => {
120+
term.render(layout(), {
121+
pointer: { x: 5, y: 5, down: false },
122+
});
123+
let result = term.render(layout());
124+
let leaves = result.events.filter((e) => e.type === "pointerleave");
125+
expect(leaves.some((e) => e.id === "left")).toBe(true);
126+
});
127+
128+
it("does not fire pointerleave on parent when moving to child", () => {
129+
// pointer starts on root but outside both children (if layout allows),
130+
// or we just verify that moving within a parent's bounds doesn't leave it
131+
term.render(layout(), {
132+
pointer: { x: 5, y: 5, down: false },
133+
});
134+
// move to a different spot still inside "root" and "left"
135+
let result = term.render(layout(), {
136+
pointer: { x: 10, y: 5, down: false },
137+
});
138+
let leaves = result.events.filter((e) => e.type === "pointerleave");
139+
expect(leaves.some((e) => e.id === "root")).toBe(false);
140+
expect(leaves.some((e) => e.id === "left")).toBe(false);
141+
});
142+
143+
it("includes parent element in enter/leave events", () => {
144+
let result = term.render(layout(), {
145+
pointer: { x: 5, y: 5, down: false },
146+
});
147+
let enters = result.events.filter((e) => e.type === "pointerenter");
148+
expect(enters.some((e) => e.id === "root")).toBe(true);
149+
expect(enters.some((e) => e.id === "left")).toBe(true);
150+
});
151+
});

test/term.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe("term", () => {
2020
}),
2121
text("Hello, World!"),
2222
close(),
23-
])),
23+
]).output),
2424
40,
2525
10,
2626
);
@@ -36,7 +36,7 @@ describe("term", () => {
3636
}),
3737
text("hi"),
3838
close(),
39-
]));
39+
]).output);
4040

4141
// the SGR active when "h" is emitted should include the
4242
// parent's red background (48;2;255;0;0), not terminal default
@@ -65,7 +65,7 @@ describe("term", () => {
6565
}),
6666
text("padded"),
6767
close(),
68-
])),
68+
]).output),
6969
40,
7070
10,
7171
);

validate.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Type } from "@sinclair/typebox";
22
import { TypeCompiler } from "@sinclair/typebox/compiler";
33
import type { Op } from "./ops.ts";
4-
import type { Term } from "./term.ts";
4+
import type { RenderOptions, RenderResult, Term } from "./term.ts";
55

66
/* ── Range helpers (match bit-packing in pack()) ──────────────────── */
77

@@ -135,9 +135,9 @@ export function assert(ops: unknown): asserts ops is Op[] {
135135

136136
export function validated(term: Term): Term {
137137
return {
138-
render(ops: Op[]): Uint8Array {
138+
render(ops: Op[], options?: RenderOptions): RenderResult {
139139
assert(ops);
140-
return term.render(ops);
140+
return term.render(ops, options);
141141
},
142142
};
143143
}

0 commit comments

Comments
 (0)