diff --git a/.changeset/pointer-shape-osc22.md b/.changeset/pointer-shape-osc22.md new file mode 100644 index 0000000..be374ec --- /dev/null +++ b/.changeset/pointer-shape-osc22.md @@ -0,0 +1,11 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds a `cursor` property to `open()`, so the mouse pointer changes shape (OSC 22) as it moves over elements + +When the terminal confirms support through the probe (`term.capabilities.pointerShape`), `render()` appends the shape change to `output`, so writing `output` keeps the pointer in sync. The shape resets to `default` when the pointer leaves, when a frame renders without `pointer`, or when `update()` withdraws the capability. `detectTerminal()` also seeds the capability from a known-support table for terminals that set shapes without answering the query (ghostty, kitty, foot, xterm ≥ 367), so those work out of the box. For anything the table misses, assert support with `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. + +```ts +term.render([open("link", { cursor: "pointer" }), text("Docs"), close()], { pointer }); +``` diff --git a/AGENTS.md b/AGENTS.md index e229098..3484d62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,9 @@ Do not include any agent marketing material (e.g. "Generated with...", - The renderer MUST NOT manage terminal state (alternate buffer, mouse reporting, keyboard protocol modes). Cursor visibility and positioning are renderer-managed only when a `caret` declaration is present on a `text()` - directive (see renderer-spec.md §7.6). + directive (see renderer-spec.md §7.6). The mouse pointer shape is + renderer-managed only while the `pointerShape` capability is true (see + renderer-spec.md §7.10). - Each frame is a complete snapshot. The renderer carries no UI tree state between frames — only cell buffers for diffing. 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/debug-terminfo.ts b/debug-terminfo.ts new file mode 100644 index 0000000..5aaad94 --- /dev/null +++ b/debug-terminfo.ts @@ -0,0 +1,185 @@ +// debug-terminfo.ts — pinpoint where the terminfo capability chain breaks. +// Run directly in the terminal you are testing, from the repo root: +// +// deno run -A debug-terminfo.ts +// node debug-terminfo.ts +// +// Walks the full chain and reports which link fails: environment identity, +// detection (static Capabilities), the probe round-trip with a per-query +// ✓/✗ breakdown, the parser's CapabilityEvents, and the term's merged +// RuntimeCapabilities. The pointer shape section at the end exercises the +// one capability render() consumes today; if no OSC 22 reply arrives, a +// set-only check flashes the hand cursor so query-less terminals (ghostty) +// can be told apart from terminals without OSC 22 at all. +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { + type CapabilityEvent, + close, + createInput, + createTerm, + detectTerminal, + open, + text, +} from "./mod.ts"; + +const decode = new TextDecoder(); +const esc = (b: Uint8Array) => JSON.stringify(decode.decode(b)).slice(1, -1); +const json = (v: unknown) => JSON.stringify(v); + +console.log("── environment"); +console.log(" TERM:", process.env.TERM ?? "(unset)"); +console.log( + " TERM_PROGRAM:", + process.env.TERM_PROGRAM ?? "(unset)", + process.env.TERM_PROGRAM_VERSION ?? "", +); +console.log(" COLORTERM:", process.env.COLORTERM ?? "(unset)"); +if (process.env.TMUX) { + console.log(" ⚠ running inside tmux — queries may not pass through"); +} + +const info = await detectTerminal(); +console.log("── detection"); +console.log(" probe bytes:", info.probe.length); +console.log(" static capabilities:", json(info.capabilities)); + +if (!process.stdin.isTTY) { + console.log(" ✗ stdin is not a TTY; run this directly in your terminal"); + process.exit(1); +} + +console.log("── probing (2s window, raw mode on; type nothing)…"); +if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(true); +} +process.stdout.write(info.probe); + +const chunks: Buffer[] = []; +const onData = (chunk: Buffer) => chunks.push(chunk); +process.stdin.on("data", onData); +await new Promise((r) => setTimeout(r, 2000)); +process.stdin.removeListener("data", onData); + +const reply = Buffer.concat(chunks); +console.log("── raw reply"); +console.log(" bytes:", reply.length); +console.log( + " escaped:", + reply.length ? esc(reply) : "(nothing — the terminal answered nothing)", +); + +console.log("── which queries were answered"); +const replyText = decode.decode(reply); +const markers: [string, string][] = [ + ["foreground (OSC 10)", "\x1b]10;"], + ["background (OSC 11)", "\x1b]11;"], + ["cursor color (OSC 12)", "\x1b]12;"], + ["colors (OSC 21)", "\x1b]21;"], + ["POINTER SHAPE (OSC 22)", "\x1b]22;"], + ["XTGETTCAP (DCS)", "\x1bP"], + ["sync output (DECRPM 2026)", "?2026"], + ["kitty keyboard (?u)", "?u"], + ["kitty graphics (APC)", "\x1b_G"], +]; +for (const [name, marker] of markers) { + console.log(` ${replyText.includes(marker) ? "✓" : "✗"} ${name}`); +} + +// The query tells us about reply support, not set support. Ghostty sets +// shapes since 1.0 but never answers the query, so give the terminal a +// chance to show what it can do: set the hand for 1.5s, then restore. +if (!replyText.includes("\x1b]22;")) { + console.log("── set-only check (watch your cursor)"); + process.stdout.write("\x1b]22;pointer\x1b\\"); + await new Promise((r) => setTimeout(r, 1500)); + process.stdout.write("\x1b]22;default\x1b\\"); + console.log( + " the cursor should have flashed to a hand just now. If it did," + + " this terminal can SET shapes but never answers the query" + + " (ghostty behaves exactly like this) — detectTerminal()'s" + + " known-support table should already cover it. If it did not" + + " change, OSC 22 is not supported at all.", + ); +} + +if (typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(false); +} + +console.log("── parser"); +const input = await createInput({ terminfo: info }); +let events = input.scan(new Uint8Array(reply)).events; +events = [...events, ...input.scan(new Uint8Array(0)).events]; +const caps = events.filter((e): e is CapabilityEvent => + e.type === "capability" +); +if (caps.length === 0) { + console.log(" (no capability events parsed)"); +} +for (const c of caps) { + console.log(` capability: ${c.key} = ${json(c.value)}`); +} + +console.log("── term (merged view)"); +const term = await createTerm({ width: 40, height: 6, terminfo: info }); +term.update(caps); +console.log(" merged capabilities:", json(term.capabilities)); +const staticCaps = info.capabilities as unknown as Record; +const mergedCaps = term.capabilities as unknown as Record; +const raised = Object.keys(mergedCaps).filter((k) => + json(mergedCaps[k]) !== json(staticCaps[k]) +); +console.log( + " raised by the probe:", + raised.length > 0 ? raised.join(", ") : "(nothing — probe added no evidence)", +); + +// Pointer shape is the one capability render() consumes today, so it doubles +// as the end-to-end emission check. +console.log("── pointer shape (renderer-spec §7.10)"); +const result = term.render( + [open("box", { cursor: "pointer" }), text("hover"), close()], + { pointer: { x: 2, y: 2, down: false } }, +); +const emitted = decode + .decode(result.output) + .includes("\x1b]22;pointer\x1b\\"); +console.log(" OSC 22 in render output:", emitted); + +console.log("── verdict"); +if (emitted) { + console.log( + " ✓ render emits OSC 22" + + (info.capabilities.pointerShape && !replyText.includes("\x1b]22;") + ? " — statically granted by the known-support table (no query reply," + + " which is expected for set-only terminals like ghostty)" + : " — the probe confirmed support and the parser folded it in") + + ". If the demos still show nothing, hover a key/button — and note the" + + " shape only changes while the pointer is over an element that" + + " declares one.", + ); +} else if (!replyText.includes("\x1b]22;")) { + console.log( + info.capabilities.pointerShape + ? " ✗ no OSC 22 reply and no emission despite a static grant — please" + + " report this with the escaped reply above." + : " ✗ your terminal did not answer the OSC 22 query and it is not on" + + " the known-support table. If the set-only check above moved your" + + " cursor, it can set shapes but we cannot detect it — run the demos" + + " with TTY_POINTER_SHAPES=1 (or nominate the terminal for the" + + " table). If the cursor never changed, OSC 22 is not supported at" + + " all (iTerm2, Terminal.app, VS Code, tmux without passthrough).", + ); +} else if (caps.length === 0) { + console.log( + " ✗ the terminal replied but the parser produced no capability events." + + " Please report this with the escaped reply above.", + ); +} else { + console.log( + " ✗ capability recognized but render did not emit OSC 22. Please report" + + " this with the escaped reply above.", + ); +} +process.exit(0); diff --git a/ops.ts b/ops.ts index e89b291..e34e79d 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,7 @@ +import { type PointerShape, pointerShapeIndex } from "./pointer-shape.ts"; + +export type { PointerShape }; + export type TransitionProperty = | "x" | "y" @@ -34,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(); @@ -152,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; @@ -279,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; } @@ -410,6 +422,7 @@ export interface OpenElement { zIndex?: number; }; transition?: Transition; + cursor?: PointerShape; } export type AttachPoint = @@ -535,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: { diff --git a/pointer-shape.ts b/pointer-shape.ts new file mode 100644 index 0000000..8ed5a88 --- /dev/null +++ b/pointer-shape.ts @@ -0,0 +1,54 @@ +export 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", +] as const; + +/** + * A mouse pointer shape, named with the CSS `cursor` keyword vocabulary used + * by the OSC 22 pointer shape protocol. + * + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export type PointerShape = typeof POINTER_SHAPES[number]; + +const INDEX: ReadonlyMap = new Map( + POINTER_SHAPES.map((shape, i) => [shape, i]), +); + +export function pointerShapeIndex(shape: unknown): number | undefined { + return INDEX.get(shape); +} + +export const POINTER_SHAPE_RESET: Uint8Array = new TextEncoder().encode( + "\x1b]22;default\x1b\\", +); diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 80bf451..2b70afd 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -449,9 +449,9 @@ be a no-op for that step. **Capability semantics.** A `CapabilityEvent` step folds the event's `key` and `value` into the Term's private `RuntimeCapabilities`. The foundation update -transaction does not emit bytes or alter renderer output as a consequence of a -capability event. A focused capability specification MUST define any output -invalidation or immediate bytes required by its consumer. +transaction emits bytes or alters renderer output as a consequence of a +capability event only where a capability section of this specification says so +(§7.10). **Return value.** `update()` returns a `Uint8Array` of bytes to write to the terminal immediately. An empty array is valid when the update changes no @@ -469,16 +469,15 @@ is valid until the next `render()` **or** `update()` call). ### 7.8 Capability consumption -The renderer may consume the read-only capability snapshot maintained by -`Term.update()`, but this foundation specification does not define any -capability-specific output. Protocols that change emitted bytes MUST add their -own renderer section, capability evidence, invalidation rules, and tests in a -focused feature specification. +The renderer consumes the read-only capability snapshot maintained by +`Term.update()`. A protocol that changes emitted bytes MUST define its gating +capability, output, and invalidation rules in its own section of this +specification. Synchronized output (§7.9) and pointer shape (§7.10) are +specified so far. -In particular, color encoding, synchronized-output wrapping, pointer-shape -output, Kitty keyboard mode setup, and Kitty graphics emission are deferred to -their respective follow-up PRs. The renderer continues to emit its existing -hardcoded ANSI output until one of those specifications is adopted. +Color encoding, Kitty keyboard mode setup, and Kitty graphics emission are +deferred to their respective follow-up PRs. The renderer continues to emit its +existing hardcoded ANSI output until each is specified. ### 7.9 Synchronized output (DEC mode 2026) @@ -524,6 +523,53 @@ frames changes only whether the next frame is wrapped. This section discharges the synchronized-output deferral recorded in §7.8. +### 7.10 Pointer shape + +An `open()` directive MAY declare a `cursor` property naming the mouse pointer +shape to show while the pointer is over the element. Values are the kitty +pointer shape names (the CSS `cursor` keywords): `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`. Any other value MUST be +treated as absent and MUST NOT reach the output. The property does not affect +layout or cell output. + +The gate is `RuntimeCapabilities.pointerShape` (see +[Terminfo Specification](terminfo-spec.md) §6.3). While it is `false`, the +renderer MUST NOT emit OSC 22. + +**Resolved shape.** While the capability is `true`, each render resolves one +shape: + +- `default` when the render options carry no `pointer`. +- Otherwise, the `cursor` of the last declaring element in hit-test order (the + order behind `pointerenter` events, §12.4), or `default` when none declares + one. Ancestors precede descendants, so the innermost element wins. A floating + element in `"capture"` mode hides the elements beneath it; one in + `"passthrough"` mode lets them decide. + +Elements inside a `snapshot()` participate as direct directives would. +Resolution and output are identical in line mode (§8.2.2). + +**Output.** The Term tracks the shape it last emitted, starting at `default`. +When the resolved shape differs, the render MUST append `ESC ] 22 ; ST` +to `output` after the frame's cell bytes and record the new shape. Otherwise it +MUST NOT emit OSC 22. + +**Restore.** OSC 22 set cannot pop, so the Term restores by setting `default`: + +- A render that resolves `default` emits it per **Output** above. +- An update step that changes `pointerShape` from `true` to `false` while the + emitted shape is not `default` MUST return `ESC ] 22 ; default ST` and record + `default`. +- A resize step leaves the emitted shape unchanged. + +To restore before exit, the caller renders a frame without `pointer`, or passes +`{ type: "capability", key: "pointer-shape", value: false }` to `update()`, and +writes the result. + --- ## 8. Public Rendering API @@ -860,8 +906,9 @@ declaration is present, the cursor-positioning and cursor-visibility sequences specified in §7.6. Capability-specific output modes are not terminal-state management owned by the -foundation renderer. A focused feature specification must define their state -boundaries separately. +foundation renderer. Each capability section of §7 defines its own state +boundary. The mouse pointer shape (§7.10) is distinct from the cursor shape +above, which remains caller-managed. ### 11.3 The renderer does not own application lifecycle @@ -947,6 +994,7 @@ The `open()` constructor currently accepts the following property groups in its reference, attach target, structured attach points, pointer capture mode, clip target, z-index) - **`scroll`** — scroll container configuration +- **`cursor`** — mouse pointer shape while hovered (§7.10) The `floating` object shape is: diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 7286e98..6093ba9 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -181,6 +181,7 @@ interface Capabilities { readonly altScreen: boolean; readonly styledUnderline: boolean; readonly syncOutput: boolean; + readonly pointerShape: boolean; } ``` @@ -194,11 +195,36 @@ interface Capabilities { | `altScreen` | `smcup` string present | | `styledUnderline` | `Su` boolean or `Smulx` string | | `syncOutput` | `Sync` extended cap; `TMUX` env evidence (§7.2); probe overrides (§6.3) | +| `pointerShape` | known OSC 22 support table (below); probe overrides | Key sequences (`key_*` capabilities) are not stored here. The input parser reads them directly from the raw terminfo bytes in `TerminalInfo.keys` at initialization time. +**Known OSC 22 support.** Some terminals implement OSC 22 pointer shape setting +but never answer its support query (Renderer Specification §7.10); silence must +not count as denial, so `detectTerminal()` seeds `pointerShape: true` when the +environment identifies a terminal in this table. Entries require primary +evidence from the terminal's own documentation or specification. + +| Terminal | Environment evidence | Version floor | Evidence | +| -------- | ---------------------------------------------- | ------------- | -------------------------- | +| ghostty | `TERM=xterm-ghostty` or `TERM_PROGRAM=ghostty` | 1.0.0 | ghostty.org/docs/vt/osc/22 | +| kitty | `TERM=xterm-kitty` or `KITTY_WINDOW_ID` set | 0.31.0 | kitty pointer-shapes spec | +| foot | `TERM` starting with `foot` | 1.12.0 | foot-ctlseqs(7) | +| xterm | `XTERM_VERSION` parses as patch ≥ 367 | 367 | xterm ctlseqs (OSC 22) | + +The table names terminals by what they _emit_, not by what they parse: an +implementation that accepts only a non-CSS vocabulary (e.g. Windows cursor +names) MUST NOT be listed, since the renderer emits the kitty CSS names +exclusively (§6.3). This is static evidence with known failure modes: nested +contexts that inherit the environment unchanged (for example Emacs `term-mode` +running inside a listed terminal) are granted by association and will garble +OSC 22. Terminals that rewrite `TERM` (tmux, Zellij) or `TERM_PROGRAM` (IDE +terminals) are naturally excluded; ssh forwards are true positives because the +bytes still reach the real terminal. Stale entries degrade to the same silent +swallowing the gate was built to avoid. + ### 6.2 `ColorDepth` ```ts @@ -296,7 +322,12 @@ per field the reply carries. OSC 21 arriving alongside OSC 10/11/12 replies does not merge; each response yields its own event. **OSC 22.** An OSC 22 reply sets `key: "pointer-shape", value: true`. The shape -name in the reply is discarded in v1; only protocol support is recorded. +name in the reply is discarded in v1; only protocol support is recorded. The +renderer emits pointer shapes only while `pointerShape` is `true` (Renderer +Specification §7.10). Terminals that implement OSC 22 set but not its query +never reply; `detectTerminal()` supplies that evidence itself from the +known-support table (§6.1), and a caller may still override it with +`term.update([{ type: "capability", key: "pointer-shape", value: true }])`. **DA1.** The DA1 reply is recognized internally as the probe fence and MUST NOT surface as a `CapabilityEvent`. @@ -308,7 +339,6 @@ interface RuntimeCapabilities extends Capabilities { readonly syncOutput: boolean; readonly kittyKeyboard: boolean; readonly kittyGraphics: boolean; - readonly pointerShape: boolean; readonly theme: { readonly foreground?: Rgb; readonly background?: Rgb; @@ -318,7 +348,9 @@ interface RuntimeCapabilities extends Capabilities { ``` `RuntimeCapabilities` is the renderer's current merged view: the static -`Capabilities` fields plus all `CapabilityEvent` values folded in so far. The +`Capabilities` fields (including `pointerShape`, which starts from the +known-support table and is raised by a probe reply or a `pointer-shape` +CapabilityEvent) plus all other `CapabilityEvent` values folded in so far. The `trueColor` field reflects the latest evidence at any precedence level; a `colordepth` probe event overrides the static value. `RuntimeCapabilities` is exposed as `term.capabilities` and is a frozen snapshot at the moment of access. @@ -344,6 +376,7 @@ With no terminfo bytes, no environment evidence, and no probe responses, | `altScreen` | `true` | | `styledUnderline` | `false` | | `syncOutput` | `false` | +| `pointerShape` | `false` | Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. The same holds for synchronized output: no frame is wrapped until a `Sync` @@ -444,8 +477,8 @@ and mouse events in arrival order. The host loop routes them to `term.update()`. A capability event can arrive after the renderer has already emitted frames. `term.update()` folds it into the runtime snapshot. Any renderer-side output -invalidation or immediate bytes are defined by the focused feature specification -that consumes that capability. +invalidation or immediate bytes are defined by the renderer section that +consumes that capability (Renderer Specification §7.8). --- 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 215f9eb..5cc7443 100644 --- a/term.ts +++ b/term.ts @@ -1,7 +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 { POINTER_SHAPE_RESET } from "./pointer-shape.ts"; export type { BoundingBox }; @@ -24,7 +31,6 @@ export interface RuntimeCapabilities extends Capabilities { readonly syncOutput: boolean; readonly kittyKeyboard: boolean; readonly kittyGraphics: boolean; - readonly pointerShape: boolean; readonly theme: { readonly foreground?: Rgb; readonly background?: Rgb; @@ -88,7 +94,6 @@ function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { ...caps, kittyKeyboard: false, kittyGraphics: false, - pointerShape: false, theme: Object.freeze({}), }); } @@ -173,11 +178,13 @@ export async function createTerm(options: TermOptions): Promise { altScreen: true, styledUnderline: false, syncOutput: false, + pointerShape: false, }, ); let native = await createTermNative(width, height, { syncOutput: currentCaps.syncOutput, + pointerShape: currentCaps.pointerShape, }); let { memory } = native; @@ -211,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, @@ -227,6 +240,7 @@ export async function createTerm(options: TermOptions): Promise { let current = new Set( options?.pointer ? native.getPointerOverIds() : [], ); + let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -308,8 +322,15 @@ export async function createTerm(options: TermOptions): Promise { } } - if (next.syncOutput !== currentCaps.syncOutput) { - native.setCapabilities({ syncOutput: next.syncOutput }); + if ( + next.syncOutput !== currentCaps.syncOutput || + next.pointerShape !== currentCaps.pointerShape + ) { + 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/terminfo.ts b/terminfo.ts index c0b2a89..f00175b 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -45,6 +45,7 @@ export interface Capabilities { readonly altScreen: boolean; readonly styledUnderline: boolean; readonly syncOutput: boolean; + readonly pointerShape: boolean; } /** Opaque type alias for the raw terminfo key-sequence bytes. */ @@ -94,6 +95,40 @@ const PROBE = encoder.encode( const WASM_PAGE_BYTES = 65536; +/** + * The known OSC 22 pointer-shape support table (terminfo-spec §6.1): terminals + * that implement OSC 22 pointer shape setting but never answer its support + * query, identified by environment evidence. Entries require primary evidence + * from the terminal's own documentation. Each entry records the version that + * introduced support; a stale entry degrades to the same silent swallowing the + * capability gate already produces, never to garbage. + */ +function knownPointerShape( + env: Record, +): boolean { + // ghostty: OSC 22 since 1.0.0 (ghostty.org/docs/vt/osc/22); set only — + // the reference documents no query form. + if (env.TERM === "xterm-ghostty" || env.TERM_PROGRAM === "ghostty") { + return true; + } + // kitty: full kitty pointer-shapes protocol since 0.31.0, including the + // query (sw.kovidgoyal.net/kitty/pointer-shapes/). Seeded here so + // term.capabilities is correct before the probe reply arrives. + if (env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined) { + return true; + } + // foot: OSC 22 sets the xcursor pointer (foot-ctlseqs(7)); no query form. + if (env.TERM?.startsWith("foot")) { + return true; + } + // xterm: OSC 22 since patch 367 (ctlseqs); XTERM_VERSION is `xterm(N)`. + let patch = env.XTERM_VERSION?.match(/xterm\((\d+)\)/); + if (patch && Number(patch[1]) >= 367) { + return true; + } + return false; +} + function rgbOf(packed: number): Rgb { return { r: (packed >> 16) & 0xff, @@ -191,6 +226,7 @@ export async function detectTerminal( altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), syncOutput: !!(flags & FLAG_SYNC), + pointerShape: knownPointerShape(env), }); return Object.freeze({ diff --git a/test/input.test.ts b/test/input.test.ts index 6ad3bdb..50712b5 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -996,6 +996,72 @@ describe("terminfo integration", () => { }); }); + it("surfaces a BEL-terminated OSC 22 pointer shape report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;pointer\x07")); + expect(result.events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("surfaces an OSC 22 empty-stack report", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;0\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("buffers an OSC 22 report fed one byte at a time", async () => { + let { input } = await withTerminfo(); + let reply = str("\x1b]22;default\x1b\\"); + for (let i = 0; i < reply.length - 1; i++) { + expect(input.scan(reply.subarray(i, i + 1)).events).toEqual([]); + } + expect(input.scan(reply.subarray(reply.length - 1)).events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("rejects an OSC 22 report whose ST is split from its ESC by a non-backslash", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]22;default\x1bx")); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + }); + + it("bails on an OSC number longer than any it accepts without overflowing", async () => { + let { input } = await withTerminfo(); + let digits = "9".repeat(64); + let result = input.scan(str(`\x1b]${digits};pointer\x07`)); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + expect(input.scan(str("\x1b]22;default\x1b\\")).events).toEqual([ + { type: "capability", key: "pointer-shape", value: true }, + ]); + }); + + it("does not read OSC 220 as OSC 22", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1b]220;pointer\x07")); + expect(result.events.some((e) => e.type === "capability")).toBe(false); + }); + + it("drops an OSC 22 report whose payload exceeds the response bound", async () => { + let { input } = await withTerminfo(); + let events = input.scan(str(`\x1b]22;${"x".repeat(2048)}\x07`)).events; + expect(events.some((e) => e.type === "capability")).toBe(false); + events = input.scan(str("\x1b]22;default\x07")).events; + for (let i = 0; i < 32 && events.length > 0; i++) { + let last = events[events.length - 1]; + if (last.type === "capability") break; + events = input.scan().events; + } + expect(events[events.length - 1]).toEqual({ + type: "capability", + key: "pointer-shape", + value: true, + }); + }); + it("surfaces truecolor colordepth from a valid XTGETTCAP reply", async () => { let terminfo = await detectTerminal({ env: {}, entry: CLAYTERM_16 }); let input = await createInput({ terminfo }); diff --git a/test/pointer-shape.test.ts b/test/pointer-shape.test.ts new file mode 100644 index 0000000..c2b918e --- /dev/null +++ b/test/pointer-shape.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { + close, + fixed, + grow, + type Op, + open, + type PointerShape, + snapshot, + 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 = { + type: "capability", + key: "pointer-shape", + value: true, +}; + +const WITHDRAWN: CapabilityEvent = { + type: "capability", + key: "pointer-shape", + value: false, +}; + +const osc22 = (shape: string) => `\x1b]22;${shape}\x1b\\`; + +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + +const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); + +function sequences(bytes: Uint8Array): string[] { + let [, ...rest] = decode(bytes).split("\x1b]22;"); + return rest.map((tail) => osc22(tail.slice(0, tail.indexOf("\x1b\\")))); +} + +function over(x: number, y: number) { + return { pointer: { x, y, down: false } }; +} + +// ┌─root (40x6, ltr)───────────────────────┐ +// │┌─link (20x6) pointer─┐┌─plain (20x6)──┐│ +// ││┌─field (10x2) text┐ ││P ││ +// │││F │ ││ ││ +// ││└──────────────────┘ ││ ││ +// ││L ││ ││ +// │└─────────────────────┘└───────────────┘│ +// └────────────────────────────────────────┘ +function layout(link: { cursor?: PointerShape } = {}): Op[] { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("link", { + layout: { width: fixed(20), height: fixed(6), direction: "ttb" }, + cursor: "pointer", + ...link, + }), + open("field", { + layout: { width: fixed(10), height: fixed(2) }, + cursor: "text", + }), + text("F"), + close(), + text("L"), + close(), + open("plain", { + layout: { width: fixed(20), height: fixed(6) }, + }), + text("P"), + close(), + close(), + ]; +} + +const FIELD = over(2, 0); +const LINK = over(2, 4); +const PLAIN = over(25, 2); + +describe("pointer shape", () => { + let term: Term; + + beforeEach(async () => { + term = await createTerm({ width: 40, height: 6 }); + term.update([SUPPORTED]); + }); + + describe("capability gate", () => { + it("never emits OSC 22 without the pointer-shape capability", async () => { + let unsupported = await createTerm({ width: 40, height: 6 }); + expect(sequences(unsupported.render(layout(), LINK).output)).toEqual([]); + expect(sequences(unsupported.render(layout(), PLAIN).output)).toEqual( + [], + ); + }); + + it("emits once the capability arrives", async () => { + let late = await createTerm({ width: 40, height: 6 }); + expect(sequences(late.render(layout(), LINK).output)).toEqual([]); + expect(late.update([SUPPORTED])).toEqual(new Uint8Array(0)); + expect(sequences(late.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + }); + + describe("output", () => { + it("appends the set sequence after the unchanged frame bytes", async () => { + let plain = await createTerm({ width: 40, height: 6 }); + let frame = decode(plain.render(layout(), LINK).output); + let output = decode(term.render(layout(), LINK).output); + + expect(output).toBe(frame + osc22("pointer")); + expect(trim(print(frame, 40, 6))).toBe(trim( + [ + "F P", + "", + "L", + "", + "", + "", + ].join("\n"), + )); + }); + + it("emits only when the resolved shape changes", () => { + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), over(3, 5)).output)).toEqual([]); + }); + + it("emits in line mode", () => { + let result = term.render(layout(), { mode: "line", ...LINK }); + expect(sequences(result.output)).toEqual([osc22("pointer")]); + }); + + it("emits nothing while the pointer only crosses undeclared elements", () => { + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([]); + expect(sequences(term.render(layout()).output)).toEqual([]); + }); + }); + + describe("resolution", () => { + it("lets the innermost declaring element win", () => { + expect(sequences(term.render(layout(), FIELD).output)).toEqual([ + osc22("text"), + ]); + expect(sequences(term.render(layout(), LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + + it("follows a declared shape that changes under a still pointer", () => { + term.render(layout(), LINK); + expect( + sequences( + term.render(layout({ cursor: "progress" }), LINK) + .output, + ), + ).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; + expect(decode(output)).not.toContain("pwned"); + expect(sequences(output)).toEqual([]); + }); + + it("sees elements inside a snapshot", () => { + let ops = layout(); + let frozen = snapshot(ops.slice(1, -1)); + let result = term.render([ops[0], frozen, ops[ops.length - 1]], FIELD); + expect(sequences(result.output)).toEqual([osc22("text")]); + }); + + it("lets a capture-mode floating overlay win over what is beneath", () => { + let ops = layout(); + ops.splice( + ops.length - 1, + 0, + open("menu", { + layout: { width: fixed(6), height: fixed(3) }, + floating: { x: 1, y: 3, attachTo: "root" }, + cursor: "grab", + }), + close(), + ); + expect(sequences(term.render(ops, LINK).output)).toEqual([ + osc22("grab"), + ]); + }); + + it("lets a passthrough floating overlay defer to what is beneath", () => { + let ops = layout(); + ops.splice( + ops.length - 1, + 0, + open("tooltip", { + layout: { width: fixed(6), height: fixed(3) }, + floating: { + x: 1, + y: 3, + attachTo: "root", + pointerCaptureMode: "passthrough", + }, + cursor: "help", + }), + close(), + ); + expect(sequences(term.render(ops, LINK).output)).toEqual([ + osc22("pointer"), + ]); + }); + }); + + describe("restore", () => { + it("resets to default when the pointer moves off declaring elements", () => { + term.render(layout(), LINK); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + }); + + it("resets to default on a render without pointer", () => { + term.render(layout(), LINK); + expect(sequences(term.render(layout()).output)).toEqual([ + osc22("default"), + ]); + }); + + it("returns the reset from update() when the capability is withdrawn", () => { + term.render(layout(), LINK); + expect(decode(term.update([WITHDRAWN]))).toBe(osc22("default")); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + }); + + it("returns nothing from update() when withdrawn at default", () => { + term.render(layout(), PLAIN); + expect(term.update([WITHDRAWN])).toEqual(new Uint8Array(0)); + }); + + it("returns the reset once from a batch that withdraws twice", () => { + term.render(layout(), LINK); + expect(decode(term.update([WITHDRAWN, SUPPORTED, WITHDRAWN]))).toBe( + osc22("default"), + ); + }); + + it("keeps the emitted shape across a resize", () => { + term.render(layout(), LINK); + expect(term.update([{ type: "resize", width: 50, height: 8 }])).toEqual( + new Uint8Array(0), + ); + expect(sequences(term.render(layout(), LINK).output)).toEqual([]); + expect(sequences(term.render(layout(), PLAIN).output)).toEqual([ + osc22("default"), + ]); + }); + }); +}); diff --git a/test/term.test.ts b/test/term.test.ts index 5bf04b4..618e61e 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -923,6 +923,7 @@ hi altScreen: true, styledUnderline: false, syncOutput: true, + pointerShape: false, }, probe: new Uint8Array(0), keys: new Uint8Array(0), @@ -948,6 +949,7 @@ hi altScreen: true, styledUnderline: false, syncOutput: true, + pointerShape: false, }, probe: new Uint8Array(0), keys: new Uint8Array(0), diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 6912f54..3809875 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -25,8 +25,54 @@ const BASELINE = { altScreen: true, styledUnderline: false, syncOutput: false, + pointerShape: false, }; +describe("known OSC 22 pointer shape support (terminfo-spec §6.1)", () => { + it("grants for ghostty by TERM and by TERM_PROGRAM", async () => { + let byTerm = await detectTerminal({ env: { TERM: "xterm-ghostty" } }); + expect(byTerm.capabilities.pointerShape).toBe(true); + let byProgram = await detectTerminal({ env: { TERM_PROGRAM: "ghostty" } }); + expect(byProgram.capabilities.pointerShape).toBe(true); + }); + + it("grants for kitty by TERM and by KITTY_WINDOW_ID", async () => { + let byTerm = await detectTerminal({ env: { TERM: "xterm-kitty" } }); + expect(byTerm.capabilities.pointerShape).toBe(true); + let byWindow = await detectTerminal({ env: { KITTY_WINDOW_ID: "1" } }); + expect(byWindow.capabilities.pointerShape).toBe(true); + }); + + it("grants for foot by TERM prefix", async () => { + let d = await detectTerminal({ env: { TERM: "foot" } }); + expect(d.capabilities.pointerShape).toBe(true); + }); + + it("grants for xterm at the version floor and above", async () => { + let old = await detectTerminal({ env: { XTERM_VERSION: "xterm(366)" } }); + expect(old.capabilities.pointerShape).toBe(false); + let floor = await detectTerminal({ env: { XTERM_VERSION: "xterm(367)" } }); + expect(floor.capabilities.pointerShape).toBe(true); + let newer = await detectTerminal({ env: { XTERM_VERSION: "xterm(388)" } }); + expect(newer.capabilities.pointerShape).toBe(true); + }); + + it("does not grant for terminals that rewrite their identity", async () => { + for ( + let TERM of [ + "tmux-256color", + "screen-256color", + "xterm-256color", + ] + ) { + let d = await detectTerminal({ env: { TERM } }); + expect(d.capabilities.pointerShape).toBe(false); + } + let vscode = await detectTerminal({ env: { TERM_PROGRAM: "vscode" } }); + expect(vscode.capabilities.pointerShape).toBe(false); + }); +}); + describe("baseline", () => { it("initializes to the baseline with no evidence", async () => { let d = await detectTerminal({ env: {} }); diff --git a/test/validate.test.ts b/test/validate.test.ts index 3cd8896..0095584 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -33,6 +33,20 @@ describe("validate", () => { ])).toBe(true); }); + it("accepts a pointer shape", () => { + expect(validate([open("x", { cursor: "pointer" }), close()])).toBe( + true, + ); + }); + + it("rejects a pointer shape outside the vocabulary", () => { + expect(validate([ + // deno-lint-ignore no-explicit-any + open("x", { cursor: "hand" as any }), + close(), + ])).toBe(false); + }); + it("rejects ops with wrong directive", () => { expect(validate([{ directive: 0xff }])).toBe(false); }); 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), diff --git a/validate.ts b/validate.ts index d98c94a..b49ee88 100644 --- a/validate.ts +++ b/validate.ts @@ -1,6 +1,7 @@ import { Type } from "@sinclair/typebox"; import { TypeCompiler } from "@sinclair/typebox/compiler"; import type { Op } from "./ops.ts"; +import { POINTER_SHAPES } from "./pointer-shape.ts"; import type { RenderOptions, RenderResult, Term } from "./term.ts"; /* ── Range helpers (match bit-packing in pack()) ──────────────────── */ @@ -178,6 +179,8 @@ const Transition = Type.Object({ interactive: Type.Optional(Type.Boolean()), }); +const PointerShape = Type.Union(POINTER_SHAPES.map((s) => Type.Literal(s))); + /* ── Op types (discriminated on `directive`) ──────────────────────── */ const CloseElement = Type.Object({ directive: Type.Literal(0x04) }); @@ -192,6 +195,7 @@ const OpenElement = Type.Object({ clip: Type.Optional(Clip), floating: Type.Optional(Floating), transition: Type.Optional(Transition), + cursor: Type.Optional(PointerShape), }); const TextOp = Type.Object({