diff --git a/.changeset/terminfo-capability-layer.md b/.changeset/terminfo-capability-layer.md new file mode 100644 index 0000000..b18711b --- /dev/null +++ b/.changeset/terminfo-capability-layer.md @@ -0,0 +1,33 @@ +--- +'@bomb.sh/tty': minor +--- + +Adds `detectTerminal()`, `TerminalInfo`, `Capabilities`, `DetectOptions`, `KeyTable`, and `MAX_TERMINFO_ENTRY` to the public API, and a `terminfo` option to `createTerm`. + +`detectTerminal()` reads the compiled terminfo entry for the current terminal (from the ncurses search path, or from bytes passed as `entry`), applies environment evidence (`COLORTERM`), and resolves a frozen `TerminalInfo` carrying static `capabilities`, a `probe` query batch to write to stdout, and opaque `keys` for the input parser. + +Pass the returned `TerminalInfo` as `terminfo` to `createTerm` and `createInput`. `term.capabilities` exposes the renderer's current capability snapshot, seeded from `terminfo.capabilities` (or the 256-color baseline when omitted). + +Changes `term.update()` to accept an array of `InputEvent` values instead of `{ events }` or `{ width, height }`. Resizes are now `ResizeEvent`s tagged `type: "resize"`; capability events from `scan()` are folded into `term.capabilities`; all other input events are no-ops, so the full `events` array from `scan()` can be passed straight through. `update()` now returns a `Uint8Array` of bytes to write immediately (empty when there are none). + +#### Migration + +```diff +-term.update({ events }); ++const out = term.update(events); ++if (out.length) process.stdout.write(out); +``` + +```diff +-term.update({ width, height }); ++term.update([{ type: "resize", width, height }]); +``` + +To opt in to terminfo-based capability detection: + +```diff ++const terminfo = await detectTerminal({ env: process.env }); + const term = await createTerm({ width, height, terminfo }); + const input = await createInput({ terminfo }); ++process.stdout.write(terminfo.probe); +``` diff --git a/specs/renderer-spec.md b/specs/renderer-spec.md index 836fc45..0e1d6ff 100644 --- a/specs/renderer-spec.md +++ b/specs/renderer-spec.md @@ -418,22 +418,22 @@ The update transaction changes a Term instance's dimensions or capability state in place. Like the render transaction (§7.2), it is synchronous: it MUST NOT yield, suspend, or require callbacks during execution. -**Inputs.** The update transaction accepts one `Update` or an ordered array of -`Update` values. `Update` is a discriminated union: +**Inputs.** The update transaction accepts an ordered array of `InputEvent` +values (see [Input Specification](input-spec.md) §5), discriminated by `type`: -- `{ width: number; height: number }` — a resize to the given character-cell - dimensions. Both MUST be positive integers; the transaction MUST throw - otherwise. +- A `ResizeEvent` (`{ type: "resize"; width: number; height: number }`) — a + resize to the given character-cell dimensions. Both MUST be positive integers; + the transaction MUST throw otherwise. - A `CapabilityEvent` (see [Terminfo Specification](terminfo-spec.md) §6.3) — a capability value delivered by the input parser from a probe response. -- Any other `InputEvent` (see [Input Specification](input-spec.md) §5) — a no-op - step. It changes no state and contributes no bytes. +- Any other `InputEvent` — a no-op step. It changes no state and contributes no + bytes. -When a batch is provided, the Term folds each `Update` in order. The returned -bytes are the concatenation of each fold's output. +The Term folds each event in array order. The returned bytes are the +concatenation of each fold's output. An empty array is a no-op. -A resize `Update` whose target dimensions equal the Term's current dimensions -MUST be a no-op for that step. +A resize event whose target dimensions equal the Term's current dimensions MUST +be a no-op for that step. **Resize semantics.** A non-no-op resize step: @@ -672,39 +672,34 @@ wherever the directive model expects a color. ### 8.6 Term update ``` -term.update(change: Update | readonly Update[]): Uint8Array - -type Update = - | { width: number; height: number } - | InputEvent +term.update(events: readonly InputEvent[]): Uint8Array ``` Performs an update transaction as defined in §7.7. `update()` is the universal sink for both resize and capability change. -**`Update` shapes.** A resize step is `{ width, height }`. A capability step is -any `CapabilityEvent` value (see [Terminfo Specification](terminfo-spec.md) -§6.3). The two shapes are structurally distinct and MUST NOT be combined in a -single object. Every other `InputEvent` is accepted and ignored, so the full -`events` array from `input.scan()` can be passed without filtering. Pass an -array to apply multiple updates in one call; they are folded in order. +**Events.** A resize step is a `ResizeEvent` +(`{ type: "resize", width, height }`). A capability step is any +`CapabilityEvent` value (see [Terminfo Specification](terminfo-spec.md) §6.3). +Every other `InputEvent` is accepted and ignored, so the full `events` array +from `input.scan()` can be passed without filtering. Events are folded in array +order. **Return value.** `update()` always returns a `Uint8Array`. Write it to the terminal immediately when non-empty. Do not wait for the next `render()`. An empty array means the update changed no rendered state. -**Resize shape.** The `{ width, height }` shape is defined structurally by this -specification. It is intentionally assignable from the input specification's -`ResizeEvent`, so events from `input.scan()` pass through directly: +**Host usage.** Events from `input.scan()` pass through directly. Resizes +observed outside the input stream (e.g. `SIGWINCH`) are passed as a constructed +`ResizeEvent`: ``` const { events } = input.scan(bytes); -for (const event of events) { - if (event.type === "resize" || event.type === "capability") { - const out = term.update(event); - if (out.length) stdout.write(out); - } -} +const out = term.update(events); +if (out.length) stdout.write(out); + +// on SIGWINCH +term.update([{ type: "resize", width: cols(), height: rows() }]); ``` A resize to the Term's current dimensions is a no-op for that step. @@ -1144,6 +1139,14 @@ generated module and instantiated per Term or Input with fresh memory. renderer state struct and the transfer buffer are allocated in WASM linear memory. The specific layout is an implementation detail. +**Capability state.** `RuntimeCapabilities` is currently held in TypeScript and +folded by `update()`; the WASM renderer state carries no capability fields +because no renderer output depends on them yet (§7.8). When a +capability-consuming feature lands, the authoritative state is expected to move +into WASM linear memory alongside the renderer state, so that `render()` reads +it without per-frame transfer; `term.capabilities` remains a frozen snapshot +decoded on access. + **Layout engine.** The underlying layout engine is Clay, included as a dependency. Clay provides flexbox-like layout computation with support for fixed, grow, and fit sizing; padding; alignment; direction; gap; floating diff --git a/specs/terminfo-spec.md b/specs/terminfo-spec.md index 8c8c870..d800909 100644 --- a/specs/terminfo-spec.md +++ b/specs/terminfo-spec.md @@ -511,19 +511,15 @@ When omitted, the parser uses built-in xterm key sequences. ```ts interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; - update(change: Update | readonly Update[]): Uint8Array; + update(events: readonly InputEvent[]): Uint8Array; readonly capabilities: RuntimeCapabilities; } - -type Update = - | { width: number; height: number } - | InputEvent; ``` -`update()` accepts one change or a batch. `InputEvent` values other than +`update()` accepts an array of `InputEvent` values. Events other than `ResizeEvent` and `CapabilityEvent` are no-op steps, so the full `events` array -from `scan()` can be passed without filtering. A batch is folded in order: each -`Update` produces the next `RuntimeCapabilities` and any bytes defined by the +from `scan()` can be passed without filtering. Events are folded in order: each +event produces the next `RuntimeCapabilities` and any bytes defined by the consuming feature, and the returned bytes are concatenated. The foundation defines no such bytes. The return value is always a `Uint8Array`; callers write it to their output stream when non-empty (TINV-5). @@ -545,22 +541,13 @@ process.stdout.write(terminfo.probe); process.stdin.on("data", (bytes: Uint8Array) => { const { events } = input.scan(bytes); - for (const event of events) { - switch (event.type) { - case "capability": - case "resize": { - const out = term.update(event); - if (out.length) process.stdout.write(out); - break; - } - default: - dispatch(event); - } - } + const out = term.update(events); + if (out.length) process.stdout.write(out); + for (const event of events) dispatch(event); }); process.on("SIGWINCH", () => { - const out = term.update({ width: cols(), height: rows() }); + const out = term.update([{ type: "resize", width: cols(), height: rows() }]); if (out.length) process.stdout.write(out); }); ``` diff --git a/src/terminfo.c b/src/terminfo.c index d2f4a9f..fec4d67 100644 --- a/src/terminfo.c +++ b/src/terminfo.c @@ -1,4 +1,4 @@ -/* terminfo.c — shared terminal capability layer */ +/* terminfo.c — terminal capability layer */ #include "terminfo.h" diff --git a/src/terminfo.h b/src/terminfo.h index 1fb233a..0b7c173 100644 --- a/src/terminfo.h +++ b/src/terminfo.h @@ -1,9 +1,10 @@ -/* terminfo.h — shared terminal capability layer +/* terminfo.h — terminal capability layer * * Implements the capability struct and terminfo binary parsing defined - * by specs/terminfo-spec.md. The renderer reads the struct; the input - * parser writes probe responses into it; this module owns the baseline - * and the parse path. + * by specs/terminfo-spec.md. detectTerminal() uses the struct to resolve + * static capabilities; the renderer and the input parser share no + * memory through it (terminfo-spec 4.2, TINV-6). This module owns the + * baseline and the parse path. */ #ifndef TERMINFO_H @@ -29,8 +30,7 @@ #define TERMINFO_THEME_CURSOR (1u << 14) /* Probe-fence marker: set in `confirmed` (never in `flags`) when a DA1 - * device attributes report is recognized. The queryTermInfo probe - * window uses it to detect completion. */ + * device attributes report is recognized. */ #define TERMINFO_DA1 (1u << 31) struct TermInfo { diff --git a/term.ts b/term.ts index 174f311..5326daa 100644 --- a/term.ts +++ b/term.ts @@ -1,44 +1,102 @@ import { type Op, pack } from "./ops.ts"; import { type BoundingBox, createTermNative } from "./term-native.ts"; +import type { InputEvent } from "./input.ts"; +import type { Capabilities, Rgb, TerminalInfo } from "./terminfo.ts"; + +export type { BoundingBox }; export interface TermOptions { height: number; width: number; + /** + * Terminal info from detectTerminal(). Seeds term.capabilities from + * terminfo.capabilities. When omitted, the renderer uses the 256-color + * baseline. + */ + terminfo?: TerminalInfo; } /** - * Structural resize event accepted by update() (renderer-spec 7.7). - * The input parser's ResizeEvent is assignable to this shape. + * The renderer's merged view of capabilities: the static Capabilities fields + * plus all CapabilityEvent values folded in by update() calls. */ -export interface TermResizeEvent { - type: "resize"; - width: number; - height: number; +export interface RuntimeCapabilities extends Capabilities { + readonly syncOutput: boolean; + readonly kittyKeyboard: boolean; + readonly kittyGraphics: boolean; + readonly pointerShape: boolean; + readonly theme: { + readonly foreground?: Rgb; + readonly background?: Rgb; + readonly cursor?: Rgb; + }; } /** - * Options bag for update() (renderer-spec 8.6): explicit dimensions, - * or an event array from which resize events are read (last one wins; - * non-resize events are ignored). + * Fold one InputEvent into the current RuntimeCapabilities and return the next + * snapshot plus any bytes to write now. Pure: performs no IO, no WASM calls. */ -export type UpdateOptions = - | { width: number; height: number } - | { events: ReadonlyArray }; +function applyUpdate( + current: RuntimeCapabilities, + event: InputEvent, +): { readonly next: RuntimeCapabilities; readonly bytes: Uint8Array } { + if (event.type !== "capability") { + return { next: current, bytes: new Uint8Array(0) }; + } + let next: RuntimeCapabilities; + switch (event.key) { + case "foreground-color": + next = { + ...current, + theme: { ...current.theme, foreground: event.value }, + }; + break; + case "background-color": + next = { + ...current, + theme: { ...current.theme, background: event.value }, + }; + break; + case "cursor-color": + next = { ...current, theme: { ...current.theme, cursor: event.value } }; + break; + case "colordepth": { + let trueColor = event.value === "truecolor"; + next = { ...current, trueColor }; + break; + } + case "sync-output": + next = { ...current, syncOutput: event.value }; + break; + case "kitty-keyboard": + next = { ...current, kittyKeyboard: event.value }; + break; + case "kitty-graphics": + next = { ...current, kittyGraphics: event.value }; + break; + case "pointer-shape": + next = { ...current, pointerShape: event.value }; + break; + default: + next = current; + } + return { next: Object.freeze(next), bytes: new Uint8Array(0) }; +} + +function runtimeFromStatic(caps: Capabilities): RuntimeCapabilities { + return Object.freeze({ + ...caps, + syncOutput: false, + kittyKeyboard: false, + kittyGraphics: false, + pointerShape: false, + theme: Object.freeze({}), + }); +} export interface RenderOptions { mode?: "line"; - - /** - * Row where to begin rendering. This should only be used when - * rendering into a region as part of the CLI main screen. For - * interfaces that use the entire screen, leave unset which will - * default to 0. This is 1-based which which is the DSR native - * format. - * - * https://www.ecma-international.org/publications-and-standards/standards/ecma-48/ - */ row?: number; - pointer?: { x: number; y: number; @@ -52,8 +110,6 @@ export type PointerEvent = | { type: "pointerleave"; id: string } | { type: "pointerclick"; id: string }; -export type { BoundingBox }; - export interface ElementInfo { bounds: BoundingBox; } @@ -93,18 +149,39 @@ export interface Term { render(ops: Op[], options?: RenderOptions): RenderResult; /** - * Change dimensions in place (renderer-spec 7.7). Synchronous. The - * next render() after a non-no-op update emits a complete redraw, - * and output views from prior renders become invalid. + * Fold InputEvents in order. Returns bytes to write now. An empty array is + * valid when no immediate output is needed (TINV-5). + * + * Pass the events array from scan() directly. For an out-of-band resize, + * pass [{ type: "resize", width, height }]. */ - update(options: UpdateOptions): void; + update(events: readonly InputEvent[]): Uint8Array; + + /** Frozen snapshot of the current merged capability state. */ + readonly capabilities: RuntimeCapabilities; } export async function createTerm(options: TermOptions): Promise { - let { width, height } = options; - let native = await createTermNative(width, height); + let { width, height, terminfo } = options; + + let native = await createTermNative( + width, + height, + ); let { memory } = native; + let currentCaps: RuntimeCapabilities = runtimeFromStatic( + terminfo?.capabilities ?? { + colors: 256, + trueColor: false, + bce: true, + autoMargin: true, + xenl: true, + altScreen: true, + styledUnderline: false, + }, + ); + let prev = new Set(); let pressed = new Set(); let wasDown = false; @@ -112,6 +189,10 @@ export async function createTerm(options: TermOptions): Promise { let wasAnimating = false; return { + get capabilities(): RuntimeCapabilities { + return currentCaps; + }, + render(ops: Op[], options?: RenderOptions): RenderResult { let len = pack( ops, @@ -182,9 +263,7 @@ export async function createTerm(options: TermOptions): Promise { let info: RenderInfo = { get(id: string): ElementInfo | undefined { let bounds = native.getElementBounds(id); - if (bounds) { - return { bounds }; - } + if (bounds) return { bounds }; return undefined; }, }; @@ -203,40 +282,46 @@ export async function createTerm(options: TermOptions): Promise { wasAnimating = animating; return { output, events, info, errors, animating }; }, - update(options: UpdateOptions): void { - let w: number | undefined; - let h: number | undefined; - if ("events" in options) { - for (let e of options.events) { - if (e.type === "resize") { - let r = e as TermResizeEvent; - w = r.width; - h = r.height; + + update(events: readonly InputEvent[]): Uint8Array { + let out: Uint8Array[] = []; + + for (let c of events) { + let { next, bytes } = applyUpdate(currentCaps, c); + + if (c.type === "resize") { + let w = c.width; + let h = c.height; + if ( + !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 + ) { + throw new RangeError(`invalid terminal dimensions ${w}x${h}`); + } + if (w !== width || h !== height) { + width = w; + height = h; + native.update(width, height); + prev = new Set(); + pressed = new Set(); + wasDown = false; + lastRenderAt = undefined; + wasAnimating = false; } } - if (w === undefined || h === undefined) { - return; - } - } else { - w = options.width; - h = options.height; - } - if ( - !Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0 - ) { - throw new RangeError(`invalid terminal dimensions ${w}x${h}`); + + currentCaps = next; + if (bytes.length) out.push(bytes); } - if (w === width && h === height) { - return; + + if (out.length === 0) return new Uint8Array(0); + let total = out.reduce((n, b) => n + b.length, 0); + let result = new Uint8Array(total); + let offset = 0; + for (let b of out) { + result.set(b, offset); + offset += b.length; } - width = w; - height = h; - native.update(w, h); - prev = new Set(); - pressed = new Set(); - wasDown = false; - lastRenderAt = undefined; - wasAnimating = false; + return result; }, }; } diff --git a/test/term.test.ts b/test/term.test.ts index fe72c6c..7190fd6 100644 --- a/test/term.test.ts +++ b/test/term.test.ts @@ -1,6 +1,7 @@ // deno-lint-ignore-file no-control-regex import { beforeEach, describe, expect, it } from "./suite.ts"; import { createTerm, type Term } from "../term.ts"; +import type { InputEvent } from "../input.ts"; import { close, fixed, @@ -12,6 +13,7 @@ import { text, } from "../ops.ts"; import { print } from "./print.ts"; +import { trueColorDetect } from "./caps.ts"; const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); const trim = (s: string) => s.split("\n").map((l) => l.trimEnd()).join("\n"); @@ -53,8 +55,8 @@ describe("term", () => { ]).output, ); - // the SGR active when "h" is emitted should include the - // parent's red background (48;2;255;0;0), not terminal default + // The SGR active when "h" is emitted should include the parent's red + // background, not the terminal default. let before = ansi.slice(0, ansi.indexOf("h")); expect(before).toContain("\x1b[48;2;255;0;0"); }); @@ -737,6 +739,22 @@ hi }); }); + describe("capabilities", () => { + it("starts from the 256-color baseline without terminfo", () => { + expect(term.capabilities.colors).toBe(256); + expect(term.capabilities.trueColor).toBe(false); + }); + + it("seeds static capabilities from terminfo", async () => { + let terminfo = await trueColorDetect(); + let seeded = await createTerm({ width: 40, height: 10, terminfo }); + expect(seeded.capabilities.trueColor).toBe(true); + for (let [key, value] of Object.entries(terminfo.capabilities)) { + expect(seeded.capabilities).toHaveProperty(key, value); + } + }); + }); + describe("update", () => { let frame: Op[] = [ open("root", { @@ -749,7 +767,7 @@ hi it("emits a complete redraw on the first render after update", () => { term.render(frame); expect(term.render(frame).output.length).toBe(0); - term.update({ width: 20, height: 5 }); + term.update([{ type: "resize", width: 20, height: 5 }]); let out = decode(term.render(frame).output); expect(trim(print(out, 20, 5))).toContain("Hi"); expect(out.length).toBeGreaterThan(0); @@ -757,27 +775,27 @@ hi it("is a no-op when dimensions are unchanged", () => { term.render(frame); - term.update({ width: 40, height: 10 }); + term.update([{ type: "resize", width: 40, height: 10 }]); expect(term.render(frame).output.length).toBe(0); }); it("throws on non-positive or non-integer dimensions", () => { - expect(() => term.update({ width: 0, height: 10 })).toThrow(RangeError); - expect(() => term.update({ width: 40, height: -1 })).toThrow(RangeError); - expect(() => term.update({ width: 40.5, height: 10 })).toThrow( - RangeError, - ); - }); - - it("accepts an event array, last resize wins, non-resize ignored", () => { - term.update({ - events: [ - { type: "key" }, - { type: "resize", width: 30, height: 8 }, - { type: "paste" }, - { type: "resize", width: 12, height: 4 }, - ], - }); + expect(() => term.update([{ type: "resize", width: 0, height: 10 }])) + .toThrow(RangeError); + expect(() => term.update([{ type: "resize", width: 40, height: -1 }])) + .toThrow(RangeError); + expect(() => term.update([{ type: "resize", width: 40.5, height: 10 }])) + .toThrow( + RangeError, + ); + }); + + it("accepts an update array, last resize wins, non-resize updates applied", () => { + term.update([ + { type: "resize", width: 30, height: 8 }, + { type: "capability", key: "sync-output", value: false }, + { type: "resize", width: 12, height: 4 }, + ]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ x: 0, @@ -787,11 +805,30 @@ hi }); }); - it("treats an event array with no resize events as a no-op", () => { + it("treats an update array with no resize as a no-op for layout", () => { term.render(frame); - term.update({ events: [{ type: "key" }, { type: "paste" }] }); + term.update([{ type: "capability", key: "sync-output", value: false }]); expect(term.render(frame).output.length).toBe(0); - term.update({ events: [] }); + term.update([]); + expect(term.render(frame).output.length).toBe(0); + }); + + it('resizes only on events tagged type: "resize"', () => { + term.render(frame); + let untagged = { width: 12, height: 4 } as unknown as InputEvent; + term.update([untagged]); + expect(term.render(frame).output.length).toBe(0); + }); + + it("ignores input events that are neither resize nor capability", () => { + term.render(frame); + let before = term.capabilities; + let out = term.update([ + { type: "keydown", key: "a", code: "a", text: "a" }, + { type: "mousemove", button: "left", x: 1, y: 1 }, + ]); + expect(out).toEqual(new Uint8Array(0)); + expect(term.capabilities).toEqual(before); expect(term.render(frame).output.length).toBe(0); }); @@ -800,14 +837,14 @@ hi let first = term.render(frame, { pointer }); expect(first.events).toContainEqual({ type: "pointerenter", id: "root" }); expect(term.render(frame, { pointer }).events).toEqual([]); - term.update({ width: 20, height: 5 }); + term.update([{ type: "resize", width: 20, height: 5 }]); let after = term.render(frame, { pointer }); expect(after.events).toContainEqual({ type: "pointerenter", id: "root" }); }); it("resizes in place and lays out at the new dimensions", () => { term.render(frame); - term.update({ width: 12, height: 4 }); + term.update([{ type: "resize", width: 12, height: 4 }]); let result = term.render(frame); expect(result.info.get("root")?.bounds).toEqual({ x: 0, @@ -829,7 +866,7 @@ hi it("survives downsize then upsize past the original size", () => { term.render(frame); - term.update({ width: 10, height: 3 }); + term.update([{ type: "resize", width: 10, height: 3 }]); let small = term.render(frame); expect(small.info.get("root")?.bounds).toEqual({ x: 0, @@ -837,7 +874,7 @@ hi width: 10, height: 3, }); - term.update({ width: 120, height: 40 }); + term.update([{ type: "resize", width: 120, height: 40 }]); let large = term.render(frame); expect(large.info.get("root")?.bounds).toEqual({ x: 0, diff --git a/validate.ts b/validate.ts index e92c1e6..d98c94a 100644 --- a/validate.ts +++ b/validate.ts @@ -230,12 +230,15 @@ export function assert(ops: unknown): asserts ops is Op[] { export function validated(term: Term): Term { return { + get capabilities() { + return term.capabilities; + }, render(ops: Op[], options?: RenderOptions): RenderResult { assert(ops); return term.render(ops, options); }, - update(options) { - return term.update(options); + update(events) { + return term.update(events); }, }; }