Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,30 @@ 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.

`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
Expand Down
20 changes: 7 additions & 13 deletions examples/arcade/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<void> {
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<void> {
Expand Down
85 changes: 41 additions & 44 deletions examples/chat/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -148,62 +148,59 @@ async function connect(options: Options): Promise<HostConnection & { pump?(): bo

/** One frame, to stdout. The same application, against a terminal that is a size. */
async function still(options: Options): Promise<void> {
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<void> {
Expand Down
14 changes: 3 additions & 11 deletions examples/flipbook/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,23 +72,15 @@ async function loadMovie(file?: string): Promise<void> {
}

async function still(options: Options): Promise<void> {
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<void> {
Expand Down
8 changes: 4 additions & 4 deletions examples/ink/src/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ const TMPLT: Record<string, Grid> = {
'b': ["┓ ", "┣┓", "┗┛"],
'c': ["", "┏", "┗"],
'd': [" ┓", "┏┫", "┗┻"],
'e': ["", "┏┓", "┗"],
'e': ["", "┏┓", "┗"],
'f': [" ┏", " ╋", " ┛"],
'g': ["", "┏┓", "┗┫", " ┛"],
'h': ["┓ ", "┣┓ ", "┛┗ ",],
Expand All @@ -316,13 +316,13 @@ const TMPLT: Record<string, Grid> = {
'q': ["", "┏┓", "┗┫", " ┗",],
'r': ["", "┏┓", "┛ ",],
's': ["", "┏ ", "┛ ",],
't': ["", "╋ ", "┗ ",],
't': ["", "╋ ", "┗━"],
'u': ["", "┓┏", "┗┻",],
'v': ["", "┓┏", "┗┛",],
'w': ["", "┓┏┏", "┗┻┛",],
'w': ["", "┓┃┏", "┗┻┛"],
'x': ["", "┓┏", "┛┗",],
'y': [" ", "┓┏", "┗┫", " ┛"],
'z': ["", "┓", "┗",],
'z': ["", "┓", "┗━"],
'.': [" ", " ", "•"],
',': [" ", " ", "┛"],
':': [" ", "•", "•"],
Expand Down
16 changes: 3 additions & 13 deletions examples/ink/src/main.tsx
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -57,23 +55,15 @@ function overrides(options: Options): CapabilityOverrides {
}

async function still(options: Options): Promise<void> {
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<void> {
Expand Down
16 changes: 16 additions & 0 deletions examples/ink/test/fonts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 22 additions & 29 deletions examples/showcase/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -74,13 +74,11 @@ async function still(options: Options): Promise<void> {
// 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, {
Expand All @@ -89,35 +87,30 @@ async function still(options: Options): Promise<void> {
...(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`);
}

/**
Expand Down
Loading
Loading