diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index fffa499..f92b3fb 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -137,8 +137,9 @@ await main(function* () { pointer.state = undefined; } - let { output, events } = term.render(keyboard(context), { + let { output, events, cursor } = term.render(keyboard(context), { pointer: pointer.state, + trackCursor: true, }); for (let event of events) { @@ -146,6 +147,9 @@ await main(function* () { } writeStdout(output); + if (cursor) { + writeStdout(cursor); + } yield* each.next(); } @@ -229,6 +233,7 @@ function key(ops: Op[], k: KeyDef, ctx: AppContext): void { alignY: "center", }, bg, + cursor: "pointer", border: hover ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } : undefined, diff --git a/input-native.ts b/input-native.ts index 7ed6d1b..f2258c2 100644 --- a/input-native.ts +++ b/input-native.ts @@ -2,6 +2,7 @@ export const EVENT_KEY = 1; export const EVENT_MOUSE = 2; export const EVENT_RESIZE = 3; export const EVENT_CURSOR = 4; +export const EVENT_POINTERSHAPE = 5; export const MOD_ALT = 1; export const MOD_CTRL = 2; @@ -89,6 +90,7 @@ import { } from "./typedef.ts"; const MAX_TEXT_CODEPOINTS = 8; +const MAX_REPORT_BYTES = 64; const InputEventLayout = struct({ type: uint8(), @@ -104,6 +106,8 @@ const InputEventLayout = struct({ base: uint32(), text: array(uint32(), MAX_TEXT_CODEPOINTS), text_len: uint8(), + report_len: uint16(), + report: array(uint8(), MAX_REPORT_BYTES), }); const { @@ -120,6 +124,8 @@ const { shifted: OFFSET_SHIFTED, base: OFFSET_BASE, text: OFFSET_TEXT, + report_len: OFFSET_REPORT_LEN, + report: OFFSET_REPORT, } = offsets(InputEventLayout); export interface NativeInputEvent { @@ -135,6 +141,7 @@ export interface NativeInputEvent { shifted: number; base: number; text: number[]; + report: number[]; } export function readEvent(view: DataView, ptr: number): NativeInputEvent { @@ -143,6 +150,11 @@ export function readEvent(view: DataView, ptr: number): NativeInputEvent { for (let i = 0; i < len && i < MAX_TEXT_CODEPOINTS; i++) { text.push(view.getUint32(ptr + OFFSET_TEXT + i * 4, true)); } + let reportLen = view.getUint16(ptr + OFFSET_REPORT_LEN, true); + let report: number[] = []; + for (let i = 0; i < reportLen && i < MAX_REPORT_BYTES; i++) { + report.push(view.getUint8(ptr + OFFSET_REPORT + i)); + } return { type: view.getUint8(ptr + OFFSET_TYPE), mod: view.getUint8(ptr + OFFSET_MOD), @@ -156,6 +168,7 @@ export function readEvent(view: DataView, ptr: number): NativeInputEvent { shifted: view.getUint32(ptr + OFFSET_SHIFTED, true), base: view.getUint32(ptr + OFFSET_BASE, true), text, + report, }; } diff --git a/input.ts b/input.ts index 5163e3a..1308638 100644 --- a/input.ts +++ b/input.ts @@ -11,6 +11,7 @@ import { EVENT_CURSOR, EVENT_KEY, EVENT_MOUSE, + EVENT_POINTERSHAPE, EVENT_RESIZE, KEY_ALT_LEFT, KEY_ALT_RIGHT, @@ -371,6 +372,22 @@ export interface CursorEvent { column: number; } +/** + * Reply to an OSC 22 mouse-pointer-shape query. + * + * Emitted when the terminal answers a pointer-shape query (sent by the + * output side) on the input stream. The `report` is the raw payload the + * terminal returned between `OSC 22 ;` and the terminator — for example a + * shape name (`"pointer"`), `"0"` for an empty stack, or a comma-separated + * list of `1`/`0` support flags. The parser does not interpret it; + * correlating a reply with the query that produced it is the caller's + * responsibility. + */ +export interface PointerShapeEvent { + type: "pointershape"; + report: string; +} + import type { PointerEvent } from "./term.ts"; export type InputEvent = @@ -381,6 +398,7 @@ export type InputEvent = | WheelEvent | ResizeEvent | CursorEvent + | PointerShapeEvent | PointerEvent; /** @@ -684,6 +702,12 @@ function mapEvent(native: NativeInputEvent): InputEvent { case EVENT_CURSOR: { return { type: "cursor", row: native.y, column: native.x }; } + case EVENT_POINTERSHAPE: { + return { + type: "pointershape", + report: new TextDecoder().decode(new Uint8Array(native.report)), + }; + } default: { return mapKeyEvent(native); } diff --git a/ops.ts b/ops.ts index c5d7ca9..a18524d 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,5 @@ +import type { CursorShape } from "./termcodes.ts"; + /* Command buffer opcodes — mirrors ops.h */ const OP_OPEN_ELEMENT = 0x02; const OP_TEXT = 0x03; @@ -323,6 +325,14 @@ export interface OpenElement { bottom?: number; }; clip?: { horizontal?: boolean; vertical?: boolean }; + /** + * Mouse pointer shape to request while the pointer is over this element. + * + * This is a pure annotation: it does not affect layout or output and is not + * sent to the WASM module. It is consumed only when pointer-shape tracking is + * enabled via the `trackCursor` render option. + */ + cursor?: CursorShape; floating?: { x?: number; y?: number; @@ -436,6 +446,11 @@ export function close(): CloseElement { return { directive: OP_CLOSE_ELEMENT }; } +/** Narrow an `Op` to an element-open directive. */ +export function isOpen(op: Op): op is OpenElement { + return op.directive === OP_OPEN_ELEMENT; +} + function packSize(ops: Op[]): number { let n = 0; for (let op of ops) { diff --git a/specs/input-spec.md b/specs/input-spec.md index 3416ebd..d9fd6e7 100644 --- a/specs/input-spec.md +++ b/specs/input-spec.md @@ -31,6 +31,7 @@ surface and guide future stabilization. - The scan API and its return type - The `InputEvent` discriminated union and its variants - The ESC timeout resolution model +- Decoding inbound OSC 22 mouse-pointer-shape replies into events ### Out of scope @@ -128,11 +129,69 @@ current variants are: - **`ResizeEvent`** (`type: "resize"`) — A terminal resize notification. Fields include `columns` and `rows`. +- **`PointerShapeEvent`** (`type: "pointershape"`) — A terminal reply to an OSC + 22 mouse-pointer-shape query. Carries a single `report` field: the raw payload + string the terminal returned between `OSC 22 ;` and the string terminator. The + parser does not interpret the payload and does not correlate it with any + outstanding query; correlation is the caller's responsibility. See Section + 5.1. + The discriminant values and the type splits are deliberate design decisions. However, the field sets within each variant are expected to grow when Kitty progressive enhancement types are surfaced in the TypeScript layer (the C struct has already been extended with fields that are not yet mapped to the TS types). +### 5.1 Pointer shape reports (OSC 22) + +> **Status:** Implemented. Code conforms to the spec, not the reverse (see +> AGENTS.md). + +Some terminals implement the OSC 22 mouse-pointer-shape protocol, under which an +application can _query_ the terminal's current pointer shape or its support for +named shapes. The terminal answers a query with a reply on the input stream. The +input parser recognizes these replies and surfaces them as `PointerShapeEvent`s. + +The parser's role is strictly inbound decoding. It never sends OSC 22 queries +and never sets the pointer shape — emitting OSC 22 is an output concern +specified separately (see [Renderer Specification](renderer-spec.md)). This +preserves the parser's independence from the renderer (INV-8): the parser only +decodes bytes it is given. + +**Recognized reply grammar.** A reply has the form: + +``` +ESC ] 22 ; +``` + +where `` is either ST (`ESC \`, bytes `0x1B 0x5C`) or BEL (`0x07`), +and `` is the run of bytes up to the terminator. The parser emits one +`PointerShapeEvent` per complete reply, with `report` set to the decoded +`` string. Payloads are truncated to 64 bytes; this comfortably fits +any shape name and a support-query reply for a reasonable number of shapes. The +parser does not validate or interpret the payload. Per the kitty pointer-shape +protocol the payload may be: + +- a shape name (reply to `?__current__`, `?__default__`, or `?__grabbed__`), +- `0` (current-shape query when the shape stack is empty), or +- a comma-separated list of `1`/`0` flags (reply to a support query of the form + `?name1,name2,...`). + +Which interpretation applies depends on the query the caller sent; the parser +does not track outstanding queries, so the caller is responsible for that +correlation. + +**Graceful degradation.** Terminals that do not implement the query side of OSC +22 (for example, set-only implementations) never send a reply, and therefore +never produce a `PointerShapeEvent`. A caller that issues a query and receives +no event within a timeout MUST treat the feature as unsupported. Absence of the +event is the contract for unsupported terminals; it is not an error. + +**Incremental bytes.** An OSC 22 reply split across multiple `scan()` calls is +buffered like any other escape sequence and surfaced as a single event once the +terminator arrives. A lone `ESC` does not apply here: the `]` that follows +disambiguates immediately, so OSC 22 replies do not participate in ESC timeout +resolution. + --- ## 6. Deferred / Future Areas diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index f808abe..5427c4c 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -565,15 +565,28 @@ terminal-management operations: - Entering or leaving the alternate screen buffer - Hiding or showing the cursor -- Setting the cursor shape or blink state +- Setting the text cursor (caret) shape or blink state - Enabling or disabling mouse reporting - Enabling or disabling keyboard protocol modes (e.g., Kitty progressive enhancement) - Enabling or disabling raw mode or similar terminal disciplines -These are the caller's responsibility. The renderer's output contains only the -escape sequences needed to render the frame content (cursor positioning for cell -writes, SGR attributes for styling, and UTF-8 text). +These are the caller's responsibility. The render `output` (Section 7.3) +contains only the escape sequences needed to render the frame content (cursor +positioning for cell writes, SGR attributes for styling, and UTF-8 text). + +**Exception — mouse pointer shape (OSC 22).** The CSS-style mouse _pointer_ +shape (the shape of the mouse cursor as it moves over the UI, set via OSC 22) is +distinct from the text caret named above and is the one piece of +terminal-pointer presentation the renderer MAY participate in, because it is +derived directly from the renderer's own hit-testing. When — and only when — the +caller explicitly opts in (Section 12.6), the renderer MAY compute pointer-shape +transitions and return the corresponding OSC 22 bytes in a dedicated, separate +field of the render result. These bytes MUST NOT appear in `output`, and the +renderer still performs no IO: it produces the bytes and the caller decides +whether to write them, so INV-1 holds. With the opt-in disabled (the default), +this section's prohibition applies in full and the renderer emits nothing +related to pointer shape. ### 11.3 The renderer does not own application lifecycle @@ -653,6 +666,17 @@ 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`** — the mouse pointer shape to request while the pointer is over + this element (see Section 12.6) + +The `cursor` property names a mouse pointer shape using the CSS `cursor` keyword +vocabulary (for example `"pointer"`, `"text"`, `"default"`, `"not-allowed"`, +`"grab"`, `"progress"`, `"ew-resize"`). It is a pure layout-tree annotation: it +does NOT affect layout, cell output, or the transfer encoding, and it is not +sent to the WASM module. The TS layer reads it directly off the plain directive +objects (Section 9.1) and uses it only when pointer-shape tracking is enabled +(Section 12.6). An element with no `cursor` property contributes no shape +preference. The `floating` object shape is: @@ -726,11 +750,19 @@ prevent overlap. ### 12.3 Render return type The `render()` method currently returns a `RenderResult` object shaped as -`{ output: Uint8Array, events: PointerEvent[], info: RenderInfo, errors: ClayError[] }`. +`{ output: Uint8Array, events: PointerEvent[], info: RenderInfo, errors: ClayError[], cursor?: Uint8Array }`. The `output` field is the ANSI byte output specified normatively in Section 7.3 and Section 8.2. +The `cursor` field is present only when pointer-shape tracking is enabled via +the `trackCursor` render option (Section 12.6). When present and non-empty, it +carries OSC 22 bytes that, when written to the terminal, update the mouse +pointer shape to match the element currently under the pointer. It is kept +strictly separate from `output` so that the render content stream stays pure +(Section 11.2). When tracking is disabled, or when no shape change occurred this +frame, the field is absent. + The `events` field contains pointer events (enter, leave, click) derived from the underlying layout engine's element hit-testing. This field was added during a pointer-events feature implementation. The pointer event model is functional @@ -816,6 +848,78 @@ and used in tests. array into the transfer encoding described in Section 12.1. Currently exported but not public API; its exposure is incidental to the module structure. +### 12.6 Pointer shape tracking (OSC 22) + +> **Status:** Prospective, non-normative. This subsection specifies intended +> behavior to be implemented. Like the pointer event model (Section 12.4) on +> which it builds, it is new and expected to settle; it MAY change without +> constituting a breaking change to the normative core. + +Pointer shape tracking lets the mouse pointer change shape as it moves over the +UI — a hand over a clickable element, an I-beam over a text field, and so on — +driven entirely by the renderer's existing hit-testing (Section 12.4). It is +**opt-in** and, when disabled, the renderer emits nothing related to pointer +shape (Section 11.2). + +**Declaring a shape.** An element declares its desired pointer shape with the +`cursor` property on `open()` (Section 12.2), named with the CSS `cursor` +keyword vocabulary. The renderer ignores this property for layout and output; it +is consumed only by the tracking described here. + +**Enabling tracking.** The caller enables tracking by passing +`trackCursor: true` in the render options, alongside the existing `pointer` +state used for hit-testing: + +```ts +const r = term.render(ops, { pointer: { x, y, down }, trackCursor: true }); +if (r.cursor) stdout.write(r.cursor); +``` + +**Per-frame behavior.** On each render with tracking enabled, the TS term layer: + +1. Reads the `cursor` property off the plain directive objects to build an + `id → shape` map for the frame. +2. Determines the element currently under the pointer from the same hit-test + data that produces `PointerEvent[]`. When the pointer is over nested + elements, the topmost (innermost) element that declares a `cursor` wins. +3. Compares the resulting shape against the shape emitted on the previous frame. + Cross-frame shape state is held in the TS term layer — the same layer that + already tracks pointer enter/leave across frames (Section 12.4) — not in the + WASM core, which remains frame-stateless (Section 4.3). +4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that + effect the transition. When nothing changed, `result.cursor` is absent. + +**Setting and restoring.** Transitions use the bare _set_ form of OSC 22 for +portability — kitty and Ghostty both honor it, whereas the kitty push/pop +_stack_ extension is silently ignored by set-only terminals (Ghostty parses the +whole payload after `22;` as a literal shape name, so a `>`/`<` prefix is not a +valid shape and is dropped): + +- Entering an element with a declared shape sets it (`OSC 22 ; shape ST`). +- Returning to no declared shape restores the base by setting `default` + (`OSC 22 ; default ST`). + +The base shape is assumed to be `default` (the ordinary pointer); the renderer +does not attempt to restore a non-default prior shape. Callers targeting kitty +exclusively who want exact save/restore can drive the push/pop helpers manually. + +**Capability detection and graceful degradation.** Before relying on tracking, +the caller MAY query support. The OSC 22 query is sent through the normal output +path (it is a separate, caller-initiated byte sequence, not part of `output`), +and the terminal's reply arrives on the **input** stream, where it is decoded as +a `PointerShapeEvent` (see [Input Specification](input-spec.md), Section 5.1). +Correlating the reply with the query is the caller's responsibility, preserving +the renderer/input independence (INV-7). Because tracking uses the bare set +form, it works on set-only terminals (such as Ghostty) as well as kitty; only +terminals that do not implement OSC 22 at all ignore it entirely, and the +absence of a reply within a timeout is the unsupported signal. + +**OSC 22 byte helpers.** The byte sequences above are produced by small, +caller-usable helpers (set, push, pop, and query builders). These are the first +concrete instance of the caller-side terminal-control helpers anticipated in +Section 14, and exist independently of the render transaction so that callers +who want manual control can drive pointer shape without `trackCursor`. + --- ## 13. Implementation Notes @@ -865,6 +969,9 @@ renderer. **CSI helper for terminal setup.** A helper for generating paired apply/rollback byte arrays for terminal mode configuration was discussed but not implemented. +The OSC 22 pointer-shape byte helpers (Section 12.6) are a first, narrow +instance of this category; a general apply/rollback helper for the other +terminal modes in Section 11.2 remains unimplemented. **Browser-specific adapter.** The renderer's zero-IO architecture makes browser portability possible. No adapter exists. @@ -946,3 +1053,11 @@ resolution. 7. **What are the validation and error semantics?** How the renderer responds to invalid input is unspecified. Callers SHOULD validate, but the validation model is not yet settled enough to define normatively. + +8. **Is pointer shape tracking part of the rendering contract?** Section 12.6 + adds an opt-in mouse-pointer-shape feature that returns OSC 22 bytes in a + separate `cursor` field, with a narrow normative carve-out in Section 11.2. + Like the pointer event model it builds on, it is currently elastic surface. + Whether pointer-shape tracking, the `cursor` property, and the OSC 22 helpers + belong in the normative core — or should live in a higher-level layer above + the renderer — is unresolved. diff --git a/src/input.c b/src/input.c index 1f6257c..537f01b 100644 --- a/src/input.c +++ b/src/input.c @@ -616,6 +616,67 @@ static int parse_cursor(struct InputState *st, struct InputEvent *ev) { return PARSE_NEED_MORE; } +/* Parse an OSC 22 mouse-pointer-shape reply: ESC ] 22 ; + * where is ST (ESC \) or BEL (0x07). The payload is surfaced verbatim; + * the parser does not interpret it. Only OSC 22 is recognized here. */ +static int parse_osc(struct InputState *st, struct InputEvent *ev) { + if (st->len < 2) + return PARSE_NEED_MORE; + if (st->buf[0] != '\x1b' || st->buf[1] != ']') + return PARSE_ERR; + + /* numeric OSC code */ + int i = 2; + int code = -1; + while (i < st->len && st->buf[i] >= '0' && st->buf[i] <= '9') { + if (code == -1) + code = 0; + code = code * 10 + (st->buf[i] - '0'); + i++; + } + if (i >= st->len) + return PARSE_NEED_MORE; /* code digits not yet terminated */ + if (code != 22 || st->buf[i] != ';') + return PARSE_ERR; /* only OSC 22 with a payload separator */ + i++; /* skip ';' */ + + /* payload runs until ST (ESC \) or BEL */ + int payload_start = i; + int payload_end = -1; + int term_len = 0; + while (i < st->len) { + uint8_t c = (uint8_t)st->buf[i]; + if (c == 0x07) { + payload_end = i; + term_len = 1; + break; + } + if (c == 0x1b) { + if (i + 1 >= st->len) + return PARSE_NEED_MORE; + if (st->buf[i + 1] != '\\') + return PARSE_ERR; /* ESC not forming ST inside payload */ + payload_end = i; + term_len = 2; + break; + } + i++; + } + if (payload_end == -1) + return PARSE_NEED_MORE; /* terminator not seen yet */ + + int n = payload_end - payload_start; + if (n > MAX_REPORT_BYTES) + n = MAX_REPORT_BYTES; /* truncate overly long payloads */ + for (int j = 0; j < n; j++) + ev->report[j] = (uint8_t)st->buf[payload_start + j]; + ev->report_len = (uint16_t)n; + ev->type = EVENT_POINTERSHAPE; + + shift(st, payload_end + term_len); + return PARSE_OK; +} + /* Parse Kitty-enhanced legacy CSI sequences (non-u terminators). * Format: CSI [number] [; mod[:action]] terminator * Handles A-D, F, H, P, Q, S, ~ terminators with optional :action */ @@ -977,6 +1038,22 @@ int input_scan(struct InputState *st, const char *buf, int len, double now) { return accepted; } + /* try OSC (ESC ]) — pointer-shape replies */ + { + struct InputEvent oev; + memset(&oev, 0, sizeof(oev)); + int rv = parse_osc(st, &oev); + if (rv == PARSE_OK) { + struct InputEvent *ev = emit(st); + *ev = oev; + st->esc_time = 0; + continue; + } + if (rv == PARSE_NEED_MORE) { + return accepted; + } + } + /* try trie match */ { int consumed = 0; diff --git a/src/input.h b/src/input.h index c38404e..adb9521 100644 --- a/src/input.h +++ b/src/input.h @@ -59,6 +59,7 @@ #define EVENT_MOUSE 2 #define EVENT_RESIZE 3 #define EVENT_CURSOR 4 +#define EVENT_POINTERSHAPE 5 /* ── Modifier flags (bitwise) ─────────────────────────────────────── */ @@ -174,9 +175,15 @@ * @field base Base layout key codepoint (Kitty alternate keys). * @field text_len Number of valid codepoints in text[] (0-8). * @field text Associated text codepoints (Kitty enhancement level 16+). + * @field report_len Number of valid bytes in report[]. Only valid for + * EVENT_POINTERSHAPE. + * @field report Raw payload of an OSC 22 pointer-shape reply, the bytes + * between `OSC 22 ;` and the terminator. Truncated to + * MAX_REPORT_BYTES. Only valid for EVENT_POINTERSHAPE. */ #define MAX_TEXT_CODEPOINTS 8 +#define MAX_REPORT_BYTES 64 struct InputEvent { uint8_t type; @@ -192,6 +199,8 @@ struct InputEvent { uint32_t base; uint32_t text[MAX_TEXT_CODEPOINTS]; uint8_t text_len; + uint16_t report_len; + uint8_t report[MAX_REPORT_BYTES]; }; /** diff --git a/term.ts b/term.ts index 12517d0..03c1be9 100644 --- a/term.ts +++ b/term.ts @@ -1,5 +1,6 @@ -import { type Op, pack } from "./ops.ts"; +import { isOpen, type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; +import { type CursorShape, POINTERSHAPE } from "./termcodes.ts"; export interface TermOptions { height: number; @@ -25,6 +26,15 @@ export interface RenderOptions { y: number; down: boolean; }; + + /** + * Track the mouse pointer shape across frames. When enabled, the element + * currently under the pointer that declares a `cursor` shape drives the + * terminal's mouse pointer, and {@link RenderResult.cursor} carries the OSC 22 + * bytes for any change. Requires `pointer` to be provided for the shape to + * follow the cursor. See the renderer specification, Section 12.6. + */ + trackCursor?: boolean; } export type PointerEvent = @@ -64,6 +74,14 @@ export interface RenderResult { events: PointerEvent[]; info: RenderInfo; errors: ClayError[]; + + /** + * OSC 22 bytes that update the terminal's mouse pointer shape this frame. + * Present only when `trackCursor` is enabled and the shape changed; write it + * to the terminal separately from `output`. See the renderer specification, + * Section 12.6. + */ + cursor?: Uint8Array; } export interface Term { @@ -78,6 +96,7 @@ export async function createTerm(options: TermOptions): Promise { let prev = new Set(); let pressed = new Set(); let wasDown = false; + let cursorShape: CursorShape = "default"; return { render(ops: Op[], options?: RenderOptions): RenderResult { @@ -97,9 +116,8 @@ export async function createTerm(options: TermOptions): Promise { native.length(statePtr), ); - let current = new Set( - options?.pointer ? native.getPointerOverIds() : [], - ); + let overIds = options?.pointer ? native.getPointerOverIds() : []; + let current = new Set(overIds); let down = options?.pointer?.down ?? false; let events: PointerEvent[] = []; @@ -132,6 +150,33 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; + let cursor: Uint8Array | undefined; + if (options?.trackCursor) { + // Set-only OSC 22: the base is "default" (kitty and Ghostty both honor + // a bare set; the kitty push/pop stack is ignored by set-only terminals + // like Ghostty). + let active: CursorShape = "default"; + if (overIds.length > 0) { + let shapes = new Map(); + for (let op of ops) { + if (isOpen(op) && op.cursor) shapes.set(op.id, op.cursor); + } + // pointerOverIds is outermost-first; the innermost (topmost) + // declaring element wins, so scan from the end. + for (let i = overIds.length - 1; i >= 0; i--) { + let shape = shapes.get(overIds[i]); + if (shape) { + active = shape; + break; + } + } + } + if (active !== cursorShape) { + cursor = POINTERSHAPE(active); + cursorShape = active; + } + } + let info: RenderInfo = { get(id: string): ElementInfo | undefined { let bounds = native.getElementBounds(id); @@ -152,7 +197,7 @@ export async function createTerm(options: TermOptions): Promise { }); } - return { output, events, info, errors }; + return { output, events, info, errors, cursor }; }, }; } diff --git a/termcodes.ts b/termcodes.ts index bc8534c..373a5e5 100644 --- a/termcodes.ts +++ b/termcodes.ts @@ -85,6 +85,108 @@ export function MAINSCREEN(): Uint8Array { return CSI("?1049l"); } +/** + * A mouse pointer shape, named with the CSS `cursor` keyword vocabulary. + * + * These are the values understood by terminals implementing the OSC 22 + * pointer-shape protocol (kitty, Ghostty). Terminals that do not recognize a + * given shape ignore it. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/cursor | CSS cursor} + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export type CursorShape = + | "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" + | "col-resize" + | "row-resize" + | "all-scroll" + | "zoom-in" + | "zoom-out"; + +/** + * Encode an Operating System Command (OSC). + * + * Wraps the given string as `ESC ] str ST`, where ST is the String Terminator + * (`ESC \`). + * + * @see {@link https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ | ECMA-48} + */ +export function OSC(str: string): Uint8Array { + return encode(`\x1b]${str}\x1b\\`); +} + +/** + * Set the mouse pointer shape (OSC 22). + * + * Replaces the current pointer shape. Prefer {@link PUSHPOINTERSHAPE} / + * {@link POPPOINTERSHAPE} when you want the terminal's prior shape restored. + * + * @see {@link https://sw.kovidgoyal.net/kitty/pointer-shapes/ | kitty pointer shapes} + */ +export function POINTERSHAPE(shape: CursorShape): Uint8Array { + return OSC(`22;${shape}`); +} + +/** + * Push a mouse pointer shape onto the terminal's pointer-shape stack (OSC 22). + * + * The pushed shape becomes current; {@link POPPOINTERSHAPE} restores whatever + * was current before. This is the kitty stack extension and is how shapes are + * saved and restored without querying the terminal's prior shape. + */ +export function PUSHPOINTERSHAPE(shape: CursorShape): Uint8Array { + return OSC(`22;>${shape}`); +} + +/** + * Pop the top mouse pointer shape off the stack (OSC 22), restoring the shape + * that was current before the matching {@link PUSHPOINTERSHAPE}. + */ +export function POPPOINTERSHAPE(): Uint8Array { + return OSC("22;<"); +} + +/** + * Query the terminal's mouse pointer shape support (OSC 22). + * + * With no arguments, asks for the current shape (`?__current__`). With one or + * more shape names, asks which are supported. The terminal replies on the + * input stream; the reply is decoded as a `PointerShapeEvent` (see the input + * parser). Terminals without query support never reply. + */ +export function QUERYPOINTERSHAPE(...shapes: CursorShape[]): Uint8Array { + return OSC(`22;?${shapes.length > 0 ? shapes.join(",") : "__current__"}`); +} + const encoder = new TextEncoder(); function encode(str: string): Uint8Array { diff --git a/test/cursor.test.ts b/test/cursor.test.ts new file mode 100644 index 0000000..84726b2 --- /dev/null +++ b/test/cursor.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it } from "./suite.ts"; +import { createTerm, type Term } from "../term.ts"; +import { close, fixed, grow, open, text } from "../ops.ts"; + +const decoder = new TextDecoder(); + +function shown(bytes: Uint8Array | undefined): string | undefined { + return bytes === undefined ? undefined : decoder.decode(bytes); +} + +const SET = (shape: string) => `\x1b]22;${shape}\x1b\\`; + +// ┌─root (40x10, ltr)──────────────────┐ +// │┌─btn (20x10)──┐┌─field (20x10)───┐│ +// ││ cursor:pointer ││ cursor:text ││ +// │└───────────────┘└────────────────┘│ +// └───────────────────────────────────┘ +function layout() { + return [ + open("root", { + layout: { width: grow(), height: grow(), direction: "ltr" }, + }), + open("btn", { + layout: { width: fixed(20), height: fixed(10) }, + cursor: "pointer", + }), + text("B"), + close(), + open("field", { + layout: { width: fixed(20), height: fixed(10) }, + cursor: "text", + }), + text("F"), + close(), + close(), + ]; +} + +describe("pointer shape tracking", () => { + let term: Term; + + beforeEach(async () => { + term = await createTerm({ width: 40, height: 10 }); + }); + + it("emits no cursor field when trackCursor is not enabled", () => { + let result = term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + }); + expect(result.cursor).toBeUndefined(); + }); + + it("sets the shape when the pointer enters a declaring element", () => { + let result = term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(SET("pointer")); + }); + + it("emits nothing on a subsequent frame over the same element", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { + pointer: { x: 6, y: 5, down: false }, + trackCursor: true, + }); + expect(result.cursor).toBeUndefined(); + }); + + it("sets the new shape when moving between elements of different shapes", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { + pointer: { x: 25, y: 5, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(SET("text")); + }); + + it("restores default when the pointer leaves all declaring elements", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { + pointer: { x: 100, y: 100, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(SET("default")); + }); + + it("restores default when the pointer is removed entirely", () => { + term.render(layout(), { + pointer: { x: 5, y: 5, down: false }, + trackCursor: true, + }); + let result = term.render(layout(), { trackCursor: true }); + expect(shown(result.cursor)).toBe(SET("default")); + }); + + it("uses the topmost (innermost) declaring element's shape", () => { + // root declares "default"; the inner box declares "pointer". + let nested = () => [ + open("root", { + layout: { width: grow(), height: grow() }, + cursor: "default", + }), + open("inner", { + layout: { width: fixed(10), height: fixed(5) }, + cursor: "pointer", + }), + text("x"), + close(), + close(), + ]; + let result = term.render(nested(), { + pointer: { x: 2, y: 2, down: false }, + trackCursor: true, + }); + expect(shown(result.cursor)).toBe(SET("pointer")); + }); + + it("emits nothing when the hovered element declares no shape", () => { + let plain = () => [ + open("root", { layout: { width: grow(), height: grow() } }), + text("x"), + close(), + ]; + let result = term.render(plain(), { + pointer: { x: 2, y: 2, down: false }, + trackCursor: true, + }); + expect(result.cursor).toBeUndefined(); + }); +}); diff --git a/test/input.test.ts b/test/input.test.ts index 38af941..4341078 100644 --- a/test/input.test.ts +++ b/test/input.test.ts @@ -715,6 +715,66 @@ describe("input", () => { }); }); + describe("OSC 22 pointer shape reports", () => { + it("parses a current-shape reply terminated by ST", () => { + let result = input.scan(str("\x1b]22;pointer\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "pointer", + }); + }); + + it("parses a reply terminated by BEL", () => { + let result = input.scan(str("\x1b]22;text\x07")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "text", + }); + }); + + it("parses an empty-stack reply (0)", () => { + let result = input.scan(str("\x1b]22;0\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "0", + }); + }); + + it("parses a support-query reply (comma list) verbatim", () => { + let result = input.scan(str("\x1b]22;1,0,1\x1b\\")); + expect(result.events.length).toBe(1); + expect(result.events[0]).toMatchObject({ + type: "pointershape", + report: "1,0,1", + }); + }); + + it("buffers a reply split across scans", () => { + let first = input.scan(str("\x1b]22;poin")); + expect(first.events.length).toBe(0); + let second = input.scan(str("ter\x1b\\")); + expect(second.events.length).toBe(1); + expect(second.events[0]).toMatchObject({ + type: "pointershape", + report: "pointer", + }); + }); + + it("parses a reply interleaved with other input", () => { + let result = input.scan(str("a\x1b]22;default\x1b\\b")); + expect(result.events.length).toBe(3); + expect(result.events[0]).toMatchObject({ type: "keydown", key: "a" }); + expect(result.events[1]).toMatchObject({ + type: "pointershape", + report: "default", + }); + expect(result.events[2]).toMatchObject({ type: "keydown", key: "b" }); + }); + }); + describe("UTF-8", () => { it("parses 2-byte UTF-8 (é)", () => { let result = input.scan(bytes(0xc3, 0xa9));