Merge branch 'master' into keydup
This commit is contained in:
@@ -126,8 +126,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
public readonly onCursorMove = this._onCursorMove.event;
|
||||
private readonly _onKey = this._register(new Emitter<{ key: string, domEvent: KeyboardEvent }>());
|
||||
public readonly onKey = this._onKey.event;
|
||||
private readonly _onRender = this._register(new Emitter<{ start: number, end: number }>());
|
||||
public readonly onRender = this._onRender.event;
|
||||
private readonly _onSelectionChange = this._register(new Emitter<void>());
|
||||
public readonly onSelectionChange = this._onSelectionChange.event;
|
||||
private readonly _onTitleChange = this._register(new Emitter<string>());
|
||||
|
||||
@@ -75,7 +75,7 @@ export class OscLinkProvider implements ILinkProvider {
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
ignoreLink = true;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore invalid URLs to prevent unexpected behaviors
|
||||
ignoreLink = true;
|
||||
}
|
||||
|
||||
@@ -1079,47 +1079,6 @@ describe('Terminal', () => {
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false);
|
||||
});
|
||||
});
|
||||
describe('Windows Mode', () => {
|
||||
it('should mark lines as wrapped when the line ends in a non-null character after a LF', async () => {
|
||||
const data = [
|
||||
'aaaaaaaaaa\n\r', // cannot wrap as it's the first
|
||||
'aaaaaaaaa\n\r', // wrapped (windows mode only)
|
||||
'aaaaaaaaa' // not wrapped
|
||||
];
|
||||
|
||||
const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: false });
|
||||
await normalTerminal.writeP(data.join(''));
|
||||
assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false);
|
||||
assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false);
|
||||
assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false);
|
||||
|
||||
const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: true });
|
||||
await windowsModeTerminal.writeP(data.join(''));
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false);
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character');
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false);
|
||||
});
|
||||
|
||||
it('should mark lines as wrapped when the line ends in a non-null character after a CUP', async () => {
|
||||
const data = [
|
||||
'aaaaaaaaaa\x1b[2;1H', // cannot wrap as it's the first
|
||||
'aaaaaaaaa\x1b[3;1H', // wrapped (windows mode only)
|
||||
'aaaaaaaaa' // not wrapped
|
||||
];
|
||||
|
||||
const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: false });
|
||||
await normalTerminal.writeP(data.join(''));
|
||||
assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false);
|
||||
assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false);
|
||||
assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false);
|
||||
|
||||
const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsMode: true });
|
||||
await windowsModeTerminal.writeP(data.join(''));
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false);
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character');
|
||||
assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false);
|
||||
});
|
||||
});
|
||||
it('convertEol setting', async () => {
|
||||
// not converting
|
||||
const termNotConverting = new TestTerminal({ cols: 15, rows: 10 });
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import * as glob from 'glob';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
@@ -15,7 +14,10 @@ import { IDisposable } from '@xterm/xterm';
|
||||
const COLS = 80;
|
||||
const ROWS = 25;
|
||||
|
||||
const TESTFILES = glob.sync('**/escape_sequence_files/*.in', { cwd: path.join(__dirname, '../..')});
|
||||
const escapeSequenceFilesDir = path.join(__dirname, '../../fixtures/escape_sequence_files');
|
||||
const TESTFILES = fs.readdirSync(escapeSequenceFilesDir)
|
||||
.filter(f => f.endsWith('.in'))
|
||||
.map(f => path.join(escapeSequenceFilesDir, f));
|
||||
const SKIP_FILES = [
|
||||
't0055-EL.in', // EL/ED handle cursor at cols differently (see #3362)
|
||||
't0084-CBT.in',
|
||||
@@ -33,7 +35,7 @@ if (os.platform() === 'darwin') {
|
||||
);
|
||||
}
|
||||
// filter skipFilenames
|
||||
const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(value.split('/').slice(-1)[0]));
|
||||
const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(path.basename(value)));
|
||||
|
||||
describe('Escape Sequence Files', function(): void {
|
||||
this.timeout(1000);
|
||||
@@ -62,7 +64,7 @@ describe('Escape Sequence Files', function(): void {
|
||||
});
|
||||
|
||||
for (const filename of FILES) {
|
||||
(process.platform === 'win32' ? it.skip : it)(filename.split('/').slice(-1)[0], async () => {
|
||||
(process.platform === 'win32' ? it.skip : it)(path.basename(filename), async () => {
|
||||
// reset terminal and handler
|
||||
if (customHandler) {
|
||||
customHandler.dispose();
|
||||
@@ -88,7 +90,7 @@ describe('Escape Sequence Files', function(): void {
|
||||
});
|
||||
|
||||
// compare with expected output (right trimmed)
|
||||
const expected = fs.readFileSync(filename.split('.')[0] + '.text', 'utf8');
|
||||
const expected = fs.readFileSync(filename.replace(/\.in$/, '.text'), 'utf8');
|
||||
const expectedRightTrimmed = expected.split('\n').map(l => l.replace(/\s+$/, '')).join('\n');
|
||||
if (content !== expectedRightTrimmed) {
|
||||
throw new Error(formatError(fs.readFileSync(filename, 'utf8'), content, expected));
|
||||
|
||||
@@ -45,7 +45,7 @@ export class TimeBasedDebouncer implements IRenderDebouncer {
|
||||
|
||||
// Only refresh if the time since last refresh is above a threshold, otherwise wait for
|
||||
// enough time to pass before refreshing again.
|
||||
const refreshRequestTime: number = Date.now();
|
||||
const refreshRequestTime: number = performance.now();
|
||||
if (refreshRequestTime - this._lastRefreshMs >= this._debounceThresholdMS) {
|
||||
// Enough time has lapsed since the last refresh; refresh immediately
|
||||
this._lastRefreshMs = refreshRequestTime;
|
||||
@@ -57,7 +57,7 @@ export class TimeBasedDebouncer implements IRenderDebouncer {
|
||||
this._additionalRefreshRequested = true;
|
||||
|
||||
this._refreshTimeoutID = window.setTimeout(() => {
|
||||
this._lastRefreshMs = Date.now();
|
||||
this._lastRefreshMs = performance.now();
|
||||
this._innerRefresh();
|
||||
this._additionalRefreshRequested = false;
|
||||
this._refreshTimeoutID = undefined; // No longer need to clear the timeout
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ColorZoneStore } from 'browser/decorations/ColorZoneStore';
|
||||
const optionsRedFull = {
|
||||
overviewRulerOptions: {
|
||||
color: 'red',
|
||||
position: 'full' as 'full'
|
||||
position: 'full' as const
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,8 +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([
|
||||
'customGlyphs',
|
||||
this._register(this._optionsService.onMultipleOptionChange([
|
||||
'drawBoldTextInBrightColors',
|
||||
'letterSpacing',
|
||||
'lineHeight',
|
||||
@@ -96,15 +108,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 +149,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 +172,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 +314,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user