Pull renderer out of xterm.js

This commit is contained in:
Daniel Imms 2017-01-31 22:45:28 -08:00
parent 047729ee9e
commit 92068f36f2
3 changed files with 345 additions and 283 deletions

View File

@ -18,10 +18,19 @@ export interface ITerminal {
element: HTMLElement;
rowContainer: HTMLElement;
textarea: HTMLTextAreaElement;
ybase: number;
ydisp: number;
lines: string[];
lines: ICircularList<string>;
rows: number;
cols: number;
browser: IBrowser;
writeBuffer: string[];
children: HTMLElement[];
cursorHidden: boolean;
cursorState: number;
x: number;
y: number;
defAttr: number;
/**
* Emit the 'data' event and populate the given data.
@ -31,6 +40,22 @@ export interface ITerminal {
on(event: string, callback: () => void);
scrollDisp(disp: number, suppressScrollEvent: boolean);
cancel(ev: Event, force?: boolean);
log(text: string): void;
emit(event: string, data: any);
}
interface ICircularList<T> {
length: number;
maxLength: number;
forEach(callbackfn: (value: T, index: number, array: T[]) => void): void;
get(index: number): T;
set(index: number, value: T): void;
push(value: T): void;
pop(): T;
splice(start: number, deleteCount: number, ...items: T[]): void;
trimStart(count: number): void;
shiftElements(start: number, count: number, offset: number): void;
}
/**

303
src/Renderer.ts Normal file
View File

@ -0,0 +1,303 @@
import { ITerminal } from './Interfaces';
/**
* The maximum number of refresh frames to skip when the write buffer is non-
* empty. Note that these frames may be intermingled with frames that are
* skipped via requestAnimationFrame's mechanism.
*/
const MAX_REFRESH_FRAME_SKIP = 5;
// TODO: Convert flags to number enum
/**
* Flags used to render terminal text properly
*/
const FLAGS = {
BOLD: 1,
UNDERLINE: 2,
BLINK: 4,
INVERSE: 8,
INVISIBLE: 16
};
let brokenBold: boolean = null;
export class Renderer {
/** A queue of the rows to be refreshed */
private _refreshRowsQueue: {start: number, end: number}[] = [];
private _refreshFramesSkipped = 0;
private _refreshAnimationFrame = null;
constructor(private _terminal: ITerminal) {
// Figure out whether boldness affects
// the character width of monospace fonts.
if (brokenBold === null) {
brokenBold = checkBoldBroken((<any>this._terminal).document);
console.log('check brokenBold: ' + brokenBold);
}
// TODO: Pull all DOM interactions into Renderer.constructor
}
/**
* Queues a refresh between two rows (inclusive), to be done on next animation
* frame.
* @param {number} start The start row.
* @param {number} end The end row.
*/
public queueRefresh(start: number, end: number): void {
this._refreshRowsQueue.push({ start: start, end: end });
if (!this._refreshAnimationFrame) {
this._refreshAnimationFrame = window.requestAnimationFrame(this._refreshLoop.bind(this));
}
}
/**
* Performs the refresh loop callback, calling refresh only if a refresh is
* necessary before queueing up the next one.
*/
private _refreshLoop(): void {
// Skip MAX_REFRESH_FRAME_SKIP frames if the writeBuffer is non-empty as it
// will need to be immediately refreshed anyway. This saves a lot of
// rendering time as the viewport DOM does not need to be refreshed, no
// scroll events, no layouts, etc.
const skipFrame = this._terminal.writeBuffer.length > 0 && this._refreshFramesSkipped++ <= MAX_REFRESH_FRAME_SKIP;
if (skipFrame) {
this._refreshAnimationFrame = window.requestAnimationFrame(this._refreshLoop.bind(this));
return;
}
this._refreshFramesSkipped = 0;
let start;
let end;
if (this._refreshRowsQueue.length > 4) {
// Just do a full refresh when 5+ refreshes are queued
start = 0;
end = this._terminal.rows - 1;
} else {
// Get start and end rows that need refreshing
start = this._refreshRowsQueue[0].start;
end = this._refreshRowsQueue[0].end;
for (let i = 1; i < this._refreshRowsQueue.length; i++) {
if (this._refreshRowsQueue[i].start < start) {
start = this._refreshRowsQueue[i].start;
}
if (this._refreshRowsQueue[i].end > end) {
end = this._refreshRowsQueue[i].end;
}
}
}
this._refreshRowsQueue = [];
this._refreshAnimationFrame = null;
this._refresh(start, end);
}
/**
* Refreshes (re-renders) terminal content within two rows (inclusive)
*
* Rendering Engine:
*
* In the screen buffer, each character is stored as a an array with a character
* and a 32-bit integer:
* - First value: a utf-16 character.
* - Second value:
* - Next 9 bits: background color (0-511).
* - Next 9 bits: foreground color (0-511).
* - Next 14 bits: a mask for misc. flags:
* - 1=bold
* - 2=underline
* - 4=blink
* - 8=inverse
* - 16=invisible
*
* @param {number} start The row to start from (between 0 and terminal's height terminal - 1)
* @param {number} end The row to end at (between fromRow and terminal's height terminal - 1)
*/
private _refresh(start: number, end: number): void {
// TODO: Use fat arrow functions for callbacks to avoid `self`
let self = this;
let x, y, i, line, out, ch, ch_width, width, data, attr, bg, fg, flags, row, parent, focused = document.activeElement;
// If this is a big refresh, remove the terminal rows from the DOM for faster calculations
if (end - start >= this._terminal.rows / 2) {
parent = this._terminal.element.parentNode;
if (parent) {
this._terminal.element.removeChild(this._terminal.rowContainer);
}
}
width = this._terminal.cols;
y = start;
if (end >= this._terminal.rows) {
this._terminal.log('`end` is too large. Most likely a bad CSR.');
end = this._terminal.rows - 1;
}
for (; y <= end; y++) {
row = y + this._terminal.ydisp;
line = this._terminal.lines.get(row);
if (!line || !this._terminal.children[y]) {
// Continue if the line is not available, this means a resize is currently in progress
continue;
}
out = '';
if (this._terminal.y === y - (this._terminal.ybase - this._terminal.ydisp)
&& this._terminal.cursorState
&& !this._terminal.cursorHidden) {
x = this._terminal.x;
} else {
x = -1;
}
attr = this._terminal.defAttr;
i = 0;
for (; i < width; i++) {
if (!line[i]) {
// Continue if the character is not available, this means a resize is currently in progress
continue;
}
data = line[i][0];
ch = line[i][1];
ch_width = line[i][2];
if (!ch_width)
continue;
if (i === x) data = -1;
if (data !== attr) {
if (attr !== this._terminal.defAttr) {
out += '</span>';
}
if (data !== this._terminal.defAttr) {
if (data === -1) {
out += '<span class="reverse-video terminal-cursor">';
} else {
let classNames = [];
bg = data & 0x1ff;
fg = (data >> 9) & 0x1ff;
flags = data >> 18;
if (flags & FLAGS.BOLD) {
if (!brokenBold) {
classNames.push('xterm-bold');
}
// See: XTerm*boldColors
if (fg < 8) fg += 8;
}
if (flags & FLAGS.UNDERLINE) {
classNames.push('xterm-underline');
}
if (flags & FLAGS.BLINK) {
classNames.push('xterm-blink');
}
// If inverse flag is on, then swap the foreground and background variables.
if (flags & FLAGS.INVERSE) {
/* One-line variable swap in JavaScript: http://stackoverflow.com/a/16201730 */
bg = [fg, fg = bg][0];
// Should inverse just be before the
// above boldColors effect instead?
if ((flags & 1) && fg < 8) fg += 8;
}
if (flags & FLAGS.INVISIBLE) {
classNames.push('xterm-hidden');
}
/**
* Weird situation: Invert flag used black foreground and white background results
* in invalid background color, positioned at the 256 index of the 256 terminal
* color map. Pin the colors manually in such a case.
*
* Source: https://github.com/sourcelair/xterm.js/issues/57
*/
if (flags & FLAGS.INVERSE) {
if (bg === 257) {
bg = 15;
}
if (fg === 256) {
fg = 0;
}
}
if (bg < 256) {
classNames.push('xterm-bg-color-' + bg);
}
if (fg < 256) {
classNames.push('xterm-color-' + fg);
}
out += '<span';
if (classNames.length) {
out += ' class="' + classNames.join(' ') + '"';
}
out += '>';
}
}
}
if (ch_width === 2) {
out += '<span class="xterm-wide-char">';
}
switch (ch) {
case '&':
out += '&amp;';
break;
case '<':
out += '&lt;';
break;
case '>':
out += '&gt;';
break;
default:
if (ch <= ' ') {
out += '&nbsp;';
} else {
out += ch;
}
break;
}
if (ch_width === 2) {
out += '</span>';
}
attr = data;
}
if (attr !== this._terminal.defAttr) {
out += '</span>';
}
this._terminal.children[y].innerHTML = out;
}
if (parent) {
this._terminal.element.appendChild(this._terminal.rowContainer);
}
this._terminal.emit('refresh', {element: this._terminal.element, start: start, end: end});
};
}
// if bold is broken, we can't
// use it in the terminal.
function checkBoldBroken(document) {
const body = document.getElementsByTagName('body')[0];
const el = document.createElement('span');
el.innerHTML = 'hello world';
body.appendChild(el);
const w1 = el.scrollWidth;
el.style.fontWeight = 'bold';
const w2 = el.scrollWidth;
body.removeChild(el);
return w1 !== w2;
}

View File

@ -18,6 +18,7 @@ import { CircularList } from './utils/CircularList';
import { C0 } from './EscapeSequences';
import { InputHandler } from './InputHandler';
import { Parser } from './Parser';
import { Renderer } from './Renderer';
import { CharMeasure } from './utils/CharMeasure';
import * as Browser from './utils/Browser';
import * as Keyboard from './utils/Keyboard';
@ -50,13 +51,6 @@ var WRITE_BUFFER_PAUSE_THRESHOLD = 5;
*/
var WRITE_BATCH_SIZE = 300;
/**
* The maximum number of refresh frames to skip when the write buffer is non-
* empty. Note that these frames may be intermingled with frames that are
* skipped via requestAnimationFrame's mechanism.
*/
var MAX_REFRESH_FRAME_SKIP = 5;
/**
* Terminal
*/
@ -157,9 +151,6 @@ function Terminal(options) {
*/
this.y = 0;
/** A queue of the rows to be refreshed */
this.refreshRowsQueue = [];
this.cursorState = 0;
this.cursorHidden = false;
this.convertEol;
@ -217,12 +208,11 @@ function Terminal(options) {
this.inputHandler = new InputHandler(this);
this.parser = new Parser(this.inputHandler, this);
this.renderer = null;
// user input states
this.writeBuffer = [];
this.writeInProgress = false;
this.refreshFramesSkipped = 0;
this.refreshAnimationFrame = null;
/**
* Whether _xterm.js_ sent XOFF in order to catch up with the pty process.
@ -657,6 +647,7 @@ Terminal.prototype.open = function(parent) {
this.charMeasure.measure();
this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure);
this.renderer = new Renderer(this);
// Setup loop that draws to screen
this.queueRefresh(0, this.rows - 1);
@ -681,12 +672,6 @@ Terminal.prototype.open = function(parent) {
// them into terminal mouse protocols.
this.bindMouse();
// Figure out whether boldness affects
// the character width of monospace fonts.
if (Terminal.brokenBold == null) {
Terminal.brokenBold = isBoldBroken(this.document);
}
/**
* This event is emitted when terminal has completed opening.
*
@ -1065,263 +1050,26 @@ Terminal.prototype.destroy = function() {
//this.emit('close');
};
/**
* Flags used to render terminal text properly
*/
Terminal.flags = {
BOLD: 1,
UNDERLINE: 2,
BLINK: 4,
INVERSE: 8,
INVISIBLE: 16
}
/**
* Queues a refresh between two rows (inclusive), to be done on next animation
* frame.
* @param {number} start The start row.
* @param {number} end The end row.
*/
Terminal.prototype.queueRefresh = function(start, end) {
this.refreshRowsQueue.push({ start: start, end: end });
if (!this.refreshAnimationFrame) {
this.refreshAnimationFrame = window.requestAnimationFrame(this.refreshLoop.bind(this));
}
}
/**
* Performs the refresh loop callback, calling refresh only if a refresh is
* necessary before queueing up the next one.
*/
Terminal.prototype.refreshLoop = function() {
// Skip MAX_REFRESH_FRAME_SKIP frames if the writeBuffer is non-empty as it
// will need to be immediately refreshed anyway. This saves a lot of
// rendering time as the viewport DOM does not need to be refreshed, no
// scroll events, no layouts, etc.
var skipFrame = this.writeBuffer.length > 0 && this.refreshFramesSkipped++ <= MAX_REFRESH_FRAME_SKIP;
if (skipFrame) {
this.refreshAnimationFrame = window.requestAnimationFrame(this.refreshLoop.bind(this));
return;
}
this.refreshFramesSkipped = 0;
var start;
var end;
if (this.refreshRowsQueue.length > 4) {
// Just do a full refresh when 5+ refreshes are queued
start = 0;
end = this.rows - 1;
} else {
// Get start and end rows that need refreshing
start = this.refreshRowsQueue[0].start;
end = this.refreshRowsQueue[0].end;
for (var i = 1; i < this.refreshRowsQueue.length; i++) {
if (this.refreshRowsQueue[i].start < start) {
start = this.refreshRowsQueue[i].start;
}
if (this.refreshRowsQueue[i].end > end) {
end = this.refreshRowsQueue[i].end;
}
}
}
this.refreshRowsQueue = [];
this.refreshAnimationFrame = null;
this.refresh(start, end);
}
/**
* Refreshes (re-renders) terminal content within two rows (inclusive)
*
* Rendering Engine:
*
* In the screen buffer, each character is stored as a an array with a character
* and a 32-bit integer:
* - First value: a utf-16 character.
* - Second value:
* - Next 9 bits: background color (0-511).
* - Next 9 bits: foreground color (0-511).
* - Next 14 bits: a mask for misc. flags:
* - 1=bold
* - 2=underline
* - 4=blink
* - 8=inverse
* - 16=invisible
*
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
* opportunity.
* @param {number} start The row to start from (between 0 and terminal's height terminal - 1)
* @param {number} end The row to end at (between fromRow and terminal's height terminal - 1)
*/
Terminal.prototype.refresh = function(start, end) {
var self = this;
this.queueRefresh(start, end);
};
var x, y, i, line, out, ch, ch_width, width, data, attr, bg, fg, flags, row, parent, focused = document.activeElement;
// If this is a big refresh, remove the terminal rows from the DOM for faster calculations
if (end - start >= this.rows / 2) {
parent = this.element.parentNode;
if (parent) {
this.element.removeChild(this.rowContainer);
}
/**
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
* opportunity.
* @param {number} start The row to start from (between 0 and terminal's height terminal - 1)
* @param {number} end The row to end at (between fromRow and terminal's height terminal - 1)
*/
Terminal.prototype.queueRefresh = function(start, end) {
if (this.renderer) {
this.renderer.queueRefresh(start, end);
}
width = this.cols;
y = start;
if (end >= this.rows.length) {
this.log('`end` is too large. Most likely a bad CSR.');
end = this.rows.length - 1;
}
for (; y <= end; y++) {
row = y + this.ydisp;
line = this.lines.get(row);
if (!line || !this.children[y]) {
// Continue if the line is not available, this means a resize is currently in progress
continue;
}
out = '';
if (this.y === y - (this.ybase - this.ydisp)
&& this.cursorState
&& !this.cursorHidden) {
x = this.x;
} else {
x = -1;
}
attr = this.defAttr;
i = 0;
for (; i < width; i++) {
if (!line[i]) {
// Continue if the character is not available, this means a resize is currently in progress
continue;
}
data = line[i][0];
ch = line[i][1];
ch_width = line[i][2];
if (!ch_width)
continue;
if (i === x) data = -1;
if (data !== attr) {
if (attr !== this.defAttr) {
out += '</span>';
}
if (data !== this.defAttr) {
if (data === -1) {
out += '<span class="reverse-video terminal-cursor">';
} else {
var classNames = [];
bg = data & 0x1ff;
fg = (data >> 9) & 0x1ff;
flags = data >> 18;
if (flags & Terminal.flags.BOLD) {
if (!Terminal.brokenBold) {
classNames.push('xterm-bold');
}
// See: XTerm*boldColors
if (fg < 8) fg += 8;
}
if (flags & Terminal.flags.UNDERLINE) {
classNames.push('xterm-underline');
}
if (flags & Terminal.flags.BLINK) {
classNames.push('xterm-blink');
}
// If inverse flag is on, then swap the foreground and background variables.
if (flags & Terminal.flags.INVERSE) {
/* One-line variable swap in JavaScript: http://stackoverflow.com/a/16201730 */
bg = [fg, fg = bg][0];
// Should inverse just be before the
// above boldColors effect instead?
if ((flags & 1) && fg < 8) fg += 8;
}
if (flags & Terminal.flags.INVISIBLE) {
classNames.push('xterm-hidden');
}
/**
* Weird situation: Invert flag used black foreground and white background results
* in invalid background color, positioned at the 256 index of the 256 terminal
* color map. Pin the colors manually in such a case.
*
* Source: https://github.com/sourcelair/xterm.js/issues/57
*/
if (flags & Terminal.flags.INVERSE) {
if (bg == 257) {
bg = 15;
}
if (fg == 256) {
fg = 0;
}
}
if (bg < 256) {
classNames.push('xterm-bg-color-' + bg);
}
if (fg < 256) {
classNames.push('xterm-color-' + fg);
}
out += '<span';
if (classNames.length) {
out += ' class="' + classNames.join(' ') + '"';
}
out += '>';
}
}
}
if (ch_width === 2) {
out += '<span class="xterm-wide-char">';
}
switch (ch) {
case '&':
out += '&amp;';
break;
case '<':
out += '&lt;';
break;
case '>':
out += '&gt;';
break;
default:
if (ch <= ' ') {
out += '&nbsp;';
} else {
out += ch;
}
break;
}
if (ch_width === 2) {
out += '</span>';
}
attr = data;
}
if (attr !== this.defAttr) {
out += '</span>';
}
this.children[y].innerHTML = out;
}
if (parent) {
this.element.appendChild(this.rowContainer);
}
this.emit('refresh', {element: this.element, start: start, end: end});
};
/**
@ -2389,20 +2137,6 @@ function inherits(child, parent) {
child.prototype = new f;
}
// if bold is broken, we can't
// use it in the terminal.
function isBoldBroken(document) {
var body = document.getElementsByTagName('body')[0];
var el = document.createElement('span');
el.innerHTML = 'hello world';
body.appendChild(el);
var w1 = el.scrollWidth;
el.style.fontWeight = 'bold';
var w2 = el.scrollWidth;
body.removeChild(el);
return w1 !== w2;
}
function indexOf(obj, el) {
var i = obj.length;
while (i--) {