From 23e588254690abf067702be334e488cda16e4c25 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 10:38:06 -0500 Subject: [PATCH 01/17] feat(examples): wire OSC 22 pointer shapes into the interactive demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keyboard, 2048, and text-input now run the full capability flow: detectTerminal() seeds both createTerm() and the input parser, the probe is written at startup, and CapabilityEvents are routed to term.update() so features switch on only after the terminal confirms support. Shapes: hand on the keyboard keys and 2048 buttons, not-allowed on the disabled Undo button, I-beam over the text field (mouse tracking added to text-input so the pointer position exists to hit-test against). 2048 now resizes through term.update([{ type: "resize" }]) instead of rebuilding the term, so capability and pointer-shape state survive a SIGWINCH (renderer-spec §7.9: a resize step leaves the emitted shape unchanged; a rebuild would reset it and strand a stale shape in the terminal). --- examples/2048/index.ts | 35 ++++++++++++++++++++----- examples/2048/primitives/button.ts | 5 ++++ examples/README.md | 11 +++++--- examples/keyboard/index.ts | 19 ++++++++++++-- examples/text-input/index.ts | 42 +++++++++++++++++++++++++----- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/examples/2048/index.ts b/examples/2048/index.ts index b8fd557..ba5d7f3 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, @@ -279,16 +284,23 @@ 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); }); @@ -368,15 +380,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 +401,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..5b345c2 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. + pointerShape: state.disabled ? "not-allowed" : "pointer", }), text(label, { color: fg }), close(), diff --git a/examples/README.md b/examples/README.md index 0e3476c..5f51176 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,6 +34,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 +101,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/keyboard/index.ts b/examples/keyboard/index.ts index e4b7ef3..08421b4 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -14,6 +14,7 @@ import { import { close, createTerm, + detectTerminal, fixed, grow, type InputEvent, @@ -73,16 +74,26 @@ 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); + let modality = recognizer(); let context = modality.next().value; @@ -108,6 +119,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 +246,7 @@ function key(ops: Op[], k: KeyDef, ctx: AppContext): void { border: hover ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } : undefined, + pointerShape: "pointer", }), text(k.label, { color: hover ? highlight : label }), close(), diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts index e0553fb..160d104 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,7 +13,12 @@ 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"; @@ -24,18 +30,31 @@ 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); + let value = ""; let caret = 0; + let pointer = undefined as + | { x: number; y: number; down: boolean } + | undefined; yield* ensure(() => { setRawMode(false); @@ -82,11 +101,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 +140,7 @@ function frame(value: string, caret: number): Op[] { padding: { left: 1, right: 1 }, }, bg: inputBg, + pointerShape: "text", }), text(value, { color: label, caret }), close(), From debc9661c08e3effc756084d557a06f937bf3d15 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 12:40:26 -0500 Subject: [PATCH 02/17] fix(examples): start the stdin reader when the input stream is subscribed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probe replies were being lost: useStdin's reader started when the resource body ran — before the event loop subscribed the stream — and the channel's internal drain subscription consumed everything read in that window. CapabilityEvents were the first feature to care, so probe responses arriving within milliseconds of the probe write could vanish silently and capabilities.pointerShape stayed false. The reader now starts at subscription time. Nothing is lost by waiting: probe replies and early keystrokes wait in the OS pty buffer until the first read begins. --- examples/use-stdin.ts | 46 ++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 18 deletions(-) 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()]); + }); } From 8d4d9c80d590a311919f31b75577e787922a772d Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 13:07:53 -0500 Subject: [PATCH 03/17] feat(examples): TTY_POINTER_SHAPES=1 opt-in for set-only terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghostty has supported OSC 22 since 1.0 but never answers the support query, so capability detection cannot see it — the demos silently degrade there even though the terminal fully supports the feature. A shared optInPointerShapes() helper folds the documented manual CapabilityEvent into the term when TTY_POINTER_SHAPES=1, per renderer-spec §7.9's guidance for terminals that set shapes without answering the query. The examples README documents it. --- examples/2048/index.ts | 3 +++ examples/README.md | 11 +++++++++++ examples/keyboard/index.ts | 3 +++ examples/pointer-shape-opt-in.ts | 17 +++++++++++++++++ examples/text-input/index.ts | 3 +++ 5 files changed, 37 insertions(+) create mode 100644 examples/pointer-shape-opt-in.ts diff --git a/examples/2048/index.ts b/examples/2048/index.ts index ba5d7f3..8d29116 100644 --- a/examples/2048/index.ts +++ b/examples/2048/index.ts @@ -41,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, @@ -305,6 +306,8 @@ await main(function* () { 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. diff --git a/examples/README.md b/examples/README.md index 5f51176..595dd74 100644 --- a/examples/README.md +++ b/examples/README.md @@ -17,6 +17,17 @@ Build the generated WebAssembly bundle before running the examples: make ``` +## Pointer shapes + +The keyboard, 2048, and text-input demos change the mouse pointer over their +interactive elements (OSC 22, renderer-spec §7.9). Terminals that answer the OSC +22 probe — kitty does — switch this on automatically. Ghostty can set shapes but +never answers the query, so assert support by hand with: + +```sh +TTY_POINTER_SHAPES=1 deno run examples/keyboard/index.ts +``` + ## Keyboard Path: `examples/keyboard/index.ts` diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index 08421b4..4cafb0b 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -35,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); @@ -94,6 +95,8 @@ await main(function* () { // pointer shapes (renderer-spec §7.9) once the terminal confirms support. writeStdout(info.probe); + optInPointerShapes(term); + let modality = recognizer(); let context = modality.next().value; 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 160d104..af56aa4 100644 --- a/examples/text-input/index.ts +++ b/examples/text-input/index.ts @@ -21,6 +21,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 bg = rgba(20, 20, 30); const inputBg = rgba(35, 35, 50); @@ -50,6 +51,8 @@ await main(function* () { // 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 From e7db28a2e8ff00ac3fc43927519d249f942cc70b Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:24:05 -0500 Subject: [PATCH 04/17] =?UTF-8?q?feat(examples):=20cursor=20demo=20?= =?UTF-8?q?=E2=80=94=20a=20shape=20catalog=20with=20half-block=20bevels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 4×4 grid of 16 tiles, one curated pointer shape per tile (the full 32-name vocabulary would not fit an 80×24 terminal), each declaring its pointerShape per renderer-spec §7.9. The 3D-ish bevel is drawn with half blocks: ▀ shades the top edge, ▄ the bottom edge. Hovering presses the tile in — the edges swap — so the hover state reads even in terminals without OSC 22, while the terminal itself shows the shape on supporting ones. The status line tracks the hovered shape and whether pointer shapes are currently on (probe reply or known-support table grant). The view lives in examples/cursor/view.ts so the layout is verifiable headlessly; the exit path renders a final frame without pointer so the default pointer is restored per §7.9. --- examples/README.md | 33 ++++++++- examples/cursor/index.ts | 155 +++++++++++++++++++++++++++++++++++++++ examples/cursor/view.ts | 130 ++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 examples/cursor/index.ts create mode 100644 examples/cursor/view.ts diff --git a/examples/README.md b/examples/README.md index 595dd74..c08005c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,15 +19,40 @@ make ## Pointer shapes -The keyboard, 2048, and text-input demos change the mouse pointer over their -interactive elements (OSC 22, renderer-spec §7.9). Terminals that answer the OSC -22 probe — kitty does — switch this on automatically. Ghostty can set shapes but -never answers the query, so assert support by hand with: +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 16 pointer shapes, one tile per shape declaring its + `pointerShape` (renderer-spec §7.9) — hovering a tile shows that exact cursor + in terminals that support OSC 22 +- a 3D-ish bevel drawn with half blocks: `▀` shades the top edge, `▄` the bottom + edge, and hovering presses the tile in, so the hover state reads even in + terminals without OSC 22 +- a status line showing the hovered shape and whether pointer shapes are + currently on (probe reply or known-support table) +- a final frame on exit that restores the default pointer (§7.9 restore) + ## Keyboard Path: `examples/keyboard/index.ts` diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts new file mode 100644 index 0000000..26d000a --- /dev/null +++ b/examples/cursor/index.ts @@ -0,0 +1,155 @@ +/** + * cursor — a shape catalog for the OSC 22 pointer-shape protocol. + * + * - Each tile declares a different `pointerShape` (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. + * - The bevel is drawn with half blocks (see `view.ts`): `▀` shades the top + * edge, `▄` the bottom edge, and hovering presses the tile in. + * - Curated to 16 shapes so the grid fits an 80×24 terminal. + * + * 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, + race, + resource, + 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 } 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 }; + + let pointerEvents = createChannel(); + + 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 }), + ).output, + ); + setRawMode(false); + writeStdout(tty.revert); + }); + + let { output } = term.render(frame(ctx)); + writeStdout(output); + + for (let event of yield* each(merge(input, pointerEvents))) { + 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 ("x" in event) { + ctx.pointer = { + x: event.x, + y: event.y, + down: event.type === "mousedown", + }; + } + + ctx.capsOn = term.capabilities.pointerShape; + + let { output, events } = term.render(frame(ctx), { 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)); +} + +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..4f2286e --- /dev/null +++ b/examples/cursor/view.ts @@ -0,0 +1,130 @@ +/** + * cursor — the tile grid view for examples/cursor/index.ts. + * + * Each tile declares a different `pointerShape` (renderer-spec §7.9), so + * hovering it asks the terminal to show that exact cursor. The bevel is + * drawn with half blocks: `▀` shades the top edge, `▄` the bottom edge. + * Hovering presses the tile in — the edges swap — so the hover state reads + * even in terminals without OSC 22. + */ + +import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; + +const page = rgba(18, 18, 26); +const face = rgba(52, 52, 70); +const faceHover = rgba(74, 74, 102); +const edgeLight = rgba(112, 112, 152); +const edgeDark = rgba(28, 28, 40); +const label = rgba(220, 220, 230); +const highlight = rgba(255, 214, 110); +const dim = rgba(100, 100, 120); + +const TILE_W = 15; +const COLS = 4; + +// One representative per interaction family; the full 32-name vocabulary is +// in renderer-spec §7.9 and POINTER_SHAPES. +export const SHAPES = [ + "default", + "pointer", + "text", + "crosshair", + "help", + "progress", + "wait", + "move", + "grab", + "grabbing", + "not-allowed", + "zoom-in", + "zoom-out", + "ew-resize", + "ns-resize", + "none", +] as const; + +export interface Ctx { + entered: Set; + pointer: { x: number; y: number; down: boolean } | undefined; + capsOn: boolean; +} + +function center(name: string, width: number): string { + let pad = width - name.length; + let left = Math.floor(pad / 2); + return " ".repeat(left) + name + " ".repeat(pad - left); +} + +function tile(ops: Op[], name: (typeof SHAPES)[number], ctx: Ctx): void { + let hovered = ctx.entered.has(`shape:${name}`); + // Pressed-in look: the bevel edges swap when the cursor is over the tile. + let top = hovered ? edgeDark : edgeLight; + let bottom = hovered ? edgeLight : edgeDark; + ops.push( + open(`shape:${name}`, { + layout: { width: fixed(TILE_W), height: fixed(3), direction: "ttb" }, + bg: hovered ? faceHover : face, + pointerShape: name, + }), + text("▀".repeat(TILE_W), { color: top }), + text(center(name, TILE_W), { color: hovered ? highlight : label }), + text("▄".repeat(TILE_W), { color: bottom }), + 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 }, + }, + bg: page, + }), + ); + + ops.push( + open("header", { layout: { height: fixed(1) } }), + text("cursor shapes — hover a tile", { color: label }), + close(), + ); + + for (let row = 0; row < SHAPES.length / COLS; row++) { + ops.push( + open("", { layout: { direction: "ltr", gap: 1, height: fixed(3) } }), + ); + for ( + let col = row * COLS; + col < (row + 1) * COLS && col < SHAPES.length; + col++ + ) { + tile(ops, SHAPES[col], ctx); + } + ops.push(close()); + } + + let hovered = [...ctx.entered].find((id) => id.startsWith("shape:")); + let status = hovered + ? `hover: ${hovered.slice("shape:".length)}` + : "hover: —"; + ops.push( + open("status", { layout: { height: fixed(1), padding: { top: 1 } } }), + text( + `${status} · pointer shapes: ${ctx.capsOn ? "on" : "off"} · ctrl+c quits`, + { color: ctx.capsOn ? highlight : dim }, + ), + close(), + ); + + ops.push(close()); + + return ops; +} From 8d800a8d2a936a6e401cd078cf079c3317a49f5d Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:29:31 -0500 Subject: [PATCH 05/17] refactor(examples): brand hues and border-only tiles in the cursor demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the painted fills — tiles are border-only rounded boxes over the terminal's own background. Colors come from bomb.sh's CSS: the eight brand hues (--c-pink through --c-red) cycle across the tiles, with the brand grays for idle borders, labels, and the status line. Hovering lights the tile's ring and name in its hue; @bomb.sh/tty's card on the site is pink, and the pink tile is the first one. --- examples/README.md | 11 +++-- examples/cursor/index.ts | 5 ++- examples/cursor/view.ts | 91 ++++++++++++++++++++++------------------ 3 files changed, 59 insertions(+), 48 deletions(-) diff --git a/examples/README.md b/examples/README.md index c08005c..6c62cd2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,12 +43,11 @@ node examples/cursor/index.ts What it shows: -- a catalog of 16 pointer shapes, one tile per shape declaring its - `pointerShape` (renderer-spec §7.9) — hovering a tile shows that exact cursor - in terminals that support OSC 22 -- a 3D-ish bevel drawn with half blocks: `▀` shades the top edge, `▄` the bottom - edge, and hovering presses the tile in, so the hover state reads even in - terminals without OSC 22 +- a catalog of 16 pointer shapes, one border-only rounded tile per shape + declaring its `pointerShape` (renderer-spec §7.9) — hovering a tile shows that + exact cursor in terminals that support OSC 22 +- tiles 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 - a status line showing the hovered shape and whether pointer shapes are currently on (probe reply or known-support table) - a final frame on exit that restores the default pointer (§7.9 restore) diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 26d000a..78eb60e 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -5,8 +5,9 @@ * 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. - * - The bevel is drawn with half blocks (see `view.ts`): `▀` shades the top - * edge, `▄` the bottom edge, and hovering presses the tile in. + * - 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. * - Curated to 16 shapes so the grid fits an 80×24 terminal. * * Run: `deno run examples/cursor/index.ts` (or with node). diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 4f2286e..a16c49f 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -2,24 +2,33 @@ * cursor — the tile grid view for examples/cursor/index.ts. * * Each tile declares a different `pointerShape` (renderer-spec §7.9), so - * hovering it asks the terminal to show that exact cursor. The bevel is - * drawn with half blocks: `▀` shades the top edge, `▄` the bottom edge. - * Hovering presses the tile in — the edges swap — so the hover state reads - * even in terminals without OSC 22. + * 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. */ import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; -const page = rgba(18, 18, 26); -const face = rgba(52, 52, 70); -const faceHover = rgba(74, 74, 102); -const edgeLight = rgba(112, 112, 152); -const edgeDark = rgba(28, 28, 40); -const label = rgba(220, 220, 230); -const highlight = rgba(255, 214, 110); -const dim = rgba(100, 100, 120); +// Brand hues from bomb.sh: --c-pink, --c-cyan, --c-green, --c-yellow, +// --c-purple, --c-orange, --c-blue, --c-red. +const BRAND = [ + rgba(255, 53, 206), + rgba(0, 227, 245), + rgba(0, 239, 89), + rgba(255, 226, 33), + rgba(135, 26, 255), + rgba(255, 145, 42), + rgba(0, 87, 246), + 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 = 15; +const TILE_W = 17; const COLS = 4; // One representative per interaction family; the full 32-name vocabulary is @@ -49,26 +58,30 @@ export interface Ctx { capsOn: boolean; } -function center(name: string, width: number): string { - let pad = width - name.length; - let left = Math.floor(pad / 2); - return " ".repeat(left) + name + " ".repeat(pad - left); -} +const TILES = SHAPES.map((name, i) => ({ name, hue: BRAND[i % BRAND.length] })); -function tile(ops: Op[], name: (typeof SHAPES)[number], ctx: Ctx): void { - let hovered = ctx.entered.has(`shape:${name}`); - // Pressed-in look: the bevel edges swap when the cursor is over the tile. - let top = hovered ? edgeDark : edgeLight; - let bottom = hovered ? edgeLight : edgeDark; +function tile(ops: Op[], t: (typeof TILES)[number], ctx: Ctx): void { + let hovered = ctx.entered.has(`shape:${t.name}`); ops.push( - open(`shape:${name}`, { - layout: { width: fixed(TILE_W), height: fixed(3), direction: "ttb" }, - bg: hovered ? faceHover : face, - pointerShape: name, + open(`shape:${t.name}`, { + layout: { + width: fixed(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 }, + pointerShape: t.name, }), - text("▀".repeat(TILE_W), { color: top }), - text(center(name, TILE_W), { color: hovered ? highlight : label }), - text("▄".repeat(TILE_W), { color: bottom }), + text(t.name, { color: hovered ? t.hue : label }), close(), ); } @@ -87,39 +100,37 @@ export function frame(ctx: Ctx): Op[] { gap: 1, padding: { left: 2, right: 2, top: 1, bottom: 1 }, }, - bg: page, }), ); ops.push( open("header", { layout: { height: fixed(1) } }), - text("cursor shapes — hover a tile", { color: label }), + text("cursor shapes — hover a tile", { color: heading }), close(), ); - for (let row = 0; row < SHAPES.length / COLS; row++) { + for (let row = 0; row < TILES.length / COLS; row++) { ops.push( open("", { layout: { direction: "ltr", gap: 1, height: fixed(3) } }), ); for ( let col = row * COLS; - col < (row + 1) * COLS && col < SHAPES.length; + col < (row + 1) * COLS && col < TILES.length; col++ ) { - tile(ops, SHAPES[col], ctx); + tile(ops, TILES[col], ctx); } ops.push(close()); } - let hovered = [...ctx.entered].find((id) => id.startsWith("shape:")); - let status = hovered - ? `hover: ${hovered.slice("shape:".length)}` - : "hover: —"; + let hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.name}`)); + let status = hoveredTile ? `hover: ${hoveredTile.name}` : "hover: —"; + let statusColor = hoveredTile ? hoveredTile.hue : dim; ops.push( open("status", { layout: { height: fixed(1), padding: { top: 1 } } }), text( `${status} · pointer shapes: ${ctx.capsOn ? "on" : "off"} · ctrl+c quits`, - { color: ctx.capsOn ? highlight : dim }, + { color: statusColor }, ), close(), ); From c88b1abd77135865ab5ae7514ef7263bdce3c2a5 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:33:13 -0500 Subject: [PATCH 06/17] refactor(examples): trim the cursor demo to ghostty-confirmed shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row 2 (help, progress, wait, move) plus zoom-in, zoom-out, and none don't render as cursors in ghostty 1.3.1 — it drops them. The demo is a catalog of shapes you can actually see, so it curates to the 9 confirmed working: 3×3 grid, with the dropped names recorded in the view comments as the boundary of ghostty's vocabulary. --- examples/README.md | 7 ++++--- examples/cursor/index.ts | 3 ++- examples/cursor/view.ts | 15 +++++---------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/examples/README.md b/examples/README.md index 6c62cd2..00906ac 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,9 +43,10 @@ node examples/cursor/index.ts What it shows: -- a catalog of 16 pointer shapes, one border-only rounded tile per shape - declaring its `pointerShape` (renderer-spec §7.9) — hovering a tile shows that - exact cursor in terminals that support OSC 22 +- a catalog of 9 pointer shapes — the subset confirmed working in ghostty — one + border-only rounded tile per shape declaring its `pointerShape` (renderer-spec + §7.9); hovering a tile shows that exact cursor in terminals that support OSC + 22 - tiles 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 - a status line showing the hovered shape and whether pointer shapes are diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 78eb60e..93932fd 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -8,7 +8,8 @@ * - 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. - * - Curated to 16 shapes so the grid fits an 80×24 terminal. + * - 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). */ diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index a16c49f..04af577 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -29,27 +29,22 @@ const label = rgba(195, 199, 208); const dim = rgba(137, 142, 156); const TILE_W = 17; -const COLS = 4; +const COLS = 3; -// One representative per interaction family; the full 32-name vocabulary is -// in renderer-spec §7.9 and POINTER_SHAPES. +// Shapes confirmed working in set-supporting terminals (verified against +// ghostty 1.3.1: it draws these and drops help/progress/wait/move/ +// zoom-in/zoom-out/none). The full 32-name vocabulary is in renderer-spec +// §7.9 and POINTER_SHAPES. export const SHAPES = [ "default", "pointer", "text", "crosshair", - "help", - "progress", - "wait", - "move", "grab", "grabbing", "not-allowed", - "zoom-in", - "zoom-out", "ew-resize", "ns-resize", - "none", ] as const; export interface Ctx { From 01c2a6680483e8b04f6e87c6507f16fb70e824a3 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:35:39 -0500 Subject: [PATCH 07/17] fix(examples): set width on the cursor demo's status line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status element was fit-sized, so when the hovered shape's name changed length the root's alignX re-centered the line and it jittered across tiles. The status now takes the grid's width (COLS * TILE_W + gaps), so its left edge is fixed and aligned with the grid; text is left-aligned inside. The quit hint shortens to "esc quits" — already wired — so the longest line (52) fits the 53-wide bar. --- examples/cursor/view.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 04af577..0d24547 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -30,6 +30,10 @@ const dim = rgba(137, 142, 156); const TILE_W = 17; const COLS = 3; +// Matches the 3×3 grid width so the status line's left edge is stable: the +// hovered name changes length, and a fit-sized line would re-center and +// jitter under the root's alignX. +const STATUS_W = COLS * TILE_W + (COLS - 1); // Shapes confirmed working in set-supporting terminals (verified against // ghostty 1.3.1: it draws these and drops help/progress/wait/move/ @@ -122,9 +126,15 @@ export function frame(ctx: Ctx): Op[] { let status = hoveredTile ? `hover: ${hoveredTile.name}` : "hover: —"; let statusColor = hoveredTile ? hoveredTile.hue : dim; ops.push( - open("status", { layout: { height: fixed(1), padding: { top: 1 } } }), + open("status", { + layout: { + width: fixed(STATUS_W), + height: fixed(1), + padding: { top: 1 }, + }, + }), text( - `${status} · pointer shapes: ${ctx.capsOn ? "on" : "off"} · ctrl+c quits`, + `${status} · pointer shapes: ${ctx.capsOn ? "on" : "off"} · esc quits`, { color: statusColor }, ), close(), From 7311370c8aeee9ed4df5b678e2722d87bd79d532 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:37:21 -0500 Subject: [PATCH 08/17] fix(examples): semantic hue assignment in the cursor demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grab orange, grabbing yellow, not-allowed red, both resizers pink — the grab family reads as a set and the resizers pair up. The first row keeps its brand-cycle colors (default pink, pointer cyan, text green, crosshair yellow). Hues live in a Record over the SHAPES union, so a shape without an assigned hue is a type error, and the full 8-hue brand record from bomb.sh stays in code for future re-theming. --- examples/cursor/view.ts | 40 ++++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 0d24547..5f39504 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -10,18 +10,18 @@ import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; -// Brand hues from bomb.sh: --c-pink, --c-cyan, --c-green, --c-yellow, +// 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 = [ - rgba(255, 53, 206), - rgba(0, 227, 245), - rgba(0, 239, 89), - rgba(255, 226, 33), - rgba(135, 26, 255), - rgba(255, 145, 42), - rgba(0, 87, 246), - rgba(255, 64, 75), -]; +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); @@ -57,7 +57,23 @@ export interface Ctx { capsOn: boolean; } -const TILES = SHAPES.map((name, i) => ({ name, hue: BRAND[i % BRAND.length] })); +// Per-shape hues: the grab family reads as a set (open hand orange, closed +// hand yellow, forbidden red), the resizers pair in pink, and the first row +// keeps its brand-cycle colors. The Record over SHAPES makes a missing +// shape a type error. +const HUES: Record<(typeof SHAPES)[number], number> = { + default: BRAND.pink, + pointer: BRAND.cyan, + text: BRAND.green, + crosshair: BRAND.yellow, + grab: BRAND.orange, + grabbing: BRAND.yellow, + "not-allowed": BRAND.red, + "ew-resize": BRAND.pink, + "ns-resize": BRAND.pink, +}; + +const TILES = SHAPES.map((name) => ({ name, hue: HUES[name] })); function tile(ops: Op[], t: (typeof TILES)[number], ctx: Ctx): void { let hovered = ctx.entered.has(`shape:${t.name}`); From d6d70691da1a6ff73adff709dc0fbdbce154b964 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 15:58:47 -0500 Subject: [PATCH 09/17] feat(examples): grab/grabbing toggle tile and status refinements in the cursor demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grab tile is now double-wide (crosshair + toggle = one grid row) and clicking it toggles between grab and grabbing — cursor and label switch together, the color stays orange. The stable tile id keeps hover tracking and the status label coherent across the toggle. Status line: the "hover: " prefix and the right group stay muted; only the hovered shape's name takes the tile's hue. The pointer-shapes indicator reads "● feature supported" / "■ feature unsupported" with the glyph in brand green/red, and "esc quits" moves to its own muted row under the grid. --- examples/README.md | 18 +++--- examples/cursor/index.ts | 19 +++++- examples/cursor/view.ts | 131 +++++++++++++++++++++++---------------- 3 files changed, 104 insertions(+), 64 deletions(-) diff --git a/examples/README.md b/examples/README.md index 00906ac..877f7a1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -43,14 +43,16 @@ node examples/cursor/index.ts What it shows: -- a catalog of 9 pointer shapes — the subset confirmed working in ghostty — one - border-only rounded tile per shape declaring its `pointerShape` (renderer-spec - §7.9); hovering a tile shows that exact cursor in terminals that support OSC - 22 -- tiles 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 -- a status line showing the hovered shape and whether pointer shapes are - currently on (probe reply or known-support table) +- a catalog of 9 pointer shapes — the subset confirmed working in ghostty — as + border-only rounded tiles in Bombshell brand hues, declaring their + `pointerShape` (renderer-spec §7.9); hovering a tile shows that exact cursor + in terminals that support OSC 22 +- the grab tile is double-wide: clicking toggles it between grab and grabbing + (cursor and label), staying orange +- 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 diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 93932fd..ab0b73a 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -8,6 +8,8 @@ * - 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: clicking toggles it between grab and + * grabbing (cursor and label), staying orange. * - Curated to the 9 shapes confirmed working in ghostty 1.3.1; ghostty * drops help, progress, wait, move, zoom-in, zoom-out, and none. * @@ -64,7 +66,12 @@ await main(function* () { let entered = new Set(); let pointer = undefined as Ctx["pointer"]; - let ctx: Ctx = { entered, pointer, capsOn: term.capabilities.pointerShape }; + let ctx: Ctx = { + entered, + pointer, + capsOn: term.capabilities.pointerShape, + grabbing: false, + }; let pointerEvents = createChannel(); @@ -73,7 +80,12 @@ await main(function* () { // 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 }), + frame({ + entered: new Set(), + pointer: undefined, + capsOn: false, + grabbing: false, + }), ).output, ); setRawMode(false); @@ -99,6 +111,9 @@ await main(function* () { if (event.type === "pointerleave") { ctx.entered.delete(event.id); } + if (event.type === "pointerclick" && event.id === "shape:grab") { + ctx.grabbing = !ctx.grabbing; + } if ("x" in event) { ctx.pointer = { x: event.x, diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 5f39504..e25a206 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -5,7 +5,9 @@ * 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. + * so the hover state reads even in terminals without OSC 22. The grab tile + * is double-wide and toggles between grab and grabbing on click, staying + * orange. */ import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; @@ -30,57 +32,44 @@ const dim = rgba(137, 142, 156); const TILE_W = 17; const COLS = 3; -// Matches the 3×3 grid width so the status line's left edge is stable: the +// Matches the grid width so the status line's left edge is stable: the // hovered name changes length, and a fit-sized line would re-center and // jitter under the root's alignX. const STATUS_W = COLS * TILE_W + (COLS - 1); -// Shapes confirmed working in set-supporting terminals (verified against -// ghostty 1.3.1: it draws these and drops help/progress/wait/move/ -// zoom-in/zoom-out/none). The full 32-name vocabulary is in renderer-spec -// §7.9 and POINTER_SHAPES. -export const SHAPES = [ - "default", - "pointer", - "text", - "crosshair", - "grab", - "grabbing", - "not-allowed", - "ew-resize", - "ns-resize", -] as const; - 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; } -// Per-shape hues: the grab family reads as a set (open hand orange, closed -// hand yellow, forbidden red), the resizers pair in pink, and the first row -// keeps its brand-cycle colors. The Record over SHAPES makes a missing -// shape a type error. -const HUES: Record<(typeof SHAPES)[number], number> = { - default: BRAND.pink, - pointer: BRAND.cyan, - text: BRAND.green, - crosshair: BRAND.yellow, - grab: BRAND.orange, - grabbing: BRAND.yellow, - "not-allowed": BRAND.red, - "ew-resize": BRAND.pink, - "ns-resize": BRAND.pink, -}; +// 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; -const TILES = SHAPES.map((name) => ({ name, hue: HUES[name] })); +type Tile = (typeof TILES)[number]; -function tile(ops: Op[], t: (typeof TILES)[number], ctx: Ctx): void { - let hovered = ctx.entered.has(`shape:${t.name}`); +function tile(ops: Op[], t: Tile, ctx: Ctx): void { + let id = `shape:${t.shape}`; + let hovered = ctx.entered.has(id); + let shape = "toggle" in t && ctx.grabbing ? t.toggle : t.shape; ops.push( - open(`shape:${t.name}`, { + open(id, { layout: { - width: fixed(TILE_W), + width: fixed("toggle" in t ? TILE_W * 2 + 1 : TILE_W), height: fixed(3), padding: { left: 2, right: 2 }, alignX: "center", @@ -94,9 +83,9 @@ function tile(ops: Op[], t: (typeof TILES)[number], ctx: Ctx): void { bottom: 1, }, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, - pointerShape: t.name, + pointerShape: shape, }), - text(t.name, { color: hovered ? t.hue : label }), + text(shape, { color: hovered ? t.hue : label }), close(), ); } @@ -124,35 +113,69 @@ export function frame(ctx: Ctx): Op[] { close(), ); - for (let row = 0; row < TILES.length / COLS; row++) { + // 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 col = row * COLS; - col < (row + 1) * COLS && col < TILES.length; - col++ - ) { - tile(ops, TILES[col], ctx); + for (let t of r) { + tile(ops, t, ctx); } ops.push(close()); } - let hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.name}`)); - let status = hoveredTile ? `hover: ${hoveredTile.name}` : "hover: —"; - let statusColor = hoveredTile ? hoveredTile.hue : dim; + // 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 hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.shape}`)); + let hoveredShape: string | undefined; + let hoveredHue = dim; + if (hoveredTile) { + hoveredShape = "toggle" in hoveredTile && ctx.grabbing + ? hoveredTile.toggle + : hoveredTile.shape; + hoveredHue = hoveredTile.hue; + } + let indicator = ctx.capsOn ? "●" : "■"; + let indicatorColor = ctx.capsOn ? BRAND.green : BRAND.red; ops.push( open("status", { layout: { width: fixed(STATUS_W), height: fixed(1), + direction: "ltr", padding: { top: 1 }, }, }), - text( - `${status} · pointer shapes: ${ctx.capsOn ? "on" : "off"} · esc quits`, - { color: statusColor }, - ), + 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: { height: fixed(1) } }), + text("esc quits", { color: dim }), close(), ); From b137a827b1b187fdaa19764cfef30ee49fa9b5e0 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 16:49:26 -0500 Subject: [PATCH 10/17] feat(examples): grab tile switches on pointer down/up, not click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holding the pointer down on the grab tile shows grabbing; releasing returns to grab — real press-and-hold semantics. Wiring: the input parser's mousedown/mouseup (SGR mouse, verified 0-based coords) plus the renderer's existing hit-test tracking — a press counts only when the grab tile is in the entered set, and the state persists until mouseup, so dragging off and back re-resolves grabbing without a re-press. Mid-drag the cursor reverts to default while off-tile, per §7.9's hit-test resolution (the label persists); drag-persistent cursors would need a spec'd render option and are out of scope. --- examples/README.md | 4 ++-- examples/cursor/index.ts | 15 +++++++++++---- examples/cursor/view.ts | 4 ++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/examples/README.md b/examples/README.md index 877f7a1..41f4d43 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,8 +47,8 @@ What it shows: border-only rounded tiles in Bombshell brand hues, declaring their `pointerShape` (renderer-spec §7.9); hovering a tile shows that exact cursor in terminals that support OSC 22 -- the grab tile is double-wide: clicking toggles it between grab and grabbing - (cursor and label), staying orange +- the grab tile is double-wide: holding the pointer down on it shows grabbing, + releasing returns to grab (cursor and label), staying orange throughout - 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 diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index ab0b73a..10870d2 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -8,8 +8,10 @@ * - 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: clicking toggles it between grab and - * grabbing (cursor and label), staying orange. + * - The grab tile is double-wide: holding the pointer down on it shows + * grabbing, releasing returns to grab — real drag semantics, tracked + * through the renderer's hit-test (pointerenter/pointerleave) plus the + * parser's mousedown/mouseup. * - Curated to the 9 shapes confirmed working in ghostty 1.3.1; ghostty * drops help, progress, wait, move, zoom-in, zoom-out, and none. * @@ -111,8 +113,13 @@ await main(function* () { if (event.type === "pointerleave") { ctx.entered.delete(event.id); } - if (event.type === "pointerclick" && event.id === "shape:grab") { - ctx.grabbing = !ctx.grabbing; + 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 = { diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index e25a206..1da76d3 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -6,8 +6,8 @@ * 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 toggles between grab and grabbing on click, staying - * orange. + * is double-wide and shows grabbing while the pointer is held down on it, + * returning to grab on release, staying orange throughout. */ import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; From 1e3ead2c8dc716e50903da1b9f498d737b32528b Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 16:36:50 -0500 Subject: [PATCH 11/17] chore(examples): cursor demo title --- examples/cursor/view.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 1da76d3..e911e51 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -109,7 +109,7 @@ export function frame(ctx: Ctx): Op[] { ops.push( open("header", { layout: { height: fixed(1) } }), - text("cursor shapes — hover a tile", { color: heading }), + text("cursor shape - hover a tile", { color: heading }), close(), ); From 4c39a818d4132f71e9aa726db7a23f6ff0778be9 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 16:54:58 -0500 Subject: [PATCH 12/17] feat(examples): drag shield keeps the grabbing cursor anywhere while held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dragging off the grab tile previously reverted the cursor to default, since §7.9 resolves the shape by hit-testing the pointer position. That is the CSS-behavior-correct rule, so the fix lives in userland, not the spec: while held, the demo appends a capture-mode floating overlay that declares grabbing across the viewport. The shield wins the hit test everywhere, freezing hover beneath it exactly like browsers freeze :hover during a drag, and disappears on release. Verified headlessly through the real parser and renderer: grab → grabbing on press, no change dragging to the far corner, default on release in empty space, state clean after. --- examples/README.md | 5 ++-- examples/cursor/index.ts | 13 ++++++---- examples/cursor/view.ts | 54 ++++++++++++++++++++++++++++++++++------ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/examples/README.md b/examples/README.md index 41f4d43..e48e192 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,8 +47,9 @@ What it shows: border-only rounded tiles in Bombshell brand hues, declaring their `pointerShape` (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 on it shows grabbing, - releasing returns to grab (cursor and label), staying orange throughout +- 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 - 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 diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 10870d2..66e8361 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -9,9 +9,9 @@ * 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, releasing returns to grab — real drag semantics, tracked - * through the renderer's hit-test (pointerenter/pointerleave) plus the - * parser's mousedown/mouseup. + * grabbing anywhere on screen while held (a capture-mode drag shield — + * userland drag persistence, no spec support needed), releasing returns + * to grab. * - Curated to the 9 shapes confirmed working in ghostty 1.3.1; ghostty * drops help, progress, wait, move, zoom-in, zoom-out, and none. * @@ -40,7 +40,7 @@ import { import { useInput } from "../use-input.ts"; import { useStdin } from "../use-stdin.ts"; import { optInPointerShapes } from "../pointer-shape-opt-in.ts"; -import { type Ctx, frame } from "./view.ts"; +import { type Ctx, frame, withDragShield } from "./view.ts"; await main(function* () { let { columns, rows } = terminalSize(); @@ -131,7 +131,10 @@ await main(function* () { ctx.capsOn = term.capabilities.pointerShape; - let { output, events } = term.render(frame(ctx), { pointer: ctx.pointer }); + let { output, events } = term.render( + withDragShield(frame(ctx), columns, rows, ctx.grabbing), + { pointer: ctx.pointer }, + ); for (let e of events) { yield* pointerEvents.send(e); } diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index e911e51..f290660 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -6,8 +6,10 @@ * 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, - * returning to grab on release, staying orange throughout. + * 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. */ import { close, fixed, grow, type Op, open, rgba, text } from "../../mod.ts"; @@ -62,6 +64,38 @@ const TILES = [ 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", + }, + pointerShape: "grabbing", + }), + close(), + ); + return out; +} + function tile(ops: Op[], t: Tile, ctx: Ctx): void { let id = `shape:${t.shape}`; let hovered = ctx.entered.has(id); @@ -142,14 +176,18 @@ export function frame(ctx: Ctx): Op[] { // 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 hoveredTile = TILES.find((t) => ctx.entered.has(`shape:${t.shape}`)); let hoveredShape: string | undefined; let hoveredHue = dim; - if (hoveredTile) { - hoveredShape = "toggle" in hoveredTile && ctx.grabbing - ? hoveredTile.toggle - : hoveredTile.shape; - hoveredHue = hoveredTile.hue; + 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; From 2dea5a1daaf91bac80c169140b376b064444c443 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 16:58:26 -0500 Subject: [PATCH 13/17] =?UTF-8?q?feat(examples):=20@bomb.sh/tty=20?= =?UTF-8?q?=E2=96=AA=20cursor=20title=20bar=20in=20the=20cursor=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header becomes a brand title bar spanning the grid width: "@bomb.sh/tty" on the left, "cursor" on the right, and a run of ▪ characters filling exactly the space between (computed from the fixed bar width, so the bar is always 53 cells). Both bars — title and status — share the grid-derived width constant, now BAR_W, keeping every edge aligned and flicker-free. --- examples/cursor/view.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index f290660..161afea 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -34,10 +34,13 @@ const dim = rgba(137, 142, 156); const TILE_W = 17; const COLS = 3; -// Matches the grid width so the status line's left edge is stable: the -// hovered name changes length, and a fit-sized line would re-center and +// 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 STATUS_W = COLS * TILE_W + (COLS - 1); +const BAR_W = COLS * TILE_W + (COLS - 1); + +const PKG = "@bomb.sh/tty"; +const DEMO = "cursor"; export interface Ctx { entered: Set; @@ -142,8 +145,18 @@ export function frame(ctx: Ctx): Op[] { ); ops.push( - open("header", { layout: { height: fixed(1) } }), - text("cursor shape - hover a tile", { color: heading }), + open("header", { + layout: { + width: fixed(BAR_W), + height: fixed(1), + direction: "ltr", + }, + }), + text(`${PKG} `, { color: heading }), + text("▪".repeat(Math.max(0, BAR_W - PKG.length - DEMO.length - 2)), { + color: dim, + }), + text(` ${DEMO}`, { color: heading }), close(), ); @@ -194,7 +207,7 @@ export function frame(ctx: Ctx): Op[] { ops.push( open("status", { layout: { - width: fixed(STATUS_W), + width: fixed(BAR_W), height: fixed(1), direction: "ltr", padding: { top: 1 }, From 30f69d98c4d0906e20be67061e50f8a617b5af88 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 17:04:56 -0500 Subject: [PATCH 14/17] feat(examples): ambient brightness wave in the cursor demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle tiles breathe on a phase-offset sine: borders shade between ×0.85 and ×1.75 of their base gray, labels subtler, so a ripple of light sweeps the grid in reading order (~4s cycle). Hovered and grabbed tiles keep their full hue treatment instead. A ticker resource drives ~12fps re-renders (the renderer diffs, so only the modulated border/label cells emit); ctx.now carries wall-clock ms into the frame. The ticker provides the channel's subscription, not the channel itself — merge() pulls via .next(), and providing the bare channel crashed the demo with "subscription.b.next is not a function". --- examples/README.md | 3 +++ examples/cursor/index.ts | 43 +++++++++++++++++++++++++++++++++++++++- examples/cursor/view.ts | 30 +++++++++++++++++++++++----- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/examples/README.md b/examples/README.md index e48e192..81136d2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,6 +50,9 @@ What it shows: - 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 sweeps the tiles: idle borders and labels breathe + on a phase-offset sine, so a ripple of light crosses the grid (~12fps ticker; + hovered and grabbed tiles keep their full hue) - 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 diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 66e8361..6e3633e 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -12,6 +12,10 @@ * 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 sweeps the tiles: idle borders and labels + * breathe on a phase-offset sine (~4s cycle, ~12fps ticker), so a ripple + * of light crosses the grid. Hovered and grabbed tiles keep their full + * hue treatment. * - Curated to the 9 shapes confirmed working in ghostty 1.3.1; ghostty * drops help, progress, wait, move, zoom-in, zoom-out, and none. * @@ -25,8 +29,11 @@ import { each, ensure, main, + type Operation, race, resource, + sleep, + spawn, type Stream, until, } from "effection"; @@ -73,9 +80,11 @@ await main(function* () { 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 @@ -87,6 +96,7 @@ await main(function* () { pointer: undefined, capsOn: false, grabbing: false, + now: 0, }), ).output, ); @@ -94,10 +104,11 @@ await main(function* () { writeStdout(tty.revert); }); + ctx.now = performance.now(); let { output } = term.render(frame(ctx)); writeStdout(output); - for (let event of yield* each(merge(input, pointerEvents))) { + for (let event of yield* each(merge(merge(input, pointerEvents), ticks))) { if (event.type === "keydown" && event.ctrl && event.key === "c") { break; } @@ -130,6 +141,7 @@ await main(function* () { } ctx.capsOn = term.capabilities.pointerShape; + ctx.now = performance.now(); let { output, events } = term.render( withDragShield(frame(ctx), columns, rows, ctx.grabbing), @@ -163,6 +175,35 @@ 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, diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 161afea..5998606 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -9,11 +9,24 @@ * 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. + * staying orange throughout. Idle tiles breathe with a brightness wave: a + * phase-offset sine sweeps the grid so a ripple of light crosses it. */ 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 = { @@ -48,6 +61,8 @@ export interface Ctx { 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 @@ -99,10 +114,14 @@ export function withDragShield( return out; } -function tile(ops: Op[], t: Tile, ctx: Ctx): void { +function tile(ops: Op[], t: Tile, idx: number, ctx: Ctx): void { let id = `shape:${t.shape}`; let hovered = ctx.entered.has(id); let shape = "toggle" in t && ctx.grabbing ? t.toggle : t.shape; + // Ambient brightness wave: each idle tile breathes on a phase-offset sine, + // sweeping the grid in reading order (~4s per cycle). Hovered and grabbed + // tiles keep their full hue treatment instead. + let wave = 0.5 + 0.5 * Math.sin(ctx.now / 650 - idx * 0.55); ops.push( open(id, { layout: { @@ -113,7 +132,7 @@ function tile(ops: Op[], t: Tile, ctx: Ctx): void { alignY: "center", }, border: { - color: hovered ? t.hue : borderIdle, + color: hovered ? t.hue : shade(borderIdle, 0.85 + wave * 0.9), left: 1, right: 1, top: 1, @@ -122,7 +141,7 @@ function tile(ops: Op[], t: Tile, ctx: Ctx): void { cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, pointerShape: shape, }), - text(shape, { color: hovered ? t.hue : label }), + text(shape, { color: hovered ? t.hue : shade(label, 0.85 + wave * 0.3) }), close(), ); } @@ -176,12 +195,13 @@ export function frame(ctx: Ctx): Op[] { } if (row.length > 0) rows.push(row); + let idx = 0; 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); + tile(ops, t, idx++, ctx); } ops.push(close()); } From c579ca71dd1355c5e546d1eb76862b95e5941a89 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 17:10:09 -0500 Subject: [PATCH 15/17] feat(examples): brightness wave on the title spacers, hover persists while dragging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wave moves off the tiles onto the ▪ spacer run in the title bar: each square breathes on a phase-offset sine (shade ×0.85–×1.5 of the brand gray), so a ripple of light travels the bar (~4s per square cycle); each square is its own text op, so the renderer diffs and only re-emits squares whose brightness moved. Tiles return to static colors. While the drag shield is up, capture mode swallows the grab tile's pointerenter, so the panel's hover treatment is now persisted for the whole hold: the grabbed tile keeps its orange ring and label (and the status line's hover label keeps its hue) until the button is released. --- examples/README.md | 6 +++--- examples/cursor/index.ts | 7 +++---- examples/cursor/view.ts | 35 ++++++++++++++++++----------------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/examples/README.md b/examples/README.md index 81136d2..2924115 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,9 +50,9 @@ What it shows: - 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 sweeps the tiles: idle borders and labels breathe - on a phase-offset sine, so a ripple of light crosses the grid (~12fps ticker; - hovered and grabbed tiles keep their full hue) +- 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 diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 6e3633e..01a0680 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -12,10 +12,9 @@ * 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 sweeps the tiles: idle borders and labels - * breathe on a phase-offset sine (~4s cycle, ~12fps ticker), so a ripple - * of light crosses the grid. Hovered and grabbed tiles keep their full - * hue treatment. + * - 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. * diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 5998606..398a832 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -9,8 +9,8 @@ * 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. Idle tiles breathe with a brightness wave: a - * phase-offset sine sweeps the grid so a ripple of light crosses it. + * 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"; @@ -114,14 +114,13 @@ export function withDragShield( return out; } -function tile(ops: Op[], t: Tile, idx: number, ctx: Ctx): void { +function tile(ops: Op[], t: Tile, ctx: Ctx): void { let id = `shape:${t.shape}`; - let hovered = ctx.entered.has(id); + // 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; - // Ambient brightness wave: each idle tile breathes on a phase-offset sine, - // sweeping the grid in reading order (~4s per cycle). Hovered and grabbed - // tiles keep their full hue treatment instead. - let wave = 0.5 + 0.5 * Math.sin(ctx.now / 650 - idx * 0.55); ops.push( open(id, { layout: { @@ -132,7 +131,7 @@ function tile(ops: Op[], t: Tile, idx: number, ctx: Ctx): void { alignY: "center", }, border: { - color: hovered ? t.hue : shade(borderIdle, 0.85 + wave * 0.9), + color: hovered ? t.hue : borderIdle, left: 1, right: 1, top: 1, @@ -141,7 +140,7 @@ function tile(ops: Op[], t: Tile, idx: number, ctx: Ctx): void { cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, pointerShape: shape, }), - text(shape, { color: hovered ? t.hue : shade(label, 0.85 + wave * 0.3) }), + text(shape, { color: hovered ? t.hue : label }), close(), ); } @@ -172,12 +171,15 @@ export function frame(ctx: Ctx): Op[] { }, }), text(`${PKG} `, { color: heading }), - text("▪".repeat(Math.max(0, BAR_W - PKG.length - DEMO.length - 2)), { - color: dim, - }), - text(` ${DEMO}`, { color: heading }), - close(), ); + // Brightness wave: the ▪ spacers breathe on a phase-offset sine, so a + // ripple of light travels the bar (~4s per square cycle, ~4s to cross it). + let fill = Math.max(0, BAR_W - PKG.length - DEMO.length - 2); + for (let i = 0; i < fill; i++) { + let wave = 0.5 + 0.5 * Math.sin(ctx.now / 650 - i * 0.19); + ops.push(text("▪", { color: shade(dim, 0.85 + wave * 0.65) })); + } + ops.push(text(` ${DEMO}`, { color: heading }), close()); // Pack tiles into rows of COLS units; the toggle tile spans two. let rows: Tile[][] = []; @@ -195,13 +197,12 @@ export function frame(ctx: Ctx): Op[] { } if (row.length > 0) rows.push(row); - let idx = 0; for (let r of rows) { ops.push( open("", { layout: { direction: "ltr", gap: 1, height: fixed(3) } }), ); for (let t of r) { - tile(ops, t, idx++, ctx); + tile(ops, t, ctx); } ops.push(close()); } From b1d7b975357b5c7ffc0d22315e3e30b038d947d3 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 9 Oct 2026 17:15:53 -0500 Subject: [PATCH 16/17] feat(examples): title text joins the brightness wave; keycap hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole title bar breathes now — "@bomb.sh/tty", the ▪ spacers, and "cursor" shade on one shared phase-offset sine (one cycle across the bar, ~4s travel). Brand text gets a dim-biased range (near-white can only dim; the wave peak is its static look) and spaces are emitted as static cells so per-character ops stay aligned. The [esc] hint moves to the left edge of the bar width (aligned with @bomb.sh/tty and the status line) with one blank row above it — measured in the rendered output: hover at row 19, hint at row 21. --- examples/cursor/view.ts | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index 398a832..df3755b 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -170,16 +170,27 @@ export function frame(ctx: Ctx): Op[] { direction: "ltr", }, }), - text(`${PKG} `, { color: heading }), ); - // Brightness wave: the ▪ spacers breathe on a phase-offset sine, so a - // ripple of light travels the bar (~4s per square cycle, ~4s to cross it). - let fill = Math.max(0, BAR_W - PKG.length - DEMO.length - 2); - for (let i = 0; i < fill; i++) { - let wave = 0.5 + 0.5 * Math.sin(ctx.now / 650 - i * 0.19); - ops.push(text("▪", { color: shade(dim, 0.85 + wave * 0.65) })); + // 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(text(` ${DEMO}`, { color: heading }), close()); + ops.push(close()); // Pack tiles into rows of COLS units; the toggle tile spans two. let rows: Tile[][] = []; @@ -246,8 +257,14 @@ export function frame(ctx: Ctx): Op[] { ); ops.push( - open("hint", { layout: { height: fixed(1) } }), - text("esc quits", { color: dim }), + open("hint", { + layout: { + width: fixed(BAR_W), + height: fixed(1), + padding: { top: 1 }, + }, + }), + text("[esc]", { color: dim }), close(), ); From 19de122882b06225c9edb0ae49fdad9b22ba2fbc Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sun, 11 Oct 2026 13:32:17 -0500 Subject: [PATCH 17/17] feat(examples): name the open() cursor property after CSS (was pointerShape) Matches the functional rename on feat/pointer-shape: tiles, buttons, keys, the text field, and the drag shield now declare cursor: instead of pointerShape:. Capability-level names (capabilities.pointerShape, the pointer-shape CapabilityEvent key, optInPointerShapes) stay. --- examples/2048/primitives/button.ts | 2 +- examples/README.md | 6 +++--- examples/cursor/index.ts | 2 +- examples/cursor/view.ts | 6 +++--- examples/keyboard/index.ts | 2 +- examples/text-input/index.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/2048/primitives/button.ts b/examples/2048/primitives/button.ts index 5b345c2..97e52fd 100644 --- a/examples/2048/primitives/button.ts +++ b/examples/2048/primitives/button.ts @@ -111,7 +111,7 @@ export function button( // 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. - pointerShape: state.disabled ? "not-allowed" : "pointer", + cursor: state.disabled ? "not-allowed" : "pointer", }), text(label, { color: fg }), close(), diff --git a/examples/README.md b/examples/README.md index 2924115..af4930c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,9 +44,9 @@ 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 - `pointerShape` (renderer-spec §7.9); hovering a tile shows that exact cursor - in terminals that support OSC 22 + 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 diff --git a/examples/cursor/index.ts b/examples/cursor/index.ts index 01a0680..65daf8f 100644 --- a/examples/cursor/index.ts +++ b/examples/cursor/index.ts @@ -1,7 +1,7 @@ /** * cursor — a shape catalog for the OSC 22 pointer-shape protocol. * - * - Each tile declares a different `pointerShape` (renderer-spec §7.9), so + * - 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. diff --git a/examples/cursor/view.ts b/examples/cursor/view.ts index df3755b..e4b4c30 100644 --- a/examples/cursor/view.ts +++ b/examples/cursor/view.ts @@ -1,7 +1,7 @@ /** * cursor — the tile grid view for examples/cursor/index.ts. * - * Each tile declares a different `pointerShape` (renderer-spec §7.9), so + * 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, @@ -107,7 +107,7 @@ export function withDragShield( attachPoints: { element: "left-top", parent: "left-top" }, pointerCaptureMode: "capture", }, - pointerShape: "grabbing", + cursor: "grabbing", }), close(), ); @@ -138,7 +138,7 @@ function tile(ops: Op[], t: Tile, ctx: Ctx): void { bottom: 1, }, cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, - pointerShape: shape, + cursor: shape, }), text(shape, { color: hovered ? t.hue : label }), close(), diff --git a/examples/keyboard/index.ts b/examples/keyboard/index.ts index 4cafb0b..cdb8482 100644 --- a/examples/keyboard/index.ts +++ b/examples/keyboard/index.ts @@ -249,7 +249,7 @@ function key(ops: Op[], k: KeyDef, ctx: AppContext): void { border: hover ? { color: highlight, left: 1, right: 1, top: 1, bottom: 1 } : undefined, - pointerShape: "pointer", + cursor: "pointer", }), text(k.label, { color: hover ? highlight : label }), close(), diff --git a/examples/text-input/index.ts b/examples/text-input/index.ts index af56aa4..ba24bbd 100644 --- a/examples/text-input/index.ts +++ b/examples/text-input/index.ts @@ -143,7 +143,7 @@ function frame(value: string, caret: number): Op[] { padding: { left: 1, right: 1 }, }, bg: inputBg, - pointerShape: "text", + cursor: "text", }), text(value, { color: label, caret }), close(),