diff --git a/demo/client.ts b/demo/client.ts index f5903aa4d7..00d0bf678f 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -1310,23 +1310,25 @@ function addVtButtons(): void { } const vtFragment = document.createDocumentFragment(); const buttonSpecs: { [key: string]: { label: string, description: string, paramCount?: number }} = { - A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, - B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, - C: { label: 'CUF →', description: 'Cursor Forward Ps Times' }, - D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' }, - E: { label: 'CNL', description: 'Cursor Next Line Ps Times' }, - F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' }, - G: { label: 'CHA', description: 'Cursor Character Absolute' }, - H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 }, - I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' }, - J: { label: 'ED', description: 'Erase in Display' }, - '?|J': { label: 'DECSED', description: 'Erase in Display' }, - K: { label: 'EL', description: 'Erase in Line' }, - '?|K': { label: 'DECSEL', description: 'Erase in Line' }, - L: { label: 'IL', description: 'Insert Ps Line(s)' }, - M: { label: 'DL', description: 'Delete Ps Line(s)' }, - P: { label: 'DCH', description: 'Delete Ps Character(s)' }, - ' q': { label: 'DECSCUSR', description: 'Set Cursor Style', paramCount: 1 } + A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, + B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, + C: { label: 'CUF →', description: 'Cursor Forward Ps Times' }, + D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' }, + E: { label: 'CNL', description: 'Cursor Next Line Ps Times' }, + F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' }, + G: { label: 'CHA', description: 'Cursor Character Absolute' }, + H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 }, + I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' }, + J: { label: 'ED', description: 'Erase in Display' }, + '?|J': { label: 'DECSED', description: 'Erase in Display' }, + K: { label: 'EL', description: 'Erase in Line' }, + '?|K': { label: 'DECSEL', description: 'Erase in Line' }, + L: { label: 'IL', description: 'Insert Ps Line(s)' }, + M: { label: 'DL', description: 'Delete Ps Line(s)' }, + P: { label: 'DCH', description: 'Delete Ps Character(s)' }, + ' q': { label: 'DECSCUSR', description: 'Set Cursor Style' }, + '?2026h': { label: 'BSU', description: 'Begin synchronized update', paramCount: 0 }, + '?2026l': { label: 'ESU', description: 'End synchronized update', paramCount: 0 } }; for (const s of Object.keys(buttonSpecs)) { const spec = buttonSpecs[s]; diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 3b4309437f..f6ec55d24f 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -123,6 +123,7 @@ export class Terminal extends Disposable implements ITerminalApi { originMode: m.origin, reverseWraparoundMode: m.reverseWraparound, sendFocusMode: m.sendFocus, + synchronizedOutputMode: m.synchronizedOutput, wraparoundMode: m.wraparound }; } diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 3ca0314afa..e9acbeb36a 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types'; import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { DebouncedIdleTask } from 'common/TaskQueue'; -import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'vs/base/common/event'; interface ISelectionState { @@ -18,6 +18,10 @@ interface ISelectionState { columnSelectMode: boolean; } +const enum Constants { + SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000 +} + export class RenderService extends Disposable implements IRenderService { public serviceBrand: undefined; @@ -32,6 +36,7 @@ export class RenderService extends Disposable implements IRenderService { private _needsSelectionRefresh: boolean = false; private _canvasWidth: number = 0; private _canvasHeight: number = 0; + private _syncOutputHandler: SynchronizedOutputHandler; private _selectionState: ISelectionState = { start: undefined, end: undefined, @@ -52,23 +57,31 @@ export class RenderService extends Disposable implements IRenderService { constructor( private _rowCount: number, screenElement: HTMLElement, - @IOptionsService optionsService: IOptionsService, + @IOptionsService private readonly _optionsService: IOptionsService, @ICharSizeService private readonly _charSizeService: ICharSizeService, + @ICoreService private readonly _coreService: ICoreService, @IDecorationService decorationService: IDecorationService, @IBufferService bufferService: IBufferService, - @ICoreBrowserService coreBrowserService: ICoreBrowserService, + @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService, @IThemeService themeService: IThemeService ) { super(); - this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), coreBrowserService); + this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService); this._register(this._renderDebouncer); - this._register(coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange())); + this._syncOutputHandler = new SynchronizedOutputHandler( + this._coreBrowserService, + this._coreService, + () => this._fullRefresh() + ); + this._register(toDisposable(() => this._syncOutputHandler.dispose())); + + this._register(this._coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange())); this._register(bufferService.onResize(() => this._fullRefresh())); this._register(bufferService.buffers.onBufferActivate(() => this._renderer.value?.clear())); - this._register(optionsService.onOptionChange(() => this._handleOptionsChanged())); + this._register(this._optionsService.onOptionChange(() => this._handleOptionsChanged())); this._register(this._charSizeService.onCharSizeChange(() => this.handleCharSizeChanged())); // Do a full refresh whenever any decoration is added or removed. This may not actually result @@ -78,7 +91,7 @@ export class RenderService extends Disposable implements IRenderService { this._register(decorationService.onDecorationRemoved(() => this._fullRefresh())); // Clear the renderer when the a change that could affect glyphs occurs - this._register(optionsService.onMultipleOptionChange([ + this._register(this._optionsService.onMultipleOptionChange([ 'customGlyphs', 'drawBoldTextInBrightColors', 'letterSpacing', @@ -96,15 +109,15 @@ export class RenderService extends Disposable implements IRenderService { })); // Refresh the cursor line when the cursor changes - this._register(optionsService.onMultipleOptionChange([ + this._register(this._optionsService.onMultipleOptionChange([ 'cursorBlink', 'cursorStyle' ], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true))); this._register(themeService.onChangeColors(() => this._fullRefresh())); - this._registerIntersectionObserver(coreBrowserService.window, screenElement); - this._register(coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement))); + this._registerIntersectionObserver(this._coreBrowserService.window, screenElement); + this._register(this._coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement))); } private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void { @@ -137,6 +150,18 @@ export class RenderService extends Disposable implements IRenderService { this._needsFullRefresh = true; return; } + + if (this._coreService.decPrivateModes.synchronizedOutput) { + this._syncOutputHandler.bufferRows(start, end); + return; + } + + const buffered = this._syncOutputHandler.flush(); + if (buffered) { + start = Math.min(start, buffered.start); + end = Math.max(end, buffered.end); + } + if (!isRedrawOnly) { this._isNextRenderRedrawOnly = false; } @@ -148,6 +173,13 @@ export class RenderService extends Disposable implements IRenderService { return; } + // Skip rendering if synchronized output mode is enabled. This check must happen here + // (in addition to refreshRows) to handle renders that were queued before the mode was enabled. + if (this._coreService.decPrivateModes.synchronizedOutput) { + this._syncOutputHandler.bufferRows(start, end); + return; + } + // Since this is debounced, a resize event could have happened between the time a refresh was // requested and when this triggers. Clamp the values of start and end to ensure they're valid // given the current viewport state. @@ -283,3 +315,62 @@ export class RenderService extends Disposable implements IRenderService { this._renderer.value?.clear(); } } + +/** + * Buffers row refresh requests during synchronized output mode (DEC mode 2026). + * When the mode is disabled, the accumulated row range is flushed for rendering. + * A safety timeout ensures rendering occurs even if the end sequence is not received. + */ +class SynchronizedOutputHandler { + private _start: number = 0; + private _end: number = 0; + private _timeout: number | undefined; + private _isBuffering: boolean = false; + + constructor( + private readonly _coreBrowserService: ICoreBrowserService, + private readonly _coreService: ICoreService, + private readonly _onTimeout: () => void + ) {} + + public bufferRows(start: number, end: number): void { + if (!this._isBuffering) { + this._start = start; + this._end = end; + this._isBuffering = true; + } else { + this._start = Math.min(this._start, start); + this._end = Math.max(this._end, end); + } + + if (this._timeout === undefined) { + this._timeout = this._coreBrowserService.window.setTimeout(() => { + this._timeout = undefined; + this._coreService.decPrivateModes.synchronizedOutput = false; + this._onTimeout(); + }, Constants.SYNCHRONIZED_OUTPUT_TIMEOUT_MS); + } + } + + public flush(): { start: number, end: number } | undefined { + if (this._timeout !== undefined) { + this._coreBrowserService.window.clearTimeout(this._timeout); + this._timeout = undefined; + } + + if (!this._isBuffering) { + return undefined; + } + + const result = { start: this._start, end: this._end }; + this._isBuffering = false; + return result; + } + + public dispose(): void { + if (this._timeout !== undefined) { + this._coreBrowserService.window.clearTimeout(this._timeout); + this._timeout = undefined; + } + } +} diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 78ce646ad6..1325cf0f56 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -2314,7 +2314,7 @@ describe('InputHandler', () => { }); it('DEC privates with set/reset semantic', async () => { // initially reset - const reset = [1, 6, 9, 12, 45, 66, 1000, 1002, 1003, 1004, 1006, 1016, 47, 1047, 1049, 2004]; + const reset = [1, 6, 9, 12, 45, 66, 1000, 1002, 1003, 1004, 1006, 1016, 47, 1047, 1049, 2004, 2026]; for (const mode of reset) { await inputHandler.parseP(`\x1b[?${mode}$p`); assert.deepEqual(reportStack.pop(), `\x1b[?${mode};2$y`); // initial reset diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index ca80a789ca..ba53941a7a 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1969,6 +1969,9 @@ export class InputHandler extends Disposable implements IInputHandler { case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) this._coreService.decPrivateModes.bracketedPasteMode = true; break; + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md) + this._coreService.decPrivateModes.synchronizedOutput = true; + break; } } return true; @@ -2197,6 +2200,10 @@ export class InputHandler extends Disposable implements IInputHandler { case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) this._coreService.decPrivateModes.bracketedPasteMode = false; break; + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md) + this._coreService.decPrivateModes.synchronizedOutput = false; + this._onRequestRefreshRows.fire(undefined); + break; } } return true; @@ -2291,6 +2298,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (p === 1048) return f(p, V.SET); // xterm always returns SET here if (p === 47 || p === 1047 || p === 1049) return f(p, b2v(active === alt)); if (p === 2004) return f(p, b2v(dm.bracketedPasteMode)); + if (p === 2026) return f(p, b2v(dm.synchronizedOutput)); return f(p, V.NOT_RECOGNIZED); } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index e12311ff64..70ff8fe64d 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -102,6 +102,7 @@ export class MockCoreService implements ICoreService { origin: false, reverseWraparound: false, sendFocus: false, + synchronizedOutput: false, wraparound: true }; public onData: Event = new Emitter().event; diff --git a/src/common/Types.ts b/src/common/Types.ts index c254d330d1..3c147b8ed4 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -273,6 +273,7 @@ export interface IDecPrivateModes { origin: boolean; reverseWraparound: boolean; sendFocus: boolean; + synchronizedOutput: boolean; wraparound: boolean; // defaults: xterm - true, vt100 - false } diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 5d3eccb8da..7b5f532d3b 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -22,6 +22,7 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ origin: false, reverseWraparound: false, sendFocus: false, + synchronizedOutput: false, wraparound: true // defaults: xterm - true, vt100 - false }); diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 1a35a0e590..5e4f026084 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -1267,6 +1267,45 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [128, 0, 0, 255]); }); }); + + test.describe('synchronized output', () => { + test('defers rendering until ESU', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + }); + + test('batches multiple writes', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■\x1b[32m■\x1b[34m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [13, 188, 121, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 1), [36, 114, 200, 255]); + }); + + test('nested BSU is idempotent', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■'); + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[32m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [13, 188, 121, 255]); + }); + + test('timeout flushes without ESU', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]); + await ctx.value.page.waitForTimeout(1500); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + }); + }); } enum CellColorPosition { diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index ebc2136a7d..830c8989bd 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -609,6 +609,7 @@ test.describe('API Integration Tests', () => { originMode: false, reverseWraparoundMode: false, sendFocusMode: false, + synchronizedOutputMode: false, wraparoundMode: true }); }); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d1c2667d09..95b6ffcbcb 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1968,6 +1968,13 @@ declare module '@xterm/xterm' { * Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h` */ readonly sendFocusMode: boolean; + /** + * Synchronized Output Mode: `CSI ? 2 0 2 6 h` + * + * When enabled, output is buffered and only rendered when the mode is + * disabled, allowing for atomic screen updates without tearing. + */ + readonly synchronizedOutputMode: boolean; /** * Auto-Wrap Mode (DECAWM): `CSI ? 7 h` */