Merge branch 'master' into scrollbar-impro

This commit is contained in:
Daniel Imms
2022-05-13 05:29:57 -07:00
committed by GitHub
82 changed files with 2356 additions and 1135 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-addon-ligatures",
"version": "0.5.2",
"version": "0.5.3",
"description": "Add support for programming ligatures to xterm.js",
"author": {
"name": "The xterm.js authors",
+18 -18
View File
@@ -78,7 +78,7 @@ describe('xterm-addon-ligatures', () => {
});
it('handles quoted font names', done => {
term.setOption('fontFamily', '"Fira Code", monospace');
term.options.fontFamily = '"Fira Code", monospace';
assert.deepEqual(term.joiner!(input), []);
onRefresh.callsFake(() => {
assert.deepEqual(term.joiner!(input), [[2, 4], [7, 10]]);
@@ -87,7 +87,7 @@ describe('xterm-addon-ligatures', () => {
});
it('falls back to later fonts if earlier ones are not present', done => {
term.setOption('fontFamily', 'notinstalled, Fira Code, monospace');
term.options.fontFamily = 'notinstalled, Fira Code, monospace';
assert.deepEqual(term.joiner!(input), []);
onRefresh.callsFake(() => {
assert.deepEqual(term.joiner!(input), [[2, 4], [7, 10]]);
@@ -98,17 +98,17 @@ describe('xterm-addon-ligatures', () => {
it('uses the current font value', done => {
// The first three calls are all synchronous so that we don't allow time for
// any fonts to load while we're switching things around
term.setOption('fontFamily', 'Fira Code');
term.options.fontFamily = 'Fira Code';
assert.deepEqual(term.joiner!(input), []);
term.setOption('fontFamily', 'notinstalled');
term.options.fontFamily = 'notinstalled';
assert.deepEqual(term.joiner!(input), []);
term.setOption('fontFamily', 'Iosevka');
term.options.fontFamily = 'Iosevka';
assert.deepEqual(term.joiner!(input), []);
onRefresh.callsFake(() => {
assert.deepEqual(term.joiner!(input), [[2, 4]]);
// And switch it back to Fira Code for good measure
term.setOption('fontFamily', 'Fira Code');
term.options.fontFamily = 'Fira Code';
// At this point, we haven't loaded the new font, so the result reverts
// back to empty until that happens
@@ -124,7 +124,7 @@ describe('xterm-addon-ligatures', () => {
it('allows multiple terminal instances that use different fonts', done => {
const onRefresh2 = sinon.stub();
const term2 = new MockTerminal(onRefresh2);
term2.setOption('fontFamily', 'Iosevka');
term2.options.fontFamily = 'Iosevka';
ligatureSupport.enableLigatures(term2 as any);
assert.deepEqual(term.joiner!(input), []);
@@ -140,7 +140,7 @@ describe('xterm-addon-ligatures', () => {
});
it('fails if it finds but cannot load the font', async () => {
term.setOption('fontFamily', 'Nonexistant Font, monospace');
term.options.fontFamily = 'Nonexistant Font, monospace';
assert.deepEqual(term.joiner!(input), []);
await delay(500);
assert.isTrue(onRefresh.notCalled);
@@ -148,7 +148,7 @@ describe('xterm-addon-ligatures', () => {
});
it('returns nothing if the font is not present on the system', async () => {
term.setOption('fontFamily', 'notinstalled');
term.options.fontFamily = 'notinstalled';
assert.deepEqual(term.joiner!(input), []);
await delay(500);
assert.isTrue(onRefresh.notCalled);
@@ -156,7 +156,7 @@ describe('xterm-addon-ligatures', () => {
});
it('returns nothing if no specific font is specified', async () => {
term.setOption('fontFamily', 'monospace');
term.options.fontFamily = 'monospace';
assert.deepEqual(term.joiner!(input), []);
await delay(500);
assert.isTrue(onRefresh.notCalled);
@@ -164,7 +164,7 @@ describe('xterm-addon-ligatures', () => {
});
it('returns nothing if no fonts are provided', async () => {
term.setOption('fontFamily', '');
term.options.fontFamily = '';
assert.deepEqual(term.joiner!(input), []);
await delay(500);
assert.isTrue(onRefresh.notCalled);
@@ -172,7 +172,7 @@ describe('xterm-addon-ligatures', () => {
});
it('fails when given malformed inputs', async () => {
term.setOption('fontFamily', {} as any);
term.options.fontFamily = {} as any;
assert.deepEqual(term.joiner!(input), []);
await delay(500);
assert.isTrue(onRefresh.notCalled);
@@ -181,7 +181,7 @@ describe('xterm-addon-ligatures', () => {
it('ensures no empty errors are thrown', async () => {
sinon.stub(fontLigatures, 'loadFile').callsFake(async () => { throw undefined; });
term.setOption('fontFamily', 'Iosevka');
term.options.fontFamily = 'Iosevka';
assert.deepEqual(term.joiner!(input), []);
await delay(500);
assert.isTrue(onRefresh.notCalled);
@@ -209,11 +209,11 @@ class MockTerminal {
public deregisterCharacterJoiner(id: number): void {
this.joiner = undefined;
}
public setOption(name: string, value: string | number): void {
this._options[name] = value;
}
public getOption(name: string): string | number {
return this._options[name];
public get options(): { [name: string]: string | number } { return this._options; }
public set options(options: { [name: string]: string | number }) {
for (const key in this._options) {
this._options[key] = options[key];
}
}
}
+4 -4
View File
@@ -34,7 +34,7 @@ export function enableLigatures(term: Terminal): void {
term.registerCharacterJoiner((text: string): [number, number][] => {
// If the font hasn't been loaded yet, load it and return an empty result
const termFont = term.getOption('fontFamily');
const termFont = term.options.fontFamily;
if (
termFont &&
(loadingState === LoadingState.UNLOADED || currentFontName !== termFont)
@@ -48,20 +48,20 @@ export function enableLigatures(term: Terminal): void {
.then(f => {
// Another request may have come in while we were waiting, so make
// sure our font is still vaild.
if (currentCallFontName === term.getOption('fontFamily')) {
if (currentCallFontName === term.options.fontFamily) {
loadingState = LoadingState.LOADED;
font = f;
// Only refresh things if we actually found a font
if (f) {
term.refresh(0, term.getOption('rows') - 1);
term.refresh(0, term.options.rows! - 1);
}
}
})
.catch(e => {
// Another request may have come in while we were waiting, so make
// sure our font is still vaild.
if (currentCallFontName === term.getOption('fontFamily')) {
if (currentCallFontName === term.options.fontFamily) {
loadingState = LoadingState.FAILED;
font = undefined;
loadError = e;
+3 -3
View File
@@ -141,9 +141,9 @@ lru-cache@^6.0.0:
yallist "^4.0.0"
minimist@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
mkdirp@0.5.5:
version "0.5.5"
+295 -18
View File
@@ -3,13 +3,25 @@
* @license MIT
*/
import { Terminal, IBufferLine, IDisposable, ITerminalAddon, ISelectionPosition } from 'xterm';
import { Terminal, IDisposable, ITerminalAddon, ISelectionPosition, IDecoration } from 'xterm';
import { EventEmitter } from 'common/EventEmitter';
export interface ISearchOptions {
regex?: boolean;
wholeWord?: boolean;
caseSensitive?: boolean;
incremental?: boolean;
decorations?: ISearchDecorationOptions;
noScroll?: boolean;
}
interface ISearchDecorationOptions {
matchBackground?: string;
matchBorder?: string;
matchOverviewRuler: string;
activeMatchBackground?: string;
activeMatchBorder?: string;
activeMatchColorOverviewRuler: string;
}
export interface ISearchPosition {
@@ -40,7 +52,14 @@ const LINES_CACHE_TIME_TO_LIVE = 15 * 1000; // 15 secs
export class SearchAddon implements ITerminalAddon {
private _terminal: Terminal | undefined;
private _cachedSearchTerm: string | undefined;
private _selectedDecoration: IDecoration | undefined;
private _resultDecorations: Map<number, IDecoration[]> | undefined;
private _searchResults: Map<string, ISearchResult> | undefined;
private _onDataDisposable: IDisposable | undefined;
private _onResizeDisposable: IDisposable | undefined;
private _lastSearchOptions: ISearchOptions | undefined;
private _highlightTimeout: number | undefined;
/**
* translateBufferLineToStringWithWrap is a fairly expensive call.
* We memoize the calls into an array that has a time based ttl.
@@ -51,11 +70,55 @@ export class SearchAddon implements ITerminalAddon {
private _cursorMoveListener: IDisposable | undefined;
private _resizeListener: IDisposable | undefined;
private _resultIndex: number | undefined;
private readonly _onDidChangeResults = new EventEmitter<{resultIndex: number, resultCount: number} | undefined>();
public readonly onDidChangeResults = this._onDidChangeResults.event;
public activate(terminal: Terminal): void {
this._terminal = terminal;
this._onDataDisposable = this._terminal.onData(() => this._updateMatches());
this._onResizeDisposable = this._terminal.onResize(() => this._updateMatches());
}
public dispose(): void { }
private _updateMatches(): void {
if (this._highlightTimeout) {
window.clearTimeout(this._highlightTimeout);
}
if (this._cachedSearchTerm && this._lastSearchOptions?.decorations) {
this._highlightTimeout = setTimeout(() => {
this.findPrevious(this._cachedSearchTerm!, { ...this._lastSearchOptions, incremental: true, noScroll: true });
this._onDidChangeResults.fire({ resultIndex: this._searchResults ? this._searchResults.size - 1 : -1, resultCount: this._searchResults ? this._searchResults.size : -1 });
}, 200);
}
}
public dispose(): void {
this.clearDecorations();
this._onDataDisposable?.dispose();
this._onResizeDisposable?.dispose();
}
public clearDecorations(retainCachedSearchTerm?: boolean): void {
this._selectedDecoration?.dispose();
this._searchResults?.clear();
this._resultDecorations?.forEach(decorations => {
for (const d of decorations) {
d.dispose();
}
});
this._resultDecorations?.clear();
this._searchResults = undefined;
this._resultDecorations = undefined;
if (!retainCachedSearchTerm) {
this._cachedSearchTerm = undefined;
}
}
public clearActiveDecoration(): void {
this._selectedDecoration?.dispose();
this._selectedDecoration = undefined;
}
/**
* Find the next instance of the term, then scroll to and select it. If it
@@ -68,12 +131,107 @@ export class SearchAddon implements ITerminalAddon {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
this._lastSearchOptions = searchOptions;
if (searchOptions?.decorations) {
if (this._resultIndex !== undefined || this._cachedSearchTerm && term !== this._cachedSearchTerm) {
this._highlightAllMatches(term, searchOptions);
}
}
return this._fireResults(term, this._findNextAndSelect(term, searchOptions), searchOptions);
}
private _highlightAllMatches(term: string, searchOptions: ISearchOptions): void {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
this.clearDecorations();
return;
}
searchOptions = searchOptions || {};
// new search, clear out the old decorations
this.clearDecorations(true);
this._searchResults = new Map<string, ISearchResult>();
this._resultDecorations = new Map<number, IDecoration[]>();
const resultDecorations = this._resultDecorations;
let result = this._find(term, 0, 0, searchOptions);
while (result && !this._searchResults.get(`${result.row}-${result.col}`)) {
this._searchResults.set(`${result.row}-${result.col}`, result);
result = this._find(
term,
result.col + result.term.length >= this._terminal.cols ? result.row + 1 : result.row,
result.col + result.term.length >= this._terminal.cols ? 0 : result.col + 1,
searchOptions
);
if (this._searchResults.size > 1000) {
this.clearDecorations();
this._resultIndex = undefined;
return;
}
}
this._searchResults.forEach(result => {
const resultDecoration = this._createResultDecoration(result, searchOptions.decorations!);
if (resultDecoration) {
const decorationsForLine = resultDecorations.get(resultDecoration.marker.line) || [];
decorationsForLine.push(resultDecoration);
resultDecorations.set(resultDecoration.marker.line, decorationsForLine);
}
});
}
private _find(term: string, startRow: number, startCol: number, searchOptions?: ISearchOptions): ISearchResult | undefined {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
return undefined;
}
if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
let result: ISearchResult | undefined = undefined;
this._initLinesCache();
const searchPosition: ISearchPosition = {
startRow,
startCol
};
// Search startRow
result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
for (let y = startRow + 1; y < this._terminal.buffer.active.baseY + this._terminal.rows; y++) {
searchPosition.startRow = y;
searchPosition.startCol = 0;
// If the current line is wrapped line, increase index of column to ignore the previous scan
// Otherwise, reset beginning column index to zero with set new unwrapped line index
result = this._findInLine(term, searchPosition, searchOptions);
if (result) {
break;
}
}
}
return result;
}
private _findNextAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal || !term || term.length === 0) {
this._terminal?.clearSelection();
this.clearDecorations();
this._cachedSearchTerm = undefined;
this._resultIndex = -1;
return false;
}
if (this._cachedSearchTerm !== term) {
this._resultIndex = undefined;
this._terminal.clearSelection();
}
let startCol = 0;
let startRow = 0;
let currentSelection: ISelectionPosition | undefined;
@@ -95,7 +253,6 @@ export class SearchAddon implements ITerminalAddon {
// Search startRow
let result = this._findInLine(term, searchPosition, searchOptions);
// Search from startRow + 1 to end
if (!result) {
@@ -129,10 +286,19 @@ export class SearchAddon implements ITerminalAddon {
result = this._findInLine(term, searchPosition, searchOptions);
}
if (this._searchResults) {
if (this._resultIndex === undefined) {
this._resultIndex = 0;
} else {
this._resultIndex++;
if (this._resultIndex >= this._searchResults.size) {
this._resultIndex = 0;
}
}
}
// Set selection and scroll if a result was found
return this._selectResult(result);
return this._selectResult(result, searchOptions?.decorations, searchOptions?.noScroll);
}
/**
* Find the previous instance of the term, then scroll to and select it. If it
* doesn't exist, do nothing.
@@ -144,16 +310,49 @@ export class SearchAddon implements ITerminalAddon {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
this._lastSearchOptions = searchOptions;
if (searchOptions?.decorations && (this._resultIndex !== undefined || term !== this._cachedSearchTerm)) {
this._highlightAllMatches(term, searchOptions);
}
return this._fireResults(term, this._findPreviousAndSelect(term, searchOptions), searchOptions);
}
if (!term || term.length === 0) {
this._terminal.clearSelection();
private _fireResults(term: string, found: boolean, searchOptions?: ISearchOptions): boolean {
if (searchOptions?.decorations) {
if (found && this._resultIndex !== undefined && this._searchResults?.size) {
this._onDidChangeResults.fire({ resultIndex: this._resultIndex, resultCount: this._searchResults.size });
} else if (this._resultIndex === -1) {
this._onDidChangeResults.fire({ resultIndex: -1, resultCount: -1 });
} else {
this._onDidChangeResults.fire(undefined);
}
}
this._cachedSearchTerm = term;
return found;
}
private _findPreviousAndSelect(term: string, searchOptions?: ISearchOptions): boolean {
if (!this._terminal) {
throw new Error('Cannot use addon until it has been loaded');
}
let result: ISearchResult | undefined;
if (!this._terminal || !term || term.length === 0) {
result = undefined;
this._terminal?.clearSelection();
this.clearDecorations();
this._resultIndex = -1;
return false;
}
const isReverseSearch = true;
if (this._cachedSearchTerm !== term) {
this._resultIndex = undefined;
this._terminal.clearSelection();
}
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows;
let startCol = this._terminal.cols;
let result: ISearchResult | undefined;
const isReverseSearch = true;
const incremental = searchOptions ? searchOptions.incremental : false;
let currentSelection: ISelectionPosition | undefined;
if (this._terminal.hasSelection()) {
@@ -207,11 +406,22 @@ export class SearchAddon implements ITerminalAddon {
}
}
if (this._searchResults) {
if (this._resultIndex === undefined || this._resultIndex < 0) {
this._resultIndex = this._searchResults?.size - 1;
} else {
this._resultIndex--;
if (this._resultIndex === -1) {
this._resultIndex = this._searchResults?.size - 1;
}
}
}
// If there is only one result, return true.
if (!result && currentSelection) return true;
// Set selection and scroll if a result was found
return this._selectResult(result);
return this._selectResult(result, searchOptions?.decorations, searchOptions?.noScroll);
}
/**
@@ -446,21 +656,88 @@ export class SearchAddon implements ITerminalAddon {
/**
* Selects and scrolls to a result.
* @param result The result to select.
* @return Whethera result was selected.
* @return Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined): boolean {
private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {
const terminal = this._terminal!;
this.clearActiveDecoration();
if (!result) {
terminal.clearSelection();
return false;
}
terminal.select(result.col, result.row, result.size);
if (options) {
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
if (marker) {
this._selectedDecoration = terminal.registerDecoration({
marker,
x: result.col,
width: result.size,
backgroundColor: options.activeMatchBackground,
layer: 'top',
overviewRulerOptions: {
color: options.activeMatchColorOverviewRuler
}
});
this._selectedDecoration?.onRender((e) => this._applyStyles(e, options.activeMatchBorder));
this._selectedDecoration?.onDispose(() => marker.dispose());
}
}
if (!noScroll) {
// If it is not in the viewport then we scroll else it just gets selected
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
let scroll = result.row - terminal.buffer.active.viewportY;
scroll -= Math.floor(terminal.rows / 2);
terminal.scrollLines(scroll);
if (result.row >= (terminal.buffer.active.viewportY + terminal.rows) || result.row < terminal.buffer.active.viewportY) {
let scroll = result.row - terminal.buffer.active.viewportY;
scroll -= Math.floor(terminal.rows / 2);
terminal.scrollLines(scroll);
}
}
return true;
}
/**
* Applies styles to the decoration when it is rendered
* @param element the decoration's element
* @param backgroundColor the background color to apply
* @param borderColor the border color to apply
* @returns
*/
private _applyStyles(element: HTMLElement, borderColor: string | undefined): void {
if (element.clientWidth <= 0) {
return;
}
if (!element.classList.contains('xterm-find-result-decoration')) {
element.classList.add('xterm-find-result-decoration');
if (borderColor) {
element.style.outline = `1px solid ${borderColor}`;
}
}
}
/**
* Creates a decoration for the result and applies styles
* @param result the search result for which to create the decoration
* @param options the options for the decoration
* @returns the {@link IDecoration} or undefined if the marker has already been disposed of
*/
private _createResultDecoration(result: ISearchResult, options: ISearchDecorationOptions): IDecoration | undefined {
const terminal = this._terminal!;
const marker = terminal.registerMarker(-terminal.buffer.active.baseY - terminal.buffer.active.cursorY + result.row);
if (!marker) {
return undefined;
}
const findResultDecoration = terminal.registerDecoration({
marker,
x: result.col,
width: result.size,
backgroundColor: options.matchBackground,
overviewRulerOptions: this._resultDecorations?.get(marker.line) ? undefined : {
color: options.matchOverviewRuler,
position: 'center'
}
});
findResultDecoration?.onRender((e) => this._applyStyles(e, options.matchBorder));
findResultDecoration?.onDispose(() => marker.dispose());
return findResultDecoration;
}
}
+11 -1
View File
@@ -13,10 +13,20 @@
"strict": true,
"types": [
"../../../node_modules/@types/mocha"
]
],
"paths": {
"common/*": [
"../../../src/common/*"
]
}
},
"include": [
"./**/*",
"../../../typings/xterm.d.ts"
],
"references": [
{
"path": "../../../src/common"
}
]
}
@@ -134,7 +134,7 @@ describe('Search Tests', function(): void {
.replace(/\n/g, '\\n\\r');
}
fixture = fixture
.replace(/'/g, '\\\'');
.replace(/'/g, `\\'`);
});
it('should find all occurrences using findNext', async () => {
await writeSync(page, fixture);
+63 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Terminal, ILinkMatcherOptions, IDisposable, ITerminalAddon } from 'xterm';
import { Terminal, ITerminalAddon, IEvent } from 'xterm';
declare module 'xterm-addon-search' {
/**
@@ -32,6 +32,47 @@ declare module 'xterm-addon-search' {
* `findNext`, not `findPrevious`.
*/
incremental?: boolean;
/**
* When set, will highlight all instances of the word on search and show
* them in the overview ruler if it's enabled.
*/
decorations?: ISearchDecorationOptions;
}
/**
* Options for showing decorations when searching.
*/
interface ISearchDecorationOptions {
/**
* The background color of a match, this must use #RRGGBB format.
*/
matchBackground?: string;
/**
* The border color of a match.
*/
matchBorder?: string;
/**
* The overview ruler color of a match.
*/
matchOverviewRuler: string;
/**
* The background color for the currently active match, this must use #RRGGBB format.
*/
activeMatchBackground?: string;
/**
* The border color of the currently active match.
*/
activeMatchBorder?: string;
/**
* The overview ruler color of the currently active match.
*/
activeMatchColorOverviewRuler: string;
}
/**
@@ -64,5 +105,26 @@ declare module 'xterm-addon-search' {
* @param searchOptions The options for the search.
*/
public findPrevious(term: string, searchOptions?: ISearchOptions): boolean;
/**
* Clears the decorations and selection
*/
public clearDecorations(): void;
/**
* Clears the active result decoration, this decoration is applied on top of the selection so
* removing it will reveal the selection underneath. This is intended to be called on the search
* textarea's `blur` event.
*/
public clearActiveDecoration(): void;
/**
* When decorations are enabled, fires when
* the search results change.
* @returns -1 if there are no matches and
* @returns undefined when the threshold of 1k results
* is exceeded and decorations are disposed of.
*/
readonly onDidChangeResults: IEvent<{ resultIndex: number, resultCount: number } | undefined>;
}
}
@@ -21,6 +21,13 @@ module.exports = {
}
]
},
resolve: {
modules: ['./node_modules'],
extensions: [ '.js' ],
alias: {
common: path.resolve('../../out/common')
}
},
output: {
filename: mainFile,
path: path.resolve('./lib'),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "xterm-addon-serialize",
"version": "0.6.1",
"version": "0.6.2",
"author": {
"name": "The xterm.js authors",
"url": "https://xtermjs.org/"
@@ -43,7 +43,7 @@ class TestSelectionService {
}
}
describe('xterm-addon-serialize html', () => {
describe('xterm-addon-serialize', () => {
let cm: ColorManager;
let dom: jsdom.JSDOM;
let document: Document;
@@ -83,123 +83,132 @@ describe('xterm-addon-serialize html', () => {
(terminal as any)._core._selectionService = selectionService;
});
it('empty terminal with selection turned off', () => {
const output = serializeAddon.serializeAsHTML();
assert.notEqual(output, '');
assert.equal((output.match(new RegExp('<div><span> {10}</span><\/div>', 'g')) || []).length, 2);
});
it('empty terminal with no selection', () => {
const output = serializeAddon.serializeAsHTML({
onlySelection: true
describe('text', () => {
it('restoring cursor styles', async () => {
await writeP(terminal, sgr('32') + '> ' + sgr('0'));
assert.equal(serializeAddon.serialize(), '\u001b[32m> \u001b[0m');
});
assert.equal(output, '');
});
it('basic terminal with selection', async () => {
await writeP(terminal, ' terminal ');
terminal.select(1, 0, 8);
const output = serializeAddon.serializeAsHTML({
onlySelection: true
describe('html', () => {
it('empty terminal with selection turned off', () => {
const output = serializeAddon.serializeAsHTML();
assert.notEqual(output, '');
assert.equal((output.match(/<div><span> {10}<\/span><\/div>/g) || []).length, 2);
});
assert.equal((output.match(new RegExp('<div><span>terminal<\/span><\/div>', 'g')) || []).length, 1, output);
});
it('cells with bold styling', async () => {
await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'font-weight: bold;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with italic styling', async () => {
await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'font-style: italic;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with inverse styling', async () => {
await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'color: #000000; background-color: #BFBFBF;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with underline styling', async () => {
await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'text-decoration: underline;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with invisible styling', async () => {
await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'visibility: hidden;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with dim styling', async () => {
await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'opacity: 0.5;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with strikethrough styling', async () => {
await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'text-decoration: line-through;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with combined styling', async () => {
await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'font-weight: bold;\'> <\/span>', 'g')) || []).length, 1, output);
assert.equal((output.match(new RegExp('<span style=\'font-weight: bold; text-decoration: line-through;\'>termi<\/span>', 'g')) || []).length, 1, output);
assert.equal((output.match(new RegExp('<span style=\'text-decoration: line-through;\'>nal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with color styling', async () => {
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'color: #00ff00;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('cells with background styling', async () => {
await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('<span style=\'background-color: #00ff00;\'>terminal<\/span>', 'g')) || []).length, 1, output);
});
it('empty terminal with default options', async () => {
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(new RegExp('color: #000000; background-color: #ffffff; font-family: courier-new, courier, monospace; font-size: 15px;', 'g')) || []).length, 1, output);
});
it('empty terminal with custom options', async () => {
terminal.options.fontFamily = 'verdana';
terminal.options.fontSize = 20;
terminal.options.theme = {
foreground: '#ff00ff',
background: '#00ff00'
};
const output = serializeAddon.serializeAsHTML({
includeGlobalBackground: true
it('empty terminal with no selection', () => {
const output = serializeAddon.serializeAsHTML({
onlySelection: true
});
assert.equal(output, '');
});
assert.equal((output.match(new RegExp('color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;', 'g')) || []).length, 1, output);
});
it('empty terminal with background included', async () => {
const output = serializeAddon.serializeAsHTML({
includeGlobalBackground: true
it('basic terminal with selection', async () => {
await writeP(terminal, ' terminal ');
terminal.select(1, 0, 8);
const output = serializeAddon.serializeAsHTML({
onlySelection: true
});
assert.equal((output.match(/<div><span>terminal<\/span><\/div>/g) || []).length, 1, output);
});
it('cells with bold styling', async () => {
await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='font-weight: bold;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with italic styling', async () => {
await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='font-style: italic;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with inverse styling', async () => {
await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='color: #000000; background-color: #BFBFBF;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with underline styling', async () => {
await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: underline;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with invisible styling', async () => {
await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='visibility: hidden;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with dim styling', async () => {
await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='opacity: 0.5;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with strikethrough styling', async () => {
await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: line-through;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with combined styling', async () => {
await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='font-weight: bold;'> <\/span>/g) || []).length, 1, output);
assert.equal((output.match(/<span style='font-weight: bold; text-decoration: line-through;'>termi<\/span>/g) || []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: line-through;'>nal<\/span>/g) || []).length, 1, output);
});
it('cells with color styling', async () => {
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with background styling', async () => {
await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='background-color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
});
it('empty terminal with default options', async () => {
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: courier-new, courier, monospace; font-size: 15px;/g) || []).length, 1, output);
});
it('empty terminal with custom options', async () => {
terminal.options.fontFamily = 'verdana';
terminal.options.fontSize = 20;
terminal.options.theme = {
foreground: '#ff00ff',
background: '#00ff00'
};
const output = serializeAddon.serializeAsHTML({
includeGlobalBackground: true
});
assert.equal((output.match(/color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;/g) || []).length, 1, output);
});
it('empty terminal with background included', async () => {
const output = serializeAddon.serializeAsHTML({
includeGlobalBackground: true
});
assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: courier-new, courier, monospace; font-size: 15px;/g) || []).length, 1, output);
});
assert.equal((output.match(new RegExp('color: #ffffff; background-color: #000000; font-family: courier-new, courier, monospace; font-size: 15px;', 'g')) || []).length, 1, output);
});
});
@@ -7,6 +7,7 @@
import { Terminal, ITerminalAddon, IBuffer, IBufferCell, IBufferRange } from 'xterm';
import { IColorSet } from 'browser/Types';
import { IAttributeData } from 'common/Types';
function constrain(value: number, low: number, high: number): number {
return Math.max(low, Math.min(value, high));
@@ -62,17 +63,17 @@ abstract class BaseSerializeHandler {
protected _serializeString(): string { return ''; }
}
function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean {
function equalFg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
return cell1.getFgColorMode() === cell2.getFgColorMode()
&& cell1.getFgColor() === cell2.getFgColor();
}
function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean {
function equalBg(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
return cell1.getBgColorMode() === cell2.getBgColorMode()
&& cell1.getBgColor() === cell2.getBgColor();
}
function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
return cell1.isInverse() === cell2.isInverse()
&& cell1.isBold() === cell2.isBold()
&& cell1.isUnderline() === cell2.isUnderline()
@@ -229,7 +230,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount = 0;
}
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
const sgrSeq: number[] = [];
const fgChanged = !equalFg(cell, oldCell);
const bgChanged = !equalBg(cell, oldCell);
@@ -393,6 +394,15 @@ class StringSerializeHandler extends BaseSerializeHandler {
moveRight(realCursorCol - this._lastCursorCol);
}
// Restore the cursor's current style, see https://github.com/xtermjs/xterm.js/issues/3677
// HACK: Internal API access since it's awkward to expose this in the API and serialize will
// likely be the only consumer
const curAttrData: IAttributeData = (this._terminal as any)._core._inputHandler._curAttrData;
const sgrSeq = this._diffStyle(curAttrData, this._cursorStyle);
if (sgrSeq.length > 0) {
content += `\u001b[${sgrSeq.join(';')}m`;
}
return content;
}
}
@@ -544,7 +554,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
return target;
}
targetLength = targetLength - target.length;
targetLength -= target.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length);
}
@@ -14,7 +14,7 @@ let page: Page;
const width = 800;
const height = 600;
const writeRawSync = (page: any, str: string): Promise<void> => writeSync(page, '\' +' + JSON.stringify(str) + '+ \'');
const writeRawSync = (page: any, str: string): Promise<void> => writeSync(page, `' +` + JSON.stringify(str) + `+ '`);
const testNormalScreenEqual = async (page: any, str: string): Promise<void> => {
await writeRawSync(page, str);
@@ -5,9 +5,10 @@
import { ILinkProvider, ILink, Terminal, IViewportRange } from 'xterm';
interface ILinkProviderOptions {
export interface ILinkProviderOptions {
hover?(event: MouseEvent, text: string, location: IViewportRange): void;
leave?(event: MouseEvent, text: string): void;
urlRegex?: RegExp;
}
export class WebLinkProvider implements ILinkProvider {
@@ -78,10 +79,17 @@ export class LinkComputer {
endY++;
}
let startX = stringIndex + 1;
let startY = startLineIndex + 1;
while (startX > terminal.cols) {
startX -= terminal.cols;
startY++;
}
const range = {
start: {
x: stringIndex + 1,
y: startLineIndex + 1
x: startX,
y: startY
},
end: {
x: endX,
@@ -3,8 +3,8 @@
* @license MIT
*/
import { Terminal, ILinkMatcherOptions, ITerminalAddon, IDisposable, IViewportRange } from 'xterm';
import { WebLinkProvider } from './WebLinkProvider';
import { Terminal, ILinkMatcherOptions, ITerminalAddon, IDisposable } from 'xterm';
import { ILinkProviderOptions, WebLinkProvider } from './WebLinkProvider';
const protocolClause = '(https?:\\/\\/)';
const domainCharacterSet = '[\\da-z\\.-]+';
@@ -40,12 +40,6 @@ function handleLink(event: MouseEvent, uri: string): void {
}
}
interface ILinkProviderOptions {
hover?(event: MouseEvent, text: string, location: IViewportRange): void;
leave?(event: MouseEvent, text: string): void;
urlRegex?: RegExp;
}
export class WebLinksAddon implements ITerminalAddon {
private _linkMatcherId: number | undefined;
private _terminal: Terminal | undefined;
@@ -49,5 +49,10 @@ declare module 'xterm-addon-web-links' {
* happen even when tooltipCallback hasn't fired for the link yet.
*/
leave?(event: MouseEvent, text: string): void;
/**
* A callback to use instead of the default one.
*/
urlRegex?: RegExp;
}
}
+7 -95
View File
@@ -6,14 +6,11 @@
import { createProgram, PROJECTION_MATRIX, throwIfFalsy } from './WebglUtils';
import { WebglCharAtlas } from './atlas/WebglCharAtlas';
import { IWebGL2RenderingContext, IWebGLVertexArrayObject, IRenderModel, IRasterizedGlyph } from './Types';
import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_INDICIES_PER_CELL, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_BG_OFFSET } from './RenderModel';
import { fill } from 'common/TypedArrayUtils';
import { slice } from './TypedArray';
import { NULL_CELL_CODE, WHITESPACE_CELL_CODE, Attributes, FgFlags } from 'common/buffer/Constants';
import { NULL_CELL_CODE } from 'common/buffer/Constants';
import { Terminal, IBufferLine } from 'xterm';
import { IColorSet, IColor } from 'browser/Types';
import { IColorSet } from 'browser/Types';
import { IRenderDimensions } from 'browser/renderer/Types';
import { AttributeData } from 'common/buffer/AttributeData';
interface IVertices {
attributes: Float32Array;
@@ -24,7 +21,6 @@ interface IVertices {
* working on the next frame.
*/
attributesBuffers: Float32Array[];
selectionAttributes: Float32Array;
count: number;
}
@@ -91,8 +87,7 @@ export class GlyphRenderer {
attributesBuffers: [
new Float32Array(0),
new Float32Array(0)
],
selectionAttributes: new Float32Array(0)
]
};
constructor(
@@ -187,6 +182,8 @@ export class GlyphRenderer {
if (!this._atlas) {
return;
}
// Get the glyph
if (chars && chars.length > 1) {
rasterizedGlyph = this._atlas.getRasterizedGlyphCombinedChar(chars, bg, fg);
} else {
@@ -214,91 +211,6 @@ export class GlyphRenderer {
// a_cellpos only changes on resize
}
public updateSelection(model: IRenderModel): void {
const terminal = this._terminal;
this._vertices.selectionAttributes = slice(this._vertices.attributes, 0);
const bg = (this._colors.selectionOpaque.rgba >>> 8) | Attributes.CM_RGB;
if (model.selection.columnSelectMode) {
const startCol = model.selection.startCol;
const width = model.selection.endCol - startCol;
const height = model.selection.viewportCappedEndRow - model.selection.viewportCappedStartRow + 1;
for (let y = model.selection.viewportCappedStartRow; y < model.selection.viewportCappedStartRow + height; y++) {
this._updateSelectionRange(startCol, startCol + width, y, model, bg);
}
} else {
// Draw first row
const startCol = model.selection.viewportStartRow === model.selection.viewportCappedStartRow ? model.selection.startCol : 0;
const startRowEndCol = model.selection.viewportCappedStartRow === model.selection.viewportCappedEndRow ? model.selection.endCol : terminal.cols;
this._updateSelectionRange(startCol, startRowEndCol, model.selection.viewportCappedStartRow, model, bg);
// Draw middle rows
const middleRowsCount = Math.max(model.selection.viewportCappedEndRow - model.selection.viewportCappedStartRow - 1, 0);
for (let y = model.selection.viewportCappedStartRow + 1; y <= model.selection.viewportCappedStartRow + middleRowsCount; y++) {
this._updateSelectionRange(0, startRowEndCol, y, model, bg);
}
// Draw final row
if (model.selection.viewportCappedStartRow !== model.selection.viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewportStartRow
const endCol = model.selection.viewportEndRow === model.selection.viewportCappedEndRow ? model.selection.endCol : terminal.cols;
this._updateSelectionRange(0, endCol, model.selection.viewportCappedEndRow, model, bg);
}
}
}
private _updateSelectionRange(startCol: number, endCol: number, y: number, model: IRenderModel, bg: number): void {
const terminal = this._terminal;
const row = y + terminal.buffer.active.viewportY;
let line: IBufferLine | undefined;
for (let x = startCol; x < endCol; x++) {
const offset = (y * this._terminal.cols + x) * RENDER_MODEL_INDICIES_PER_CELL;
const code = model.cells[offset];
let fg = model.cells[offset + RENDER_MODEL_FG_OFFSET];
if (fg & FgFlags.INVERSE) {
const workCell = new AttributeData();
workCell.fg = fg;
workCell.bg = model.cells[offset + RENDER_MODEL_BG_OFFSET];
// Get attributes from fg (excluding inverse) and resolve inverse by pullibng rgb colors
// from bg. This is needed since the inverse fg color should be based on the original bg
// color, not on the selection color
fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK | FgFlags.INVERSE);
switch (workCell.getBgColorMode()) {
case Attributes.CM_P16:
case Attributes.CM_P256:
const c = this._getColorFromAnsiIndex(workCell.getBgColor()).rgba;
fg |= (c >> 8) & Attributes.RED_MASK | (c >> 8) & Attributes.GREEN_MASK | (c >> 8) & Attributes.BLUE_MASK;
case Attributes.CM_RGB:
const arr = AttributeData.toColorRGB(workCell.getBgColor());
fg |= arr[0] << Attributes.RED_SHIFT | arr[1] << Attributes.GREEN_SHIFT | arr[2] << Attributes.BLUE_SHIFT;
case Attributes.CM_DEFAULT:
default:
const c2 = this._colors.background.rgba;
fg |= (c2 >> 8) & Attributes.RED_MASK | (c2 >> 8) & Attributes.GREEN_MASK | (c2 >> 8) & Attributes.BLUE_MASK;
}
fg |= Attributes.CM_RGB;
}
if (code & COMBINED_CHAR_BIT_MASK) {
if (!line) {
line = terminal.buffer.active.getLine(row);
}
const chars = line!.getCell(x)!.getChars();
this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, fg, chars);
} else {
this._updateCell(this._vertices.selectionAttributes, x, y, model.cells[offset], bg, fg);
}
}
}
private _getColorFromAnsiIndex(idx: number): IColor {
if (idx >= this._colors.ansi.length) {
throw new Error('No color found for idx ' + idx);
}
return this._colors.ansi[idx];
}
public clear(force?: boolean): void {
const terminal = this._terminal;
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
@@ -333,7 +245,7 @@ export class GlyphRenderer {
public setColors(): void {
}
public render(renderModel: IRenderModel, isSelectionVisible: boolean): void {
public render(renderModel: IRenderModel): void {
if (!this._atlas) {
return;
}
@@ -357,7 +269,7 @@ export class GlyphRenderer {
let bufferLength = 0;
for (let y = 0; y < renderModel.lineLengths.length; y++) {
const si = y * this._terminal.cols * INDICES_PER_CELL;
const sub = (isSelectionVisible ? this._vertices.selectionAttributes : this._vertices.attributes).subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
activeBuffer.set(sub, bufferLength);
bufferLength += sub.length;
}
@@ -4,11 +4,11 @@
*/
import { createProgram, expandFloat32Array, PROJECTION_MATRIX, throwIfFalsy } from './WebglUtils';
import { IRenderModel, IWebGLVertexArrayObject, IWebGL2RenderingContext, ISelectionRenderModel } from './Types';
import { fill } from 'common/TypedArrayUtils';
import { IRenderModel, IWebGLVertexArrayObject, IWebGL2RenderingContext } from './Types';
import { Attributes, FgFlags } from 'common/buffer/Constants';
import { Terminal } from 'xterm';
import { IColorSet, IColor } from 'browser/Types';
import { IColor } from 'common/Types';
import { IColorSet } from 'browser/Types';
import { IRenderDimensions } from 'browser/renderer/Types';
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
@@ -49,7 +49,6 @@ void main() {
interface IVertices {
attributes: Float32Array;
selection: Float32Array;
count: number;
}
@@ -66,12 +65,10 @@ export class RectangleRenderer {
private _attributesBuffer: WebGLBuffer;
private _projectionLocation: WebGLUniformLocation;
private _bgFloat!: Float32Array;
private _selectionFloat!: Float32Array;
private _vertices: IVertices = {
count: 0,
attributes: new Float32Array(INITIAL_BUFFER_RECTANGLE_CAPACITY),
selection: new Float32Array(3 * INDICES_PER_RECTANGLE)
attributes: new Float32Array(INITIAL_BUFFER_RECTANGLE_CAPACITY)
};
constructor(
@@ -137,11 +134,6 @@ export class RectangleRenderer {
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._vertices.attributes, gl.DYNAMIC_DRAW);
gl.drawElementsInstanced(this._gl.TRIANGLES, 6, gl.UNSIGNED_BYTE, 0, this._vertices.count);
// Bind selection buffer and draw
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, this._vertices.selection, gl.DYNAMIC_DRAW);
gl.drawElementsInstanced(this._gl.TRIANGLES, 6, gl.UNSIGNED_BYTE, 0, 3);
}
public onResize(): void {
@@ -155,7 +147,6 @@ export class RectangleRenderer {
private _updateCachedColors(): void {
this._bgFloat = this._colorToFloat32Array(this._colors.background);
this._selectionFloat = this._colorToFloat32Array(this._colors.selectionOpaque);
}
private _updateViewportRectangle(): void {
@@ -171,73 +162,6 @@ export class RectangleRenderer {
);
}
public updateSelection(model: ISelectionRenderModel): void {
const terminal = this._terminal;
if (!model.hasSelection) {
fill(this._vertices.selection, 0, 0);
return;
}
if (model.columnSelectMode) {
const startCol = model.startCol;
const width = model.endCol - startCol;
const height = model.viewportCappedEndRow - model.viewportCappedStartRow + 1;
this._addRectangleFloat(
this._vertices.selection,
0,
startCol * this._dimensions.scaledCellWidth,
model.viewportCappedStartRow * this._dimensions.scaledCellHeight,
width * this._dimensions.scaledCellWidth,
height * this._dimensions.scaledCellHeight,
this._selectionFloat
);
fill(this._vertices.selection, 0, INDICES_PER_RECTANGLE);
} else {
// Draw first row
const startCol = model.viewportStartRow === model.viewportCappedStartRow ? model.startCol : 0;
const startRowEndCol = model.viewportCappedStartRow === model.viewportEndRow ? model.endCol : terminal.cols;
this._addRectangleFloat(
this._vertices.selection,
0,
startCol * this._dimensions.scaledCellWidth,
model.viewportCappedStartRow * this._dimensions.scaledCellHeight,
(startRowEndCol - startCol) * this._dimensions.scaledCellWidth,
this._dimensions.scaledCellHeight,
this._selectionFloat
);
// Draw middle rows
const middleRowsCount = Math.max(model.viewportCappedEndRow - model.viewportCappedStartRow - 1, 0);
this._addRectangleFloat(
this._vertices.selection,
INDICES_PER_RECTANGLE,
0,
(model.viewportCappedStartRow + 1) * this._dimensions.scaledCellHeight,
terminal.cols * this._dimensions.scaledCellWidth,
middleRowsCount * this._dimensions.scaledCellHeight,
this._selectionFloat
);
// Draw final row
if (model.viewportCappedStartRow !== model.viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewportStartRow
const endCol = model.viewportEndRow === model.viewportCappedEndRow ? model.endCol : terminal.cols;
this._addRectangleFloat(
this._vertices.selection,
INDICES_PER_RECTANGLE * 2,
0,
model.viewportCappedEndRow * this._dimensions.scaledCellHeight,
endCol * this._dimensions.scaledCellWidth,
this._dimensions.scaledCellHeight,
this._selectionFloat
);
} else {
fill(this._vertices.selection, 0, INDICES_PER_RECTANGLE * 2);
}
}
}
public updateBackgrounds(model: IRenderModel): void {
const terminal = this._terminal;
const vertices = this._vertices;
+3 -1
View File
@@ -9,6 +9,7 @@ import { ICharacterJoinerService, IRenderService } from 'browser/services/Servic
import { IColorSet } from 'browser/Types';
import { EventEmitter } from 'common/EventEmitter';
import { isSafari } from 'common/Platform';
import { IDecorationService } from 'common/services/Services';
export class WebglAddon implements ITerminalAddon {
private _terminal?: Terminal;
@@ -30,8 +31,9 @@ export class WebglAddon implements ITerminalAddon {
this._terminal = terminal;
const renderService: IRenderService = (terminal as any)._core._renderService;
const characterJoinerService: ICharacterJoinerService = (terminal as any)._core._characterJoinerService;
const decorationService: IDecorationService = (terminal as any)._core._decorationService;
const colors: IColorSet = (terminal as any)._core._colorManager.colors;
this._renderer = new WebglRenderer(terminal, colors, characterJoinerService, this._preserveDrawingBuffer);
this._renderer = new WebglRenderer(terminal, colors, characterJoinerService, decorationService, this._preserveDrawingBuffer);
this._renderer.onContextLoss(() => this._onContextLoss.fire());
renderService.setRenderer(this._renderer);
}
+114 -28
View File
@@ -12,7 +12,7 @@ import { RectangleRenderer } from './RectangleRenderer';
import { IWebGL2RenderingContext } from './Types';
import { RenderModel, COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
import { Disposable } from 'common/Lifecycle';
import { Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
import { Terminal, IEvent } from 'xterm';
import { IRenderLayer } from './renderLayer/Types';
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/Types';
@@ -23,6 +23,7 @@ import { addDisposableDomListener } from 'browser/Lifecycle';
import { ICharacterJoinerService } from 'browser/services/Services';
import { CharData, ICellData } from 'common/Types';
import { AttributeData } from 'common/buffer/AttributeData';
import { IDecorationService } from 'common/services/Services';
export class WebglRenderer extends Disposable implements IRenderer {
private _renderLayers: IRenderLayer[];
@@ -31,6 +32,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
private _model: RenderModel = new RenderModel();
private _workCell: CellData = new CellData();
private _workColors: { fg: number, bg: number } = { fg: 0, bg: 0 };
private _canvas: HTMLCanvasElement;
private _gl: IWebGL2RenderingContext;
@@ -52,6 +54,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
private _terminal: Terminal,
private _colors: IColorSet,
private readonly _characterJoinerService: ICharacterJoinerService,
private readonly _decorationService: IDecorationService,
preserveDrawingBuffer?: boolean
) {
super();
@@ -164,10 +167,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._core.screenElement!.style.height = `${this.dimensions.canvasHeight}px`;
this._rectangleRenderer.onResize();
if (this._model.selection.hasSelection) {
// Update selection as dimensions have changed
this._rectangleRenderer.updateSelection(this._model.selection);
}
this._glyphRenderer.setDimensions(this.dimensions);
this._glyphRenderer.onResize();
@@ -198,10 +197,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
for (const l of this._renderLayers) {
l.onSelectionChanged(this._terminal, start, end, columnSelectMode);
}
this._updateSelectionModel(start, end, columnSelectMode);
this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1 });
this._requestRedrawViewport();
}
public onCursorMove(): void {
@@ -243,7 +240,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._charAtlas?.clearTexture();
this._model.clear();
this._updateModel(0, this._terminal.rows - 1);
this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1 });
this._requestRedrawViewport();
}
public clear(): void {
@@ -289,7 +286,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Render
this._rectangleRenderer.render();
this._glyphRenderer.render(this._model, this._model.selection.hasSelection);
this._glyphRenderer.render(this._model);
}
private _updateModel(start: number, end: number): void {
@@ -331,14 +328,17 @@ export class WebglRenderer extends Disposable implements IRenderer {
let code = cell.getCode();
const i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
// Load colors/resolve overrides into work colors
this._loadColorsForCell(x, row);
if (code !== NULL_CELL_CODE) {
this._model.lineLengths[y] = x + 1;
}
// Nothing has changed, no updates needed
if (this._model.cells[i] === code &&
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === cell.bg &&
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === cell.fg) {
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._workColors.bg &&
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._workColors.fg) {
continue;
}
@@ -349,10 +349,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Cache the results in the model
this._model.cells[i] = code;
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = cell.bg;
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = cell.fg;
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._workColors.bg;
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._workColors.fg;
this._glyphRenderer.updateCell(x, y, code, cell.bg, cell.fg, chars);
this._glyphRenderer.updateCell(x, y, code, this._workColors.bg, this._workColors.fg, chars);
if (isJoined) {
// Restore work cell
@@ -363,17 +363,103 @@ export class WebglRenderer extends Disposable implements IRenderer {
const j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
this._glyphRenderer.updateCell(x, y, NULL_CELL_CODE, 0, 0, NULL_CELL_CHAR);
this._model.cells[j] = NULL_CELL_CODE;
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._workCell.bg;
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._workCell.fg;
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._workColors.bg;
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._workColors.fg;
}
}
}
}
this._rectangleRenderer.updateBackgrounds(this._model);
if (this._model.selection.hasSelection) {
// Model could be updated but the selection is unchanged
this._glyphRenderer.updateSelection(this._model);
}
/**
* Loads colors for the cell into the work colors object. This resolves overrides/inverse if
* necessary which is why the work cell object is not used.
*/
private _loadColorsForCell(x: number, y: number): void {
this._workColors.bg = this._workCell.bg;
this._workColors.fg = this._workCell.fg;
// Get any foreground/background overrides, this happens on the model to avoid spreading
// override logic throughout the different sub-renderers
let bgOverride: number | undefined;
let fgOverride: number | undefined;
// Apply decorations on the bottom layer
for (const d of this._decorationService.getDecorationsAtCell(x, y, 'bottom')) {
if (d.backgroundColorRGB) {
bgOverride = d.backgroundColorRGB.rgba >> 8 & 0xFFFFFF;
}
if (d.foregroundColorRGB) {
fgOverride = d.foregroundColorRGB.rgba >> 8 & 0xFFFFFF;
}
}
// Apply the selection color if needed
if (this._isCellSelected(x, y)) {
bgOverride = this._colors.selectionOpaque.rgba >> 8 & 0xFFFFFF;
}
// Apply decorations on the top layer
for (const d of this._decorationService.getDecorationsAtCell(x, y, 'top')) {
if (d.backgroundColorRGB) {
bgOverride = d.backgroundColorRGB.rgba >> 8 & 0xFFFFFF;
}
if (d.foregroundColorRGB) {
fgOverride = d.foregroundColorRGB.rgba >> 8 & 0xFFFFFF;
}
}
// Convert any overrides from rgba to the fg/bg packed format. This resolves the inverse flag
// ahead of time in order to use the correct cache key
if (bgOverride !== undefined) {
// Non-RGB attributes from model + override + force RGB color mode
bgOverride = (this._workCell.bg & ~Attributes.RGB_MASK) | bgOverride | Attributes.CM_RGB;
}
if (fgOverride !== undefined) {
// Non-RGB attributes from model + force disable inverse + override + force RGB color mode
fgOverride = (this._workCell.fg & ~Attributes.RGB_MASK & ~FgFlags.INVERSE) | fgOverride | Attributes.CM_RGB;
}
// Handle case where inverse was specified by only one of bgOverride or fgOverride was set,
// resolving the other inverse color and setting the inverse flag if needed.
if (this._workColors.fg & FgFlags.INVERSE) {
if (bgOverride !== undefined && fgOverride === undefined) {
// Resolve bg color type (default color has a different meaning in fg vs bg)
if ((this._workColors.bg & Attributes.CM_MASK) === Attributes.CM_DEFAULT) {
fgOverride = (this._workColors.fg & ~(Attributes.RGB_MASK | FgFlags.INVERSE | Attributes.CM_MASK)) | ((this._colors.background.rgba >> 8 & 0xFFFFFF) & Attributes.RGB_MASK) | Attributes.CM_RGB;
} else {
fgOverride = (this._workColors.fg & ~(Attributes.RGB_MASK | FgFlags.INVERSE | Attributes.CM_MASK)) | this._workColors.bg & (Attributes.RGB_MASK | Attributes.CM_MASK);
}
}
if (bgOverride === undefined && fgOverride !== undefined) {
// Resolve bg color type (default color has a different meaning in fg vs bg)
if ((this._workColors.fg & Attributes.CM_MASK) === Attributes.CM_DEFAULT) {
bgOverride = (this._workColors.bg & ~(Attributes.RGB_MASK | Attributes.CM_MASK)) | ((this._colors.foreground.rgba >> 8 & 0xFFFFFF) & Attributes.RGB_MASK) | Attributes.CM_RGB;
} else {
bgOverride = (this._workColors.bg & ~(Attributes.RGB_MASK | Attributes.CM_MASK)) | this._workColors.fg & (Attributes.RGB_MASK | Attributes.CM_MASK);
}
}
}
// Use the override if it exists
this._workColors.bg = bgOverride ?? this._workColors.bg;
this._workColors.fg = fgOverride ?? this._workColors.fg;
}
private _isCellSelected(x: number, y: number): boolean {
if (!this._model.selection.hasSelection) {
return false;
}
y -= this._terminal.buffer.active.viewportY;
if (this._model.selection.columnSelectMode) {
return x >= this._model.selection.startCol && y >= this._model.selection.viewportCappedStartRow &&
x < this._model.selection.endCol && y < this._model.selection.viewportCappedEndRow;
}
return (y > this._model.selection.viewportStartRow && y < this._model.selection.viewportEndRow) ||
(this._model.selection.viewportStartRow === this._model.selection.viewportEndRow && y === this._model.selection.viewportStartRow && x >= this._model.selection.startCol && x < this._model.selection.endCol) ||
(this._model.selection.viewportStartRow < this._model.selection.viewportEndRow && y === this._model.selection.viewportEndRow && x < this._model.selection.endCol) ||
(this._model.selection.viewportStartRow < this._model.selection.viewportEndRow && y === this._model.selection.viewportStartRow && x >= this._model.selection.startCol);
}
private _updateSelectionModel(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void {
@@ -382,7 +468,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Selection does not exist
if (!start || !end || (start[0] === end[0] && start[1] === end[1])) {
this._model.clearSelection();
this._rectangleRenderer.updateSelection(this._model.selection);
return;
}
@@ -395,7 +480,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
// No need to draw the selection
if (viewportCappedStartRow >= terminal.rows || viewportCappedEndRow < 0) {
this._model.clearSelection();
this._rectangleRenderer.updateSelection(this._model.selection);
return;
}
@@ -407,8 +491,6 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._model.selection.viewportCappedEndRow = viewportCappedEndRow;
this._model.selection.startCol = start[0];
this._model.selection.endCol = end[0];
this._rectangleRenderer.updateSelection(this._model.selection);
}
/**
@@ -440,18 +522,18 @@ export class WebglRenderer extends Disposable implements IRenderer {
// will be floored because since lineHeight can never be lower then 1, there
// is a guarentee that the scaled line height will always be larger than
// scaled char height.
this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.getOption('lineHeight'));
this.dimensions.scaledCellHeight = Math.floor(this.dimensions.scaledCharHeight * this._terminal.options.lineHeight!);
// Calculate the y coordinate within a cell that text should draw from in
// order to draw in the center of a cell.
this.dimensions.scaledCharTop = this._terminal.getOption('lineHeight') === 1 ? 0 : Math.round((this.dimensions.scaledCellHeight - this.dimensions.scaledCharHeight) / 2);
this.dimensions.scaledCharTop = this._terminal.options.lineHeight === 1 ? 0 : Math.round((this.dimensions.scaledCellHeight - this.dimensions.scaledCharHeight) / 2);
// Calculate the scaled cell width, taking the letterSpacing into account.
this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.getOption('letterSpacing'));
this.dimensions.scaledCellWidth = this.dimensions.scaledCharWidth + Math.round(this._terminal.options.letterSpacing!);
// Calculate the x coordinate with a cell that text should draw from in
// order to draw in the center of a cell.
this.dimensions.scaledCharLeft = Math.floor(this._terminal.getOption('letterSpacing') / 2);
this.dimensions.scaledCharLeft = Math.floor(this._terminal.options.letterSpacing! / 2);
// Recalculate the canvas dimensions; scaled* define the actual number of
// pixel in the canvas
@@ -482,6 +564,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
this.dimensions.actualCellHeight = this.dimensions.scaledCellHeight / this._devicePixelRatio;
this.dimensions.actualCellWidth = this.dimensions.scaledCellWidth / this._devicePixelRatio;
}
private _requestRedrawViewport(): void {
this._onRequestRedraw.fire({ start: 0, end: this._terminal.rows - 1 });
}
}
// TODO: Share impl with core

Some files were not shown because too many files have changed in this diff Show More