Merge pull request #5453 from chrislloyd/feat/synchronized-output
Add synchronized output support (DEC mode 2026)
This commit is contained in:
@@ -490,6 +490,7 @@ export class SerializeAddon implements ITerminalAddon , ISerializeApi {
|
|||||||
if (modes.originMode) content += '\x1b[?6h';
|
if (modes.originMode) content += '\x1b[?6h';
|
||||||
if (modes.reverseWraparoundMode) content += '\x1b[?45h';
|
if (modes.reverseWraparoundMode) content += '\x1b[?45h';
|
||||||
if (modes.sendFocusMode) content += '\x1b[?1004h';
|
if (modes.sendFocusMode) content += '\x1b[?1004h';
|
||||||
|
// synchronizedOutputMode doesn't need to be serialized as it's a temporary mode
|
||||||
|
|
||||||
// Default: true
|
// Default: true
|
||||||
if (modes.wraparoundMode === false) content += '\x1b[?7l';
|
if (modes.wraparoundMode === false) content += '\x1b[?7l';
|
||||||
|
|||||||
+19
-17
@@ -1310,23 +1310,25 @@ function addVtButtons(): void {
|
|||||||
}
|
}
|
||||||
const vtFragment = document.createDocumentFragment();
|
const vtFragment = document.createDocumentFragment();
|
||||||
const buttonSpecs: { [key: string]: { label: string, description: string, paramCount?: number }} = {
|
const buttonSpecs: { [key: string]: { label: string, description: string, paramCount?: number }} = {
|
||||||
A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' },
|
A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' },
|
||||||
B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' },
|
B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' },
|
||||||
C: { label: 'CUF →', description: 'Cursor Forward Ps Times' },
|
C: { label: 'CUF →', description: 'Cursor Forward Ps Times' },
|
||||||
D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' },
|
D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' },
|
||||||
E: { label: 'CNL', description: 'Cursor Next Line Ps Times' },
|
E: { label: 'CNL', description: 'Cursor Next Line Ps Times' },
|
||||||
F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' },
|
F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' },
|
||||||
G: { label: 'CHA', description: 'Cursor Character Absolute' },
|
G: { label: 'CHA', description: 'Cursor Character Absolute' },
|
||||||
H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 },
|
H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 },
|
||||||
I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' },
|
I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' },
|
||||||
J: { label: 'ED', description: 'Erase in Display' },
|
J: { label: 'ED', description: 'Erase in Display' },
|
||||||
'?|J': { label: 'DECSED', description: 'Erase in Display' },
|
'?|J': { label: 'DECSED', description: 'Erase in Display' },
|
||||||
K: { label: 'EL', description: 'Erase in Line' },
|
K: { label: 'EL', description: 'Erase in Line' },
|
||||||
'?|K': { label: 'DECSEL', description: 'Erase in Line' },
|
'?|K': { label: 'DECSEL', description: 'Erase in Line' },
|
||||||
L: { label: 'IL', description: 'Insert Ps Line(s)' },
|
L: { label: 'IL', description: 'Insert Ps Line(s)' },
|
||||||
M: { label: 'DL', description: 'Delete Ps Line(s)' },
|
M: { label: 'DL', description: 'Delete Ps Line(s)' },
|
||||||
P: { label: 'DCH', description: 'Delete Ps Character(s)' },
|
P: { label: 'DCH', description: 'Delete Ps Character(s)' },
|
||||||
' q': { label: 'DECSCUSR', description: 'Set Cursor Style', paramCount: 1 }
|
' 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)) {
|
for (const s of Object.keys(buttonSpecs)) {
|
||||||
const spec = buttonSpecs[s];
|
const spec = buttonSpecs[s];
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export class Terminal extends Disposable implements ITerminalApi {
|
|||||||
originMode: m.origin,
|
originMode: m.origin,
|
||||||
reverseWraparoundMode: m.reverseWraparound,
|
reverseWraparoundMode: m.reverseWraparound,
|
||||||
sendFocusMode: m.sendFocus,
|
sendFocusMode: m.sendFocus,
|
||||||
|
synchronizedOutputMode: m.synchronizedOutput,
|
||||||
wraparoundMode: m.wraparound
|
wraparoundMode: m.wraparound
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
|
|||||||
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||||
import { DebouncedIdleTask } from 'common/TaskQueue';
|
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';
|
import { Emitter } from 'vs/base/common/event';
|
||||||
|
|
||||||
interface ISelectionState {
|
interface ISelectionState {
|
||||||
@@ -18,6 +18,10 @@ interface ISelectionState {
|
|||||||
columnSelectMode: boolean;
|
columnSelectMode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const enum Constants {
|
||||||
|
SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000
|
||||||
|
}
|
||||||
|
|
||||||
export class RenderService extends Disposable implements IRenderService {
|
export class RenderService extends Disposable implements IRenderService {
|
||||||
public serviceBrand: undefined;
|
public serviceBrand: undefined;
|
||||||
|
|
||||||
@@ -32,6 +36,7 @@ export class RenderService extends Disposable implements IRenderService {
|
|||||||
private _needsSelectionRefresh: boolean = false;
|
private _needsSelectionRefresh: boolean = false;
|
||||||
private _canvasWidth: number = 0;
|
private _canvasWidth: number = 0;
|
||||||
private _canvasHeight: number = 0;
|
private _canvasHeight: number = 0;
|
||||||
|
private _syncOutputHandler: SynchronizedOutputHandler;
|
||||||
private _selectionState: ISelectionState = {
|
private _selectionState: ISelectionState = {
|
||||||
start: undefined,
|
start: undefined,
|
||||||
end: undefined,
|
end: undefined,
|
||||||
@@ -52,23 +57,31 @@ export class RenderService extends Disposable implements IRenderService {
|
|||||||
constructor(
|
constructor(
|
||||||
private _rowCount: number,
|
private _rowCount: number,
|
||||||
screenElement: HTMLElement,
|
screenElement: HTMLElement,
|
||||||
@IOptionsService optionsService: IOptionsService,
|
@IOptionsService private readonly _optionsService: IOptionsService,
|
||||||
@ICharSizeService private readonly _charSizeService: ICharSizeService,
|
@ICharSizeService private readonly _charSizeService: ICharSizeService,
|
||||||
|
@ICoreService private readonly _coreService: ICoreService,
|
||||||
@IDecorationService decorationService: IDecorationService,
|
@IDecorationService decorationService: IDecorationService,
|
||||||
@IBufferService bufferService: IBufferService,
|
@IBufferService bufferService: IBufferService,
|
||||||
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
|
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
|
||||||
@IThemeService themeService: IThemeService
|
@IThemeService themeService: IThemeService
|
||||||
) {
|
) {
|
||||||
super();
|
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(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.onResize(() => this._fullRefresh()));
|
||||||
this._register(bufferService.buffers.onBufferActivate(() => this._renderer.value?.clear()));
|
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()));
|
this._register(this._charSizeService.onCharSizeChange(() => this.handleCharSizeChanged()));
|
||||||
|
|
||||||
// Do a full refresh whenever any decoration is added or removed. This may not actually result
|
// 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()));
|
this._register(decorationService.onDecorationRemoved(() => this._fullRefresh()));
|
||||||
|
|
||||||
// Clear the renderer when the a change that could affect glyphs occurs
|
// Clear the renderer when the a change that could affect glyphs occurs
|
||||||
this._register(optionsService.onMultipleOptionChange([
|
this._register(this._optionsService.onMultipleOptionChange([
|
||||||
'customGlyphs',
|
'customGlyphs',
|
||||||
'drawBoldTextInBrightColors',
|
'drawBoldTextInBrightColors',
|
||||||
'letterSpacing',
|
'letterSpacing',
|
||||||
@@ -96,15 +109,15 @@ export class RenderService extends Disposable implements IRenderService {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Refresh the cursor line when the cursor changes
|
// Refresh the cursor line when the cursor changes
|
||||||
this._register(optionsService.onMultipleOptionChange([
|
this._register(this._optionsService.onMultipleOptionChange([
|
||||||
'cursorBlink',
|
'cursorBlink',
|
||||||
'cursorStyle'
|
'cursorStyle'
|
||||||
], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true)));
|
], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true)));
|
||||||
|
|
||||||
this._register(themeService.onChangeColors(() => this._fullRefresh()));
|
this._register(themeService.onChangeColors(() => this._fullRefresh()));
|
||||||
|
|
||||||
this._registerIntersectionObserver(coreBrowserService.window, screenElement);
|
this._registerIntersectionObserver(this._coreBrowserService.window, screenElement);
|
||||||
this._register(coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement)));
|
this._register(this._coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void {
|
private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void {
|
||||||
@@ -137,6 +150,18 @@ export class RenderService extends Disposable implements IRenderService {
|
|||||||
this._needsFullRefresh = true;
|
this._needsFullRefresh = true;
|
||||||
return;
|
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) {
|
if (!isRedrawOnly) {
|
||||||
this._isNextRenderRedrawOnly = false;
|
this._isNextRenderRedrawOnly = false;
|
||||||
}
|
}
|
||||||
@@ -148,6 +173,13 @@ export class RenderService extends Disposable implements IRenderService {
|
|||||||
return;
|
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
|
// 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
|
// requested and when this triggers. Clamp the values of start and end to ensure they're valid
|
||||||
// given the current viewport state.
|
// given the current viewport state.
|
||||||
@@ -283,3 +315,62 @@ export class RenderService extends Disposable implements IRenderService {
|
|||||||
this._renderer.value?.clear();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2314,7 +2314,7 @@ describe('InputHandler', () => {
|
|||||||
});
|
});
|
||||||
it('DEC privates with set/reset semantic', async () => {
|
it('DEC privates with set/reset semantic', async () => {
|
||||||
// initially reset
|
// 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) {
|
for (const mode of reset) {
|
||||||
await inputHandler.parseP(`\x1b[?${mode}$p`);
|
await inputHandler.parseP(`\x1b[?${mode}$p`);
|
||||||
assert.deepEqual(reportStack.pop(), `\x1b[?${mode};2$y`); // initial reset
|
assert.deepEqual(reportStack.pop(), `\x1b[?${mode};2$y`); // initial reset
|
||||||
|
|||||||
@@ -1969,6 +1969,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
|||||||
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
|
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
|
||||||
this._coreService.decPrivateModes.bracketedPasteMode = true;
|
this._coreService.decPrivateModes.bracketedPasteMode = true;
|
||||||
break;
|
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;
|
return true;
|
||||||
@@ -2197,6 +2200,10 @@ export class InputHandler extends Disposable implements IInputHandler {
|
|||||||
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
|
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
|
||||||
this._coreService.decPrivateModes.bracketedPasteMode = false;
|
this._coreService.decPrivateModes.bracketedPasteMode = false;
|
||||||
break;
|
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;
|
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 === 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 === 47 || p === 1047 || p === 1049) return f(p, b2v(active === alt));
|
||||||
if (p === 2004) return f(p, b2v(dm.bracketedPasteMode));
|
if (p === 2004) return f(p, b2v(dm.bracketedPasteMode));
|
||||||
|
if (p === 2026) return f(p, b2v(dm.synchronizedOutput));
|
||||||
return f(p, V.NOT_RECOGNIZED);
|
return f(p, V.NOT_RECOGNIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export class MockCoreService implements ICoreService {
|
|||||||
origin: false,
|
origin: false,
|
||||||
reverseWraparound: false,
|
reverseWraparound: false,
|
||||||
sendFocus: false,
|
sendFocus: false,
|
||||||
|
synchronizedOutput: false,
|
||||||
wraparound: true
|
wraparound: true
|
||||||
};
|
};
|
||||||
public onData: Event<string> = new Emitter<string>().event;
|
public onData: Event<string> = new Emitter<string>().event;
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export interface IDecPrivateModes {
|
|||||||
origin: boolean;
|
origin: boolean;
|
||||||
reverseWraparound: boolean;
|
reverseWraparound: boolean;
|
||||||
sendFocus: boolean;
|
sendFocus: boolean;
|
||||||
|
synchronizedOutput: boolean;
|
||||||
wraparound: boolean; // defaults: xterm - true, vt100 - false
|
wraparound: boolean; // defaults: xterm - true, vt100 - false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({
|
|||||||
origin: false,
|
origin: false,
|
||||||
reverseWraparound: false,
|
reverseWraparound: false,
|
||||||
sendFocus: false,
|
sendFocus: false,
|
||||||
|
synchronizedOutput: false,
|
||||||
wraparound: true // defaults: xterm - true, vt100 - false
|
wraparound: true // defaults: xterm - true, vt100 - false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ describe('Headless API Tests', function (): void {
|
|||||||
originMode: false,
|
originMode: false,
|
||||||
reverseWraparoundMode: false,
|
reverseWraparoundMode: false,
|
||||||
sendFocusMode: false,
|
sendFocusMode: false,
|
||||||
|
synchronizedOutputMode: false,
|
||||||
wraparoundMode: true
|
wraparoundMode: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export class Terminal extends Disposable implements ITerminalApi {
|
|||||||
originMode: m.origin,
|
originMode: m.origin,
|
||||||
reverseWraparoundMode: m.reverseWraparound,
|
reverseWraparoundMode: m.reverseWraparound,
|
||||||
sendFocusMode: m.sendFocus,
|
sendFocusMode: m.sendFocus,
|
||||||
|
synchronizedOutputMode: m.synchronizedOutput,
|
||||||
wraparoundMode: m.wraparound
|
wraparoundMode: m.wraparound
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
import { IImage32, decodePng } from '@lunapaint/png-codec';
|
import { IImage32, decodePng } from '@lunapaint/png-codec';
|
||||||
import { LocatorScreenshotOptions, test } from '@playwright/test';
|
import { LocatorScreenshotOptions, test } from '@playwright/test';
|
||||||
import { ITheme } from '@xterm/xterm';
|
import { ITheme, type ITerminalOptions } from '@xterm/xterm';
|
||||||
import { ITestContext, MaybeAsync, openTerminal, pollFor, pollForApproximate } from './TestUtils';
|
import { ITestContext, MaybeAsync, openTerminal, pollFor, pollForApproximate, timeout } from './TestUtils';
|
||||||
|
import { notDeepStrictEqual } from 'node:assert';
|
||||||
|
|
||||||
export interface ISharedRendererTestContext {
|
export interface ISharedRendererTestContext {
|
||||||
value: ITestContext;
|
value: ITestContext;
|
||||||
@@ -1267,6 +1268,79 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
|
|||||||
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [128, 0, 0, 255]);
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [128, 0, 0, 255]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('synchronized output', () => {
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
const theme: ITheme = {
|
||||||
|
background: '#000000FF',
|
||||||
|
|
||||||
|
red: '#FF0000FF',
|
||||||
|
green: '#00FF00FF',
|
||||||
|
blue: '#0000FFFF'
|
||||||
|
};
|
||||||
|
const options: ITerminalOptions = {}
|
||||||
|
await ctx.value.page.evaluate(`
|
||||||
|
window.term.options.theme = ${JSON.stringify(theme)};
|
||||||
|
window.term.options.cursorStyle = 'underline';
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
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), [255, 0, 0, 255], undefined, {
|
||||||
|
equalityFn: (a, b) => {
|
||||||
|
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ctx.value.proxy.write('\x1b[?2026l'); // ESU
|
||||||
|
frameDetails = undefined;
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 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), [255, 0, 0, 255], undefined, {
|
||||||
|
equalityFn: (a, b) => {
|
||||||
|
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ctx.value.proxy.write('\x1b[?2026l'); // ESU
|
||||||
|
frameDetails = undefined;
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [0, 255, 0, 255]);
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 1), [0, 0, 255, 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), [255, 0, 0, 255], undefined, {
|
||||||
|
equalityFn: (a, b) => {
|
||||||
|
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ctx.value.proxy.write('\x1b[?2026l'); // ESU
|
||||||
|
frameDetails = undefined;
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [0, 255, 0, 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), [255, 0, 0, 255], undefined, {
|
||||||
|
equalityFn: (a, b) => {
|
||||||
|
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await ctx.value.page.waitForTimeout(1000); // Timeout hard coded
|
||||||
|
frameDetails = undefined;
|
||||||
|
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CellColorPosition {
|
enum CellColorPosition {
|
||||||
@@ -1324,9 +1398,9 @@ export function injectSharedRendererTestsStandalone(ctx: ISharedRendererTestCont
|
|||||||
* @param col The 1-based column index to get the color for.
|
* @param col The 1-based column index to get the color for.
|
||||||
* @param row The 1-based row index to get the color for.
|
* @param row The 1-based row index to get the color for.
|
||||||
*/
|
*/
|
||||||
function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): MaybeAsync<[red: number, green: number, blue: number, alpha: number]> {
|
async function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): Promise<[red: number, green: number, blue: number, alpha: number]> {
|
||||||
if (!frameDetails) {
|
if (!frameDetails) {
|
||||||
return getFrameDetails(ctx).then(frameDetails => getCellColorInner(frameDetails, col, row));
|
frameDetails = await getFrameDetails(ctx);
|
||||||
}
|
}
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case CellColorPosition.CENTER:
|
case CellColorPosition.CENTER:
|
||||||
|
|||||||
@@ -609,6 +609,7 @@ test.describe('API Integration Tests', () => {
|
|||||||
originMode: false,
|
originMode: false,
|
||||||
reverseWraparoundMode: false,
|
reverseWraparoundMode: false,
|
||||||
sendFocusMode: false,
|
sendFocusMode: false,
|
||||||
|
synchronizedOutputMode: false,
|
||||||
wraparoundMode: true
|
wraparoundMode: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+7
@@ -1370,6 +1370,13 @@ declare module '@xterm/headless' {
|
|||||||
* Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h`
|
* Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h`
|
||||||
*/
|
*/
|
||||||
readonly sendFocusMode: boolean;
|
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`
|
* Auto-Wrap Mode (DECAWM): `CSI ? 7 h`
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
+7
@@ -1968,6 +1968,13 @@ declare module '@xterm/xterm' {
|
|||||||
* Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h`
|
* Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h`
|
||||||
*/
|
*/
|
||||||
readonly sendFocusMode: boolean;
|
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`
|
* Auto-Wrap Mode (DECAWM): `CSI ? 7 h`
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user