From 7263241c903a28be3cdc494e4cb6384e018c2bd7 Mon Sep 17 00:00:00 2001 From: Softov Date: Thu, 27 Aug 2026 02:22:19 -0400 Subject: [PATCH 1/3] feat(core,terminal): settled(), and renderStill for the print path `flush` forces a frame. Nothing could ask whether the frame was *finished*, so every example guessed: a sleep loop of 4ms times eight, four in the showcase, twelve in the chat. Too small writes a half-drawn frame. `settled()` renders until a pass finds nothing pending. `renderStill` is the eight lines either side of that guess - virtual terminal, app, writer, start, flush, capture, stop - as one call. All seven examples use it, and each one's output is byte-for-byte what it was. --- CHANGELOG.md | 14 ++++ examples/arcade/src/main.tsx | 20 ++--- examples/chat/src/main.tsx | 85 ++++++++++----------- examples/flipbook/src/main.tsx | 14 +--- examples/ink/src/main.tsx | 16 +--- examples/showcase/src/main.tsx | 51 ++++++------- examples/surfaces/src/main.tsx | 14 +--- examples/todo/src/main.tsx | 19 ++--- packages/core/src/app/app.ts | 38 ++++++++++ packages/core/src/types/app.ts | 13 ++++ packages/terminal/src/index.ts | 2 + packages/terminal/src/still.ts | 111 ++++++++++++++++++++++++++++ packages/testing/test/still.test.ts | 111 ++++++++++++++++++++++++++++ 13 files changed, 373 insertions(+), 135 deletions(-) create mode 100644 packages/terminal/src/still.ts create mode 100644 packages/testing/test/still.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2110f0..e2ff4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ This file records the set. Anything package-specific says which package. ## Unreleased +### Nobody knew how long to wait for a frame + +Every example that writes a still ended the same way: `for (let i = 0; i < 8; i++) await sleep(4)`, then `flush`. Eight, mostly - four in the showcase, twelve in the chat - three numbers for one question, each arrived at by trying until the picture looked right. A number too small does not fail. It writes a half-drawn frame. + +`flush` forces a frame; there was no way to ask whether the frame was *finished*. `TextUIApp.settled()` is that question. A frame settles in more than one pass by design - an effect marks something dirty, a measurement runs the layout again - so it is a loop that yields to the task queue and renders until a pass finds nothing pending. It answers `false` when the passes never stop, which is a render loop that does not converge rather than one that is merely busy: an application that animates settles *between* its frames, which is what makes a still of one possible at all. + +### `renderStill`, so a program that is piped has one line to write + +The eight lines either side of that sleep loop were also copied seven times: a virtual terminal, an app, a writer, start, flush, capture, stop. `renderStill({ width, height, ...appOptions })` is all of it, and hands back the text, the cells, and whether it settled. + +`before` drives the application to the moment worth photographing - push a screen, send a message, pump a scripted host. `after` reaches it once the frame is drawn and before it is captured, which is where the showcase crops four hundred rows down to the ones it used and both it and the chat read the theme's own two colours for an SVG export. + +All seven examples now use it, and each one's output is byte-for-byte what it was. + ### 0.2.0 - the mouse, and the keys that reach a field Pre-1.0, and the surface is still moving. Nothing here is a rename, but a diff --git a/examples/arcade/src/main.tsx b/examples/arcade/src/main.tsx index 7cf7d76..0df2914 100644 --- a/examples/arcade/src/main.tsx +++ b/examples/arcade/src/main.tsx @@ -1,7 +1,7 @@ import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; import { - captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, + createNodeTerminal, createWriter, renderStill, } from '@textui/terminal'; import { registerArcade } from './app.js'; import { SEED } from './data.js'; @@ -60,28 +60,22 @@ function overrides(options: Options): CapabilityOverrides { /** One frame, to stdout: the cabinet, or a game with `--play`. */ async function still(options: Options): Promise { - const terminal = createVirtualTerminal({ + const { text } = await renderStill({ width: options.width, height: options.height, capabilities: overrides(options), - }); - const app = createApp({ - terminal, theme: 'console', shell: 'plain', onBoot: (booted) => { registerArcade(booted); if (options.seed !== undefined) booted.store.set(SEED, options.seed); }, + // A game is worth a picture only once it is running. + before: async (app) => { + if (options.play) await app.execute('arcade.play', { gameId: options.play }); + }, }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - if (options.play) await app.execute('arcade.play', { gameId: options.play }); - for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); - - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); - await app.stop(); + process.stdout.write(`${text}\n`); } async function main(): Promise { diff --git a/examples/chat/src/main.tsx b/examples/chat/src/main.tsx index b4f2bac..34817f4 100644 --- a/examples/chat/src/main.tsx +++ b/examples/chat/src/main.tsx @@ -2,7 +2,7 @@ import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; import { writeFile } from 'node:fs/promises'; import { - bufferToSvg, captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, + bufferToSvg, createNodeTerminal, createWriter, renderStill, } from '@textui/terminal'; import { registerChat } from './app.js'; import { CONTROLLER } from './control.js'; @@ -148,62 +148,59 @@ async function connect(options: Options): Promise { - const terminal = createVirtualTerminal({ + const host = await connect(options); + + const { text } = await renderStill({ width: options.width, height: options.height, capabilities: overrides(options), - }); - const host = await connect(options); - const app = createApp({ - terminal, theme: options.theme, shell: options.shell, onBoot: (booted) => { registerChat(booted, { host }); }, - }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - const controller = app.services.require(CONTROLLER); - if (options.session) { - controller.open(options.session); - if (options.screen !== 'sessions') app.screens.push(options.screen); - } - if (options.say) controller.send(options.say); + // A still of a turn mid-flight is what `--pump` is for: run a fixed number + // of scripted words rather than all of them, and the caret is wherever the + // agent had got to. `--settled` runs until the script has nothing left it + // can do without being answered, which is how the confirmation is reached. + before: (app) => { + const controller = app.services.require(CONTROLLER); + if (options.session) { + controller.open(options.session); + if (options.screen !== 'sessions') app.screens.push(options.screen); + } + if (options.say) controller.send(options.say); - // A still of a turn mid-flight is what `--pump` is for: run a fixed number - // of scripted words rather than all of them, and the caret is wherever the - // agent had got to. `--settled` runs until the script has nothing left it - // can do without being answered, which is how the confirmation is reached. - const steps = options.pump ?? (options.settled ? 100_000 : 0); - for (let i = 0; i < steps; i++) if (host.pump?.() !== true) break; - if (options.approve) { - controller.approve(); - for (let i = 0; i < 100_000; i++) if (host.pump?.() !== true) break; - } - if (options.answer) { - controller.answer({ q1: { kind: 'selected', value: 'transcript-scope' } }, true); - for (let i = 0; i < 100_000; i++) if (host.pump?.() !== true) break; - } + const steps = options.pump ?? (options.settled ? 100_000 : 0); + for (let i = 0; i < steps; i++) if (host.pump?.() !== true) break; + if (options.approve) { + controller.approve(); + for (let i = 0; i < 100_000; i++) if (host.pump?.() !== true) break; + } + if (options.answer) { + controller.answer({ q1: { kind: 'selected', value: 'transcript-scope' } }, true); + for (let i = 0; i < 100_000; i++) if (host.pump?.() !== true) break; + } + }, - for (let i = 0; i < 12; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); + after: async (app) => { + if (options.svg === undefined) return; + // The theme's own two colours, not the exporter's defaults: a cell left + // at the terminal default means "whatever the emulator is set to", and + // the honest answer for a picture of *this* application is the + // background it was drawn against. + await writeFile(options.svg, `${bufferToSvg(app.buffer(), { + background: app.theme.colors.canvas, + foreground: app.theme.colors.text, + title: `chat - ${options.screen}`, + })}\n`, 'utf8'); + }, + }); if (options.svg !== undefined) { - // The theme's own two colours, not the exporter's defaults: a cell left at - // the terminal default means "whatever the emulator is set to", and the - // honest answer for a picture of *this* application is the background it - // was drawn against. - const theme = app.theme; - await writeFile(options.svg, `${bufferToSvg(app.buffer(), { - background: theme.colors.canvas, - foreground: theme.colors.text, - title: `chat - ${options.screen}`, - })}\n`, 'utf8'); process.stderr.write(`${options.svg}\n`); - } else { - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); + return; } - await app.stop(); + process.stdout.write(`${text}\n`); } async function main(): Promise { diff --git a/examples/flipbook/src/main.tsx b/examples/flipbook/src/main.tsx index 2834131..4463e7d 100644 --- a/examples/flipbook/src/main.tsx +++ b/examples/flipbook/src/main.tsx @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; import { - captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, + createNodeTerminal, createWriter, renderStill, } from '@textui/terminal'; import { Frame, loaded, registerFlipbook } from './app.js'; import type { MotionDocument } from './motion.js'; @@ -72,23 +72,15 @@ async function loadMovie(file?: string): Promise { } async function still(options: Options): Promise { - const terminal = createVirtualTerminal({ + const { text } = await renderStill({ width: options.width, height: options.height, capabilities: overrides(options), - }); - const app = createApp({ - terminal, theme: options.theme, root: { component: 'FlipbookFrame' }, onBoot: (booted) => { registerFlipbook(booted); }, }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); - await app.stop(); + process.stdout.write(`${text}\n`); } async function main(): Promise { diff --git a/examples/ink/src/main.tsx b/examples/ink/src/main.tsx index 0ef0162..3afee6a 100644 --- a/examples/ink/src/main.tsx +++ b/examples/ink/src/main.tsx @@ -1,8 +1,6 @@ import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; -import { - captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, -} from '@textui/terminal'; +import { createNodeTerminal, createWriter, renderStill } from '@textui/terminal'; import { Frame, registerInk } from './app.js'; /** @@ -57,23 +55,15 @@ function overrides(options: Options): CapabilityOverrides { } async function still(options: Options): Promise { - const terminal = createVirtualTerminal({ + const { text } = await renderStill({ width: options.width, height: options.height, capabilities: overrides(options), - }); - const app = createApp({ - terminal, theme: options.theme, root: { component: 'InkFrame' }, onBoot: (booted) => { registerInk(booted); }, }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); - await app.stop(); + process.stdout.write(`${text}\n`); } async function main(): Promise { diff --git a/examples/showcase/src/main.tsx b/examples/showcase/src/main.tsx index 5bbd14a..266db22 100644 --- a/examples/showcase/src/main.tsx +++ b/examples/showcase/src/main.tsx @@ -2,7 +2,7 @@ import { writeFile } from 'node:fs/promises'; import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; import { - bufferToSvg, captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, + bufferToSvg, createNodeTerminal, createWriter, renderStill, } from '@textui/terminal'; import { registerShowcase } from './screen.js'; @@ -74,13 +74,11 @@ async function still(options: Options): Promise { // The alternative is asking for a height and getting a picture with the last // row of panels missing, which is the shape of every screenshot mistake. const fit = options.height === undefined; - const terminal = createVirtualTerminal({ + + const { text } = await renderStill({ width: options.width, height: options.height ?? 400, capabilities: overrides(options), - }); - const app = createApp({ - terminal, theme: options.theme, onBoot: (booted) => { registerShowcase(booted, { @@ -89,35 +87,30 @@ async function still(options: Options): Promise { ...(fit ? { fit: true } : {}), }); }, + // The crop, and the export, both want the application after the frame and + // before it goes away. `resize` keeps the top-left region, so shrinking it + // is a crop - and it runs before the capture, so `text` is the cropped + // picture rather than four hundred rows of mostly nothing. + after: async (app) => { + if (fit) app.buffer().resize(options.width, lastUsedRow(app.buffer())); + if (options.svg === undefined) return; + await writeFile(options.svg, `${bufferToSvg(app.buffer(), { + // The theme's own colours rather than the exporter's defaults: a cell + // left at the terminal default means "whatever the emulator is set + // to", and the honest answer for a picture of this screen is the + // background it was drawn against. + background: app.theme.colors.canvas, + foreground: app.theme.colors.text, + title: `textui - ${options.theme}`, + })}\n`, 'utf8'); + }, }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - - // A frame or two, because a panel that measures itself is a frame behind by - // design - the layout decides the width and the content is drawn to it on - // the pass after. Without this the first still is the unwrapped one. - for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); - - // `resize` keeps the top-left region, so shrinking it is a crop. Done to the - // app's own buffer because the next thing to happen to it is `stop`. - if (fit) app.buffer().resize(options.width, lastUsedRow(app.buffer())); if (options.svg !== undefined) { - await writeFile(options.svg, `${bufferToSvg(app.buffer(), { - // The theme's own colours rather than the exporter's defaults: a cell - // left at the terminal default means "whatever the emulator is set to", - // and the honest answer for a picture of this screen is the background - // it was drawn against. - background: app.theme.colors.canvas, - foreground: app.theme.colors.text, - title: `textui - ${options.theme}`, - })}\n`, 'utf8'); process.stderr.write(`${options.svg}\n`); - } else { - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); + return; } - await app.stop(); + process.stdout.write(`${text}\n`); } /** diff --git a/examples/surfaces/src/main.tsx b/examples/surfaces/src/main.tsx index f9e87bc..245c8c7 100644 --- a/examples/surfaces/src/main.tsx +++ b/examples/surfaces/src/main.tsx @@ -1,7 +1,7 @@ import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; import { - captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, + createNodeTerminal, createWriter, renderStill, } from '@textui/terminal'; import { Frame, registerSurfaces } from './app.js'; @@ -57,23 +57,15 @@ function overrides(options: Options): CapabilityOverrides { } async function still(options: Options): Promise { - const terminal = createVirtualTerminal({ + const { text } = await renderStill({ width: options.width, height: options.height, capabilities: overrides(options), - }); - const app = createApp({ - terminal, theme: options.theme, root: { component: 'SurfacesFrame' }, onBoot: (booted) => { registerSurfaces(booted); }, }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); - await app.stop(); + process.stdout.write(`${text}\n`); } async function main(): Promise { diff --git a/examples/todo/src/main.tsx b/examples/todo/src/main.tsx index 1081e76..ffda678 100644 --- a/examples/todo/src/main.tsx +++ b/examples/todo/src/main.tsx @@ -1,7 +1,7 @@ import { WRITER_KEY, createApp } from '@textui/core'; import type { CapabilityOverrides, UnicodeLevel } from '@textui/core'; import { - captureBuffer, createNodeTerminal, createVirtualTerminal, createWriter, + createNodeTerminal, createWriter, renderStill, } from '@textui/terminal'; import { registerTodo } from './app.js'; import { fileStore } from './storage.js'; @@ -60,26 +60,17 @@ function overrides(options: Options): CapabilityOverrides { * cell it painted. */ async function still(options: Options): Promise { - const terminal = createVirtualTerminal({ + // `renderStill` settles it: a measured component only knows its size once it + // has been laid out, and what it draws next depends on that. + const { text } = await renderStill({ width: options.width, height: options.height, capabilities: overrides(options), - }); - const app = createApp({ - terminal, theme: 'workbench', shell: 'workbench', onBoot: (booted) => { registerTodo(booted); }, }); - app.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); - await app.start(); - // Settle: a measured component only knows its size once it has been laid - // out, and what it draws next depends on that. - for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 4)); - app.flush(); - - process.stdout.write(`${captureBuffer(app.buffer(), terminal.capabilities())}\n`); - await app.stop(); + process.stdout.write(`${text}\n`); } async function main(): Promise { diff --git a/packages/core/src/app/app.ts b/packages/core/src/app/app.ts index 03affc7..4b87e44 100644 --- a/packages/core/src/app/app.ts +++ b/packages/core/src/app/app.ts @@ -466,6 +466,44 @@ export class App implements TextUIApp { this.renderFrame(); } + /** + * Render until there is nothing left to render. + * + * `flush` forces one frame; this is the other question - *has it finished?* - + * and there was no way to ask it. What a program wanting one true frame did + * instead was guess: every example that writes a still ended with a sleep + * loop of four milliseconds times a number somebody tried until the picture + * looked right. Eight, mostly. Four in one, twelve in another. A number too + * small does not fail; it writes a half-drawn frame. + * + * A frame settles in more than one pass by design - an effect may mark + * something dirty, and a measurement changing runs the layout again - so the + * answer is a loop rather than a flag. Each turn yields to the task queue + * first, because what has not run yet cannot have marked anything. + * + * It returns as soon as a pass finds nothing pending, so an application that + * animates settles *between* its frames - which is what makes a still of one + * possible at all, and why the answer is not "has it stopped moving". + * + * `false` is the other thing: passes that kept producing work until the + * limit ran out, which is a render loop that does not converge - an effect + * with no dependency list setting the state it reads. Worth reporting rather + * than hanging on, and worth a number rather than a promise a caller can + * wait on for ever. + */ + async settled(options: { limit?: number } = {}): Promise { + const limit = options.limit ?? 100; + for (let i = 0; i < limit; i++) { + // Deliberately not `unref`'d: this timer is what keeps the process alive + // while a still is being rendered, and one that lets it exit would leave + // the await unresolved and the frame unwritten. + await new Promise((resolve) => setTimeout(resolve, 0)); + if (!this.frameScheduled && !this.isDirty()) return true; + this.flush(); + } + return false; + } + /** * The tree the frame renders: the shell, always, when one is registered. * diff --git a/packages/core/src/types/app.ts b/packages/core/src/types/app.ts index ce4ff71..313dc54 100644 --- a/packages/core/src/types/app.ts +++ b/packages/core/src/types/app.ts @@ -69,6 +69,19 @@ export interface TextUIApp extends Disposable { stop(): Promise; /** Force a frame now, outside the scheduler. Tests and screenshots use it. */ flush(): void; + /** + * Render until nothing is left to render, and answer whether it went quiet. + * + * The question `flush` cannot answer. A frame settles in more than one pass - + * an effect marks something dirty, a measurement runs the layout again - so a + * program that wants one true frame has to wait rather than force one. + * + * It returns as soon as a pass finds nothing pending, so an animating + * application settles between its frames. `false` means the passes kept + * producing work until the limit ran out - a render loop that does not + * converge, rather than one that is merely busy. + */ + settled(options?: { limit?: number }): Promise; /** The last painted frame. */ buffer(): CellBuffer; diff --git a/packages/terminal/src/index.ts b/packages/terminal/src/index.ts index 80d0491..9827065 100644 --- a/packages/terminal/src/index.ts +++ b/packages/terminal/src/index.ts @@ -2,6 +2,8 @@ export * as ansi from './ansi.js'; export { Writer, createWriter } from './writer.js'; export { captureBuffer } from './capture.js'; export type { CaptureOptions } from './capture.js'; +export { renderStill } from './still.js'; +export type { Still, StillOptions } from './still.js'; export { bufferToSvg } from './svg.js'; export type { SvgOptions } from './svg.js'; export { diff --git a/packages/terminal/src/still.ts b/packages/terminal/src/still.ts new file mode 100644 index 0000000..c8b2c35 --- /dev/null +++ b/packages/terminal/src/still.ts @@ -0,0 +1,111 @@ +import type { + CapabilityOverrides, CellBuffer, CreateAppOptions, TextUIApp, +} from '@textui/core'; +import { WRITER_KEY, createApp } from '@textui/core'; +import { createVirtualTerminal } from './virtual.js'; +import { createWriter } from './writer.js'; +import { captureBuffer } from './capture.js'; +import type { CaptureOptions } from './capture.js'; + +/** + * One frame of an application, as text. + * + * The same application, not a second rendering path: it is mounted against a + * terminal that is a size and nothing else, rendered until it stops changing, + * and every cell it painted is what comes back. Which is what a program does + * when its output is a pipe rather than a screen - there is no frame after + * this one to correct it, so it has to be the finished picture. + * + * Every example in this repository had written this out by hand, and all of + * them ended the same way: a sleep loop of four milliseconds times a number + * somebody had tried until the picture looked right. Eight, mostly - four in + * one, twelve in another - for the same eight lines of setup either side. The + * number was the tell. Nobody knew it, a small one silently writes a + * half-drawn frame, and `TextUIApp.settled` is the answer they were all + * approximating. + */ +export interface StillOptions extends Omit { + /** Columns. The terminal is only a size, so this is the whole of it. */ + width?: number; + height?: number; + /** What the terminal should claim to be able to do. */ + capabilities?: CapabilityOverrides; + /** + * Drive it before the frame is taken. + * + * A still of an application in its opening state is the least interesting + * one. This is where a screen is pushed, a message sent, or a scripted host + * pumped to the point worth photographing - and it may be async, because + * most of those are. + */ + before?(app: TextUIApp): void | Promise; + /** + * The frame is drawn and the application is still alive. + * + * For what a string cannot carry and `stop` would take away: the buffer, to + * crop it to the rows that were used, and the theme, whose two colours are + * the honest background for a picture of *this* screen rather than the + * exporter's guess. It runs before the capture, so a buffer changed here is + * the buffer that comes back as `text`. + */ + after?(app: TextUIApp): void | Promise; + /** Plain text or SGR, and at what depth. Defaults to what the terminal claims. */ + capture?: CaptureOptions; + /** Settle passes before giving up. See `TextUIApp.settled`. */ + settleLimit?: number; +} + +export interface Still { + /** Every cell it painted, rows separated by newlines. */ + text: string; + /** + * The cells themselves, for anything text cannot carry - `bufferToSvg`, a + * pixel diff, an assertion about one cell's colour. Valid after the + * application has stopped: nothing writes to it again. + */ + buffer: CellBuffer; + /** + * Whether it went quiet, or the limit ran out first. + * + * An application that animates settles between its frames, so this is + * `true` for one of those and the picture is a photograph of something + * moving. `false` means the passes never stopped producing work - a render + * loop that does not converge - and the frame was taken anyway, because a + * still is better evidence of that than nothing is. + */ + settled: boolean; +} + +export async function renderStill(options: StillOptions = {}): Promise { + const { + width = 80, height = 24, capabilities, before, after, capture, settleLimit, ...app + } = options; + + const terminal = createVirtualTerminal({ + width, + height, + ...(capabilities ? { capabilities } : {}), + }); + + const created = createApp({ ...app, terminal }); + // The writer is provided even though the buffer is what is read: a virtual + // terminal records what was written to it, and a still that quietly stopped + // producing that would break anything reading the bytes rather than the + // cells. + created.services.provide(WRITER_KEY, createWriter(terminal.capabilities())); + + await created.start(); + await before?.(created); + + const settled = await created.settled(settleLimit === undefined ? {} : { limit: settleLimit }); + // Even when it did not settle: the limit means "stop waiting", not "give up + // on the frame", and a still of a moving thing is still a still. + created.flush(); + await after?.(created); + + const text = captureBuffer(created.buffer(), terminal.capabilities(), capture ?? {}); + const buffer = created.buffer(); + await created.stop(); + + return { text, buffer, settled }; +} diff --git a/packages/testing/test/still.test.ts b/packages/testing/test/still.test.ts new file mode 100644 index 0000000..50b2e85 --- /dev/null +++ b/packages/testing/test/still.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { h, defineComponent, useState, useEffect, useMeasure } from '@textui/core'; +import { renderStill } from '@textui/terminal'; + +/** + * One frame, and the question of when it is finished. + * + * Every example in this repository used to end its print path with a sleep + * loop of four milliseconds times a number somebody had tried until the + * picture looked right - eight, mostly, four in one and twelve in another. + * The number was never the point; the point is that a frame settles in more + * than one pass and there was no way to ask whether it had. + * + * So these are about the passes, not the milliseconds: a component that only + * knows its size after it has been laid out, and one that changes its mind in + * an effect, both have to be finished before the frame is taken. + */ + +/** Draws nothing until it has been measured, then draws its own width. */ +const Measured = defineComponent>('Measured', () => { + const rect = useMeasure(); + return h('box', { flex: 1 }, h('text', { content: rect.width > 0 ? `w=${rect.width}` : '' })); +}); + +/** Settles on its second thought, one effect later. */ +const Deferred = defineComponent>('Deferred', () => { + const [state, setState] = useState('first'); + useEffect(() => { setState('second'); }, []); + return h('text', { content: state }); +}); + +/** + * Never converges, which is what a limit is for. + * + * Not a ticker - one of those settles between its frames, which is what makes + * a still of an animation possible. This is the other thing: an effect with no + * dependency list, setting the state it reads, so every pass produces another. + */ +const Restless = defineComponent>('Restless', () => { + const [n, setN] = useState(0); + useEffect(() => { setN(n + 1); }); + return h('text', { content: `tick ${n}` }); +}); + +describe('renderStill', () => { + it('waits for a component that has to be measured first', async () => { + const still = await renderStill({ width: 24, height: 3, root: h(Measured, {}) }); + expect(still.settled).toBe(true); + expect(still.text).toContain('w=24'); + }); + + it('waits for an effect that changes its mind', async () => { + const still = await renderStill({ width: 24, height: 3, root: h(Deferred, {}) }); + expect(still.text).toContain('second'); + expect(still.text).not.toContain('first'); + }); + + it('says so rather than hanging on something that never stops', async () => { + const still = await renderStill({ + width: 24, height: 3, root: h(Restless, {}), settleLimit: 3, + }); + // Reported rather than hidden, and the frame is taken anyway: a still is + // better evidence of a loop that will not converge than nothing is. + expect(still.settled).toBe(false); + expect(still.text).toContain('tick'); + }); + + it('drives it before the frame, and reaches it after', async () => { + const order: string[] = []; + const still = await renderStill({ + width: 24, height: 3, + root: h('text', { content: 'body' }), + before: () => { order.push('before'); }, + after: (app) => { + order.push('after'); + // Alive, and its buffer is the one about to be captured. + expect(app.buffer().width).toBe(24); + }, + }); + expect(order).toEqual(['before', 'after']); + expect(still.text).toContain('body'); + }); + + it('captures what `after` changed, not what was there before it', async () => { + // Which is the whole reason `after` runs on this side of the capture: the + // showcase crops four hundred rows down to the ones it used. + const still = await renderStill({ + width: 12, height: 8, + root: h('text', { content: 'kept' }), + after: (app) => { app.buffer().resize(12, 1); }, + }); + expect(still.text.split('\n')).toHaveLength(1); + expect(still.text).toContain('kept'); + }); + + it('hands back cells as well as text, and they outlive the application', async () => { + const still = await renderStill({ width: 10, height: 2, root: h('text', { content: 'A' }) }); + expect(still.buffer.width).toBe(10); + expect(still.buffer.get(0, 0)?.char).toBe('A'); + }); + + it('leaves the colour out when asked, which is what a diff can read', async () => { + const still = await renderStill({ + width: 12, height: 2, + root: h('text', { content: 'plain', fg: 'danger' }), + capture: { colors: false }, + }); + // eslint-disable-next-line no-control-regex + expect(/\[/.test(still.text)).toBe(false); + }); +}); From bf5d9d0aa56ed97de5fd32c1ca2ad4c2404d1ecb Mon Sep 17 00:00:00 2001 From: Softov Date: Thu, 27 Aug 2026 03:04:25 -0400 Subject: [PATCH 2/3] fix(core,widgets): a shell after boot, and a sidebar that gave way `root` reaches the screen two ways - a mount when a shell exists, wrapped directly when none does. `setRoot` has always done both; `setShell` is the moment a program crosses from the second case to the first and it did only half, so a shell registered after boot left a framed, themed, empty screen. And sideways the layout shrinks a plain `width` before it clips anything, which is right for content and wrong for chrome: one long line in the pane crushed the sidebar to four columns. `shrink: 0`, which the layout has always honoured. --- CHANGELOG.md | 10 +++ packages/core/src/app/app.ts | 16 ++++ packages/testing/test/shells.test.ts | 85 +++++++++++++++++++ .../widgets/src/shells/workbench-shell.ts | 6 ++ 4 files changed, 117 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ff4c9..8c7f24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ This file records the set. Anything package-specific says which package. ## Unreleased +### A shell registered after boot took the application off the screen + +`root` reaches the screen two ways. With a shell it is a mount like any other and the surface registry owns it; with no shell at all - which is every application built out of primitives - `rootNode` wraps it directly and the registry is never consulted. `setRoot` has always done both, and says why: setting one and not the other works in exactly half of the programs that can exist. + +`setShell` is the moment a program crosses from the second case to the first, and it did only half. `rootNode` began answering with the shell, nothing had ever put `root` into `main`, and what was left was a framed, themed, empty screen. + +### The sidebar is not a column of the document + +Sideways, the layout shrinks a child with a plain `width` before it clips anything - which is how terminals have always narrowed, and is right for content. Applied to chrome it meant a pane with one long line in it crushed twenty-four columns of file tree down to four. The workbench shell's sidebar is `shrink: 0` now, which the layout has always honoured; nothing in core changed. + ### Nobody knew how long to wait for a frame Every example that writes a still ended the same way: `for (let i = 0; i < 8; i++) await sleep(4)`, then `flush`. Eight, mostly - four in the showcase, twelve in the chat - three numbers for one question, each arrived at by trying until the picture looked right. A number too small does not fail. It writes a half-drawn frame. diff --git a/packages/core/src/app/app.ts b/packages/core/src/app/app.ts index 4b87e44..45d165a 100644 --- a/packages/core/src/app/app.ts +++ b/packages/core/src/app/app.ts @@ -304,6 +304,22 @@ export class App implements TextUIApp { this.store.set('$/layout/shell', id); const shell = this.shells.get(id); if (shell?.theme && this.themes.get(shell.theme)) this.setTheme(shell.theme); + + // `root` reaches the screen two ways, and this is the moment it changes + // which. With no shell registered at boot it was never opened into `main` - + // `rootNode` wraps it directly, which is the path an application built out + // of primitives takes. A shell arriving afterwards makes `rootNode` return + // the shell instead, and the application's entire content was simply gone: + // a framed, themed, empty screen. + // + // `setRoot` has always handled both paths, and says why - "setting one and + // not the other works in exactly half of the programs that can exist". This + // is the other half of the same sentence. Opening the same key twice + // replaces the mount rather than stacking one, so no guard is needed. + if (this.options.root) { + this.surfaces.open({ surface: 'main', key: ROOT_KEY, target: this.options.root }); + } + this.buffer_.invalidate(); this.requestRender(true); } diff --git a/packages/testing/test/shells.test.ts b/packages/testing/test/shells.test.ts index c4722c1..2560519 100644 --- a/packages/testing/test/shells.test.ts +++ b/packages/testing/test/shells.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { renderApp } from '../src/index.js'; import { h, defineComponent, useFocus, useScreen } from '@textui/core'; +import { registerBuiltins } from '@textui/widgets'; /** * The acceptance test for the whole architecture. @@ -553,3 +554,87 @@ describe('the sidebar with more than one panel in it', () => { }); } }); + +/** + * `root` is a mount, and both ways it reaches the screen have to agree. + * + * With a shell registered it is opened into `main` and the surface registry + * owns it. With no shell at all - which is every application built out of + * primitives - `rootNode` wraps it directly and the registry is never + * consulted. `setRoot` has always handled both, and says why: setting one and + * not the other works in exactly half of the programs that can exist. + * + * `setShell` is the moment a program crosses from the second case to the + * first, and it did not. + */ +describe('a shell that arrives after boot', () => { + it('keeps what root was drawing', async () => { + const t = await renderApp({ + width: 40, height: 6, + // No shell at boot: the harness registers the built-ins, shells and all, + // unless told not to. + builtins: false, + root: h('text', { content: 'THE-APPLICATION' }), + }); + await t.settle(); + expect(t.hasText('THE-APPLICATION')).toBe(true); + + registerBuiltins(t.app); + t.app.setShell('workbench'); + for (let i = 0; i < 4; i++) await t.settle(); + + // It used to be a framed, themed, empty screen: `rootNode` started + // answering with the shell, and nothing had ever put `root` into `main`. + expect(t.hasText('THE-APPLICATION')).toBe(true); + await t.unmount(); + }); + + it('does not stack a second mount when the shell changes again', async () => { + const t = await renderApp({ + width: 40, height: 6, + builtins: false, + root: h('text', { content: 'ONCE' }), + }); + registerBuiltins(t.app); + t.app.setShell('workbench'); + t.app.setShell('plain'); + t.app.setShell('workbench'); + for (let i = 0; i < 4; i++) await t.settle(); + + // Opening the same key replaces the mount rather than stacking one, so the + // surface never grows a tab strip out of the same node three times over. + const lines = t.lines().join('\n'); + expect(lines.split('ONCE')).toHaveLength(2); + await t.unmount(); + }); +}); + +/** + * The sidebar is a decision about the window, not a column of the document. + * + * Sideways, the layout shrinks a child with a plain `width` before it clips + * anything - which is how terminals have always narrowed, and is right for + * content. Applied to chrome it meant a pane with a long line in it could + * crush twenty-four columns of file tree down to four. + */ +describe('the sidebar holds its width', () => { + it('is not crushed by an overflowing pane beside it', async () => { + // Ninety columns or the shell hides the sidebar as too narrow to be worth + // the space, and the test would pass by drawing nothing. + const t = await renderApp({ width: 100, height: 6, shell: 'workbench' }); + t.app.open({ + surface: 'sidebar', key: 'tree', + target: { component: 'text', content: 'S'.repeat(24), wrap: 'none' }, + }); + // The pane beside it, with a line four hundred columns long in it. + t.app.open({ + surface: 'main', key: 'wide', + target: { component: 'text', content: 'M'.repeat(400), wrap: 'none' }, + }); + for (let i = 0; i < 4; i++) await t.settle(); + + const row = t.lines().find((line) => line.includes('S')) ?? ''; + expect(row).toContain('S'.repeat(20)); + await t.unmount(); + }); +}); diff --git a/packages/widgets/src/shells/workbench-shell.ts b/packages/widgets/src/shells/workbench-shell.ts index c139ee5..4c02880 100644 --- a/packages/widgets/src/shells/workbench-shell.ts +++ b/packages/widgets/src/shells/workbench-shell.ts @@ -39,6 +39,12 @@ export const WorkbenchShell = defineComponent('WorkbenchShell', (pro showSidebar ? h('box', { width: 24, + // Rigid. Sideways, the layout shrinks a child with a plain `width` + // before it clips anything - which is how terminals have always + // narrowed, and wrong for chrome: an overflowing pane beside it + // crushed twenty-four columns of tree down to four. A sidebar is a + // decision about the window, not a column of the document. + shrink: 0, border: { style: theme.border, sides: { right: true } }, direction: 'column', padding: { left: 1 }, From a36ea263dd52992b395e5baa10a9a96af3553ba8 Mon Sep 17 00:00:00 2001 From: Softov Date: Thu, 27 Aug 2026 05:25:51 -0400 Subject: [PATCH 3/3] fix(ink): a tmplt `t` with no ascender, and three smaller things `t` was a crossbar and a foot sitting at x-height, where b d f h k l all start on row nought - so it read as a `+` with a tail, and at the wrong height into the bargain. `w` had two right-hand stems and no middle one. `e` and `z` each gained the cell that `trim` was taking off their bottom-right, `trim` stripping any column that is blank in every row. The test that comes with it asserts the property `t` failed: an ascender reaches a row higher than an x-height letter. Not a width - a width rule is a judgement about how a letter should be drawn rather than a fact about where it sits, and `c`, `s` and `f` are all one stroke on purpose. --- examples/ink/src/fonts.ts | 8 ++++---- examples/ink/test/fonts.test.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/examples/ink/src/fonts.ts b/examples/ink/src/fonts.ts index 8ac88f2..38c102d 100644 --- a/examples/ink/src/fonts.ts +++ b/examples/ink/src/fonts.ts @@ -301,7 +301,7 @@ const TMPLT: Record = { 'b': ["┓ ", "┣┓", "┗┛"], 'c': ["", "┏", "┗"], 'd': [" ┓", "┏┫", "┗┻"], - 'e': ["", "┏┓", "┗"], + 'e': ["", "┏┓", "┗━"], 'f': [" ┏", " ╋", " ┛"], 'g': ["", "┏┓", "┗┫", " ┛"], 'h': ["┓ ", "┣┓ ", "┛┗ ",], @@ -316,13 +316,13 @@ const TMPLT: Record = { 'q': ["", "┏┓", "┗┫", " ┗",], 'r': ["", "┏┓", "┛ ",], 's': ["", "┏ ", "┛ ",], - 't': ["", "╋ ", "┗ ",], + 't': ["┃ ", "╋ ", "┗━"], 'u': ["", "┓┏", "┗┻",], 'v': ["", "┓┏", "┗┛",], - 'w': ["", "┓┏┏", "┗┻┛",], + 'w': ["", "┓┃┏", "┗┻┛"], 'x': ["", "┓┏", "┛┗",], 'y': [" ", "┓┏", "┗┫", " ┛"], - 'z': ["", "┓", "┗",], + 'z': ["", "━┓", "┗━"], '.': [" ", " ", "•"], ',': [" ", " ", "┛"], ':': [" ", "•", "•"], diff --git a/examples/ink/test/fonts.test.ts b/examples/ink/test/fonts.test.ts index 65799e2..da7d95c 100644 --- a/examples/ink/test/fonts.test.ts +++ b/examples/ink/test/fonts.test.ts @@ -163,6 +163,22 @@ describe('the transforms', () => { for (const digit of '0123456789') expect(banner(digit, tmplt), digit).not.toBe(''); }); + /** + * The ascenders reach, and the x-height letters do not. + * + * `t` was drawn with no ascender at all - a crossbar and a foot, sitting at + * x-height - so `test` had one letter of the four at the wrong height and + * `t` was indistinguishable from a `+`. + */ + it('starts a tmplt ascender a row above an x-height letter', () => { + const tmplt = fontAt('tmplt'); + // `o` is the reference: two rows, and nothing above them. + expect(banner('o', tmplt).split('\n')).toHaveLength(2); + for (const tall of 'bdfhklt') { + expect(banner(tall, tmplt).split('\n'), tall).toHaveLength(3); + } + }); + it('gives tmplt a stand-in for the terminal that cannot draw it', () => { // Box-drawing has no `#` to degrade to, so this is the one font that names // another rather than a character.