diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index c10112a..be97640 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -974,15 +974,19 @@ if (r.cursor) stdout.write(r.cursor); 4. When the shape changed, populates `result.cursor` with the OSC 22 bytes that effect the transition. When nothing changed, `result.cursor` is absent. -**Save and restore (kitty stack).** Transitions use the kitty pointer-shape -_stack_ rather than bare set, so the terminal's prior shape is preserved: +**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 pushes it (`OSC 22 ; >shape ST`). -- Returning to no declared shape pops back to what the terminal had before - (`OSC 22 ; < ST`). +- 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`). -This means the renderer never needs to know or assume the terminal's base shape; -the stack restores it. +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 @@ -990,10 +994,10 @@ 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). Terminals that do not implement OSC 22 -(or implement only the set operation, such as Ghostty) never reply and may not -honor push/pop; on these terminals tracking degrades to a no-op or a best-effort -set, and the absence of a reply within a timeout is the unsupported signal. +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 diff --git a/term.ts b/term.ts index 6716d49..810d5bc 100644 --- a/term.ts +++ b/term.ts @@ -1,10 +1,6 @@ import { isOpen, type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; -import { - type CursorShape, - POPPOINTERSHAPE, - PUSHPOINTERSHAPE, -} from "./termcodes.ts"; +import { type CursorShape, POINTERSHAPE } from "./termcodes.ts"; export interface TermOptions { height: number; @@ -105,7 +101,7 @@ export async function createTerm(options: TermOptions): Promise { let wasDown = false; let lastRenderAt: number | undefined; let wasAnimating = false; - let cursorShape: CursorShape | null = null; + let cursorShape: CursorShape = "default"; return { render(ops: Op[], options?: RenderOptions): RenderResult { @@ -171,7 +167,10 @@ export async function createTerm(options: TermOptions): Promise { let cursor: Uint8Array | undefined; if (options?.trackCursor) { - let active: CursorShape | null = null; + // 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) { @@ -188,10 +187,7 @@ export async function createTerm(options: TermOptions): Promise { } } if (active !== cursorShape) { - let parts: Uint8Array[] = []; - if (cursorShape !== null) parts.push(POPPOINTERSHAPE()); - if (active !== null) parts.push(PUSHPOINTERSHAPE(active)); - cursor = concat(parts); + cursor = POINTERSHAPE(active); cursorShape = active; } } @@ -222,15 +218,3 @@ export async function createTerm(options: TermOptions): Promise { }, }; } - -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/test/cursor.test.ts b/test/cursor.test.ts index f71b750..84726b2 100644 --- a/test/cursor.test.ts +++ b/test/cursor.test.ts @@ -8,8 +8,7 @@ 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\\`; +const SET = (shape: string) => `\x1b]22;${shape}\x1b\\`; // ┌─root (40x10, ltr)──────────────────┐ // │┌─btn (20x10)──┐┌─field (20x10)───┐│ @@ -51,12 +50,12 @@ describe("pointer shape tracking", () => { expect(result.cursor).toBeUndefined(); }); - it("pushes the shape when the pointer enters a declaring element", () => { + 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(PUSH("pointer")); + expect(shown(result.cursor)).toBe(SET("pointer")); }); it("emits nothing on a subsequent frame over the same element", () => { @@ -71,7 +70,7 @@ describe("pointer shape tracking", () => { expect(result.cursor).toBeUndefined(); }); - it("pops then pushes when moving between elements of different shapes", () => { + it("sets the new shape when moving between elements of different shapes", () => { term.render(layout(), { pointer: { x: 5, y: 5, down: false }, trackCursor: true, @@ -80,10 +79,10 @@ describe("pointer shape tracking", () => { pointer: { x: 25, y: 5, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(POP + PUSH("text")); + expect(shown(result.cursor)).toBe(SET("text")); }); - it("pops when the pointer leaves all declaring elements", () => { + it("restores default when the pointer leaves all declaring elements", () => { term.render(layout(), { pointer: { x: 5, y: 5, down: false }, trackCursor: true, @@ -92,16 +91,16 @@ describe("pointer shape tracking", () => { pointer: { x: 100, y: 100, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(POP); + expect(shown(result.cursor)).toBe(SET("default")); }); - it("pops when the pointer is removed entirely", () => { + 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(POP); + expect(shown(result.cursor)).toBe(SET("default")); }); it("uses the topmost (innermost) declaring element's shape", () => { @@ -123,7 +122,7 @@ describe("pointer shape tracking", () => { pointer: { x: 2, y: 2, down: false }, trackCursor: true, }); - expect(shown(result.cursor)).toBe(PUSH("pointer")); + expect(shown(result.cursor)).toBe(SET("pointer")); }); it("emits nothing when the hovered element declares no shape", () => {