diff --git a/Makefile b/Makefile index b9d0c33..c3e7b24 100644 --- a/Makefile +++ b/Makefile @@ -15,11 +15,14 @@ EXPORTS = \ -Wl,--export=clayterm_size \ -Wl,--export=init \ -Wl,--export=set_capabilities \ + -Wl,--export=pointer_shape \ + -Wl,--export=set_pointer_shape \ + -Wl,--export=pointer_shape \ + -Wl,--export=set_pointer_shape \ -Wl,--export=reduce \ -Wl,--export=output \ -Wl,--export=length \ -Wl,--export=measure \ - -Wl,--export=Clay_SetPointerState \ -Wl,--export=pointer_over_count \ -Wl,--export=pointer_over_id_string_length \ -Wl,--export=pointer_over_id_string_ptr \ diff --git a/ops.ts b/ops.ts index c548687..e34e79d 100644 --- a/ops.ts +++ b/ops.ts @@ -1,4 +1,4 @@ -import type { PointerShape } from "./pointer-shape.ts"; +import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; export type { PointerShape }; @@ -38,6 +38,7 @@ const PROP_BORDER = 0x08; const PROP_CLIP = 0x10; const PROP_FLOATING = 0x20; const PROP_TRANSITION = 0x40; +const PROP_CURSOR = 0x80; const encoder = new TextEncoder(); @@ -156,6 +157,8 @@ export function pack( if (op.clip) mask |= PROP_CLIP; if (op.floating) mask |= PROP_FLOATING; if (op.transition) mask |= PROP_TRANSITION; + let cursor = pointerShapeIndex(op.cursor); + if (cursor !== undefined) mask |= PROP_CURSOR; view.setUint32(o, mask, true); o += 4; @@ -283,6 +286,11 @@ export function pack( view.setUint8(o, t.interactive ? 1 : 0); o += 1; } + + if (cursor !== undefined) { + view.setUint32(o, cursor, true); + o += 4; + } break; } @@ -496,7 +504,6 @@ export interface Text { interface Snapshot { directive: typeof OP_SNAPSHOT; data: Uint8Array; - shapes?: readonly (readonly [id: string, shape: string])[]; } export type Op = OpenElement | Text | CloseElement | Snapshot; @@ -541,6 +548,7 @@ function packSize(ops: Op[]): number { // x, y, expand width/height, parent, attach/pointer, clip/z if (op.floating) n += 7 * 4; if (op.transition) n += 8; + if (pointerShapeIndex(op.cursor) !== undefined) n += 4; break; } case OP_TEXT: { @@ -557,19 +565,7 @@ export function snapshot(ops: Op[]): Op { let size = packSize(ops); let buf = new ArrayBuffer(size); let words = pack(ops, buf, 0, size); - let data = new Uint8Array(buf, 0, words * 4); - let shapes: (readonly [string, string])[] = []; - for (let op of ops) { - if (op.directive === OP_SNAPSHOT) { - if (op.shapes) shapes.push(...op.shapes); - } else if ( - op.directive === OP_OPEN_ELEMENT && op.cursor !== undefined - ) { - shapes.push([op.id, op.cursor]); - } - } - if (shapes.length === 0) return { directive: OP_SNAPSHOT, data }; - return { directive: OP_SNAPSHOT, data, shapes }; + return { directive: OP_SNAPSHOT, data: new Uint8Array(buf, 0, words * 4) }; } const TP_X = 1; diff --git a/pointer-shape.ts b/pointer-shape.ts index 67c93c7..8ed5a88 100644 --- a/pointer-shape.ts +++ b/pointer-shape.ts @@ -1,5 +1,3 @@ -import type { Op } from "./ops.ts"; - export const POINTER_SHAPES = [ "default", "none", @@ -43,39 +41,14 @@ export const POINTER_SHAPES = [ */ export type PointerShape = typeof POINTER_SHAPES[number]; -const KNOWN: ReadonlySet = new Set(POINTER_SHAPES); +const INDEX: ReadonlyMap = new Map( + POINTER_SHAPES.map((shape, i) => [shape, i]), +); -export function resolvePointerShape( - ops: readonly Op[], - over: readonly string[], -): PointerShape { - let best = -1; - let shape: PointerShape = "default"; - for (let op of ops) { - if ("data" in op) { - if (!op.shapes) continue; - for (let [id, value] of op.shapes) { - if (!KNOWN.has(value)) continue; - let i = over.lastIndexOf(id); - if (i > best) { - best = i; - shape = value as PointerShape; - } - } - } else if ("cursor" in op && op.cursor !== undefined) { - if (!KNOWN.has(op.cursor)) continue; - let i = over.lastIndexOf(op.id); - if (i > best) { - best = i; - shape = op.cursor; - } - } - } - return shape; +export function pointerShapeIndex(shape: unknown): number | undefined { + return INDEX.get(shape); } -const encoder = new TextEncoder(); - -export function osc22(shape: PointerShape): Uint8Array { - return encoder.encode(`\x1b]22;${shape}\x1b\\`); -} +export const POINTER_SHAPE_RESET: Uint8Array = new TextEncoder().encode( + "\x1b]22;default\x1b\\", +); diff --git a/src/clayterm.c b/src/clayterm.c index 015bdff..dea1c84 100644 --- a/src/clayterm.c +++ b/src/clayterm.c @@ -43,6 +43,7 @@ struct Clayterm *ct_active_context = NULL; #define PROP_CLIP 0x10 #define PROP_FLOATING 0x20 #define PROP_TRANSITION 0x40 +#define PROP_CURSOR 0x80 /* ── Instance state ───────────────────────────────────────────────── */ @@ -90,6 +91,11 @@ struct Clayterm { /* CAP_* bits pushed by the host via set_capabilities(); init() zeroes * them, so the host re-pushes after every re-init */ uint32_t caps; + /* Pointer shape (renderer-spec 7.10): hover_shape is what this frame's + * hit test resolved, pointer_shape is what was last emitted. Both index + * POINTER_SHAPES; 0 is "default". */ + int hover_shape; + int pointer_shape; /* Caret state for hardware-cursor management. The renderer records the * first text node carrying a caret declaration per frame, precomputes the * caret's byte offset into that node's content, then places the cell as @@ -119,11 +125,34 @@ struct Clayterm { * to 4 (base SMP char) + 8×4 (combining marks) = 36 bytes of cluster text. */ #define OUT_BYTES_PER_CELL 128 -/* Fixed per-frame slack on top of the per-cell budget, for output that wraps - * the whole frame rather than scaling with cell count — the BSU/ESU - * synchronized-output pair. Keeps the wrap from being dropped on tiny grids +/* Fixed per-frame slack on top of the per-cell budget, for output emitted + * once per frame rather than per cell: the BSU/ESU synchronized-output pair + * and one OSC 22 pointer shape. Keeps them from being dropped on tiny grids * where the per-cell budget alone is tight. */ -#define OUT_FRAME_OVERHEAD 32 +#define OUT_FRAME_OVERHEAD 64 + +/* Pointer shape names in wire-index order; mirrors POINTER_SHAPES in + * pointer-shape.ts. */ +static const char *const POINTER_SHAPES[] = { + "default", "none", "context-menu", "help", "pointer", + "progress", "wait", "cell", "crosshair", "text", + "vertical-text", "alias", "copy", "move", "no-drop", + "not-allowed", "grab", "grabbing", "e-resize", "n-resize", + "ne-resize", "nw-resize", "s-resize", "se-resize", "sw-resize", + "w-resize", "ew-resize", "ns-resize", "nesw-resize", "nwse-resize", + "zoom-in", "zoom-out", +}; +#define POINTER_SHAPE_COUNT \ + ((int)(sizeof(POINTER_SHAPES) / sizeof(POINTER_SHAPES[0]))) + +/* Clay calls hover functions in hit-test order, ancestors first, so the + * last call is the innermost declaring element. */ +static void cursor_hover(Clay_ElementId id, Clay_PointerData pointer, + void *shape) { + (void)id; + (void)pointer; + ct_active_context->hover_shape = (int)(uintptr_t)shape; +} /* ── Cell buffer ops ──────────────────────────────────────────────── */ @@ -747,10 +776,24 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } -void set_capabilities(struct Clayterm *ct, uint32_t caps) { ct->caps = caps; } +int set_capabilities(struct Clayterm *ct, uint32_t caps) { + int withdrawn = (ct->caps & RENDER_CAP_POINTER_SHAPE) && + !(caps & RENDER_CAP_POINTER_SHAPE); + int restore = withdrawn && ct->pointer_shape != 0; + if (restore) + ct->pointer_shape = 0; + ct->caps = caps; + return restore; +} + +int pointer_shape(struct Clayterm *ct) { return ct->pointer_shape; } + +void set_pointer_shape(struct Clayterm *ct, int shape) { + ct->pointer_shape = shape; +} void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime) { + float deltaTime, float px, float py, int pointer) { int i = 0; ct_active_context = ct; ct->error_count = 0; @@ -884,7 +927,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, : CLAY_TRANSITION_DISABLE_INTERACTIONS_WHILE_TRANSITIONING_POSITION; } + uint32_t cursor = POINTER_SHAPE_COUNT; + if (mask & PROP_CURSOR) + cursor = rd(buf, len, &i); + Clay__ConfigureOpenElement(decl); + + if (cursor < POINTER_SHAPE_COUNT && (ct->caps & RENDER_CAP_POINTER_SHAPE)) + Clay_OnHover(cursor_hover, (void *)(uintptr_t)cursor); break; } @@ -948,6 +998,12 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, Clay_RenderCommandArray cmds = Clay_EndLayout(deltaTime); + /* pointer: 0 = absent, 1 = up, 2 = down. The hit test runs against this + * frame's layout and fires cursor_hover for declaring elements. */ + ct->hover_shape = 0; + if (pointer) + Clay_SetPointerState((Clay_Vector2){px, py}, pointer == 2); + /* reset output state */ ct->out.length = 0; ct->lastfg = ct->lastbg = 0xffffffff; @@ -1063,6 +1119,14 @@ void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, present_cups(ct, row); } + if ((ct->caps & RENDER_CAP_POINTER_SHAPE) && + ct->hover_shape != ct->pointer_shape) { + ct->pointer_shape = ct->hover_shape; + buf_str(&ct->out, "\x1b]22;"); + buf_str(&ct->out, POINTER_SHAPES[ct->pointer_shape]); + buf_str(&ct->out, "\x1b\\"); + } + ct_active_context = NULL; } diff --git a/src/clayterm.h b/src/clayterm.h index 8d1c807..27069f1 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -11,13 +11,16 @@ struct Clayterm; /* Capability bits for set_capabilities(). */ #define RENDER_CAP_SYNC_OUTPUT (1u << 0) +#define RENDER_CAP_POINTER_SHAPE (1u << 1) /* WASM exports */ int clayterm_size(int w, int h); struct Clayterm *init(void *mem, int w, int h); -void set_capabilities(struct Clayterm *ct, uint32_t caps); +int set_capabilities(struct Clayterm *ct, uint32_t caps); +int pointer_shape(struct Clayterm *ct); +void set_pointer_shape(struct Clayterm *ct, int shape); void reduce(struct Clayterm *ct, uint32_t *buf, int len, int mode, int row, - float deltaTime); + float deltaTime, float px, float py, int pointer); char *output(struct Clayterm *ct); int length(struct Clayterm *ct); int animating(struct Clayterm *ct); diff --git a/term-native.ts b/term-native.ts index e40981b..62f11b3 100644 --- a/term-native.ts +++ b/term-native.ts @@ -23,14 +23,21 @@ const CLAY_DEFAULT_MAX_ELEMENT_COUNT = 8192; // Conservative fixed wire-format budget per element. This covers the largest // non-text open/close element encoding we currently support; text, element id, // and snapshot payload bytes live in TEXT_TRANSFER_BUFFER_BYTES. -const MAX_FIXED_ELEMENT_WIRE_BYTES = 116; +const MAX_FIXED_ELEMENT_WIRE_BYTES = 120; /** Capability evidence the renderer consumes for emission. */ export interface RenderCapabilities { syncOutput: boolean; + pointerShape: boolean; } const RENDER_CAP_SYNC_OUTPUT = 1 << 0; +const RENDER_CAP_POINTER_SHAPE = 1 << 1; + +/** Pointer passed to reduce(): absent, up, or down. */ +export const POINTER_ABSENT = 0; +export const POINTER_UP = 1; +export const POINTER_DOWN = 2; export interface Native { memory: WebAssembly.Memory; @@ -45,9 +52,10 @@ export interface Native { /** * Push capability evidence into the renderer. The renderer holds it across * frames; it is re-pushed internally after any re-init so it survives - * resize updates. + * resize updates. Returns true when withdrawing pointerShape leaves a + * non-default shape on screen that the caller must reset. */ - setCapabilities(caps: RenderCapabilities): void; + setCapabilities(caps: RenderCapabilities): boolean; reduce( ct: number, buf: number, @@ -55,10 +63,12 @@ export interface Native { mode: number, row: number, deltaTime: number, + px: number, + py: number, + pointer: number, ): void; output(ct: number): number; length(ct: number): number; - setPointer(x: number, y: number, down: boolean): void; getPointerOverIds(): string[]; getElementBounds(id: string): BoundingBox | undefined; animating(ct: number): number; @@ -113,11 +123,15 @@ export async function createTermNative( mode: number, row: number, deltaTime: number, + px: number, + py: number, + pointer: number, ): void; output(ct: number): number; length(ct: number): number; - set_capabilities(ct: number, caps: number): void; - Clay_SetPointerState(vec: number, down: number): void; + set_capabilities(ct: number, caps: number): number; + pointer_shape(ct: number): number; + set_pointer_shape(ct: number, shape: number): void; pointer_over_count(): number; pointer_over_id_string_length(index: number): number; pointer_over_id_string_ptr(index: number): number; @@ -135,16 +149,17 @@ export async function createTermNative( let transferBytes = TEXT_TRANSFER_BUFFER_BYTES + CLAY_DEFAULT_MAX_ELEMENT_COUNT * MAX_FIXED_ELEMENT_WIRE_BYTES; - let statePtr!: number; + let statePtr: number | undefined; let opsBuf = 0; let capFlags = 0; - function pushCaps(): void { - ct.set_capabilities(statePtr, capFlags); + function pushCaps(ptr: number): boolean { + return ct.set_capabilities(ptr, capFlags) !== 0; } function setCaps(next: RenderCapabilities): void { - capFlags = next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0; + capFlags = (next.syncOutput ? RENDER_CAP_SYNC_OUTPUT : 0) | + (next.pointerShape ? RENDER_CAP_POINTER_SHAPE : 0); } // Renderer state and the fixed transfer buffer share linear memory as @@ -160,9 +175,12 @@ export async function createTermNative( if (pages > current) { memory.grow(pages - current); } - statePtr = ct.init(heap, lw, lh); + let shape = statePtr === undefined ? 0 : ct.pointer_shape(statePtr); + let ptr = ct.init(heap, lw, lh); opsBuf = (heap + size + 3) & ~3; - pushCaps(); + pushCaps(ptr); + ct.set_pointer_shape(ptr, shape); + statePtr = ptr; } setCaps(caps); layout(w, h); @@ -170,7 +188,7 @@ export async function createTermNative( return { memory, get statePtr() { - return statePtr; + return statePtr!; }, get opsBuf() { return opsBuf; @@ -178,20 +196,14 @@ export async function createTermNative( update(uw: number, uh: number): void { layout(uw, uh); }, - setCapabilities(next: RenderCapabilities): void { + setCapabilities(next: RenderCapabilities): boolean { setCaps(next); - pushCaps(); + return pushCaps(statePtr!); }, reduce: ct.reduce, output: ct.output, length: ct.length, animating: ct.animating, - setPointer(x: number, y: number, down: boolean) { - let view = new DataView(memory.buffer); - view.setFloat32(opsBuf, x, true); - view.setFloat32(opsBuf + 4, y, true); - ct.Clay_SetPointerState(opsBuf, down ? 1 : 0); - }, getPointerOverIds(): string[] { let decoder = new TextDecoder(); let count = ct.pointer_over_count(); diff --git a/term.ts b/term.ts index c19e283..5cc7443 100644 --- a/term.ts +++ b/term.ts @@ -1,12 +1,14 @@ import { type Op, pack } from "./ops.ts"; -import { type BoundingBox, createTermNative } from "./term-native.ts"; +import { + type BoundingBox, + createTermNative, + POINTER_ABSENT, + POINTER_DOWN, + POINTER_UP, +} from "./term-native.ts"; import type { InputEvent } from "./input.ts"; import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; -import { - osc22, - type PointerShape, - resolvePointerShape, -} from "./pointer-shape.ts"; +import { POINTER_SHAPE_RESET } from "./pointer-shape.ts"; export type { BoundingBox }; @@ -165,7 +167,6 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; - let emittedShape: PointerShape = "default"; let currentCaps: RuntimeCapabilities = runtimeFromStatic( terminfo?.capabilities ?? { @@ -183,6 +184,7 @@ export async function createTerm(options: TermOptions): Promise { let native = await createTermNative(width, height, { syncOutput: currentCaps.syncOutput, + pointerShape: currentCaps.pointerShape, }); let { memory } = native; @@ -216,12 +218,18 @@ export async function createTerm(options: TermOptions): Promise { dt = now - lastRenderAt; } lastRenderAt = now; - native.reduce(native.statePtr, native.opsBuf, len, mode, row, dt); - - if (options?.pointer) { - let { x, y, down } = options.pointer; - native.setPointer(x, y, down); - } + let pointer = options?.pointer; + native.reduce( + native.statePtr, + native.opsBuf, + len, + mode, + row, + dt, + pointer?.x ?? 0, + pointer?.y ?? 0, + pointer ? (pointer.down ? POINTER_DOWN : POINTER_UP) : POINTER_ABSENT, + ); let output = new Uint8Array( memory.buffer, @@ -229,22 +237,10 @@ export async function createTerm(options: TermOptions): Promise { native.length(native.statePtr), ); - let overIds = options?.pointer ? native.getPointerOverIds() : []; - let current = new Set(overIds); - - if (currentCaps.pointerShape) { - let shape: PointerShape = overIds.length > 0 - ? resolvePointerShape(ops, overIds) - : "default"; - if (shape !== emittedShape) { - emittedShape = shape; - let seq = osc22(shape); - let joined = new Uint8Array(output.length + seq.length); - joined.set(output); - joined.set(seq, output.length); - output = joined; - } - } + let current = new Set( + options?.pointer ? native.getPointerOverIds() : [], + ); + let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -327,15 +323,14 @@ export async function createTerm(options: TermOptions): Promise { } if ( - currentCaps.pointerShape && !next.pointerShape && - emittedShape !== "default" + next.syncOutput !== currentCaps.syncOutput || + next.pointerShape !== currentCaps.pointerShape ) { - emittedShape = "default"; - out.push(osc22("default")); - } - - if (next.syncOutput !== currentCaps.syncOutput) { - native.setCapabilities({ syncOutput: next.syncOutput }); + let reset = native.setCapabilities({ + syncOutput: next.syncOutput, + pointerShape: next.pointerShape, + }); + if (reset) out.push(POINTER_SHAPE_RESET.slice()); } currentCaps = next; if (bytes.length) out.push(bytes); diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts index 60b72a4..c2b918e 100644 --- a/test/pointer-shape.test.ts +++ b/test/pointer-shape.test.ts @@ -11,6 +11,7 @@ import { text, } from "../ops.ts"; import type { CapabilityEvent } from "../input.ts"; +import { POINTER_SHAPES } from "../pointer-shape.ts"; import { print } from "./print.ts"; const SUPPORTED: CapabilityEvent = { @@ -96,20 +97,6 @@ describe("pointer shape", () => { ); }); - it("does not read cursor without the capability", async () => { - let unsupported = await createTerm({ width: 40, height: 6 }); - let reads = 0; - let ops = layout().map((op) => - "cursor" in op - ? Object.defineProperty({ ...op }, "cursor", { - get: () => (reads++, "pointer"), - }) - : op - ); - unsupported.render(ops, LINK); - expect(reads).toBe(0); - }); - it("emits once the capability arrives", async () => { let late = await createTerm({ width: 40, height: 6 }); expect(sequences(late.render(layout(), LINK).output)).toEqual([]); @@ -178,6 +165,33 @@ describe("pointer shape", () => { ).toEqual([osc22("progress")]); }); + it("emits every shape in the PointerShape vocabulary by name", () => { + let emitted = POINTER_SHAPES.map((shape) => { + let link = shape === "default" ? "pointer" : shape; + return sequences(term.render(layout({ cursor: link }), LINK).output); + }); + expect(emitted).toEqual( + POINTER_SHAPES.map((shape, i) => + i === 0 ? [osc22("pointer")] : [osc22(shape)] + ), + ); + }); + + it("applies to elements declared without an id", () => { + let ops: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + open("", { + layout: { width: fixed(10), height: fixed(2) }, + cursor: "crosshair", + }), + close(), + close(), + ]; + expect(sequences(term.render(ops, over(1, 1)).output)).toEqual([ + osc22("crosshair"), + ]); + }); + it("ignores values outside the PointerShape vocabulary", () => { let bogus = "x\x1b]0;pwned\x07" as PointerShape; let output = term.render(layout({ cursor: bogus }), LINK).output; diff --git a/test/width.test.ts b/test/width.test.ts index 592adee..94a2083 100644 --- a/test/width.test.ts +++ b/test/width.test.ts @@ -57,7 +57,10 @@ describe("width", () => { }); it("replaces surrogate codepoints from malformed UTF-8 with U+FFFD", async () => { - let native = await createTermNative(10, 3, { syncOutput: false }); + let native = await createTermNative(10, 3, { + syncOutput: false, + pointerShape: false, + }); // TextEncoder never produces surrogate bytes, so pack a placeholder // (U+1234 = E1 88 B4) and patch it to CESU-8 U+D800 (ED A0 80) to @@ -83,7 +86,7 @@ describe("width", () => { buf[at + 1] = 0xa0; buf[at + 2] = 0x80; - native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0); + native.reduce(native.statePtr, native.opsBuf, len, 0, 1, 0, 0, 0, 0); let out = new Uint8Array( native.memory.buffer, native.output(native.statePtr),