diff --git a/examples/2048/index.ts b/examples/2048/index.ts index b8fd557..8d29116 100644 --- a/examples/2048/index.ts +++ b/examples/2048/index.ts @@ -27,7 +27,12 @@ import { type Stream, until, } from "effection"; -import { createTerm, type InputEvent, type PointerEvent } from "../../mod.ts"; +import { + createTerm, + detectTerminal, + type InputEvent, + type PointerEvent, +} from "../../mod.ts"; import { alternateBuffer, cursor, @@ -36,6 +41,7 @@ import { } from "../../settings.ts"; import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; +import { optInPointerShapes } from "../pointer-shape-opt-in.ts"; import { clearFlags, cloneGame, @@ -279,20 +285,29 @@ function consoleSize(): { width: number; height: number } { await main(function* () { let { width, height } = consoleSize(); + let info = yield* until(detectTerminal()); + Deno.stdin.setRaw(true); yield* ensure(() => Deno.stdin.setRaw(false)); let stdin = yield* useStdin(); - let input = useInput(stdin); + let input = useInput(stdin, { terminfo: info }); - let term = yield* until(createTerm({ width, height })); + let term = yield* until(createTerm({ width, height, terminfo: info })); let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); Deno.stdout.writeSync(tty.apply); + // Ask the terminal about itself (OSC color queries, XTGETTCAP, DECRPM, DA1). + // Responses arrive on the input stream as CapabilityEvents and are routed to + // term.update() in the loop below, which is what switches on features like + // pointer shapes (renderer-spec §7.9) once the terminal confirms support. + Deno.stdout.writeSync(info.probe); yield* ensure(() => { Deno.stdout.writeSync(tty.revert); }); + optInPointerShapes(term); + // The native term has fixed-size buffers, and the input parser never emits // resize events, so we bridge SIGWINCH into the event stream ourselves. The // payload is just a wake-up; the handler reads the live size. @@ -368,15 +383,18 @@ await main(function* () { for (let ev of yield* each(events)) { if (ev !== undefined && typeof ev === "object" && "type" in ev) { - // Resize: rebuild the term at the new size, wipe stale cells, repaint. - // A drag-resize fires many SIGWINCHes; we read the live size each time - // and skip when unchanged, so only real size changes rebuild the term. + // Resize: tell the term the new size (the next render repaints every + // cell) and wipe stale cells. A drag-resize fires many SIGWINCHes; we + // read the live size each time and skip when unchanged, so only real + // size changes touch the term. if ((ev as { type: string }).type === "resize") { let next = consoleSize(); if (next.width !== width || next.height !== height) { width = next.width; height = next.height; - term = yield* until(createTerm({ width, height })); + Deno.stdout.writeSync( + term.update([{ type: "resize", width, height }]), + ); Deno.stdout.writeSync(CLEAR_SCREEN); draw(); } @@ -386,6 +404,12 @@ await main(function* () { let e = ev as InputEvent | PointerEvent; + if (e.type === "capability") { + Deno.stdout.writeSync(term.update([e])); + yield* each.next(); + continue; + } + if (e.type === "keydown") { if (e.ctrl && e.key === "c") break; if (e.key === "q") break; diff --git a/examples/2048/primitives/button.ts b/examples/2048/primitives/button.ts index 460cd81..97e52fd 100644 --- a/examples/2048/primitives/button.ts +++ b/examples/2048/primitives/button.ts @@ -107,6 +107,11 @@ export function button( // below the button) when focus changes. Focus/hover feedback is instant // instead, which is crisp and correct. Revisit once transitions support // color-only animation without the position-interaction coupling. + // OSC 22 pointer shape: clickable buttons show the hand; a disabled + // button shows not-allowed, mirroring CSS `cursor` conventions. Only + // emitted when the terminal confirmed OSC 22 support (renderer-spec + // §7.9); otherwise this property is ignored. + cursor: state.disabled ? "not-allowed" : "pointer", }), text(label, { color: fg }), close(), diff --git a/examples/README.md b/examples/README.md index 0e3476c..af4930c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,48 @@ Build the generated WebAssembly bundle before running the examples: make ``` +## Pointer shapes + +The cursor, keyboard, 2048, and text-input demos change the mouse pointer over +their interactive elements (OSC 22, renderer-spec §7.9). Support is detected by +the probe (kitty) and by the known-support table in `detectTerminal()` (ghostty, +foot, xterm ≥ 367), so those work out of the box. For anything else, assert +support by hand with: + +```sh +TTY_POINTER_SHAPES=1 deno run examples/keyboard/index.ts +``` + +## Cursor + +Path: `examples/cursor/index.ts` + +Run it with: + +```sh +deno run examples/cursor/index.ts +# or +node examples/cursor/index.ts +``` + +What it shows: + +- a catalog of 9 pointer shapes — the subset confirmed working in ghostty — as + border-only rounded tiles in Bombshell brand hues, declaring their `cursor` + (renderer-spec §7.9); hovering a tile shows that exact cursor in terminals + that support OSC 22 +- the grab tile is double-wide: holding the pointer down shows grabbing anywhere + on screen while held — a capture-mode drag shield (userland drag persistence, + matching CSS drag behavior); releasing returns to grab +- an ambient brightness wave travels the title bar: the ▪ spacers breathe on a + phase-offset sine, so a ripple of light crosses the brand title (~12fps + ticker) +- hover feedback lights the tile's ring and name, so the hover state reads even + in terminals without OSC 22 +- a muted status line whose hover label takes the hovered tile's hue, with a + feature indicator (green ● supported, red ■ not) pinned to the right edge +- a final frame on exit that restores the default pointer (§7.9 restore) + ## Keyboard Path: `examples/keyboard/index.ts` @@ -34,6 +76,8 @@ What it shows: - raw keyboard input decoded into structured key events - progressive keyboard protocol support - pointer tracking and hover/click-driven UI updates +- OSC 22 pointer shapes: hovering the on-screen keys shows the hand pointer when + the terminal confirms support (renderer-spec §7.9) - terminal mode configuration such as alternate buffer, hidden cursor, and mouse reporting @@ -99,15 +143,18 @@ What it shows: - the `animating` render signal gating a follow-up frame loop, so the process only renders while something is moving - pointer hit testing and keyboard focus working together on the chrome buttons +- OSC 22 pointer shapes on the chrome buttons: hand cursor while hovering, + not-allowed on the disabled Undo button, when the terminal confirms support + (renderer-spec §7.9) - an fps readout in the footer: a sliding-window count of frames pushed to stdout in the last second. It measures how fast frames are _produced_, not how fast the terminal _paints_ them — a CPU-rendered terminal (e.g. Terminal.app) can coalesce or drop frames downstream where the process can't observe it, so motion can look steppy even while this number stays high - live resize handling: a `SIGWINCH` listener is bridged into the Effection - event loop, and because the native term has fixed-size buffers, each real size - change rebuilds the term, clears the screen, and repaints so the layout - re-centers to the new dimensions + event loop, and each real size change is folded into the term with + `term.update([{ type: "resize", width, height }])`, after which the screen is + cleared and repainted so the layout re-centers to the new dimensions - a keycaster overlay: recent key presses and button clicks appear as a centered row of caps near the bottom (a floating, pointer-passthrough element) that retire on a timer, handy for screen recordings and demos diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts new file mode 100644 index 0000000..65daf8f --- /dev/null +++ b/examples/cursor/index.ts @@ -0,0 +1,222 @@ +/** + * cursor — a shape catalog for the OSC 22 pointer-shape protocol. + * + * - Each tile declares a different `cursor` (renderer-spec §7.9), so + * hovering it asks the terminal to show that exact cursor. Support comes + * from the probe (kitty) or the known-support table (ghostty, foot, + * xterm ≥ 367); the status line shows whether it is currently on. + * - Tiles are border-only rounded boxes in Bombshell brand hues (from + * bomb.sh); hovering lights the tile's ring and name, so the hover state + * reads even in terminals without OSC 22. + * - The grab tile is double-wide: holding the pointer down on it shows + * grabbing anywhere on screen while held (a capture-mode drag shield — + * userland drag persistence, no spec support needed), releasing returns + * to grab. + * - An ambient brightness wave travels the title bar: the ▪ spacers breathe + * on a phase-offset sine (~4s cycle, ~12fps ticker), so a ripple of light + * crosses the brand title. + * - Curated to the 9 shapes confirmed working in ghostty 1.3.1; ghostty + * drops help, progress, wait, move, zoom-in, zoom-out, and none. + * + * Run: `deno run examples/cursor/index.ts` (or with node). + */ + +import { Buffer } from "node:buffer"; +import process from "node:process"; +import { + createChannel, + each, + ensure, + main, + type Operation, + race, + resource, + sleep, + spawn, + type Stream, + until, +} from "effection"; +import { createTerm, detectTerminal, type PointerEvent } from "../../mod.ts"; +import { + alternateBuffer, + cursor, + mouseTracking, + settings, +} from "../../settings.ts"; +import { useInput } from "../use-input.ts"; +import { useStdin } from "../use-stdin.ts"; +import { optInPointerShapes } from "../pointer-shape-opt-in.ts"; +import { type Ctx, frame, withDragShield } from "./view.ts"; + +await main(function* () { + let { columns, rows } = terminalSize(); + + let info = yield* until(detectTerminal()); + + setRawMode(true); + + let stdin = yield* useStdin(); + let input = useInput(stdin, { terminfo: info }); + + let term = yield* until( + createTerm({ width: columns, height: rows, terminfo: info }), + ); + + let tty = settings(alternateBuffer(), cursor(false), mouseTracking()); + writeStdout(tty.apply); + + // Ask the terminal about itself; CapabilityEvents arrive on the input + // stream and are routed to term.update() below, which is what switches + // pointer shapes on once the terminal confirms support. + writeStdout(info.probe); + + optInPointerShapes(term); + + let entered = new Set(); + let pointer = undefined as Ctx["pointer"]; + let ctx: Ctx = { + entered, + pointer, + capsOn: term.capabilities.pointerShape, + grabbing: false, + now: 0, + }; + + let pointerEvents = createChannel(); + let ticks = ticker(80); + + yield* ensure(() => { + // Restore the pointer before leaving: a frame without `pointer` resolves + // default and emits the OSC 22 reset if a shape was showing (§7.9). + writeStdout( + term.render( + frame({ + entered: new Set(), + pointer: undefined, + capsOn: false, + grabbing: false, + now: 0, + }), + ).output, + ); + setRawMode(false); + writeStdout(tty.revert); + }); + + ctx.now = performance.now(); + let { output } = term.render(frame(ctx)); + writeStdout(output); + + for (let event of yield* each(merge(merge(input, pointerEvents), ticks))) { + if (event.type === "keydown" && event.ctrl && event.key === "c") { + break; + } + if (event.type === "keydown" && event.key === "Escape") { + break; + } + if (event.type === "capability") { + writeStdout(term.update([event])); + } + if (event.type === "pointerenter") { + ctx.entered.add(event.id); + } + if (event.type === "pointerleave") { + ctx.entered.delete(event.id); + } + if (event.type === "mousedown" && ctx.entered.has("shape:grab")) { + // Press on the grab tile grabs it; the hold persists even if the + // pointer drifts off the tile mid-drag, exactly like a real grab. + ctx.grabbing = true; + } + if (event.type === "mouseup") { + ctx.grabbing = false; + } + if ("x" in event) { + ctx.pointer = { + x: event.x, + y: event.y, + down: event.type === "mousedown", + }; + } + + ctx.capsOn = term.capabilities.pointerShape; + ctx.now = performance.now(); + + let { output, events } = term.render( + withDragShield(frame(ctx), columns, rows, ctx.grabbing), + { pointer: ctx.pointer }, + ); + for (let e of events) { + yield* pointerEvents.send(e); + } + writeStdout(output); + + yield* each.next(); + } +}); + +function terminalSize(): { columns: number; rows: number } { + return process.stdout.isTTY + ? { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + } + : { columns: 80, rows: 24 }; +} + +function setRawMode(enabled: boolean): void { + if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") { + process.stdin.setRawMode(enabled); + } +} + +function writeStdout(bytes: Uint8Array): void { + process.stdout.write(Buffer.from(bytes)); +} + +/** + * A steady tick stream driving the brightness wave. Ticks are disposable: + * ones sent before the loop subscribes are dropped harmlessly, the next + * arrives an interval later. ~12fps is plenty for a ~4s wave cycle. + */ +function ticker(interval: number): Stream { + return resource(function* (provide) { + let ch = createChannel(); + yield* spawn(function* () { + while (true) { + yield* sleep(interval); + yield* ch.send({ type: "tick" }); + } + }); + let sub = yield* ch; + yield* race([provide(sub), drain(ch)]); + }); +} + +function* drain(stream: Stream): Operation { + for (let _ of yield* each(stream)) { + yield* each.next(); + } +} + +interface Tick { + type: "tick"; +} + +function merge( + a: Stream, + b: Stream, +): Stream { + return resource(function* (provide) { + let subscription = { + a: yield* a, + b: yield* b, + }; + + return yield* provide({ + *next() { + return yield* race([subscription.a.next(), subscription.b.next()]); + }, + }); + }); +} diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts new file mode 100644 index 0000000..e4b4c30 --- /dev/null +++ b/examples/cursor/view.ts @@ -0,0 +1,274 @@ +/** + * cursor — the tile grid view for examples/cursor/index.ts. + * + * Each tile declares a different `cursor` (renderer-spec §7.9), so + * hovering it asks the terminal to show that exact cursor. Tiles are + * border-only rounded boxes — no painted fills — with the Bombshell brand + * hues from bomb.sh. Hovering lights the tile's ring and name in its hue, + * so the hover state reads even in terminals without OSC 22. The grab tile + * is double-wide and shows grabbing while the pointer is held down on it — + * a capture-mode drag shield keeps the grabbing cursor anywhere on screen + * until release, matching CSS drag behavior — returning to grab on release, + * staying orange throughout. The ▪ spacers in the title bar breathe with a + * brightness wave: a phase-offset sine ripple travels the bar. + */ + +import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; + +/** + * Scale a packed rgba() color's channels by `factor` (clamped to 255), + * preserving its hue — the primitive behind the brightness wave. + */ +function shade(color: number, factor: number): number { + let a = (color >>> 24) & 0xFF; + let r = Math.min(255, Math.round(((color >>> 16) & 0xFF) * factor)); + let g = Math.min(255, Math.round(((color >>> 8) & 0xFF) * factor)); + let b = Math.min(255, Math.round((color & 0xFF) * factor)); + return (a << 24) | (r << 16) | (g << 8) | b; +} + +// Brand hues from bomb.sh's CSS: --c-pink, --c-cyan, --c-green, --c-yellow, +// --c-purple, --c-orange, --c-blue, --c-red. +const BRAND = { + pink: rgba(255, 53, 206), + cyan: rgba(0, 227, 245), + green: rgba(0, 239, 89), + yellow: rgba(255, 226, 33), + purple: rgba(135, 26, 255), + orange: rgba(255, 145, 42), + blue: rgba(0, 87, 246), + red: rgba(255, 64, 75), +}; +// Brand grays: --c-gray-60, --c-gray-20, --c-gray-30, --c-gray-50. +const borderIdle = rgba(108, 110, 122); +const heading = rgba(244, 245, 249); +const label = rgba(195, 199, 208); +const dim = rgba(137, 142, 156); + +const TILE_W = 17; +const COLS = 3; +// Matches the grid width so the title and status bars align with the tiles: +// their content lengths change, and fit-sized bars would re-center and +// jitter under the root's alignX. +const BAR_W = COLS * TILE_W + (COLS - 1); + +const PKG = "@bomb.sh/tty"; +const DEMO = "cursor"; + +export interface Ctx { + entered: Set; + pointer: { x: number; y: number; down: boolean } | undefined; + capsOn: boolean; + /** The grab tile's toggled state: true shows grabbing, false grab. */ + grabbing: boolean; + /** Wall-clock ms, refreshed per frame — drives the brightness wave. */ + now: number; +} + +// One tile per confirmed shape (verified against ghostty 1.3.1, which drops +// help/progress/wait/move/zoom-in/zoom-out/none; the full 32-name vocabulary +// is in renderer-spec §7.9 and POINTER_SHAPES). grab/grabbing share a +// double-wide tile that toggles on click; each tile carries its brand hue. +const TILES = [ + { shape: "default", hue: BRAND.pink }, + { shape: "pointer", hue: BRAND.cyan }, + { shape: "text", hue: BRAND.green }, + { shape: "crosshair", hue: BRAND.yellow }, + { shape: "grab", hue: BRAND.orange, toggle: "grabbing" }, + { shape: "not-allowed", hue: BRAND.red }, + { shape: "ew-resize", hue: BRAND.pink }, + { shape: "ns-resize", hue: BRAND.pink }, +] as const; + +type Tile = (typeof TILES)[number]; + +/** + * A capture-mode floating overlay that declares `grabbing` across the + * viewport — the userland drag-persistence pattern. While held, the shield + * wins the hit test everywhere (tiles beneath stop being hovered, like CSS + * freezing :hover during a drag), so the grabbing cursor persists anywhere + * until the button is released. No spec support needed. + */ +export function withDragShield( + ops: Op[], + cols: number, + rows: number, + held: boolean, +): Op[] { + if (!held) return ops; + let out = ops.slice(); + out.splice( + -1, + 0, + open("drag-shield", { + layout: { width: fixed(cols), height: fixed(rows) }, + floating: { + attachTo: "root", + attachPoints: { element: "left-top", parent: "left-top" }, + pointerCaptureMode: "capture", + }, + cursor: "grabbing", + }), + close(), + ); + return out; +} + +function tile(ops: Op[], t: Tile, ctx: Ctx): void { + let id = `shape:${t.shape}`; + // While the drag shield is up, capture mode hides the tiles beneath and + // the grab tile takes a pointerleave — the grabbed tile keeps its hover + // treatment anyway so the panel stays lit for the whole drag. + let hovered = ctx.entered.has(id) || (ctx.grabbing && id === "shape:grab"); + let shape = "toggle" in t && ctx.grabbing ? t.toggle : t.shape; + ops.push( + open(id, { + layout: { + width: fixed("toggle" in t ? TILE_W * 2 + 1 : TILE_W), + height: fixed(3), + padding: { left: 2, right: 2 }, + alignX: "center", + alignY: "center", + }, + border: { + color: hovered ? t.hue : borderIdle, + left: 1, + right: 1, + top: 1, + bottom: 1, + }, + cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, + cursor: shape, + }), + text(shape, { color: hovered ? t.hue : label }), + close(), + ); +} + +export function frame(ctx: Ctx): Op[] { + let ops: Op[] = []; + + ops.push( + open("root", { + layout: { + width: grow(), + height: grow(), + direction: "ttb", + alignX: "center", + alignY: "center", + gap: 1, + padding: { left: 2, right: 2, top: 1, bottom: 1 }, + }, + }), + ); + + ops.push( + open("header", { + layout: { + width: fixed(BAR_W), + height: fixed(1), + direction: "ltr", + }, + }), + ); + // Brightness wave across the whole bar: every non-space cell — brand + // text and ▪ spacers alike — shades on a phase-offset sine, one cycle + // across the bar, so a ripple of light travels it (~4s). Text cells get a + // dim-biased range (near-white can only dim; the peak is the static look). + let cells = `${PKG} ` + + "▪".repeat(Math.max(0, BAR_W - PKG.length - DEMO.length - 2)) + + ` ${DEMO}`; + for (let i = 0; i < cells.length; i++) { + let ch = cells[i]; + if (ch === " ") { + ops.push(text(" ", { color: dim })); + continue; + } + let wave = 0.5 + 0.5 * Math.sin(ctx.now / 650 - i * (2 * Math.PI / BAR_W)); + let color = ch === "▪" + ? shade(dim, 0.85 + wave * 0.65) + : shade(heading, 0.82 + wave * 0.18); + ops.push(text(ch, { color })); + } + ops.push(close()); + + // Pack tiles into rows of COLS units; the toggle tile spans two. + let rows: Tile[][] = []; + let row: Tile[] = []; + let used = 0; + for (let t of TILES) { + let w = "toggle" in t ? 2 : 1; + if (used + w > COLS) { + rows.push(row); + row = []; + used = 0; + } + row.push(t); + used += w; + } + if (row.length > 0) rows.push(row); + + for (let r of rows) { + ops.push( + open("", { layout: { direction: "ltr", gap: 1, height: fixed(3) } }), + ); + for (let t of r) { + tile(ops, t, ctx); + } + ops.push(close()); + } + + // Status: a muted bar whose hover label takes the hovered tile's hue, with + // a grow spacer pinning the feature indicator to the right edge so both + // ends stay put as the hovered name changes length. + let hoveredShape: string | undefined; + let hoveredHue = dim; + if (ctx.grabbing) { + // While held, the drag shield is the hovered element. + hoveredShape = "grabbing"; + hoveredHue = BRAND.orange; + } else { + let hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.shape}`)); + if (hoveredTile) { + hoveredShape = hoveredTile.shape; + hoveredHue = hoveredTile.hue; + } + } + let indicator = ctx.capsOn ? "●" : "■"; + let indicatorColor = ctx.capsOn ? BRAND.green : BRAND.red; + ops.push( + open("status", { + layout: { + width: fixed(BAR_W), + height: fixed(1), + direction: "ltr", + padding: { top: 1 }, + }, + }), + text("hover: ", { color: dim }), + text(hoveredShape ?? "—", { color: hoveredShape ? hoveredHue : dim }), + open("", { layout: { width: grow(), height: fixed(1) } }), + close(), + text(indicator, { color: indicatorColor }), + text(` ${ctx.capsOn ? "feature supported" : "feature unsupported"}`, { + color: dim, + }), + close(), + ); + + ops.push( + open("hint", { + layout: { + width: fixed(BAR_W), + height: fixed(1), + padding: { top: 1 }, + }, + }), + text("[esc]", { color: dim }), + close(), + ); + + ops.push(close()); + + return ops; +} diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index e4b7ef3..cdb8482 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -14,6 +14,7 @@ import { import { close, createTerm, + detectTerminal, fixed, grow, type InputEvent, @@ -34,6 +35,7 @@ import { } from "../../settings.ts"; import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; +import { optInPointerShapes } from "../pointer-shape-opt-in.ts"; const active = rgba(60, 120, 220); const inactive = rgba(50, 50, 60); @@ -73,16 +75,28 @@ const logEntries: { key: string; name: keyof EventFilter }[] = [ await main(function* () { let { columns, rows } = terminalSize(); + let info = yield* until(detectTerminal()); + setRawMode(true); let stdin = yield* useStdin(); - let input = useInput(stdin); + let input = useInput(stdin, { terminfo: info }); - let term = yield* until(createTerm({ width: columns, height: rows })); + let term = yield* until( + createTerm({ width: columns, height: rows, terminfo: info }), + ); let tty = settings(alternateBuffer(), cursor(false)); writeStdout(tty.apply); + // Ask the terminal about itself (OSC color queries, XTGETTCAP, DECRPM, DA1). + // Responses arrive on the input stream as CapabilityEvents and are routed to + // term.update() in the loop below, which is what switches on features like + // pointer shapes (renderer-spec §7.9) once the terminal confirms support. + writeStdout(info.probe); + + optInPointerShapes(term); + let modality = recognizer(); let context = modality.next().value; @@ -108,6 +122,9 @@ await main(function* () { if (event.type === "keydown" && event.ctrl && event.key === "c") { break; } + if (event.type === "capability") { + writeStdout(term.update([event])); + } if (event.type === "pointerenter") { context.entered.add(event.id); } @@ -232,6 +249,7 @@ function key(ops: Op[], k: KeyDef, ctx: AppContext): void { border: hover ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } : undefined, + cursor: "pointer", }), text(k.label, { color: hover ? highlight : label }), close(), diff --git a/examples/pointer-shape-opt-in.ts b/examples/pointer-shape-opt-in.ts new file mode 100644 index 0000000..7e2c0b3 --- /dev/null +++ b/examples/pointer-shape-opt-in.ts @@ -0,0 +1,17 @@ +import process from "node:process"; +import type { Term } from "../mod.ts"; + +/** + * Assert OSC 22 pointer-shape support by hand. + * + * Some terminals (ghostty among them) can set pointer shapes but never answer + * the OSC 22 support query, so capability detection cannot see them. Run the + * demo with TTY_POINTER_SHAPES=1 to fold the equivalent CapabilityEvent into + * the term — the same opt-in a host can make with + * `term.update([{ type: "capability", key: "pointer-shape", value: true }])`. + */ +export function optInPointerShapes(term: Term): void { + if (process.env.TTY_POINTER_SHAPES === "1") { + term.update([{ type: "capability", key: "pointer-shape", value: true }]); + } +} diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts index e0553fb..ba24bbd 100644 --- a/examples/text-input/index.ts +++ b/examples/text-input/index.ts @@ -4,6 +4,7 @@ import { each, ensure, main, until } from "effection"; import { close, createTerm, + detectTerminal, fixed, grow, type KeyEvent, @@ -12,9 +13,15 @@ import { rgba, text, } from "../../mod.ts"; -import { alternateBuffer, progressiveInput, settings } from "../../settings.ts"; +import { + alternateBuffer, + mouseTracking, + progressiveInput, + settings, +} from "../../settings.ts"; import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; +import { optInPointerShapes } from "../pointer-shape-opt-in.ts"; const bg = rgba(20, 20, 30); const inputBg = rgba(35, 35, 50); @@ -24,18 +31,33 @@ const hint = rgba(80, 80, 100); await main(function* () { let { columns, rows } = terminalSize(); + let info = yield* until(detectTerminal()); + setRawMode(true); let stdin = yield* useStdin(); - let input = useInput(stdin); + let input = useInput(stdin, { terminfo: info }); - let term = yield* until(createTerm({ width: columns, height: rows })); + let term = yield* until( + createTerm({ width: columns, height: rows, terminfo: info }), + ); - let tty = settings(alternateBuffer(), progressiveInput(1)); + let tty = settings(alternateBuffer(), progressiveInput(1), mouseTracking()); writeStdout(tty.apply); + // Ask the terminal about itself (OSC color queries, XTGETTCAP, DECRPM, DA1). + // Responses arrive on the input stream as CapabilityEvents and are routed to + // term.update() in the loop below, which is what switches on features like + // pointer shapes (renderer-spec §7.9) once the terminal confirms support. + writeStdout(info.probe); + + optInPointerShapes(term); + let value = ""; let caret = 0; + let pointer = undefined as + | { x: number; y: number; down: boolean } + | undefined; yield* ensure(() => { setRawMode(false); @@ -82,11 +104,19 @@ await main(function* () { value = chars.join(""); caret++; } - - ({ output } = term.render(frame(value, caret))); - writeStdout(output); + } else if (event.type === "capability") { + writeStdout(term.update([event])); + } else if ("x" in event) { + pointer = { + x: event.x, + y: event.y, + down: event.type === "mousedown", + }; } + ({ output } = term.render(frame(value, caret), { pointer })); + writeStdout(output); + yield* each.next(); } }); @@ -113,6 +143,7 @@ function frame(value: string, caret: number): Op[] { padding: { left: 1, right: 1 }, }, bg: inputBg, + cursor: "text", }), text(value, { color: label, caret }), close(), diff --git a/examples/use-stdin.ts b/examples/use-stdin.ts index 7150ed5..2c541d9 100644 --- a/examples/use-stdin.ts +++ b/examples/use-stdin.ts @@ -1,35 +1,45 @@ import { createChannel, - each, type Operation, race, resource, spawn, type Stream, + suspend, until, } from "effection"; import process from "node:process"; export function useStdin(): Operation> { return resource(function* (provide) { - let channel = createChannel(); - let iterator = process.stdin.iterator() as AsyncIterator; + // The reader must not start until the stream is subscribed. Channels only + // deliver to subscribers that already exist, so anything read earlier is + // dropped. Starting on subscription is safe: probe replies and early + // keystrokes wait in the OS pty buffer until the first read begins. + let subscribed = false; + let stream: Stream = { + *[Symbol.iterator]() { + if (subscribed) { + throw new Error("useStdin stream supports exactly one subscription"); + } + subscribed = true; - yield* spawn(function* () { - let next = yield* until(iterator.next()); - while (!next.done) { - yield* channel.send(next.value); - next = yield* until(iterator.next()); - } - yield* channel.close(); - }); + let channel = createChannel(); + let iterator = process.stdin.iterator() as AsyncIterator; - yield* race([provide(channel), drain(channel)]); - }); -} + yield* spawn(function* () { + let next = yield* until(iterator.next()); + while (!next.done) { + yield* channel.send(next.value); + next = yield* until(iterator.next()); + } + yield* channel.close(); + }); -function* drain(stream: Stream): Operation { - for (let _ of yield* each(stream)) { - yield* each.next(); - } + return yield* channel; + }, + }; + + yield* race([provide(stream), suspend()]); + }); }