diff --git a/docs/guides/06-images-and-canvas.md b/docs/guides/06-images-and-canvas.md index a485cd8c..920292a1 100644 --- a/docs/guides/06-images-and-canvas.md +++ b/docs/guides/06-images-and-canvas.md @@ -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 diff --git a/docs/guides/07-api.md b/docs/guides/07-api.md index eea403d3..22ec9294 100644 --- a/docs/guides/07-api.md +++ b/docs/guides/07-api.md @@ -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, @@ -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`. diff --git a/src/index.ts b/src/index.ts index 8bfb9cef..cf79bbb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { import { applyMutations, attachDocument, + CellChangeEvent, checkFormControls, clampScrollOffsets, clearHighlights, @@ -63,6 +64,7 @@ import { negotiateColorScheme, negotiateGraphemeClusters, negotiateOverline, + queryCellPixels, queryDeviceAttributes, reclaimModes, releaseModes, @@ -79,6 +81,7 @@ import { writeLines, } from "./internal/exchange.ts"; import { + adoptCellPixels, beginFrame, endFrame, Framebuffer, @@ -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, @@ -370,6 +375,7 @@ const HELD_ERROR_LIMIT = 50; /** The events a TermDOM dispatches, by type. */ export interface TermDOMEventMap { + cellchange: CellChangeEvent; fetch: FetchEvent; } @@ -779,10 +785,7 @@ export class TermDOM extends EventTarget { return; } if (cellChanged) { - cellPixelsChanged(this); - if (measuresCell) { - cellSizeChanged(this); - } + cellMeasured(this); } void render(this); }, @@ -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(); @@ -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 @@ -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 diff --git a/src/internal/dom.ts b/src/internal/dom.ts index ff1c8ff2..ad2fc611 100644 --- a/src/internal/dom.ts +++ b/src/internal/dom.ts @@ -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; diff --git a/src/internal/exchange.ts b/src/internal/exchange.ts index 59b1ed2e..0efeb09b 100644 --- a/src/internal/exchange.ts +++ b/src/internal/exchange.ts @@ -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; anchorDetectionEnabled: boolean; window: Window; @@ -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(); // Anchor detection only makes sense when a shell's rows are above ours. slots.anchorDetectionEnabled = transport.sharesScreen && asking; @@ -1414,9 +1418,37 @@ export async function negotiateCellPixels( exchange: Exchange, ): Promise { 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((resolve) => (answered = resolve)); const cell = nextReply<"cell-size", {width: number; height: number} | null>( @@ -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; } /** diff --git a/src/internal/resources.ts b/src/internal/resources.ts index 7db7544c..9a9260e6 100644 --- a/src/internal/resources.ts +++ b/src/internal/resources.ts @@ -35,7 +35,13 @@ function networkFetch(request: Request): Promise { 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 { diff --git a/tests/cell-size.test.ts b/tests/cell-size.test.ts index 37b63180..f47d9f64 100644 --- a/tests/cell-size.test.ts +++ b/tests/cell-size.test.ts @@ -693,3 +693,81 @@ test("text breaks to the columns a box at a half cell is drawn across", () => { dom.dispose(); } }); + +// measureCell() asks the terminal for its cell in pixels whatever the +// cellSize setting, since an image or a canvas drawing to the cell's +// shape needs it under the unit cell too. +test("measureCell answers what the terminal reports", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + scriptReplies(terminal, [{ask: "\x1b[16t", reply: "\x1b[6;25;11t"}]); + const dom = new TermDOM({transport: terminal.transport, colorDepth: "rgb"}); + await dom.attach(); + expect(await dom.measureCell()).toEqual({width: 11, height: 25}); + // The unit cell still sizes the page. + expect(dom.window.innerWidth).toBe(40); + dom.dispose(); +}); + +test("measureCell is null with no terminal, or one that does not say", async () => { + const detached = new TermDOM({ + transport: new MockProcess({cols: 40, rows: 10}).transport, + }); + expect(await detached.measureCell()).toBe(null); + detached.dispose(); + + const terminal = new MockProcess({cols: 40, rows: 10}); + scriptReplies(terminal, [{ask: "\x1b[c", reply: "\x1b[?62c"}]); + const silent = + new TermDOM({transport: terminal.transport, colorDepth: "rgb"}); + await silent.attach(); + expect(await silent.measureCell()).toBe(null); + silent.dispose(); + expect(await silent.measureCell()).toBe(null); +}); + +test("measureCell calls made together ask the terminal once", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + scriptReplies(terminal, [{ask: "\x1b[16t", reply: "\x1b[6;25;11t"}]); + const dom = new TermDOM({transport: terminal.transport, colorDepth: "rgb"}); + await dom.attach(); + const stdout = + terminal.stdout as unknown as {write: (...args: unknown[]) => boolean}; + const write = stdout.write.bind(stdout); + let asked = 0; + stdout.write = (...args: unknown[]) => { + asked += String(args[0]).split("\x1b[16t").length - 1; + return write(...args); + }; + const answers = await Promise.all([dom.measureCell(), dom.measureCell()]); + expect(answers).toEqual([{width: 11, height: 25}, {width: 11, height: 25}]); + expect(asked).toBe(1); + dom.dispose(); +}); + +test("a font zoom under the unit cell is a cellchange", async () => { + const terminal = new MockProcess({cols: 40, rows: 10}); + const cell = {ask: "\x1b[16t", reply: "\x1b[6;25;11t"}; + scriptReplies(terminal, [cell]); + const dom = new TermDOM({transport: terminal.transport, colorDepth: "rgb"}); + const changes: Array<[number, number]> = []; + dom.addEventListener("cellchange", (event) => { + changes.push([event.width, event.height]); + }); + await dom.attach(); + for (let waited = 0; changes.length === 0 && waited < 2000; waited += 25) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + // The first answer replaces what was assumed before it. + expect(changes).toEqual([[11, 25]]); + + // Zoomed out: twice the cells, each smaller. + cell.reply = "\x1b[6;12;5t"; + terminal.resize(80, 20); + (terminal as any).emit("SIGWINCH"); + for (let waited = 0; changes.length === 1 && waited < 2000; waited += 25) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(changes).toEqual([[11, 25], [5, 12]]); + expect(await dom.measureCell()).toEqual({width: 5, height: 12}); + dom.dispose(); +}); diff --git a/tests/img.test.ts b/tests/img.test.ts index f6151ac0..0bfcdfe9 100644 --- a/tests/img.test.ts +++ b/tests/img.test.ts @@ -920,3 +920,38 @@ test("an image at 100% stops where its cell does, short of the border", async () dom.dispose(); } }); + +// Under the unit cell the page's lengths do not follow the terminal's +// cell, but an image's pixels still divide by it, so a font zoom resizes +// the image. +test("a font zoom resizes an image under the unit cell", async () => { + const square = png(40, 40, () => RED); + const terminal = new MockProcess({cols: 40, rows: 12}); + const cell = {ask: "\x1b[16t", reply: "\x1b[6;20;10t"}; + scriptReplies(terminal, [cell]); + const dom = new TermDOM({ + transport: terminal.transport, + csp: "img-src data: https:", + }); + dom.document.body.innerHTML = ``; + await dom.attach(); + const image = dom.document.querySelector("img")!; + await loaded(image); + await nextFrame(dom); + const before = image.getBoundingClientRect(); + expect([before.width, before.height]).toEqual([4, 2]); + + // Zoomed in: each cell twice as many pixels. + cell.reply = "\x1b[6;40;20t"; + terminal.resize(20, 6); + (terminal as any).emit("SIGWINCH"); + for (let waited = 0; waited < 2000; waited += 25) { + await new Promise((resolve) => setTimeout(resolve, 25)); + if (image.getBoundingClientRect().width !== 4) { + break; + } + } + const after = image.getBoundingClientRect(); + expect([after.width, after.height]).toEqual([2, 1]); + dom.dispose(); +});