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/.changeset/sync-output-capability.md b/.changeset/sync-output-capability.md new file mode 100644 index 0000000..369ee9b --- /dev/null +++ b/.changeset/sync-output-capability.md @@ -0,0 +1,7 @@ +--- +"@bomb.sh/tty": minor +--- + +Adds synchronized output (DEC mode 2026) driven by the `sync-output` capability. + +When `RuntimeCapabilities.syncOutput` is true, every full-screen render is wrapped in Begin/End Synchronized Update (`CSI ? 2026 h` / `CSI ? 2026 l`) so the terminal presents the frame as one atomic repaint without tearing. There is no new API: the capability is raised only on positive evidence, resolved from three channels — a DECRPM 2026 probe reply (`CSI ? 2026 $ p`), an XTGETTCAP `Sync` reply (the terminal's own terminfo-declared synchronization sequence, value discarded in favor of the 2026 form), and detection-time seeding from the `Sync` extended capability in the parsed terminfo entry or the `TMUX` environment variable (tmux consumes mode 2026 from inner applications). Probe replies outrank detection-time evidence in both directions, so a DECRPM 0 or 4 reply turns the capability off even when detection seeded it. Frames render unwrapped when the capability is false or unknown. Zero-diff frames emit nothing whether or not wrapping is active, and line mode is never wrapped. 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 2da950f..c3e7b24 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,15 @@ EXPORTS = \ -Wl,--export=__heap_base \ -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 0e1d6ff..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,106 @@ 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. - -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. +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. + +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) + +When the `syncOutput` runtime capability is true, each full-screen frame +(§8.2.1) is wrapped in Synchronized Output mode 2026 — Begin Synchronized Update +(`CSI ? 2026 h`) before the frame's first emitted byte and End Synchronized +Update (`CSI ? 2026 l`) after its last. The terminal buffers everything between +the pair and presents it as one atomic repaint, so a frame never tears +mid-update. + +**Capability evidence.** `syncOutput` is raised only on positive evidence. Two +runtime channels feed the `sync-output` `CapabilityEvent` (input-spec §5, +terminfo-spec §6.3): a DECRPM 2026 reply to the probe (`CSI ? 2026 $ p`) — +values 1, 2, and 3 (set, reset, permanently set) report support, 0 and 4 report +none — and an XTGETTCAP `Sync` reply, whose sequence value is discarded in favor +of the mode 2026 form (terminfo-spec §6.3). Detection may also seed the field +positively: static `Capabilities` carry `syncOutput`, raised by the `Sync` +extended capability in the parsed terminfo entry and by the `TMUX` environment +variable (terminfo-spec §7.1, §7.2). Evidence follows TINV-3 precedence: probe +replies outrank detect-time seeding in both directions, so a DECRPM 0 or 4 reply +turns the capability off even when detection seeded it. Without positive +evidence the field stays false and no frame is wrapped. + +**Invalidation and timing.** The renderer reads the capability snapshot at each +render transaction, so a capability flip takes effect on the next rendered +frame. No invalidation signal exists, and none is required: a flip between +frames changes only whether the next frame is wrapped. + +**Normative rules:** + +- When `syncOutput` is false, the renderer MUST NOT emit `?2026h` or `?2026l`. +- When `syncOutput` is true, the renderer MUST wrap each full-screen frame in + the BSU/ESU pair. Line mode (§8.2.2) is never wrapped: inline output may be + interleaved with foreign content, and an unterminated synchronized block would + suppress it. +- A full-screen render whose diff produces no cell changes and no caret + transition MUST emit zero bytes, even when `syncOutput` is true. The renderer + decides after the diff, so no-op frames carry neither the wrap prefix nor the + suffix. +- Terminals that receive the sequences without supporting the mode ignore it by + design; the probe still refines behavior for terminals that report mode 0 + or 4. + +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. --- @@ -816,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 @@ -903,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 d800909..6093ba9 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -180,23 +180,51 @@ interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly syncOutput: boolean; + readonly pointerShape: boolean; } ``` -| Field | Source | -| ----------------- | ------------------------------------------------------------------- | -| `colors` | `max_colors` from terminfo; 256 at baseline | -| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides | -| `bce` | `bce` boolean cap | -| `autoMargin` | `am` boolean cap | -| `xenl` | `xenl` boolean cap | -| `altScreen` | `smcup` string present | -| `styledUnderline` | `Su` boolean or `Smulx` string | +| Field | Source | +| ----------------- | ----------------------------------------------------------------------- | +| `colors` | `max_colors` from terminfo; 256 at baseline | +| `trueColor` | `RGB`/`Tc` extended caps; `COLORTERM` env evidence; probe overrides | +| `bce` | `bce` boolean cap | +| `autoMargin` | `am` boolean cap | +| `xenl` | `xenl` boolean cap | +| `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 @@ -252,20 +280,20 @@ type CapabilityEvent = }; ``` -Each variant maps to one probe query. The `key` identifies the capability; the -`value` is the terminal's answer. Capability references per +Each variant maps to at least one probe query. The `key` identifies the +capability; the `value` is the terminal's answer. Capability references per [terminfo.dev](https://terminfo.dev): -| `key` | Query | terminfo.dev slug | -| ------------------ | -------------------- | ----------------------------------------------------------------------------------------------- | -| `foreground-color` | OSC 10 | [`osc-10-fg-color-query`](https://terminfo.dev/extensions/osc-10-fg-color-query) | -| `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) | -| `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) | -| `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) | -| `sync-output` | DECRPM mode 2026 | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) | -| `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) | -| `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) | -| `pointer-shape` | OSC 22 | [`osc-22-pointer-shape`](https://terminfo.dev/extensions/osc-22-pointer-shape) | +| `key` | Query | terminfo.dev slug | +| ------------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------- | +| `foreground-color` | OSC 10 | [`osc-10-fg-color-query`](https://terminfo.dev/extensions/osc-10-fg-color-query) | +| `background-color` | OSC 11 | [`osc-11-bg-color-query`](https://terminfo.dev/extensions/osc-11-bg-color-query) | +| `cursor-color` | OSC 12 or OSC 21 | [`osc-12-cursor-color`](https://terminfo.dev/extensions/osc-12-cursor-color) | +| `colordepth` | XTGETTCAP `RGB`/`Tc` | [`24-bit-truecolor`](https://terminfo.dev/extensions/24-bit-truecolor) | +| `sync-output` | DECRPM mode 2026; XTGETTCAP `Sync` | [`decset-2026-synchronized-output`](https://terminfo.dev/modes/decset-2026-synchronized-output) | +| `kitty-keyboard` | `CSI ? u` | [`kitty-keyboard-protocol`](https://terminfo.dev/extensions/kitty-keyboard-protocol) | +| `kitty-graphics` | APC `_G…` | [`kitty-graphics-protocol`](https://terminfo.dev/extensions/kitty-graphics-protocol) | +| `pointer-shape` | OSC 22 | [`osc-22-pointer-shape`](https://terminfo.dev/extensions/osc-22-pointer-shape) | **Color values.** `Rgb` values in probe responses MUST be recognized in at least the `rgb:RR/GG/BB` (1–4 hex digits per channel) and `#`-hash forms. @@ -278,13 +306,28 @@ D }` where `D` is derived from the static preserves TINV-3: the probe response takes precedence over static evidence, including in the negative direction. +**XTGETTCAP `Sync`.** A reply carrying a value for `Sync` emits +`{ key: "sync-output", value: true }`. An invalid-capability reply for `Sync` +emits `{ key: "sync-output", value: false }`. The value carried by a positive +reply is the terminal's own synchronization sequence — the iTerm2 DCS form +(`DCS = 1 s` / `DCS = 2 s`) for some terminals, `CSI ? 2026 h`/`l` for others. +The value is discarded in v1: only support is recorded, and the renderer emits +the DEC mode 2026 form (renderer-spec §7.9). A terminal that advertises only the +DCS form therefore receives 2026-form bytes it ignores; this bounded cost is +accepted. + **OSC 21.** An OSC 21 reply may supply any subset of `foreground-color`, `background-color`, and `cursor-color`. The parser emits one `CapabilityEvent` 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`. @@ -296,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; @@ -306,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. @@ -331,8 +375,13 @@ With no terminfo bytes, no environment evidence, and no probe responses, | `xenl` | `true` | | `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` +extended capability, `TMUX` environment evidence, or a probe response raises it +(§7.2, §6.3). ### 7.2 Environment evidence @@ -340,6 +389,11 @@ Truecolor is not assumed at baseline. Per TINV-3, it requires positive evidence. entry: - `COLORTERM` equal to `truecolor` or `24bit` sets `trueColor: true`. +- `TMUX` set to a non-empty value sets `syncOutput: true`. A running tmux + consumes DEC mode 2026 from applications running inside it and translates it + to the outer terminal's synchronization mechanism (the tmux `Sync` terminfo + capability and `sync` terminal feature), so its presence is positive evidence + even for tmux releases that do not themselves answer the DECRPM 2026 probe. Environment evidence outranks the terminfo entry and is outranked by probe responses (TINV-3). @@ -368,7 +422,7 @@ observe only the resulting `Capabilities`. - Both storage formats MUST be supported: legacy (magic `0432`, 16-bit numbers) and extended number format (magic `01036`, 32-bit numbers). - The extended-capability string table MUST be parsed for the user-defined - capabilities `RGB`, `Tc`, `Su`, and `Smulx`. + capabilities `RGB`, `Tc`, `Su`, `Smulx`, and `Sync`. - All reads are bounds-checked against `len`. Out-of-range offsets, truncated sections, and odd-length string tables yield the parse failure path (TINV-2). - The maximum accepted size is 32 768 bytes, the extended ncurses format limit. @@ -398,13 +452,14 @@ order: | 5 | Pointer shape | `OSC 22 ; ?__current__ ST` | `pointer-shape` | | 6 | Truecolor caps | `DCS + q 524742 ; 5463 ST` (XTGETTCAP `RGB;Tc`) | `colordepth` | | 7 | Synchronized output | `CSI ? 2026 $ p` (DECRQM) | `sync-output` | -| 8 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` | -| 9 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` | -| 10 | **Fence:** DA1 | `CSI c` | (internal only) | +| 8 | Sync terminfo cap | `DCS + q 53796e63 ST` (XTGETTCAP `Sync`) | `sync-output` | +| 9 | Kitty keyboard | `CSI ? u` | `kitty-keyboard` | +| 10 | Kitty graphics | `APC _G i=31,s=1,v=1,a=q,t=d,f=24 ; AAAA ST` | `kitty-graphics` | +| 11 | **Fence:** DA1 | `CSI c` | (internal only) | Terminals answer queries in order and ignore queries they do not understand. DA1 is answered by every terminal, so its response marks the probe complete. Any of -queries 1–9 not yet answered when the DA1 reply arrives will never be answered, +queries 1–10 not yet answered when the DA1 reply arrives will never be answered, and the renderer keeps their current values. The batch is safe to emit unconditionally: every query is either answered or @@ -422,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 7e4e62e..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 ───────────────────────────────────────────────── */ @@ -87,6 +88,14 @@ struct Clayterm { Clay_ErrorData errors[MAX_ERRORS]; int error_count; int animating_count; + /* 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 @@ -116,6 +125,35 @@ 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 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 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 ──────────────────────────────────────────────── */ static Cell *cell_at(struct Clayterm *ct, Cell *buf, int x, int y) { @@ -261,6 +299,19 @@ static void present_cups(struct Clayterm *ct, int row) { ct->lastx = -1; ct->lasty = -1; + /* Synchronized Output (DEC mode 2026): the terminal buffers everything + * between BSU and ESU and presents it in one atomic repaint, so a frame + * never tears mid-update. Unsupported terminals ignore the private mode; + * whether we emit the wrap at all is decided by the sync-output + * capability. */ + int sync = ct->caps & RENDER_CAP_SYNC_OUTPUT; + int start = ct->out.length; + if (sync) + buf_str(&ct->out, "\x1b[?2026h"); + /* Body start: the no-op check below compares against this so the BSU + * itself never counts as frame content. */ + int body = ct->out.length; + for (int y = 0; y < ct->h; y++) { for (int x = 0; x < ct->w;) { Cell *back = cell_at(ct, ct->back, x, y); @@ -310,6 +361,14 @@ static void present_cups(struct Clayterm *ct, int row) { buf_str(&ct->out, "\x1b[?25l"); } ct->had_caret_last_frame = ct->has_caret; + + if (ct->out.length == body) { + /* Nothing was emitted — no cell changes and no caret transition. + * Rewind the BSU so a no-op frame still produces zero bytes. */ + ct->out.length = start; + } else if (sync) { + buf_str(&ct->out, "\x1b[?2026l"); /* ESU: end synchronized update */ + } } /** @@ -624,7 +683,7 @@ int clayterm_size(int w, int h) { set_clay_capacity(w, h); int cell_count = w * h; int cell_bytes = cell_count * (int)sizeof(Cell); - int out_bytes = cell_count * OUT_BYTES_PER_CELL; + int out_bytes = cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD; int clay_bytes = (int)Clay_MinMemorySize(); return align8((int)sizeof(struct Clayterm)) + align8(cell_bytes) /* front */ + align8(cell_bytes) /* back */ @@ -686,7 +745,7 @@ struct Clayterm *init(void *mem, int w, int h) { struct Clayterm *ct = (struct Clayterm *)mem; int cell_count = w * h; int cell_bytes = align8(cell_count * (int)sizeof(Cell)); - int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL); + int out_bytes = align8(cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD); char *base = (char *)mem + align8((int)sizeof(struct Clayterm)); char *clay_mem = base + cell_bytes * 2 + out_bytes; @@ -701,7 +760,8 @@ struct Clayterm *init(void *mem, int w, int h) { .h = h, .front = (Cell *)base, .back = (Cell *)(base + cell_bytes), - .out = {base + cell_bytes * 2, 0, cell_count * OUT_BYTES_PER_CELL}, + .out = {base + cell_bytes * 2, 0, + cell_count * OUT_BYTES_PER_CELL + OUT_FRAME_OVERHEAD}, .lastfg = 0xffffffff, .lastbg = 0xffffffff, .lastx = -1, @@ -716,8 +776,24 @@ struct Clayterm *init(void *mem, int w, int h) { return ct; } +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; @@ -851,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; } @@ -915,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; @@ -1030,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 8a24db4..27069f1 100644 --- a/src/clayterm.h +++ b/src/clayterm.h @@ -9,11 +9,18 @@ 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); +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/src/input.c b/src/input.c index 5c16e96..162e530 100644 --- a/src/input.c +++ b/src/input.c @@ -954,10 +954,34 @@ static int parse_osc_response(struct InputState *st) { } /* XTGETTCAP reply: DCS 1 + r … ST (valid) or DCS 0 + r … ST (invalid). - * We only query RGB (524742) and Tc (5463). A valid reply naming either - * confirms truecolor. The static tier is emitted only once both are - * denied without a confirmation since the last DA1 fence; an invalid - * reply naming neither denies both. */ + * We query RGB (524742), Tc (5463), and Sync (53796e63) in separate + * requests. A valid reply naming RGB or Tc confirms truecolor. The + * static tier is emitted only once both are denied without a + * confirmation since the last DA1 fence; an invalid reply naming + * neither denies both. + * + * A reply naming Sync answers the Sync query alone: a value-carrying + * reply (name '=' hex-value) confirms synchronized output; a reply + * naming Sync without a value denies it. Sync replies never touch the + * colordepth tally (terminfo-spec 6.3). */ +/* True when payload contains name, then '=', then at least one hex + * digit — an XTGETTCAP reply carrying a value for the capability. */ +static int payload_contains_value(const char *s, int len, const char *name) { + int n = (int)strlen(name); + for (int i = 0; i + n + 1 < len; i++) { + int j = 0; + while (j < n && s[i + j] == name[j]) + j++; + if (j == n && s[i + n] == '=') { + char v = s[i + n + 1]; + if ((v >= '0' && v <= '9') || (v >= 'a' && v <= 'f') || + (v >= 'A' && v <= 'F')) + return 1; + } + } + return 0; +} + static int parse_dcs_response(struct InputState *st) { if (st->len < 5) return PARSE_NEED_MORE; @@ -980,7 +1004,11 @@ static int parse_dcs_response(struct InputState *st) { int plen = end - 5 - (st->buf[end - 1] == '\x07' ? 1 : 2); int rgb = payload_contains(payload, plen, "524742"); int tc = payload_contains(payload, plen, "5463"); - if (ok == '1') { + int sync = payload_contains(payload, plen, "53796e63"); + if (sync) { + emit_cap_bool(st, CAP_SYNC_OUTPUT, + payload_contains_value(payload, plen, "53796e63")); + } else if (ok == '1') { if (rgb || tc) { st->tcap |= TCAP_CONFIRMED; struct InputEvent *ev = emit(st); diff --git a/src/terminfo.c b/src/terminfo.c index fec4d67..503adc1 100644 --- a/src/terminfo.c +++ b/src/terminfo.c @@ -94,7 +94,8 @@ static int name_is(const uint8_t *table, int table_len, int off, /* Extended capability block (after the standard sections). Returns 0 on * success, nonzero when the declared structure runs out of bounds. - * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx. */ + * Grants flag bits into *flags for RGB/Tc/Su booleans and Smulx/Sync + * strings. */ static int parse_ext(const uint8_t *b, int len, int off, int numw, uint32_t *flags) { if (off & 1) @@ -158,6 +159,8 @@ static int parse_ext(const uint8_t *b, int len, int off, int numw, continue; if (name_is(table, table_len, noff, "Smulx")) { *flags |= TERMINFO_STYLED_UNDERLINE; + } else if (name_is(table, table_len, noff, "Sync")) { + *flags |= TERMINFO_SYNC; } } } @@ -273,9 +276,10 @@ int terminfo_parse(const uint8_t *bytes, int len, struct TermInfo *ti) { /* The entry describes the terminal completely for the capabilities it * owns: replace them, leave probe-only flags and theme fields alone. */ - uint32_t keep = ~(TERMINFO_TRUECOLOR | TERMINFO_BACK_COLOR_ERASE | - TERMINFO_AUTO_RIGHT_MARGIN | TERMINFO_EAT_NEWLINE_GLITCH | - TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE); + uint32_t keep = + ~(TERMINFO_TRUECOLOR | TERMINFO_BACK_COLOR_ERASE | + TERMINFO_AUTO_RIGHT_MARGIN | TERMINFO_EAT_NEWLINE_GLITCH | + TERMINFO_ALTSCREEN | TERMINFO_STYLED_UNDERLINE | TERMINFO_SYNC); ti->flags = (ti->flags & keep) | flags; ti->colors = (uint32_t)colors; ti->generation++; diff --git a/tasks/gen-fixtures.ts b/tasks/gen-fixtures.ts index f03116c..d8eb14c 100644 --- a/tasks/gen-fixtures.ts +++ b/tasks/gen-fixtures.ts @@ -8,8 +8,9 @@ * - xterm-256color copied from the system terminfo database (legacy * format, magic 0432): real-world entry with am/xenl/bce/smcup and * colors#256, no truecolor capabilities. - * - clayterm-tc / clayterm-16 compiled with tic -x from the inline - * source below (legacy format with an extended capability block). + * - clayterm-tc / clayterm-sync / clayterm-16 compiled with tic -x from + * the inline source below (legacy format with an extended capability + * block). * - clayterm-direct hand-assembled in the extended number format * (magic 01036, 32-bit numbers) because macOS ships ncurses 6.0, * which predates that format: colors#0x1000000 plus an extended RGB @@ -26,6 +27,13 @@ const ENTRIES_SRC = `clayterm-tc|clayterm truecolor extended-caps test entry, \tkcuu1=\\EOZ, kf5=\\E[99~, \tSmulx=\\E[4:%p1%dm, +clayterm-sync|clayterm sync extended-caps test entry, +\tam, xenl, bce, +\tcolors#256, pairs#65536, cols#80, lines#24, +\tsmcup=\\E[?1049h, rmcup=\\E[?1049l, +\tcup=\\E[%i%p1%d;%p2%dH, +\tSync=\\E[?2026%p1%dh, + clayterm-16|clayterm sixteen color test entry, \tam, xenl, \tcolors#16, pairs#256, cols#80, lines#24, @@ -145,6 +153,7 @@ import { decodeBase64 } from "@std/encoding/base64"; ${constant("XTERM_256COLOR", xterm256)} ${constant("CLAYTERM_TC", compiled["clayterm-tc"])} +${constant("CLAYTERM_SYNC", compiled["clayterm-sync"])} ${constant("CLAYTERM_16", compiled["clayterm-16"])} ${constant("CLAYTERM_DIRECT", buildDirect())}`; diff --git a/term-native.ts b/term-native.ts index b54549d..62f11b3 100644 --- a/term-native.ts +++ b/term-native.ts @@ -23,7 +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; @@ -35,6 +49,13 @@ export interface Native { * opsBuf may change. Growing memory detaches prior buffer views. */ update(w: number, h: number): void; + /** + * 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. Returns true when withdrawing pointerShape leaves a + * non-default shape on screen that the caller must reset. + */ + setCapabilities(caps: RenderCapabilities): boolean; reduce( ct: number, buf: number, @@ -42,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; @@ -59,6 +82,7 @@ import { compiled } from "./wasm.ts"; export async function createTermNative( w: number, h: number, + caps: RenderCapabilities, ): Promise { let memory = new WebAssembly.Memory({ initial: 2 }); let exports: Record = {}; @@ -99,10 +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; - 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; @@ -120,8 +149,18 @@ 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(ptr: number): boolean { + return ct.set_capabilities(ptr, capFlags) !== 0; + } + + function setCaps(next: RenderCapabilities): void { + 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 // [heap: state][opsBuf]; opsBuf moves when the state size changes. Memory is @@ -136,15 +175,20 @@ 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(ptr); + ct.set_pointer_shape(ptr, shape); + statePtr = ptr; } + setCaps(caps); layout(w, h); return { memory, get statePtr() { - return statePtr; + return statePtr!; }, get opsBuf() { return opsBuf; @@ -152,16 +196,14 @@ export async function createTermNative( update(uw: number, uh: number): void { layout(uw, uh); }, + setCapabilities(next: RenderCapabilities): boolean { + setCaps(next); + 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 5326daa..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; @@ -86,10 +92,8 @@ function applyUpdate( function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { return Object.freeze({ ...caps, - syncOutput: false, kittyKeyboard: false, kittyGraphics: false, - pointerShape: false, theme: Object.freeze({}), }); } @@ -164,12 +168,6 @@ export interface Term { export async function createTerm(options: TermOptions): Promise { let { width, height, terminfo } = options; - let native = await createTermNative( - width, - height, - ); - let { memory } = native; - let currentCaps: RuntimeCapabilities = runtimeFromStatic( terminfo?.capabilities ?? { colors: 256, @@ -179,9 +177,17 @@ export async function createTerm(options: TermOptions): Promise { xenl: true, altScreen: true, styledUnderline: false, + syncOutput: false, + pointerShape: false, }, ); + let native = await createTermNative(width, height, { + syncOutput: currentCaps.syncOutput, + pointerShape: currentCaps.pointerShape, + }); + let { memory } = native; + let prev = new Set(); let pressed = new Set(); let wasDown = false; @@ -212,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, @@ -228,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[] = []; @@ -309,6 +322,16 @@ export async function createTerm(options: TermOptions): Promise { } } + 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 bb3c081..f00175b 100644 --- a/terminfo.ts +++ b/terminfo.ts @@ -14,6 +14,7 @@ const FLAG_AUTO_RIGHT_MARGIN = 1 << 2; const FLAG_EAT_NEWLINE_GLITCH = 1 << 3; const FLAG_ALTSCREEN = 1 << 4; const FLAG_STYLED_UNDERLINE = 1 << 5; +const FLAG_SYNC = 1 << 6; const TermInfoStruct = struct({ generation: uint32(), @@ -43,6 +44,8 @@ export interface Capabilities { readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; + readonly syncOutput: boolean; + readonly pointerShape: boolean; } /** Opaque type alias for the raw terminfo key-sequence bytes. */ @@ -84,6 +87,7 @@ const PROBE = encoder.encode( "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + + "\x1bP+q53796e63\x1b\\" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c", @@ -91,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, @@ -169,6 +207,14 @@ export async function detectTerminal( exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); } + /* Environment evidence (terminfo-spec §7.2): a running tmux consumes + * DEC mode 2026 from applications inside it, so its presence is + * positive sync-output evidence even for tmux releases that do not + * answer the DECRPM probe. */ + if (env.TMUX) { + exports.terminfo_grant(structPtr, FLAG_SYNC); + } + let view = new DataView(memory.buffer); let flags = view.getUint32(structPtr + TI.flags, true); let capabilities: Capabilities = Object.freeze({ @@ -179,6 +225,8 @@ export async function detectTerminal( xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH), altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), + syncOutput: !!(flags & FLAG_SYNC), + pointerShape: knownPointerShape(env), }); return Object.freeze({ diff --git a/test/fixtures.ts b/test/fixtures.ts index 8030f3a..a5cb252 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -9,6 +9,10 @@ export const CLAYTERM_TC: Uint8Array = decodeBase64( "GgE4AB0ADwBYAC0AY2xheXRlcm0tdGN8Y2xheXRlcm0gdHJ1ZWNvbG9yIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQBQAP//GAD//////////////////////////wAB/3///////////////////////////wAA/////////////////////////////////////////////xEA/////////////////////////////xoA////////////////////////////////////////////////////////////////////////////////IwD///////////////////////////////////////8pABtbJWklcDElZDslcDIlZEgAG1s/MTA0OWgAG1s/MTA0OWwAG1s5OX4AG09aAAACAAAAAQAEABcAAQEAAAAAAwAGABtbNDolcDElZG0AU3UAVGMAU211bHgA", ); +export const CLAYTERM_SYNC: Uint8Array = decodeBase64( + "GgE1AB0ADwApACMAY2xheXRlcm0tc3luY3xjbGF5dGVybSBzeW5jIGV4dGVuZGVkLWNhcHMgdGVzdCBlbnRyeQAAAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAA//8YAP//////////////////////////AAH/f///////////////////////////AAD/////////////////////////////////////////////EQD/////////////////////////////GgAbWyVpJXAxJWQ7JXAyJWRIABtbPzEwNDloABtbPzEwNDlsAAAAAAAAAQACABMAAAAAABtbPzIwMjYlcDElZGgAU3luYwA=", +); + export const CLAYTERM_16: Uint8Array = decodeBase64( "GgEuAAUADwALABEAY2xheXRlcm0tMTZ8Y2xheXRlcm0gc2l4dGVlbiBjb2xvciB0ZXN0IGVudHJ5AAABAAABAFAA//8YAP//////////////////////////EAAAAf//////////////////////////AAAbWyVpJXAxJWQ7JXAyJWRIAA==", ); diff --git a/test/input.test.ts b/test/input.test.ts index 91ce14f..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 }); @@ -1085,6 +1151,59 @@ describe("terminfo integration", () => { ]); }); + it("surfaces sync-output=true from a value-carrying XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63=1b5b3f3230323668\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + ]); + }); + + it("surfaces sync-output=false from an invalid XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP0+r53796e63\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("surfaces sync-output=false from a Sync reply without a value", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63\x1b\\")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("accepts a BEL-terminated XTGETTCAP Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan(str("\x1bP1+r53796e63=31\x07")); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + ]); + }); + + it("does not let a Sync denial disturb the colordepth tally", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1bP0+r524742\x1b\\\x1bP0+r53796e63\x1b\\"), + ); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: false }, + ]); + }); + + it("keeps the colordepth denial working after a Sync reply", async () => { + let { input } = await withTerminfo(); + let result = input.scan( + str("\x1bP1+r53796e63=31\x1b\\\x1bP0+r\x1b\\"), + ); + expect(result.events).toEqual([ + { type: "capability", key: "sync-output", value: true }, + { type: "capability", key: "colordepth", value: "256" }, + ]); + }); + it("surfaces synchronized output from a DECRPM confirm report", async () => { let { input } = await withTerminfo(); let result = input.scan(str("\x1b[?2026;2$y")); @@ -1192,6 +1311,11 @@ describe("terminfo integration", () => { ["\x1b]22;default\x1b\\", [cap("pointer-shape", true)]], ["\x1bP1+r524742=382F382F38\x1b\\", [cap("colordepth", "truecolor")]], ["\x1bP0+r\x1b\\", [cap("colordepth", "256")]], + [ + "\x1bP1+r53796e63=1b5b3f3230323668\x1b\\", + [cap("sync-output", true)], + ], + ["\x1bP0+r53796e63\x1b\\", [cap("sync-output", false)]], ["\x1b[?2026;2$y", [cap("sync-output", true)]], ["\x1b[?1u", [cap("kitty-keyboard", true)]], ["\x1b_Gi=31;OK\x1b\\", [cap("kitty-graphics", true)]], 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 7190fd6..618e61e 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -885,4 +885,116 @@ hi expect(trim(print(decode(large.output), 120, 40))).toContain("Hi"); }); }); + + describe("synchronized output", () => { + let frame: Op[] = [ + open("root", { layout: { width: grow(), height: grow() } }), + text("Hi"), + close(), + ]; + + let syncOn = { + type: "capability", + key: "sync-output", + value: true, + } as const; + let syncOff = { + type: "capability", + key: "sync-output", + value: false, + } as const; + + it("does not wrap frames by default", () => { + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("wraps frames when seeded from terminfo capabilities", async () => { + let t = await createTerm({ + width: 40, + height: 10, + terminfo: { + capabilities: { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + syncOutput: true, + pointerShape: false, + }, + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }, + }); + expect(t.capabilities.syncOutput).toBe(true); + let out = decode(t.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("stops wrapping when a probe reply refutes seeded evidence", async () => { + let t = await createTerm({ + width: 40, + height: 10, + terminfo: { + capabilities: { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + syncOutput: true, + pointerShape: false, + }, + probe: new Uint8Array(0), + keys: new Uint8Array(0), + }, + }); + t.update([syncOff]); + let out = decode(t.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("wraps full-screen frames when the capability is on", () => { + term.update([syncOn]); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("emits zero bytes for a no-op frame even when wrapped", () => { + term.update([syncOn]); + term.render(frame); + expect(term.render(frame).output.length).toBe(0); + }); + + it("stops wrapping after the capability turns off", () => { + term.update([syncOn]); + term.render(frame); + term.update([syncOff]); + let out = decode(term.render(frame).output); + expect(out).not.toContain("\x1b[?2026"); + }); + + it("keeps wrapping after a resize update", () => { + term.update([syncOn]); + term.update([{ type: "resize", width: 50, height: 12 }]); + let out = decode(term.render(frame).output); + expect(out.startsWith("\x1b[?2026h")).toBe(true); + expect(out.endsWith("\x1b[?2026l")).toBe(true); + }); + + it("never wraps line mode output", () => { + term.update([syncOn]); + let out = decode( + term.render(frame, { mode: "line", row: 1 }).output, + ); + expect(out).not.toContain("\x1b[?2026"); + }); + }); }); diff --git a/test/terminfo.test.ts b/test/terminfo.test.ts index 007b31e..3809875 100644 --- a/test/terminfo.test.ts +++ b/test/terminfo.test.ts @@ -8,6 +8,7 @@ import { detectTerminal } from "../terminfo.ts"; import { CLAYTERM_16, CLAYTERM_DIRECT, + CLAYTERM_SYNC, CLAYTERM_TC, XTERM_256COLOR, } from "./fixtures.ts"; @@ -23,8 +24,55 @@ const BASELINE = { xenl: true, 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: {} }); @@ -46,6 +94,14 @@ describe("terminfo parsing", () => { expect(caps.bce).toBe(true); expect(caps.autoMargin).toBe(true); expect(caps.altScreen).toBe(true); + expect(caps.syncOutput).toBe(false); + }); + + it("reads Sync from the extended capability table", async () => { + let d = await detectTerminal({ env: {}, entry: CLAYTERM_SYNC }); + let caps = d.capabilities; + expect(caps.syncOutput).toBe(true); + expect(caps.trueColor).toBe(false); }); it("reads Tc / Su / Smulx from the extended capability table", async () => { @@ -113,6 +169,26 @@ describe("environment evidence", () => { let d = await detectTerminal({ env: { COLORTERM: "yes" } }); expect(d.capabilities.trueColor).toBe(false); }); + + it("grants syncOutput from TMUX", async () => { + let d = await detectTerminal({ + env: { TMUX: "/tmp/tmux-1000/default,12345,0" }, + }); + expect(d.capabilities.syncOutput).toBe(true); + }); + + it("ignores an empty TMUX", async () => { + let d = await detectTerminal({ env: { TMUX: "" } }); + expect(d.capabilities.syncOutput).toBe(false); + }); + + it("TMUX evidence applies even when the entry lacks Sync", async () => { + let d = await detectTerminal({ + env: { TMUX: "/tmp/tmux-1000/default,12345,0" }, + entry: XTERM_256COLOR, + }); + expect(d.capabilities.syncOutput).toBe(true); + }); }); describe("filesystem lookup", () => { @@ -184,6 +260,7 @@ const PROBE = "\x1b]10;?\x07" + "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + + "\x1bP+q53796e63\x1b\\" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c"; 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 1c54399..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); + 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({