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/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/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/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));