From 214289aaabfcfb9f1d268572043bdaf6bc86a8df Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:58:26 -0800 Subject: [PATCH 1/3] Add dimensions API Fixes #702 --- .vscode/settings.json | 4 + addons/addon-fit/src/FitAddon.ts | 13 +--- src/browser/CoreBrowserTerminal.ts | 33 +++++++- src/browser/TestUtils.test.ts | 4 +- src/browser/Types.ts | 5 +- src/browser/public/Terminal.ts | 6 +- test/playwright/TestUtils.ts | 15 +++- typings/xterm.d.ts | 121 +++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 18 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2c1653d3..1fe5ba08 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,10 @@ "npm run build": true, "npm run esbuild": true, "npm run dev": true, + "npm run test-integration": true, + "npm run test-integration-chromium": true, + "npm run test-integration-firefox": true, + "npm run test-integration-webkit": true, "npm run lint": true, "npm run lint-fix": true, "npm run lint-api": true, diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index fa077b90..23004c1c 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -3,9 +3,8 @@ * @license MIT */ -import type { Terminal, ITerminalAddon } from '@xterm/xterm'; +import type { Terminal, ITerminalAddon, IRenderDimensions } from '@xterm/xterm'; import type { FitAddon as IFitApi } from '@xterm/addon-fit'; -import { IRenderDimensions } from 'browser/renderer/shared/Types'; import { ViewportConstants } from 'browser/shared/Constants'; interface ITerminalDimensions { @@ -49,12 +48,8 @@ export class FitAddon implements ITerminalAddon , IFitApi { return; } - // TODO: Remove reliance on private API - const core = (this._terminal as any)._core; - // Force a full render if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { - core._renderService.clear(); this._terminal.resize(dims.cols, dims.rows); } } @@ -68,11 +63,9 @@ export class FitAddon implements ITerminalAddon , IFitApi { return undefined; } - // TODO: Remove reliance on private API - const core = (this._terminal as any)._core; - const dims: IRenderDimensions = core._renderService.dimensions; + const dims: IRenderDimensions | undefined = this._terminal.dimensions; - if (dims.css.cell.width === 0 || dims.css.cell.height === 0) { + if (!dims || dims.css.cell.width === 0 || dims.css.cell.height === 0) { return undefined; } diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 368aeb77..c4e83389 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -21,7 +21,7 @@ * http://linux.die.net/man/7/urxvt */ -import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker } from '@xterm/xterm'; +import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker, IRenderDimensions as IRenderDimensionsApi } from '@xterm/xterm'; import { copyHandler, handlePasteEvent, moveTextAreaUnderMouseCursor, paste, rightClickHandler } from 'browser/Clipboard'; import * as Strings from 'browser/LocalizableStrings'; import { OscLinkProvider } from 'browser/OscLinkProvider'; @@ -143,6 +143,26 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { public get onA11yTab(): Event { return this._onA11yTabEmitter.event; } private _onWillOpen = this._register(new Emitter()); public get onWillOpen(): Event { return this._onWillOpen.event; } + private readonly _onDimensionsChange = this._register(new Emitter()); + public readonly onDimensionsChange = this._onDimensionsChange.event; + + public get dimensions(): IRenderDimensionsApi | undefined { + if (!this._renderService) { + return undefined; + } + const dimensions = this._renderService.dimensions; + return { + css: { + canvas: { ...dimensions.css.canvas }, + cell: { ...dimensions.css.cell } + }, + device: { + canvas: { ...dimensions.device.canvas }, + cell: { ...dimensions.device.cell }, + char: { ...dimensions.device.char } + } + }; + } constructor( options: Partial = {} @@ -476,6 +496,17 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._renderService = this._register(this._instantiationService.createInstance(RenderService, this.rows, this.screenElement)); this._instantiationService.setService(IRenderService, this._renderService); this._register(this._renderService.onRenderedViewportChange(e => this._onRender.fire(e))); + this._register(this._renderService.onDimensionsChange(e => this._onDimensionsChange.fire({ + css: { + canvas: { ...e.css.canvas }, + cell: { ...e.css.cell } + }, + device: { + canvas: { ...e.device.canvas }, + cell: { ...e.device.cell }, + char: { ...e.device.char } + } + }))); this.onResize(e => this._renderService!.resize(e.cols, e.rows)); this._compositionView = this._document.createElement('div'); diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index cf878cbc..fada9c54 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration } from '@xterm/xterm'; +import { IDisposable, IMarker, ILinkProvider, IDecorationOptions, IDecoration, IRenderDimensions as IRenderDimensionsApi } from '@xterm/xterm'; import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types'; import { IColorSet, ITerminal, ILinkifier2, IBrowser, IViewport, ICompositionHelper, CharacterJoinerHandler, IBufferRange, ReadonlyColorSet, IBufferElementProvider } from 'browser/Types'; @@ -47,6 +47,8 @@ export class MockTerminal implements ITerminal { public onKey!: Event<{ key: string, domEvent: KeyboardEvent }>; public onRender!: Event<{ start: number, end: number }>; public onResize!: Event<{ cols: number, rows: number }>; + public onDimensionsChange!: Event; + public dimensions: IRenderDimensionsApi | undefined; public markers!: IMarker[]; public linkifier: ILinkifier2 | undefined; public coreMouseService!: ICoreMouseService; diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 10e60411..77f6a61c 100644 --- a/src/browser/Types.ts +++ b/src/browser/Types.ts @@ -5,7 +5,7 @@ import { CharData, IColor, ICoreTerminal, ITerminalOptions } from 'common/Types'; import { IBuffer } from 'common/buffer/Types'; -import { IDisposable, Terminal as ITerminalApi } from '@xterm/xterm'; +import { IDisposable, IRenderDimensions as IRenderDimensionsApi, Terminal as ITerminalApi } from '@xterm/xterm'; import { channels, css } from 'common/Color'; import type { Event } from 'vs/base/common/event'; @@ -21,8 +21,11 @@ export interface ITerminal extends InternalPassthroughApis, ICoreTerminal { linkifier: ILinkifier2 | undefined; options: Required; + readonly dimensions: IRenderDimensionsApi | undefined; + onBlur: Event; onFocus: Event; + onDimensionsChange: Event; onA11yChar: Event; onA11yTab: Event; onWillOpen: Event; diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index f6ec55d2..1e16d556 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -12,7 +12,7 @@ import { AddonManager } from 'common/public/AddonManager'; import { BufferNamespaceApi } from 'common/public/BufferNamespaceApi'; import { ParserApi } from 'common/public/ParserApi'; import { UnicodeApi } from 'common/public/UnicodeApi'; -import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from '@xterm/xterm'; +import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, IRenderDimensions, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from '@xterm/xterm'; import type { Event } from 'vs/base/common/event'; /** @@ -80,6 +80,7 @@ export class Terminal extends Disposable implements ITerminalApi { public get onSelectionChange(): Event { return this._core.onSelectionChange; } public get onTitleChange(): Event { return this._core.onTitleChange; } public get onWriteParsed(): Event { return this._core.onWriteParsed; } + public get onDimensionsChange(): Event { return this._core.onDimensionsChange; } public get element(): HTMLElement | undefined { return this._core.element; } public get parser(): IParser { @@ -127,6 +128,9 @@ export class Terminal extends Disposable implements ITerminalApi { wraparoundMode: m.wraparound }; } + public get dimensions(): IRenderDimensions | undefined { + return this._core.dimensions; + } public get options(): Required { return this._publicOptions; } diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts index 271b0393..dfc17e75 100644 --- a/test/playwright/TestUtils.ts +++ b/test/playwright/TestUtils.ts @@ -5,12 +5,12 @@ import { Browser, JSHandle, Page } from '@playwright/test'; import { deepStrictEqual, strictEqual } from 'assert'; -import type { IRenderDimensions } from 'browser/renderer/shared/Types'; +import type { IRenderDimensions as IRenderDimensionsInternal } from 'browser/renderer/shared/Types'; import type { IRenderService } from 'browser/services/Services'; import type { ICoreTerminal, IDisposable, IMarker } from 'common/Types'; import * as playwright from '@playwright/test'; import { PageFunction } from 'playwright-core/types/structs'; -import { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm'; +import { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, IRenderDimensions, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm'; export interface ITestContext { browser: Browser; @@ -114,7 +114,7 @@ interface ITerminalProxyCustomMethods { type TerminalProxyAsyncPropOverrides = 'cols' | 'rows' | 'modes'; type TerminalProxyAsyncMethodOverrides = 'hasSelection' | 'getSelection' | 'getSelectionPosition' | 'registerMarker' | 'registerDecoration'; -type TerminalProxyCustomOverrides = 'buffer' | ( +type TerminalProxyCustomOverrides = 'buffer' | 'dimensions' | ( // The below are not implemented yet 'element' | 'textarea' | @@ -150,6 +150,7 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi await this._page.exposeFunction('onSelectionChange', () => this._onSelectionChange.fire()); await this._page.exposeFunction('onTitleChange', (e: string) => this._onTitleChange.fire(e)); await this._page.exposeFunction('onWriteParsed', () => this._onWriteParsed.fire()); + await this._page.exposeFunction('onDimensionsChange', (e: IRenderDimensions) => this._onDimensionsChange.fire(e)); } /** @@ -168,6 +169,7 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi this._onSelectionChange.dispose(); this._onTitleChange.dispose(); this._onWriteParsed.dispose(); + this._onDimensionsChange.dispose(); this._onBell = new EventEmitter(); this._onBinary = new EventEmitter(); @@ -181,6 +183,7 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi this._onSelectionChange = new EventEmitter(); this._onTitleChange = new EventEmitter(); this._onWriteParsed = new EventEmitter(); + this._onDimensionsChange = new EventEmitter(); await this.evaluate(([term]) => term.onBell((window as any).onBell)); await this.evaluate(([term]) => term.onBinary((window as any).onBinary)); @@ -194,6 +197,7 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi await this.evaluate(([term]) => term.onSelectionChange((window as any).onSelectionChange)); await this.evaluate(([term]) => term.onTitleChange((window as any).onTitleChange)); await this.evaluate(([term]) => term.onWriteParsed((window as any).onWriteParsed)); + await this.evaluate(([term]) => term.onDimensionsChange((window as any).onDimensionsChange)); } // #region Events @@ -215,6 +219,8 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi public get onResize(): IEvent<{ cols: number, rows: number }> { return this._onResize.event; } private _onScroll = new EventEmitter(); public get onScroll(): IEvent { return this._onScroll.event; } + private _onDimensionsChange = new EventEmitter(); + public get onDimensionsChange(): IEvent { return this._onDimensionsChange.event; } private _onSelectionChange = new EventEmitter(); public get onSelectionChange(): IEvent { return this._onSelectionChange.event; } private _onTitleChange = new EventEmitter(); @@ -227,6 +233,7 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi public get cols(): Promise { return this.evaluate(([term]) => term.cols); } public get rows(): Promise { return this.evaluate(([term]) => term.rows); } public get modes(): Promise { return this.evaluate(([term]) => term.modes); } + public get dimensions(): Promise { return this.evaluate(([term]) => term.dimensions); } // #endregion // #region Complex properties @@ -396,7 +403,7 @@ class TerminalCoreProxy { } public get isDisposed(): Promise { return this.evaluate(([core]) => (core as any)._isDisposed); } - public get renderDimensions(): Promise { return this.evaluate(([core]) => ((core as any)._renderService as IRenderService).dimensions); } + public get renderDimensions(): Promise { return this.evaluate(([core]) => ((core as any)._renderService as IRenderService).dimensions); } public async triggerBinaryEvent(data: string): Promise { return this._page.evaluate(([core, data]) => core.coreService.triggerBinaryEvent(data), [await this._getCoreHandle(), data] as const); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 959b4afe..6a936213 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -877,6 +877,12 @@ declare module '@xterm/xterm' { */ readonly modes: IModes; + /** + * The dimensions of the terminal. This will be undefined before + * {@link open} is called. + */ + readonly dimensions: IRenderDimensions | undefined; + /** * Gets or sets the terminal options. This supports setting multiple * options. @@ -1017,6 +1023,12 @@ declare module '@xterm/xterm' { */ onTitleChange: IEvent; + /** + * Adds an event listener for when the terminal's dimensions change. + * @returns an `IDisposable` to stop listening. + */ + onDimensionsChange: IEvent; + /** * Unfocus the terminal. */ @@ -1969,4 +1981,113 @@ declare module '@xterm/xterm' { */ readonly wraparoundMode: boolean; } + + /** + * An object containing a width and height in pixels. + */ + export interface IDimensions { + width: number; + height: number; + } + + /** + * An object containing a top and left offset. + */ + export interface IOffset { + top: number; + left: number; + } + + /** + * The dimensions of the terminal. + */ + export interface IRenderDimensions { + /** + * Dimensions measured in CSS pixels (ie. device pixels / device pixel + * ratio). + */ + css: { + /** + * The dimensions of the canvas. + */ + canvas: IDimensions; + /** + * The dimensions of a single cell. + */ + cell: IDimensions; + }; + /** + * Dimensions measured in actual pixels as rendered to the device. + */ + device: { + /** + * The dimensions of the canvas. + */ + canvas: IDimensions; + /** + * The dimensions of a single cell. + */ + cell: IDimensions; + /** + * The dimensions of a single character within a cell, including its + * offset within the cell. + */ + char: IDimensions & IOffset; + }; + } + + /** + * An object containing a width and height in pixels. + */ + export interface IDimensions { + width: number; + height: number; + } + + /** + * An object containing a top and left offset. + */ + export interface IOffset { + top: number; + left: number; + } + + /** + * The dimensions of the terminal, this is constructed and available after + * {@link Terminal.open} is called. + */ + export interface IRenderDimensions { + /** + * Dimensions measured in CSS pixels (ie. device pixels / device pixel + * ratio). + */ + css: { + /** + * The dimensions of the canvas which is the full terminal size. + */ + canvas: IDimensions; + /** + * The dimensions of a single cell. + */ + cell: IDimensions; + }; + /** + * Dimensions measured in actual pixels as rendered to the device. + */ + device: { + /** + * The dimensions of the canvas which is the full terminal size. + */ + canvas: IDimensions; + /** + * The dimensions of a single cell. + */ + cell: IDimensions; + /** + * The dimensions of a single character within a cell, including its + * offset within the cell. + */ + char: IDimensions & IOffset; + }; + } } From e27cc2c08949c2e671bcf03a9894dcfc7c38f9e3 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 30 Dec 2025 04:06:26 -0800 Subject: [PATCH 2/3] Remove private API access from tests/demo --- addons/addon-image/test/ImageAddon.test.ts | 2 +- addons/addon-web-links/test/WebLinksAddon.test.ts | 2 +- demo/client/client.ts | 4 ++-- test/playwright/InputHandler.test.ts | 2 +- test/playwright/MouseTracking.test.ts | 2 +- test/playwright/Terminal.test.ts | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 25d47c82..a26ba0f4 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -300,7 +300,7 @@ test.describe('ImageAddon', () => { * terminal access helpers. */ async function getDimensions(): Promise { - const dimensions: any = await ctx.page.evaluate(`term._core._renderService.dimensions`); + const dimensions: any = await ctx.page.evaluate(`term.dimensions`); return { cellWidth: Math.round(dimensions.css.cell.width), cellHeight: Math.round(dimensions.css.cell.height), diff --git a/addons/addon-web-links/test/WebLinksAddon.test.ts b/addons/addon-web-links/test/WebLinksAddon.test.ts index e4d5f5f2..f5764601 100644 --- a/addons/addon-web-links/test/WebLinksAddon.test.ts +++ b/addons/addon-web-links/test/WebLinksAddon.test.ts @@ -180,7 +180,7 @@ async function cellPos(col: number, row: number): Promise<[number, number]> { const coords: any = await ctx.page.evaluate(` (function() { const rect = window.term.element.getBoundingClientRect(); - const dim = term._core._renderService.dimensions; + const dim = window.term.dimensions; return {left: rect.left, top: rect.top, bottom: rect.bottom, right: rect.right, width: dim.css.cell.width, height: dim.css.cell.height}; })(); `); diff --git a/demo/client/client.ts b/demo/client/client.ts index 69913984..c2134773 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -582,9 +582,9 @@ function addDomListener(element: HTMLElement, type: string, handler: (...args: a function updateTerminalSize(): void { const width = optionsWindow.autoResize ? '100%' - : (term._core._renderService.dimensions.css.canvas.width + term._core.viewport.scrollBarWidth).toString() + 'px'; + : (term.dimensions.css.canvas.width + term._core.viewport.scrollBarWidth).toString() + 'px'; const height = optionsWindow.autoResize ? '100%' - : (term._core._renderService.dimensions.css.canvas.height).toString() + 'px'; + : (term.dimensions.css.canvas.height).toString() + 'px'; terminalContainer.style.width = width; terminalContainer.style.height = height; addons.fit.instance.fit(); diff --git a/test/playwright/InputHandler.test.ts b/test/playwright/InputHandler.test.ts index fb2eeb9c..f7da81c6 100644 --- a/test/playwright/InputHandler.test.ts +++ b/test/playwright/InputHandler.test.ts @@ -1405,7 +1405,7 @@ async function getCursor(): Promise<{ col: number, row: number }> { } async function getDimensions(): Promise { - const dim: IRenderDimensions = await ctx.page.evaluate(`term._core._renderService.dimensions`); + const dim: IRenderDimensions = await ctx.page.evaluate(`term.dimensions`); return { cellWidth: dim.css.cell.width.toFixed(0), cellHeight: dim.css.cell.height.toFixed(0), diff --git a/test/playwright/MouseTracking.test.ts b/test/playwright/MouseTracking.test.ts index 7620f881..4b6ad925 100644 --- a/test/playwright/MouseTracking.test.ts +++ b/test/playwright/MouseTracking.test.ts @@ -47,7 +47,7 @@ async function cellPos(col: number, row: number): Promise { const coords: any = await ctx.page.evaluate(` (function() { const rect = window.term.element.getBoundingClientRect(); - const dim = term._core._renderService.dimensions; + const dim = window.term.dimensions; return {left: rect.left, top: rect.top, bottom: rect.bottom, right: rect.right, width: dim.css.cell.width, height: dim.css.cell.height}; })(); `); diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index 830c8989..ee5678c3 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -719,7 +719,7 @@ test.describe('API Integration Tests', () => { await ctx.page.evaluate(`window.term = new Terminal()`); await ctx.page.evaluate(`window.term.open(document.querySelector('#terminal-container'))`); await ctx.page.evaluate(`document.querySelector('#terminal-container').style.display=''`); - await pollFor(ctx.page, `window.term._core._renderService.dimensions.css.cell.width > 0`, true); + await pollFor(ctx.page, `window.term.dimensions.css.cell.width > 0`, true); }); test.describe('registerDecoration', () => { @@ -1035,7 +1035,7 @@ async function getDimensions(): Promise { return { top: rect.top, left: rect.left, - renderDimensions: window.term._core._renderService.dimensions + renderDimensions: window.term.dimensions }; })(); `); From 13914ee01ed4f5524671f75c4b3d751e26177944 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 30 Dec 2025 04:09:56 -0800 Subject: [PATCH 3/3] Remove private use in ImageRenderer --- addons/addon-image/src/ImageRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts index e1790f47..e37169f3 100644 --- a/addons/addon-image/src/ImageRenderer.ts +++ b/addons/addon-image/src/ImageRenderer.ts @@ -124,7 +124,7 @@ export class ImageRenderer extends Disposable implements IDisposable { * Forwarded from internal render service. */ public get dimensions(): IRenderDimensions | undefined { - return this._renderService?.dimensions; + return this._terminal.dimensions; } /**