diff --git a/ops.ts b/ops.ts index 3fd1d04..96287a2 100644 --- a/ops.ts +++ b/ops.ts @@ -1,3 +1,5 @@ +import type { CursorShape } from "./termcodes.ts"; + export type TransitionProperty = | "x" | "y" @@ -394,6 +396,14 @@ export interface OpenElement { bottom?: BorderSide; }; 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; @@ -508,6 +518,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/term.ts b/term.ts index 3b8ddef..6716d49 100644 --- a/term.ts +++ b/term.ts @@ -1,5 +1,10 @@ -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, + POPPOINTERSHAPE, + PUSHPOINTERSHAPE, +} from "./termcodes.ts"; export interface TermOptions { height: number; @@ -26,6 +31,15 @@ export interface RenderOptions { down: boolean; }; deltaTime?: number; + + /** + * 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 = @@ -67,6 +81,14 @@ export interface RenderResult { info: RenderInfo; errors: ClayError[]; animating: boolean; + + /** + * 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 { @@ -83,6 +105,7 @@ export async function createTerm(options: TermOptions): Promise { let wasDown = false; let lastRenderAt: number | undefined; let wasAnimating = false; + let cursorShape: CursorShape | null = null; return { render(ops: Op[], options?: RenderOptions): RenderResult { @@ -112,9 +135,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[] = []; @@ -147,6 +169,33 @@ export async function createTerm(options: TermOptions): Promise { prev = current; wasDown = down; + let cursor: Uint8Array | undefined; + if (options?.trackCursor) { + let active: CursorShape | null = null; + 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) { + let parts: Uint8Array[] = []; + if (cursorShape !== null) parts.push(POPPOINTERSHAPE()); + if (active !== null) parts.push(PUSHPOINTERSHAPE(active)); + cursor = concat(parts); + cursorShape = active; + } + } + let info: RenderInfo = { get(id: string): ElementInfo | undefined { let bounds = native.getElementBounds(id); @@ -169,7 +218,19 @@ export async function createTerm(options: TermOptions): Promise { let animating = native.animating(statePtr) > 0; wasAnimating = animating; - return { output, events, info, errors, animating }; + return { output, events, info, errors, animating, cursor }; }, }; } + +function concat(parts: Uint8Array[]): Uint8Array { + let total = 0; + for (let part of parts) total += part.length; + let out = new Uint8Array(total); + let offset = 0; + for (let part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} 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..f71b750 --- /dev/null +++ b/test/cursor.test.ts @@ -0,0 +1,141 @@ +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 PUSH = (shape: string) => `\x1b]22;>${shape}\x1b\\`; +const POP = `\x1b]22;<\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("pushes 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(PUSH("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("pops then pushes 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(POP + PUSH("text")); + }); + + it("pops 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(POP); + }); + + it("pops 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(POP); + }); + + 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(PUSH("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(); + }); +});