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
5 changes: 4 additions & 1 deletion docs/guides/06-images-and-canvas.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,10 @@ that cell size. With the default unit cell, where one CSS pixel is one
whole cell, the engine uses the terminal's real cell size instead. It
asks the terminal for its cell size (XTWINOPS 16) and assumes 8 by 16
pixels until the terminal answers. At 8 by 16, a 400 by 400 image is
50 columns by 25 rows.
50 columns by 25 rows. It asks again when the terminal resizes, so a
font zoom resizes images too. `term.measureCell()` reports the same
measurement to a program, and a `"cellchange"` event announces a new
one.

`naturalWidth` and `naturalHeight` report this size in CSS pixels.
Under the unit cell a CSS pixel is a cell, so this works the way a
Expand Down
37 changes: 37 additions & 0 deletions docs/guides/07-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,41 @@ term.addEventListener("fetch", (event) => {
When `csp` blocks a load, TermDOM fires `securitypolicyviolation` at
the element instead. The images guide covers both events.

### `term.measureCell()`

Asks the terminal how many of its pixels one cell covers, and resolves
to `{width, height}`, or to `null` when there is no terminal to ask
(before `attach()`, after `dispose()`, or when output is not a
terminal) or the terminal does not answer. Most terminals answer
(XTWINOPS 16); the Linux console does not.

This is the terminal's own measurement, whatever the `cellSize` option
says. Cells are seldom square: an 11 by 25 pixel cell is about 2.27
times as tall as it is wide. Divide by it to draw something to its
true shape on a canvas.

```ts
const cell = await term.measureCell();
const aspect = cell ? cell.height / cell.width : 2;
```

Each call asks the terminal again. Calls made while a question is out
share its answer.

### `term.addEventListener("cellchange", listener)`

Fires a `CellChangeEvent`, with `width` and `height`, when the
terminal's cell is measured at a different size: when its first answer
after `attach()` replaces the size TermDOM assumed until then, and
after a font zoom. Images resize with it on their own. Anything drawn
to the cell's shape can redraw here.

```ts
term.addEventListener("cellchange", (event) => {
drawChart(event.height / event.width);
});
```

### `term.attach()`

`attach()` puts the terminal in raw mode. It starts input handling,
Expand Down Expand Up @@ -517,6 +552,8 @@ also exports:

- `FetchEvent`, `ExtendableEvent` and the `FetchEventInit` type, for
the `"fetch"` event.
- `CellChangeEvent` and the `CellChangeEventInit` type, for the
`"cellchange"` event.
- The types `TermDOMOptions`, `TermDOMEventMap`, `CellSize`,
`ColorDepth`, `TerminalTransport`, `TerminalCloseInfo`,
`TerminalSize`, `ProcessLike` and `SSHSessionLike`.
Expand Down
85 changes: 60 additions & 25 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
applyMutations,
attachDocument,
CellChangeEvent,
checkFormControls,
clampScrollOffsets,
clearHighlights,
Expand Down Expand Up @@ -63,6 +64,7 @@ import {
negotiateColorScheme,
negotiateGraphemeClusters,
negotiateOverline,
queryCellPixels,
queryDeviceAttributes,
reclaimModes,
releaseModes,
Expand All @@ -79,6 +81,7 @@ import {
writeLines,
} from "./internal/exchange.ts";
import {
adoptCellPixels,
beginFrame,
endFrame,
Framebuffer,
Expand Down Expand Up @@ -133,6 +136,8 @@ export type {CanvasCellGridContext} from "./internal/canvas.ts";
export type {CellSize} from "./internal/units.ts";
export {transportFromProcess, transportFromSSH} from "./internal/exchange.ts";
export {
CellChangeEvent,
type CellChangeEventInit,
ExtendableEvent,
FetchEvent,
type FetchEventInit,
Expand Down Expand Up @@ -370,6 +375,7 @@ const HELD_ERROR_LIMIT = 50;

/** The events a TermDOM dispatches, by type. */
export interface TermDOMEventMap {
cellchange: CellChangeEvent;
fetch: FetchEvent;
}

Expand Down Expand Up @@ -779,10 +785,7 @@ export class TermDOM extends EventTarget {
return;
}
if (cellChanged) {
cellPixelsChanged(this);
if (measuresCell) {
cellSizeChanged(this);
}
cellMeasured(this);
}
void render(this);
},
Expand All @@ -795,29 +798,29 @@ export class TermDOM extends EventTarget {
depthSettled,
...(measuresCell ? [cellAnswered] : []),
]).then(() => {});
// A font zoom resizes the grid, and the cell with it, so the cell
// is asked again. When the cell sizes the page, the page hears of
// the resize once, when the cell is known.
if (measuresCell) {
// A font zoom resizes the grid, and the cell with it. The page
// hears of it once, when the cell is known.
deferResizeEvent(slots.exchange);
slots.exchange.addEventListener("terminalresize", (event) => {
const {sizeChanged} = event as TerminalResizeEvent;
void negotiateCellPixels(slots.exchange).then((cellChanged) => {
if (!isAttached(this)) {
return;
}
if (cellChanged) {
cellPixelsChanged(this);
cellSizeChanged(this);
void render(this);
} else if (sizeChanged) {
dispatchAsUserAgent(
this.window,
constructInternal(() => new this.window.Event("resize")),
);
}
});
});
}
slots.exchange.addEventListener("terminalresize", (event) => {
const {sizeChanged} = event as TerminalResizeEvent;
void negotiateCellPixels(slots.exchange).then((cellChanged) => {
if (!isAttached(this)) {
return;
}
if (cellChanged) {
cellMeasured(this);
void render(this);
} else if (sizeChanged && measuresCell) {
dispatchAsUserAgent(
this.window,
constructInternal(() => new this.window.Event("resize")),
);
}
});
});
slots.lifecycle = "attached";
begun();

Expand Down Expand Up @@ -885,6 +888,29 @@ export class TermDOM extends EventTarget {
return super.dispatchEvent(event as never);
}

/**
* One cell in the terminal's pixels, asked of the terminal now: what
* an image or a canvas drawing to the cell's shape divides by. Null
* when there is no terminal to ask, before attach() or after
* dispose(), or when the terminal does not say. A cell measured at
* another size than before is also announced as a cellchange event.
*/
async measureCell(): Promise<{width: number; height: number} | null> {
if (!isAttached(this)) {
return null;
}
const slots = _(this);
const answer = await queryCellPixels(slots.exchange);
if (answer === null || !isAttached(this)) {
return null;
}
if (adoptCellPixels(slots.framebuffer, answer.width, answer.height)) {
cellMeasured(this);
void render(this);
}
return {width: answer.width, height: answer.height};
}

/**
* Render to ANSI at the transport's width: colors and line breaks, no
* cursor controls, no modes. Without an argument, the document as it
Expand Down Expand Up @@ -1258,8 +1284,17 @@ function syncHoverReporting(termDOM: TermDOM): void {

// An image's or a canvas's natural size in cells is its pixels over the
// cell's, so a cell measured anew lays them out again.
function cellPixelsChanged(termDOM: TermDOM): void {
// What the engine sized from the terminal's cell follows it: images,
// always, and every length the page wrote when the cell sizes the page.
function cellMeasured(termDOM: TermDOM): void {
relayoutReplacedElements(termDOM.document);
if (getCellSizeSetting(termDOM.document) === "auto") {
cellSizeChanged(termDOM);
}
const {width, height} = _(termDOM).framebuffer.cellPixels;
termDOM.dispatchEvent(
constructInternal(() => new CellChangeEvent("cellchange", {width, height})),
);
}

// Every length the page wrote measures differently now, and so does the
Expand Down
36 changes: 36 additions & 0 deletions src/internal/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1953,6 +1953,42 @@ function addLifetimePromise(
promise.then(settled, settled);
}

export interface CellChangeEventInit extends EventInit {
width: number;
height: number;
}

export interface CellChangeEventSlots extends EventSlots {
width: number;
height: number;
}

export interface CellChangeEvent {
readonly [internals]: CellChangeEventSlots;
}

/**
* The terminal's cell measured at another size, dispatched at the TermDOM:
* one cell in the terminal's pixels, as measureCell() reports it. A font
* zoom is the usual cause, and so is the first answer after attach() when
* it differs from the size assumed until then.
*/
export class CellChangeEvent extends Event {
constructor(type: string, init: CellChangeEventInit) {
super(type, init);
_(this).width = init.width;
_(this).height = init.height;
}

get width(): number {
return _(this).width;
}

get height(): number {
return _(this).height;
}
}

export interface FetchEventInit extends EventInit {
request: Request;
preloadResponse?: Promise<unknown>;
Expand Down
43 changes: 35 additions & 8 deletions src/internal/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,9 @@ export interface ExchangeSlots extends EventTargetSlots {
interactive: boolean;
// Whether the terminal's answers can be read, so it is asked anything.
asking: boolean;
// The cell size asked for and not yet answered, which every caller
// waits on rather than asking again.
cellQuery: Promise<{width: number; height: number} | null> | null;
engagedModes: Set<ModeName>;
anchorDetectionEnabled: boolean;
window: Window;
Expand Down Expand Up @@ -1005,6 +1008,7 @@ export class Exchange extends EventTarget {
slots.transport = transport;
slots.interactive = interactive;
slots.asking = asking;
slots.cellQuery = null;
slots.engagedModes = new Set<ModeName>();
// Anchor detection only makes sense when a shell's rows are above ours.
slots.anchorDetectionEnabled = transport.sharesScreen && asking;
Expand Down Expand Up @@ -1414,9 +1418,37 @@ export async function negotiateCellPixels(
exchange: Exchange,
): Promise<boolean> {
const slots = _(exchange);
if (!slots.interactive) {
return false;
const answer = await queryCellPixels(exchange);
return (
answer !== null &&
!slots.disposed &&
adoptCellPixels(slots.framebuffer, answer.width, answer.height)
);
}

/**
* One cell in the terminal's pixels, as it answers CSI 16 t, or null
* when nothing can be asked or it does not say. A question already
* asked is waited on, not asked again.
*/
export function queryCellPixels(
exchange: Exchange,
): Promise<{width: number; height: number} | null> {
const slots = _(exchange);
if (!slots.interactive || slots.disposed) {
return Promise.resolve(null);
}
slots.cellQuery ??= askCellPixels(exchange).finally(() => {
slots.cellQuery = null;
});
return slots.cellQuery;
}

// The terminal answers its device attributes after anything asked before
// them, so no cell size by then is none.
function askCellPixels(
exchange: Exchange,
): Promise<{width: number; height: number} | null> {
let answered!: () => void;
const until = new Promise<void>((resolve) => (answered = resolve));
const cell = nextReply<"cell-size", {width: number; height: number} | null>(
Expand All @@ -1433,12 +1465,7 @@ export async function negotiateCellPixels(
},
);
void queryDeviceAttributes(exchange).then(answered);
const answer = await cell;
return (
answer !== null &&
!slots.disposed &&
adoptCellPixels(slots.framebuffer, answer.width, answer.height)
);
return cell;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/internal/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ function networkFetch(request: Request): Promise<Response> {
return isWindowFetch(current) ? runtimeFetch(request) : current(request);
}

export {ExtendableEvent, FetchEvent, type FetchEventInit} from "./dom.ts";
export {
CellChangeEvent,
type CellChangeEventInit,
ExtendableEvent,
FetchEvent,
type FetchEventInit,
} from "./dom.ts";

/** What a load knows of what asked for it. */
interface RequestContext {
Expand Down
Loading
Loading