diff --git a/README.md b/README.md index edfa1169cb..f6b805ef15 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,10 @@ xterm.fit(); Since xterm.js is typically implemented as a developer tool, only modern browsers are supported officially. Here is a list of the versions we aim to support: -- Chrome 48+ -- Edge 13+ -- Firefox 44+ -- Internet Explorer 11+ -- Opera 35+ -- Safari 8+ +- Chrome latest +- Edge latest +- Firefox latest +- Safari latest Xterm.js works seamlessly in Electron apps and may even work on earlier versions of the browsers but these are the browsers we strive to keep working. diff --git a/demo/main.js b/demo/main.js index cb346de2af..f4fb0a2a01 100644 --- a/demo/main.js +++ b/demo/main.js @@ -2,9 +2,7 @@ var term, protocol, socketURL, socket, - pid, - charWidth, - charHeight; + pid; var terminalContainer = document.getElementById('terminal-container'), actionElements = { @@ -21,11 +19,13 @@ var terminalContainer = document.getElementById('terminal-container'), colsElement = document.getElementById('cols'), rowsElement = document.getElementById('rows'); -function setTerminalSize () { - var cols = parseInt(colsElement.value, 10), - rows = parseInt(rowsElement.value, 10), - width = (cols * charWidth).toString() + 'px', - height = (rows * charHeight).toString() + 'px'; +function setTerminalSize() { + var cols = parseInt(colsElement.value, 10); + var rows = parseInt(rowsElement.value, 10); + var viewportElement = document.querySelector('.xterm-viewport'); + var scrollBarWidth = viewportElement.offsetWidth - viewportElement.clientWidth; + var width = (cols * term.charMeasure.width + 20 /*room for scrollbar*/).toString() + 'px'; + var height = (rows * term.charMeasure.height).toString() + 'px'; terminalContainer.style.width = width; terminalContainer.style.height = height; @@ -92,27 +92,26 @@ function createTerminal() { term.open(terminalContainer); term.fit(); - var initialGeometry = term.proposeGeometry(), - cols = initialGeometry.cols, - rows = initialGeometry.rows; + // fit is called within a setTimeout, cols and rows need this. + setTimeout(() => { + colsElement.value = term.cols; + rowsElement.value = term.rows; - colsElement.value = cols; - rowsElement.value = rows; + // Set terminal size again to set the specific dimensions on the demo + setTerminalSize(); - fetch('/terminals?cols=' + cols + '&rows=' + rows, {method: 'POST'}).then(function (res) { + fetch('/terminals?cols=' + term.cols + '&rows=' + term.rows, {method: 'POST'}).then(function (res) { - charWidth = Math.ceil(term.element.offsetWidth / cols); - charHeight = Math.ceil(term.element.offsetHeight / rows); - - res.text().then(function (pid) { - window.pid = pid; - socketURL += pid; - socket = new WebSocket(socketURL); - socket.onopen = runRealTerminal; - socket.onclose = runFakeTerminal; - socket.onerror = runFakeTerminal; + res.text().then(function (pid) { + window.pid = pid; + socketURL += pid; + socket = new WebSocket(socketURL); + socket.onopen = runRealTerminal; + socket.onclose = runFakeTerminal; + socket.onerror = runFakeTerminal; + }); }); - }); + }, 0); } function runRealTerminal() { diff --git a/demo/style.css b/demo/style.css index 7138962123..1ab5f61caf 100644 --- a/demo/style.css +++ b/demo/style.css @@ -14,9 +14,3 @@ h1 { margin: 0 auto; padding: 2px; } - -#terminal-container .terminal { - background-color: #111; - color: #fafafa; - padding: 2px; -} diff --git a/fixtures/typings-test/typings-test.ts b/fixtures/typings-test/typings-test.ts index ffe6afd496..928ca23896 100644 --- a/fixtures/typings-test/typings-test.ts +++ b/fixtures/typings-test/typings-test.ts @@ -168,6 +168,10 @@ namespace methods_core { t.setOption('bellStyle', 'visual'); t.setOption('bellStyle', 'sound'); t.setOption('bellStyle', 'both'); + t.setOption('fontSize', 1); + t.setOption('lineHeight', 1); + t.setOption('fontFamily', 'foo'); + t.setOption('theme', {background: '#ff0000'}); } } namespace scrolling { @@ -210,9 +214,13 @@ namespace methods_experimental { t.registerLinkMatcher(/foo/, () => true, { matchIndex: 1, priority: 1, - validationCallback: (uri: string, element: HTMLElement, callback: (isValid: boolean) => void) => { - console.log(uri, element, callback); - } + validationCallback: (uri: string, callback: (isValid: boolean) => void) => { + console.log(uri, callback); + }, + tooltipCallback: (e: MouseEvent, uri: string) => { + console.log(e, uri); + }, + leaveCallback: () => {} }); t.deregisterLinkMatcher(1); } diff --git a/package.json b/package.json index 9b550d6765..f32918dfac 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "nodemon": "1.10.2", "sorcery": "^0.10.0", "tslint": "^4.0.2", - "typescript": "~2.2.0", + "typescript": "~2.4.0", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0" }, diff --git a/src/Buffer.test.ts b/src/Buffer.test.ts index 6baa230dd4..210d297184 100644 --- a/src/Buffer.test.ts +++ b/src/Buffer.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/Buffer.ts b/src/Buffer.ts index 4c73006391..2d23980dc9 100644 --- a/src/Buffer.ts +++ b/src/Buffer.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ @@ -6,8 +7,10 @@ import { ITerminal, IBuffer } from './Interfaces'; import { CircularList } from './utils/CircularList'; import { LineData, CharData } from './Types'; +export const CHAR_DATA_ATTR_INDEX = 0; export const CHAR_DATA_CHAR_INDEX = 1; export const CHAR_DATA_WIDTH_INDEX = 2; +export const CHAR_DATA_CODE_INDEX = 3; /** * This class represents a terminal buffer (an internal state of the terminal), where the @@ -32,8 +35,8 @@ export class Buffer implements IBuffer { /** * Create a new Buffer. * @param _terminal The terminal the Buffer will belong to. - * @param _hasScrollback Whether the buffer should respecr the scrollback of - * the terminal.. + * @param _hasScrollback Whether the buffer should respect the scrollback of + * the terminal. */ constructor( private _terminal: ITerminal, @@ -50,6 +53,12 @@ export class Buffer implements IBuffer { return this._hasScrollback && this.lines.maxLength > this._terminal.rows; } + public get isCursorInViewport(): boolean { + const absoluteY = this.ybase + this.y; + const relativeY = absoluteY - this.ydisp; + return (relativeY >= 0 && relativeY < this._terminal.rows); + } + /** * Gets the correct buffer length based on the rows provided, the terminal's * scrollback and whether this buffer is flagged to have scrollback or not. @@ -106,7 +115,7 @@ export class Buffer implements IBuffer { if (this._lines.length > 0) { // Deal with columns increasing (we don't do anything when columns reduce) if (this._terminal.cols < newCols) { - const ch: CharData = [this._terminal.defAttr, ' ', 1]; // does xterm use the default attr? + const ch: CharData = [this._terminal.defAttr, ' ', 1, 32]; // does xterm use the default attr? for (let i = 0; i < this._lines.length; i++) { // TODO: This should be removed, with tests setup for the case that was // causing the underlying bug, see https://github.com/sourcelair/xterm.js/issues/824 diff --git a/src/BufferSet.test.ts b/src/BufferSet.test.ts index 320241ca52..b9c1824dbe 100644 --- a/src/BufferSet.test.ts +++ b/src/BufferSet.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/BufferSet.ts b/src/BufferSet.ts index 64b364e92f..e31d2278f1 100644 --- a/src/BufferSet.ts +++ b/src/BufferSet.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/Charsets.ts b/src/Charsets.ts index c2c92e8d4a..0ee91d326d 100644 --- a/src/Charsets.ts +++ b/src/Charsets.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/CompositionHelper.test.ts b/src/CompositionHelper.test.ts index 0adb1719a6..09a59f729f 100644 --- a/src/CompositionHelper.test.ts +++ b/src/CompositionHelper.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ @@ -42,6 +43,16 @@ describe('CompositionHelper', () => { }, handler: (text: string) => { handledText += text; + }, + buffer: { + isCursorInViewport: true + }, + charMeasure: { + height: 10, + width: 10 + }, + options: { + lineHeight: 1 } }; handledText = ''; diff --git a/src/CompositionHelper.ts b/src/CompositionHelper.ts index 223a90e7c2..cec8fb3738 100644 --- a/src/CompositionHelper.ts +++ b/src/CompositionHelper.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ @@ -63,6 +64,7 @@ export class CompositionHelper { * @param {CompositionEvent} ev The event. */ public compositionupdate(ev: CompositionEvent): void { + console.log('compositionupdate'); this.compositionView.textContent = ev.data; this.updateCompositionElements(); setTimeout(() => { @@ -193,26 +195,26 @@ export class CompositionHelper { if (!this.isComposing) { return; } - const cursor = this.terminal.element.querySelector('.terminal-cursor'); - if (cursor) { - // Take .xterm-rows offsetTop into account as well in case it's positioned absolutely within - // the .xterm element. - const xtermRows = this.terminal.element.querySelector('.xterm-rows'); - const cursorTop = xtermRows.offsetTop + cursor.offsetTop; - - this.compositionView.style.left = cursor.offsetLeft + 'px'; + + if (this.terminal.buffer.isCursorInViewport) { + const cellHeight = Math.ceil(this.terminal.charMeasure.height * this.terminal.options.lineHeight); + const cursorTop = this.terminal.buffer.y * cellHeight; + const cursorLeft = this.terminal.buffer.x * this.terminal.charMeasure.width; + + this.compositionView.style.left = cursorLeft + 'px'; this.compositionView.style.top = cursorTop + 'px'; - this.compositionView.style.height = cursor.offsetHeight + 'px'; - this.compositionView.style.lineHeight = cursor.offsetHeight + 'px'; + this.compositionView.style.height = cellHeight + 'px'; + this.compositionView.style.lineHeight = cellHeight + 'px'; // Sync the textarea to the exact position of the composition view so the IME knows where the // text is. const compositionViewBounds = this.compositionView.getBoundingClientRect(); - this.textarea.style.left = cursor.offsetLeft + 'px'; + this.textarea.style.left = cursorLeft + 'px'; this.textarea.style.top = cursorTop + 'px'; this.textarea.style.width = compositionViewBounds.width + 'px'; this.textarea.style.height = compositionViewBounds.height + 'px'; this.textarea.style.lineHeight = compositionViewBounds.height + 'px'; } + if (!dontRecurse) { setTimeout(() => this.updateCompositionElements(true), 0); } diff --git a/src/EscapeSequences.ts b/src/EscapeSequences.ts index 9542658faa..cb97c3680e 100644 --- a/src/EscapeSequences.ts +++ b/src/EscapeSequences.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/EventEmitter.test.ts b/src/EventEmitter.test.ts index d61f994ab5..c1f0a0aba8 100644 --- a/src/EventEmitter.test.ts +++ b/src/EventEmitter.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index a1768d2d4a..414eac8956 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/InputHandler.test.ts b/src/InputHandler.test.ts index f6e07f7efd..a8b2ff1264 100644 --- a/src/InputHandler.test.ts +++ b/src/InputHandler.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/InputHandler.ts b/src/InputHandler.ts index abfdfa0e6b..88a86a4417 100644 --- a/src/InputHandler.ts +++ b/src/InputHandler.ts @@ -1,4 +1,6 @@ /** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) * @license MIT */ @@ -36,13 +38,14 @@ export class InputHandler implements IInputHandler { // dont overflow left if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1]) { if (!this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][CHAR_DATA_WIDTH_INDEX]) { - // found empty cell after fullwidth, need to go 2 cells back - if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2]) + if (this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2]) { this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2][CHAR_DATA_CHAR_INDEX] += char; - + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 2][3] = char.charCodeAt(0); + } } else { this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][CHAR_DATA_CHAR_INDEX] += char; + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x - 1][3] = char.charCodeAt(0); } this._terminal.updateRange(this._terminal.buffer.y); } @@ -81,21 +84,21 @@ export class InputHandler implements IInputHandler { if (removed[CHAR_DATA_WIDTH_INDEX] === 0 && this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] && this._terminal.buffer.lines.get(row)[this._terminal.cols - 2][CHAR_DATA_WIDTH_INDEX] === 2) { - this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1]; + this._terminal.buffer.lines.get(row)[this._terminal.cols - 2] = [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]; } // insert empty cell at cursor - this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 0, [this._terminal.curAttr, ' ', 1]); + this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 0, [this._terminal.curAttr, ' ', 1, ' '.charCodeAt(0)]); } } - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, char, ch_width]; + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, char, ch_width, char.charCodeAt(0)]; this._terminal.buffer.x++; this._terminal.updateRange(this._terminal.buffer.y); // fullwidth char - set next cell width to zero and advance cursor if (ch_width === 2) { - this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, '', 0]; + this._terminal.buffer.lines.get(row)[this._terminal.buffer.x] = [this._terminal.curAttr, '', 0, undefined]; this._terminal.buffer.x++; } } @@ -188,7 +191,7 @@ export class InputHandler implements IInputHandler { const row = this._terminal.buffer.y + this._terminal.buffer.ybase; let j = this._terminal.buffer.x; - const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param-- && j < this._terminal.cols) { this._terminal.buffer.lines.get(row).splice(j++, 0, ch); @@ -487,7 +490,7 @@ export class InputHandler implements IInputHandler { } const row = this._terminal.buffer.y + this._terminal.buffer.ybase; - const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param--) { this._terminal.buffer.lines.get(row).splice(this._terminal.buffer.x, 1); @@ -535,7 +538,7 @@ export class InputHandler implements IInputHandler { const row = this._terminal.buffer.y + this._terminal.buffer.ybase; let j = this._terminal.buffer.x; - const ch: CharData = [this._terminal.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this._terminal.eraseAttr(), ' ', 1, 32]; // xterm while (param-- && j < this._terminal.cols) { this._terminal.buffer.lines.get(row)[j++] = ch; @@ -589,7 +592,7 @@ export class InputHandler implements IInputHandler { public repeatPrecedingCharacter(params: number[]): void { let param = params[0] || 1; const line = this._terminal.buffer.lines.get(this._terminal.buffer.ybase + this._terminal.buffer.y); - const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1]; + const ch = line[this._terminal.buffer.x - 1] || [this._terminal.defAttr, ' ', 1, 32]; while (param--) { line[this._terminal.buffer.x++] = ch; diff --git a/src/Interfaces.ts b/src/Interfaces.ts index fb66f4d7cf..d022dc3ebd 100644 --- a/src/Interfaces.ts +++ b/src/Interfaces.ts @@ -1,9 +1,12 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ import { ILinkMatcherOptions } from './Interfaces'; import { LinkMatcherHandler, LinkMatcherValidationCallback, Charset, LineData } from './Types'; +import { IColorSet } from './renderer/Interfaces'; +import { IMouseZoneManager } from './input/Interfaces'; export interface IBrowser { isNode: boolean; @@ -17,10 +20,19 @@ export interface IBrowser { isMSWindows: boolean; } -export interface ITerminal extends IEventEmitter { +export interface IBufferAccessor { + buffer: IBuffer; +} + +export interface IElementAccessor { element: HTMLElement; - rowContainer: HTMLElement; - selectionContainer: HTMLElement; +} + +export interface ILinkifierAccessor { + linkifier: ILinkifier; +} + +export interface ITerminal extends ILinkifierAccessor, IBufferAccessor, IElementAccessor, IEventEmitter { selectionManager: ISelectionManager; charMeasure: ICharMeasure; textarea: HTMLTextAreaElement; @@ -28,13 +40,12 @@ export interface ITerminal extends IEventEmitter { cols: number; browser: IBrowser; writeBuffer: string[]; - children: HTMLElement[]; cursorHidden: boolean; cursorState: number; defAttr: number; options: ITerminalOptions; buffers: IBufferSet; - buffer: IBuffer; + isFocused: boolean; /** * Emit the 'data' event and populate the given data. @@ -47,6 +58,7 @@ export interface ITerminal extends IEventEmitter { reset(): void; showCursor(): void; blankLine(cur?: boolean, isWrapped?: boolean, cols?: number): LineData; + refresh(start: number, end: number): void; } /** @@ -115,20 +127,23 @@ export interface ITerminalOptions { bellSound?: string; bellStyle?: string; cancelEvents?: boolean; - colors?: string[]; cols?: number; convertEol?: boolean; cursorBlink?: boolean; cursorStyle?: string; debug?: boolean; disableStdin?: boolean; + fontSize?: number; + fontFamily?: string; geometry?: [number, number]; handler?: (data: string) => void; + lineHeight?: number; rows?: number; screenKeys?: boolean; scrollback?: number; tabStopWidth?: number; termName?: string; + theme?: ITheme; useFlowControl?: boolean; } @@ -143,6 +158,7 @@ export interface IBuffer { scrollTop: number; savedY: number; savedX: number; + isCursorInViewport: boolean; translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string; nextStop(x?: number): number; prevStop(x?: number): number; @@ -162,6 +178,7 @@ export interface IViewport { onWheel(ev: WheelEvent): void; onTouchStart(ev: TouchEvent): void; onTouchMove(ev: TouchEvent): void; + onThemeChanged(colors: IColorSet): void; } export interface ISelectionManager { @@ -186,12 +203,14 @@ export interface ICompositionHelper { export interface ICharMeasure { width: number; height: number; - measure(): void; + measure(options: ITerminalOptions): void; } -export interface ILinkifier { - linkifyRow(rowIndex: number): void; - attachHypertextLinkHandler(handler: LinkMatcherHandler): void; +export interface ILinkifier extends IEventEmitter { + attachToDom(mouseZoneManager: IMouseZoneManager): void; + linkifyRows(start: number, end: number): void; + setHypertextLinkHandler(handler: LinkMatcherHandler): void; + setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void; registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options?: ILinkMatcherOptions): number; deregisterLinkMatcher(matcherId: number): boolean; } @@ -232,6 +251,14 @@ export interface ILinkMatcherOptions { * false if invalid. */ validationCallback?: LinkMatcherValidationCallback; + /** + * A callback that fires when the mouse hovers over a link. + */ + tooltipCallback?: LinkMatcherHandler; + /** + * A callback that fires when the mouse leaves a link that was hovered. + */ + leaveCallback?: () => void; /** * The priority of the link matcher, this defines the order in which the link * matcher is evaluated relative to others, from highest to lowest. The @@ -291,3 +318,26 @@ export interface IInputHandler { /** CSI s */ saveCursor(params?: number[]): void; /** CSI u */ restoreCursor(params?: number[]): void; } + +export interface ITheme { + foreground?: string; + background?: string; + cursor?: string; + selection?: string; + black?: string; + red?: string; + green?: string; + yellow?: string; + blue?: string; + magenta?: string; + cyan?: string; + white?: string; + brightBlack?: string; + brightRed?: string; + brightGreen?: string; + brightYellow?: string; + brightBlue?: string; + brightMagenta?: string; + brightCyan?: string; + brightWhite?: string; +} diff --git a/src/Linkifier.test.ts b/src/Linkifier.test.ts index a4ed70db26..df9e459cd2 100644 --- a/src/Linkifier.test.ts +++ b/src/Linkifier.test.ts @@ -1,43 +1,90 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ -import jsdom = require('jsdom'); import { assert } from 'chai'; -import { ITerminal, ILinkifier } from './Interfaces'; +import { ITerminal, ILinkifier, IBuffer, IBufferAccessor, IElementAccessor } from './Interfaces'; import { Linkifier } from './Linkifier'; -import { LinkMatcher } from './Types'; +import { LinkMatcher, LineData } from './Types'; +import { IMouseZoneManager, IMouseZone } from './input/Interfaces'; +import { MockBuffer } from './utils/TestUtils.test'; +import { CircularList } from './utils/CircularList'; class TestLinkifier extends Linkifier { - constructor() { + constructor(_terminal: IBufferAccessor & IElementAccessor) { + super(_terminal); Linkifier.TIME_BEFORE_LINKIFY = 0; - super(); } public get linkMatchers(): LinkMatcher[] { return this._linkMatchers; } + public linkifyRows(): void { super.linkifyRows(0, this._terminal.buffer.lines.length - 1); } } -describe('Linkifier', () => { - let dom: jsdom.JSDOM; - let window: Window; - let document: Document; +class TestMouseZoneManager implements IMouseZoneManager { + public clears: number = 0; + public zones: IMouseZone[] = []; + add(zone: IMouseZone): void { + this.zones.push(zone); + } + clearAll(): void { + this.clears++; + } +} - let container: HTMLElement; - let rows: HTMLElement[]; +describe('Linkifier', () => { + let terminal: IBufferAccessor & IElementAccessor; let linkifier: TestLinkifier; + let mouseZoneManager: TestMouseZoneManager; beforeEach(() => { - dom = new jsdom.JSDOM(''); - window = dom.window; - document = window.document; - linkifier = new TestLinkifier(); + terminal = { + buffer: new MockBuffer(), + element: {} + }; + terminal.buffer.lines = new CircularList(20); + terminal.buffer.ydisp = 0; + linkifier = new TestLinkifier(terminal); + mouseZoneManager = new TestMouseZoneManager(); }); - function addRow(html: string): void { - const element = document.createElement('div'); - element.innerHTML = html; - container.appendChild(element); - rows.push(element); + function stringToRow(text: string): LineData { + let result: LineData = []; + for (let i = 0; i < text.length; i++) { + result.push([0, text.charAt(i), 1, text.charCodeAt(i)]); + } + return result; + } + + function addRow(text: string): void { + terminal.buffer.lines.push(stringToRow(text)); + } + + function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { + addRow(uri); + linkifier.linkifyRows(); + setTimeout(() => { + assert.equal(mouseZoneManager.zones[0].x1, 1); + assert.equal(mouseZoneManager.zones[0].x2, uri.length + 1); + assert.equal(mouseZoneManager.zones[0].y, terminal.buffer.lines.length); + done(); + }, 0); + } + + function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, links: {x: number, length: number}[], done: MochaDone): void { + addRow(rowText); + linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); + linkifier.linkifyRows(); + // Allow linkify to happen + setTimeout(() => { + assert.equal(mouseZoneManager.zones.length, links.length); + links.forEach((l, i) => { + assert.equal(mouseZoneManager.zones[i].x1, l.x + 1); + assert.equal(mouseZoneManager.zones[i].x2, l.x + l.length + 1); + assert.equal(mouseZoneManager.zones[i].y, terminal.buffer.lines.length); + }); + done(); + }, 0); } describe('before attachToDom', () => { @@ -52,78 +99,42 @@ describe('Linkifier', () => { describe('after attachToDom', () => { beforeEach(() => { - rows = []; - linkifier.attachToDom(document, rows); - container = document.createElement('div'); - document.body.appendChild(container); + linkifier.attachToDom(mouseZoneManager); }); - function clickElement(element: Node): void { - const event = document.createEvent('MouseEvent'); - event.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); - element.dispatchEvent(event); - } - - function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { - addRow(uri); - linkifier.linkifyRow(0); - setTimeout(() => { - assert.equal((rows[0].firstChild).tagName, 'A'); - assert.equal((rows[0].firstChild).textContent, uri); - done(); - }, 0); - } - describe('http links', () => { - function assertLinkifiesEntireRow(uri: string, done: MochaDone): void { - addRow(uri); - linkifier.linkifyRow(0); - setTimeout(() => { - assert.equal((rows[0].firstChild).tagName, 'A'); - assert.equal((rows[0].firstChild).textContent, uri); - done(); - }, 0); - } - it('should allow ~ character in URI path', done => assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done)); + it('should allow ~ character in URI path', (done) => { + assertLinkifiesEntireRow('http://foo.com/a~b#c~d?e~f', done); + }); }); describe('link matcher', () => { - function assertLinkifiesRow(rowText: string, linkMatcherRegex: RegExp, expectedHtml: string, done: MochaDone): void { - addRow(rowText); - linkifier.registerLinkMatcher(linkMatcherRegex, () => {}); - linkifier.linkifyRow(0); - // Allow linkify to happen - setTimeout(() => { - assert.equal(rows[0].innerHTML, expectedHtml); - done(); - }, 0); - } it('should match a single link', done => { - assertLinkifiesRow('foo', /foo/, 'foo', done); + assertLinkifiesRow('foo', /foo/, [{x: 0, length: 3}], done); }); it('should match a single link at the start of a text node', done => { - assertLinkifiesRow('foo bar', /foo/, 'foo bar', done); + assertLinkifiesRow('foo bar', /foo/, [{x: 0, length: 3}], done); }); it('should match a single link in the middle of a text node', done => { - assertLinkifiesRow('foo bar baz', /bar/, 'foo bar baz', done); + assertLinkifiesRow('foo bar baz', /bar/, [{x: 4, length: 3}], done); }); it('should match a single link at the end of a text node', done => { - assertLinkifiesRow('foo bar', /bar/, 'foo bar', done); + assertLinkifiesRow('foo bar', /bar/, [{x: 4, length: 3}], done); }); it('should match a link after a link at the start of a text node', done => { - assertLinkifiesRow('foo bar', /foo|bar/, 'foo bar', done); + assertLinkifiesRow('foo bar', /foo|bar/, [{x: 0, length: 3}, {x: 4, length: 3}], done); }); it('should match a link after a link in the middle of a text node', done => { - assertLinkifiesRow('foo bar baz', /bar|baz/, 'foo bar baz', done); + assertLinkifiesRow('foo bar baz', /bar|baz/, [{x: 4, length: 3}, {x: 8, length: 3}], done); }); it('should match a link immediately after a link at the end of a text node', done => { - assertLinkifiesRow('foo barbaz', /bar|baz/, 'foo barbaz', done); + assertLinkifiesRow('foo barbaz', /bar|baz/, [{x: 4, length: 3}, {x: 7, length: 3}], done); }); it('should not duplicate text after a unicode character (wrapped in a span)', done => { // This is a regression test for an issue that came about when using // an oh-my-zsh theme that added the large blue diamond unicode // character (U+1F537) which caused the path to be duplicated. See #642. - assertLinkifiesRow('echo \'🔷foo\'', /foo/, 'echo \'🔷foo\'', done); + assertLinkifiesRow('echo \'🔷foo\'', /foo/, [{x: 8, length: 3}], done); }); }); @@ -131,26 +142,31 @@ describe('Linkifier', () => { it('should enable link if true', done => { addRow('test'); linkifier.registerLinkMatcher(/test/, () => done(), { - validationCallback: (url, element, cb) => { + validationCallback: (url, cb) => { + assert.equal(mouseZoneManager.zones.length, 0); cb(true); - assert.equal((rows[0].firstChild).tagName, 'A'); - setTimeout(() => clickElement(rows[0].firstChild), 0); + assert.equal(mouseZoneManager.zones.length, 1); + assert.equal(mouseZoneManager.zones[0].x1, 1); + assert.equal(mouseZoneManager.zones[0].x2, 5); + assert.equal(mouseZoneManager.zones[0].y, 1); + // Fires done() + mouseZoneManager.zones[0].clickCallback({}); } }); - linkifier.linkifyRow(0); + linkifier.linkifyRows(); }); it('should disable link if false', done => { addRow('test'); linkifier.registerLinkMatcher(/test/, () => assert.fail(), { - validationCallback: (url, element, cb) => { + validationCallback: (url, cb) => { + assert.equal(mouseZoneManager.zones.length, 0); cb(false); - assert.equal((rows[0].firstChild).tagName, 'A'); - setTimeout(() => clickElement(rows[0].firstChild), 0); + assert.equal(mouseZoneManager.zones.length, 0); } }); - linkifier.linkifyRow(0); - // Allow time for the click to be performed + linkifier.linkifyRows(); + // Allow time for the validation callback to be performed setTimeout(() => done(), 10); }); @@ -158,7 +174,7 @@ describe('Linkifier', () => { addRow('test test'); let count = 0; linkifier.registerLinkMatcher(/test/, () => assert.fail(), { - validationCallback: (url, element, cb) => { + validationCallback: (url, cb) => { count += 1; if (count === 2) { done(); @@ -166,7 +182,7 @@ describe('Linkifier', () => { cb(false); } }); - linkifier.linkifyRow(0); + linkifier.linkifyRows(); }); }); diff --git a/src/Linkifier.ts b/src/Linkifier.ts index 2323f22679..9479542956 100644 --- a/src/Linkifier.ts +++ b/src/Linkifier.ts @@ -1,11 +1,13 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ -import { ILinkMatcherOptions } from './Interfaces'; -import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback } from './Types'; - -const INVALID_LINK_CLASS = 'xterm-invalid-link'; +import { ILinkMatcherOptions, ITerminal, IBufferAccessor, ILinkifier, IElementAccessor } from './Interfaces'; +import { LinkMatcher, LinkMatcherHandler, LinkMatcherValidationCallback, LineData, LinkHoverEvent, LinkHoverEventTypes } from './Types'; +import { IMouseZoneManager } from './input/Interfaces'; +import { MouseZone } from './input/MouseZoneManager'; +import { EventEmitter } from './EventEmitter'; const protocolClause = '(https?:\\/\\/)'; const domainCharacterSet = '[\\da-z\\.-]+'; @@ -34,7 +36,7 @@ const HYPERTEXT_LINK_MATCHER_ID = 0; /** * The Linkifier applies links to rows shortly after they have been refreshed. */ -export class Linkifier { +export class Linkifier extends EventEmitter implements ILinkifier { /** * The time to wait after a row is changed before it is linkified. This prevents * the costly operation of searching every row multiple times, potentially a @@ -42,51 +44,63 @@ export class Linkifier { */ protected static TIME_BEFORE_LINKIFY = 200; - protected _linkMatchers: LinkMatcher[]; + protected _linkMatchers: LinkMatcher[] = []; - private _document: Document; - private _rows: HTMLElement[]; - private _rowTimeoutIds: number[]; + private _mouseZoneManager: IMouseZoneManager; + private _rowsTimeoutId: number; private _nextLinkMatcherId = HYPERTEXT_LINK_MATCHER_ID; - constructor() { - this._rowTimeoutIds = []; - this._linkMatchers = []; + constructor( + protected _terminal: IBufferAccessor & IElementAccessor + ) { + super(); this.registerLinkMatcher(strictUrlRegex, null, { matchIndex: 1 }); } /** * Attaches the linkifier to the DOM, enabling linkification. - * @param document The document object. - * @param rows The array of rows to apply links to. + * @param mouseZoneManager The mouse zone manager to register link zones with. */ - public attachToDom(document: Document, rows: HTMLElement[]): void { - this._document = document; - this._rows = rows; + public attachToDom(mouseZoneManager: IMouseZoneManager): void { + this._mouseZoneManager = mouseZoneManager; } /** - * Queues a row for linkification. - * @param {number} rowIndex The index of the row to linkify. + * Queue linkification on a set of rows. + * @param start The row to linkify from (inclusive). + * @param end The row to linkify to (inclusive). */ - public linkifyRow(rowIndex: number): void { + public linkifyRows(start: number, end: number): void { // Don't attempt linkify if not yet attached to DOM - if (!this._document) { + if (!this._mouseZoneManager) { return; } - const timeoutId = this._rowTimeoutIds[rowIndex]; - if (timeoutId) { - clearTimeout(timeoutId); + // Clear out any existing links + this._mouseZoneManager.clearAll(); + + if (this._rowsTimeoutId) { + clearTimeout(this._rowsTimeoutId); + } + this._rowsTimeoutId = setTimeout(this._linkifyRows.bind(this, start, end), Linkifier.TIME_BEFORE_LINKIFY); + } + + /** + * Linkifies + * @param start The row to start at. + * @param end The row to end at. + */ + private _linkifyRows(start: number, end: number): void { + this._rowsTimeoutId = null; + for (let i = start; i <= end; i++) { + this._linkifyRow(i); } - this._rowTimeoutIds[rowIndex] = setTimeout(this._linkifyRow.bind(this, rowIndex), Linkifier.TIME_BEFORE_LINKIFY); } /** - * Attaches a handler for hypertext links, overriding default behavior - * for standard http(s) links. - * @param {LinkHandler} handler The handler to use, this can be cleared with - * null. + * Attaches a handler for hypertext links, overriding default behavior for + * tandard http(s) links. + * @param handler The handler to use, this can be cleared with null. */ public setHypertextLinkHandler(handler: LinkMatcherHandler): void { this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].handler = handler; @@ -94,8 +108,7 @@ export class Linkifier { /** * Attaches a validation callback for hypertext links. - * @param {LinkMatcherValidationCallback} callback The callback to use, this - * can be cleared with null. + * @param callback The callback to use, this can be cleared with null. */ public setHypertextValidationCallback(callback: LinkMatcherValidationCallback): void { this._linkMatchers[HYPERTEXT_LINK_MATCHER_ID].validationCallback = callback; @@ -104,12 +117,12 @@ export class Linkifier { /** * Registers a link matcher, allowing custom link patterns to be matched and * handled. - * @param {RegExp} regex The regular expression to search for, specifically - * this searches the textContent of the rows. You will want to use \s to match - * a space ' ' character for example. - * @param {LinkHandler} handler The callback when the link is called. - * @param {ILinkMatcherOptions} [options] Options for the link matcher. - * @return {number} The ID of the new matcher, this can be used to deregister. + * @param regex The regular expression to search for. Specifically, this + * searches the textContent of the rows. You will want to use \s to match a + * space ' ' character for example. + * @param handler The callback when the link is called. + * @param options Options for the link matcher. + * @return The ID of the new matcher, this can be used to deregister. */ public registerLinkMatcher(regex: RegExp, handler: LinkMatcherHandler, options: ILinkMatcherOptions = {}): number { if (this._nextLinkMatcherId !== HYPERTEXT_LINK_MATCHER_ID && !handler) { @@ -121,6 +134,8 @@ export class Linkifier { handler, matchIndex: options.matchIndex, validationCallback: options.validationCallback, + hoverTooltipCallback: options.tooltipCallback, + hoverLeaveCallback: options.leaveCallback, priority: options.priority || 0 }; this._addLinkMatcherToList(matcher); @@ -151,8 +166,8 @@ export class Linkifier { /** * Deregisters a link matcher if it has been registered. - * @param {number} matcherId The link matcher's ID (returned after register) - * @return {boolean} Whether a link matcher was found and deregistered. + * @param matcherId The link matcher's ID (returned after register) + * @return Whether a link matcher was found and deregistered. */ public deregisterLinkMatcher(matcherId: number): boolean { // ID 0 is the hypertext link matcher which cannot be deregistered @@ -167,197 +182,101 @@ export class Linkifier { /** * Linkifies a row. - * @param {number} rowIndex The index of the row to linkify. + * @param rowIndex The index of the row to linkify. */ private _linkifyRow(rowIndex: number): void { - const row = this._rows[rowIndex]; - if (!row) { + const absoluteRowIndex = this._terminal.buffer.ydisp + rowIndex; + if (absoluteRowIndex >= this._terminal.buffer.lines.length) { return; } - const text = row.textContent; + const text = this._terminal.buffer.translateBufferLineToString(absoluteRowIndex, false); for (let i = 0; i < this._linkMatchers.length; i++) { - const matcher = this._linkMatchers[i]; - const linkElements = this._doLinkifyRow(row, matcher); - if (linkElements.length > 0) { - // Fire validation callback - if (matcher.validationCallback) { - for (let j = 0; j < linkElements.length; j++) { - const element = linkElements[j]; - matcher.validationCallback(element.textContent, element, isValid => { - if (!isValid) { - element.classList.add(INVALID_LINK_CLASS); - } - }); - } - } - // Only allow a single LinkMatcher to trigger on any given row. - return; - } + this._doLinkifyRow(rowIndex, text, this._linkMatchers[i]); } } /** * Linkifies a row given a specific handler. - * @param {HTMLElement} row The row to linkify. - * @param {LinkMatcher} matcher The link matcher for this line. + * @param rowIndex The row index to linkify. + * @param text The text of the row (excludes text in the row that's already + * linkified). + * @param matcher The link matcher for this line. + * @param offset The how much of the row has already been linkified. * @return The link element(s) that were added. */ - private _doLinkifyRow(row: HTMLElement, matcher: LinkMatcher): HTMLElement[] { + private _doLinkifyRow(rowIndex: number, text: string, matcher: LinkMatcher, offset: number = 0): void { // Iterate over nodes as we want to consider text nodes let result = []; const isHttpLinkMatcher = matcher.id === HYPERTEXT_LINK_MATCHER_ID; - const nodes = row.childNodes; // Find the first match - let match = row.textContent.match(matcher.regex); + let match = text.match(matcher.regex); if (!match || match.length === 0) { - return result; + return; } let uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex]; - // Set the next searches start index - let rowStartIndex = match.index + uri.length; - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const searchIndex = node.textContent.indexOf(uri); - if (searchIndex >= 0) { - const linkElement = this._createAnchorElement(uri, matcher.handler, isHttpLinkMatcher); - if (node.textContent.length === uri.length) { - // Matches entire string - if (node.nodeType === 3 /*Node.TEXT_NODE*/) { - this._replaceNode(node, linkElement); - } else { - const element = (node); - if (element.nodeName === 'A') { - // This row has already been linkified - return result; - } - element.innerHTML = ''; - element.appendChild(linkElement); - } - } else if (node.childNodes.length > 1) { - // Matches part of string in an element with multiple child nodes - for (let j = 0; j < node.childNodes.length; j++) { - const childNode = node.childNodes[j]; - const childSearchIndex = childNode.textContent.indexOf(uri); - if (childSearchIndex !== -1) { - // Match found in currentNode - this._replaceNodeSubstringWithNode(childNode, linkElement, uri, childSearchIndex); - // Don't need to count nodesAdded by replacing the node as this - // is a child node, not a top-level node. - break; - } - } - } else { - // Matches part of string in a single text node - const nodesAdded = this._replaceNodeSubstringWithNode(node, linkElement, uri, searchIndex); - // No need to consider the new nodes - i += nodesAdded; - } - result.push(linkElement); + // Get index, match.index is for the outer match which includes negated chars + const index = text.indexOf(uri); - // Find the next match - match = row.textContent.substring(rowStartIndex).match(matcher.regex); - if (!match || match.length === 0) { - return result; + // Ensure the link is valid before registering + if (matcher.validationCallback) { + matcher.validationCallback(text, isValid => { + // Discard link if the line has already changed + if (this._rowsTimeoutId) { + return; } - uri = match[typeof matcher.matchIndex !== 'number' ? 0 : matcher.matchIndex]; - rowStartIndex += match.index + uri.length; - } - } - return result; - } - - /** - * Creates a link anchor element. - * @param {string} uri The uri of the link. - * @return {HTMLAnchorElement} The link. - */ - private _createAnchorElement(uri: string, handler: LinkMatcherHandler, isHypertextLinkHandler: boolean): HTMLAnchorElement { - const element = this._document.createElement('a'); - element.textContent = uri; - element.draggable = false; - if (isHypertextLinkHandler) { - element.href = uri; - // Force link on another tab so work is not lost - element.target = '_blank'; - element.addEventListener('click', (event: MouseEvent) => { - if (handler) { - return handler(event, uri); + if (isValid) { + this._addLink(offset + index, rowIndex, uri, matcher); } }); } else { - element.addEventListener('click', (event: MouseEvent) => { - // Don't execute the handler if the link is flagged as invalid - if (element.classList.contains(INVALID_LINK_CLASS)) { - return; - } - return handler(event, uri); - }); + this._addLink(offset + index, rowIndex, uri, matcher); } - return element; - } - /** - * Replace a node with 1 or more other nodes. - * @param {Node} oldNode The node to replace. - * @param {Node[]} newNodes The new nodes to insert in order. - */ - private _replaceNode(oldNode: Node, ...newNodes: Node[]): void { - const parent = oldNode.parentNode; - for (let i = 0; i < newNodes.length; i++) { - parent.insertBefore(newNodes[i], oldNode); + // Recursively check for links in the rest of the text + const remainingStartIndex = index + uri.length; + const remainingText = text.substr(remainingStartIndex); + if (remainingText.length > 0) { + this._doLinkifyRow(rowIndex, remainingText, matcher, offset + remainingStartIndex); } - parent.removeChild(oldNode); } /** - * Replace a substring within a node with a new node. - * @param {Node} targetNode The target node; either a text node or a - * containing a single text node. - * @param {Node} newNode The new node to insert. - * @param {string} substring The substring to replace. - * @param {number} substringIndex The index of the substring within the string. - * @return The number of nodes to skip when searching for the next uri. + * Registers a link to the mouse zone manager. + * @param x The column the link starts. + * @param y The row the link is on. + * @param uri The URI of the link. + * @param matcher The link matcher for the link. */ - private _replaceNodeSubstringWithNode(targetNode: Node, newNode: Node, substring: string, substringIndex: number): number { - // If the targetNode is a non-text node with a single child, make the child - // the new targetNode. - if (targetNode.childNodes.length === 1) { - targetNode = targetNode.childNodes[0]; - } - - // The targetNode will be either a text node or a . The text node - // (targetNode or its only-child) needs to be replaced with newNode plus new - // text nodes potentially on either side. - if (targetNode.nodeType !== 3/*Node.TEXT_NODE*/) { - throw new Error('targetNode must be a text node or only contain a single text node'); - } - - const fullText = targetNode.textContent; - - if (substringIndex === 0) { - // Replace with - const rightText = fullText.substring(substring.length); - const rightTextNode = this._document.createTextNode(rightText); - this._replaceNode(targetNode, newNode, rightTextNode); - return 0; - } - - if (substringIndex === targetNode.textContent.length - substring.length) { - // Replace with - const leftText = fullText.substring(0, substringIndex); - const leftTextNode = this._document.createTextNode(leftText); - this._replaceNode(targetNode, leftTextNode, newNode); - return 0; - } - - // Replace with - const leftText = fullText.substring(0, substringIndex); - const leftTextNode = this._document.createTextNode(leftText); - const rightText = fullText.substring(substringIndex + substring.length); - const rightTextNode = this._document.createTextNode(rightText); - this._replaceNode(targetNode, leftTextNode, newNode, rightTextNode); - return 1; + private _addLink(x: number, y: number, uri: string, matcher: LinkMatcher): void { + this._mouseZoneManager.add(new MouseZone( + x + 1, + x + 1 + uri.length, + y + 1, + e => { + if (matcher.handler) { + return matcher.handler(e, uri); + } + window.open(uri, '_blank'); + }, + e => { + this.emit(LinkHoverEventTypes.HOVER, { x, y, length: uri.length}); + this._terminal.element.style.cursor = 'pointer'; + }, + e => { + this.emit(LinkHoverEventTypes.TOOLTIP, { x, y, length: uri.length}); + if (matcher.hoverTooltipCallback) { + matcher.hoverTooltipCallback(e, uri); + } + }, + () => { + this.emit(LinkHoverEventTypes.LEAVE, { x, y, length: uri.length}); + this._terminal.element.style.cursor = ''; + if (matcher.hoverLeaveCallback) { + matcher.hoverLeaveCallback(); + } + } + )); } } diff --git a/src/Parser.ts b/src/Parser.ts index 983b3536e6..b6493fea52 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -1,4 +1,6 @@ /** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) * @license MIT */ @@ -189,6 +191,9 @@ export class Parser { let code; let low; + const cursorStartX = this._terminal.buffer.x; + const cursorStartY = this._terminal.buffer.y; + if (this._terminal.debug) { this._terminal.log('data: ' + data); } @@ -580,6 +585,13 @@ export class Parser { break; } } + + // Fire the cursormove event if it's moved. This is done inside the parser + // as a render cannot happen in the middle of a parsing round. + if (this._terminal.buffer.x !== cursorStartX || this._terminal.buffer.y !== cursorStartY) { + this._terminal.emit('cursormove'); + } + return this._state; } diff --git a/src/Renderer.ts b/src/Renderer.ts deleted file mode 100644 index b51a9ff93c..0000000000 --- a/src/Renderer.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * @license MIT - */ - -import { ITerminal } from './Interfaces'; -import { DomElementObjectPool } from './utils/DomElementObjectPool'; -import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from './Buffer'; - -/** - * 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; - -/** - * Flags used to render terminal text properly. - */ -enum 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; - - private _spanElementObjectPool = new DomElementObjectPool('span'); - - constructor(private _terminal: ITerminal) { - // Figure out whether boldness affects - // the character width of monospace fonts. - if (brokenBold === null) { - brokenBold = checkBoldBroken(this._terminal.element); - } - this._spanElementObjectPool = new DomElementObjectPool('span'); - - // TODO: Pull more DOM interactions into Renderer.constructor, element for - // example should be owned by Renderer (and also exposed by Terminal due to - // to established public API). - } - - /** - * 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 { - // If this is a big refresh, remove the terminal rows from the DOM for faster calculations - let parent; - if (end - start >= this._terminal.rows / 2) { - parent = this._terminal.element.parentNode; - if (parent) { - this._terminal.element.removeChild(this._terminal.rowContainer); - } - } - - let width = this._terminal.cols; - let 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++) { - let row = y + this._terminal.buffer.ydisp; - - let line = this._terminal.buffer.lines.get(row); - - let x; - if (this._terminal.buffer.y === y - (this._terminal.buffer.ybase - this._terminal.buffer.ydisp) && - this._terminal.cursorState && - !this._terminal.cursorHidden) { - x = this._terminal.buffer.x; - } else { - x = -1; - } - - let attr = this._terminal.defAttr; - - const documentFragment = document.createDocumentFragment(); - let innerHTML = ''; - let currentElement; - - // Return the row's spans to the pool - while (this._terminal.children[y].children.length) { - const child = this._terminal.children[y].children[0]; - this._terminal.children[y].removeChild(child); - this._spanElementObjectPool.release(child); - } - - for (let i = 0; i < width; i++) { - // TODO: Could data be a more specific type? - let data: any = line[i][0]; - const ch = line[i][CHAR_DATA_CHAR_INDEX]; - const ch_width: any = line[i][CHAR_DATA_WIDTH_INDEX]; - const isCursor: boolean = i === x; - if (!ch_width) { - continue; - } - - if (data !== attr || isCursor) { - if (attr !== this._terminal.defAttr && !isCursor) { - if (innerHTML) { - currentElement.innerHTML = innerHTML; - innerHTML = ''; - } - documentFragment.appendChild(currentElement); - currentElement = null; - } - if (data !== this._terminal.defAttr || isCursor) { - if (innerHTML && !currentElement) { - currentElement = this._spanElementObjectPool.acquire(); - } - if (currentElement) { - if (innerHTML) { - currentElement.innerHTML = innerHTML; - innerHTML = ''; - } - documentFragment.appendChild(currentElement); - } - currentElement = this._spanElementObjectPool.acquire(); - - let bg = data & 0x1ff; - let fg = (data >> 9) & 0x1ff; - let flags = data >> 18; - - if (isCursor) { - currentElement.classList.add('reverse-video'); - currentElement.classList.add('terminal-cursor'); - } - - if (flags & FLAGS.BOLD) { - if (!brokenBold) { - currentElement.classList.add('xterm-bold'); - } - // See: XTerm*boldColors - if (fg < 8) { - fg += 8; - } - } - - if (flags & FLAGS.UNDERLINE) { - currentElement.classList.add('xterm-underline'); - } - - if (flags & FLAGS.BLINK) { - currentElement.classList.add('xterm-blink'); - } - - // If inverse flag is on, then swap the foreground and background variables. - if (flags & FLAGS.INVERSE) { - let temp = bg; - bg = fg; - fg = temp; - // Should inverse just be before the above boldColors effect instead? - if ((flags & 1) && fg < 8) { - fg += 8; - } - } - - if (flags & FLAGS.INVISIBLE && !isCursor) { - currentElement.classList.add('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) { - currentElement.classList.add(`xterm-bg-color-${bg}`); - } - - if (fg < 256) { - currentElement.classList.add(`xterm-color-${fg}`); - } - - } - } - - if (ch_width === 2) { - // Wrap wide characters so they're sized correctly. It's more difficult to release these - // from the object pool so just create new ones via innerHTML. - innerHTML += `${ch}`; - } else if (ch.charCodeAt(0) > 255) { - // Wrap any non-wide unicode character as some fonts size them badly - innerHTML += `${ch}`; - } else { - switch (ch) { - case '&': - innerHTML += '&'; - break; - case '<': - innerHTML += '<'; - break; - case '>': - innerHTML += '>'; - break; - default: - if (ch <= ' ') { - innerHTML += ' '; - } else { - innerHTML += ch; - } - break; - } - } - - // The cursor needs its own element, therefore we set attr to -1 - // which will cause the next character to be rendered in a new element - attr = isCursor ? -1 : data; - - } - - if (innerHTML && !currentElement) { - currentElement = this._spanElementObjectPool.acquire(); - } - if (currentElement) { - if (innerHTML) { - currentElement.innerHTML = innerHTML; - innerHTML = ''; - } - documentFragment.appendChild(currentElement); - currentElement = null; - } - - this._terminal.children[y].appendChild(documentFragment); - } - - if (parent) { - this._terminal.element.appendChild(this._terminal.rowContainer); - } - - this._terminal.emit('refresh', {start, end}); - }; - - /** - * Refreshes the selection in the DOM. - * @param start The selection start. - * @param end The selection end. - */ - public refreshSelection(start: [number, number], end: [number, number]): void { - // Remove all selections - while (this._terminal.selectionContainer.children.length) { - this._terminal.selectionContainer.removeChild(this._terminal.selectionContainer.children[0]); - } - - // Selection does not exist - if (!start || !end) { - return; - } - - // Translate from buffer position to viewport position - const viewportStartRow = start[1] - this._terminal.buffer.ydisp; - const viewportEndRow = end[1] - this._terminal.buffer.ydisp; - const viewportCappedStartRow = Math.max(viewportStartRow, 0); - const viewportCappedEndRow = Math.min(viewportEndRow, this._terminal.rows - 1); - - // No need to draw the selection - if (viewportCappedStartRow >= this._terminal.rows || viewportCappedEndRow < 0) { - return; - } - - // Create the selections - const documentFragment = document.createDocumentFragment(); - // Draw first row - const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; - const endCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : this._terminal.cols; - documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol)); - // Draw middle rows - const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1; - documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._terminal.cols, middleRowsCount)); - // Draw final row - if (viewportCappedStartRow !== viewportCappedEndRow) { - // Only draw viewportEndRow if it's not the same as viewporttartRow - const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._terminal.cols; - documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol)); - } - this._terminal.selectionContainer.appendChild(documentFragment); - } - - /** - * Creates a selection element at the specified position. - * @param row The row of the selection. - * @param colStart The start column. - * @param colEnd The end columns. - */ - private _createSelectionElement(row: number, colStart: number, colEnd: number, rowCount: number = 1): HTMLElement { - const element = document.createElement('div'); - element.style.height = `${rowCount * this._terminal.charMeasure.height}px`; - element.style.top = `${row * this._terminal.charMeasure.height}px`; - element.style.left = `${colStart * this._terminal.charMeasure.width}px`; - element.style.width = `${this._terminal.charMeasure.width * (colEnd - colStart)}px`; - return element; - } -} - - -// If bold is broken, we can't use it in the terminal. -function checkBoldBroken(terminalElement: HTMLElement): boolean { - const document = terminalElement.ownerDocument; - const el = document.createElement('span'); - el.innerHTML = 'hello world'; - terminalElement.appendChild(el); - const w1 = el.offsetWidth; - const h1 = el.offsetHeight; - el.style.fontWeight = 'bold'; - const w2 = el.offsetWidth; - const h2 = el.offsetHeight; - terminalElement.removeChild(el); - return w1 !== w2 || h1 !== h2; -} diff --git a/src/SelectionManager.test.ts b/src/SelectionManager.test.ts index 4774b3bb9d..e5bdc117c5 100644 --- a/src/SelectionManager.test.ts +++ b/src/SelectionManager.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ @@ -17,10 +18,9 @@ class TestSelectionManager extends SelectionManager { constructor( terminal: ITerminal, buffer: IBuffer, - rowContainer: HTMLElement, charMeasure: CharMeasure ) { - super(terminal, buffer, rowContainer, charMeasure); + super(terminal, buffer, charMeasure); } public get model(): SelectionModel { return this._model; } @@ -48,7 +48,6 @@ describe('SelectionManager', () => { dom = new jsdom.JSDOM(''); window = dom.window; document = window.document; - rowContainer = document.createElement('div'); terminal = new MockTerminal(); terminal.cols = 80; terminal.rows = 2; @@ -56,13 +55,13 @@ describe('SelectionManager', () => { terminal.buffers = new BufferSet(terminal); terminal.buffer = terminal.buffers.active; buffer = terminal.buffer; - selectionManager = new TestSelectionManager(terminal, buffer, rowContainer, null); + selectionManager = new TestSelectionManager(terminal, buffer, null); }); function stringToRow(text: string): LineData { let result: LineData = []; for (let i = 0; i < text.length; i++) { - result.push([0, text.charAt(i), 1]); + result.push([0, text.charAt(i), 1, text.charCodeAt(i)]); } return result; } @@ -101,21 +100,21 @@ describe('SelectionManager', () => { it('should expand selection for wide characters', () => { // Wide characters use a special format buffer.lines.set(0, [ - [null, '中', 2], - [null, '', 0], - [null, 'æ–‡', 2], - [null, '', 0], - [null, ' ', 1], - [null, 'a', 1], - [null, '中', 2], - [null, '', 0], - [null, 'æ–‡', 2], - [null, '', 0], - [null, 'b', 1], - [null, ' ', 1], - [null, 'f', 1], - [null, 'o', 1], - [null, 'o', 1] + [null, '中', 2, '中'.charCodeAt(0)], + [null, '', 0, null], + [null, 'æ–‡', 2, 'æ–‡'.charCodeAt(0)], + [null, '', 0, null], + [null, ' ', 1, ' '.charCodeAt(0)], + [null, 'a', 1, 'a'.charCodeAt(0)], + [null, '中', 2, '中'.charCodeAt(0)], + [null, '', 0, null], + [null, 'æ–‡', 2, 'æ–‡'.charCodeAt(0)], + [null, '', 0, ''.charCodeAt(0)], + [null, 'b', 1, 'b'.charCodeAt(0)], + [null, ' ', 1, ' '.charCodeAt(0)], + [null, 'f', 1, 'f'.charCodeAt(0)], + [null, 'o', 1, 'o'.charCodeAt(0)], + [null, 'o', 1, 'o'.charCodeAt(0)] ]); // Ensure wide characters take up 2 columns selectionManager.selectWordAt([0, 0]); diff --git a/src/SelectionManager.ts b/src/SelectionManager.ts index 1aaec14b00..29f12169b5 100644 --- a/src/SelectionManager.ts +++ b/src/SelectionManager.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ @@ -98,7 +99,6 @@ export class SelectionManager extends EventEmitter implements ISelectionManager constructor( private _terminal: ITerminal, private _buffer: IBuffer, - private _rowContainer: HTMLElement, private _charMeasure: CharMeasure ) { super(); @@ -116,8 +116,6 @@ export class SelectionManager extends EventEmitter implements ISelectionManager this._mouseMoveListener = event => this._onMouseMove(event); this._mouseUpListener = event => this._onMouseUp(event); - this._rowContainer.addEventListener('mousedown', event => this._onMouseDown(event)); - // Only adjust the selection on trim, shiftElements is rarely used (only in // reverseIndex) and delete in a splice is only ever used when the same // number of elements was just added. Given this is could actually be @@ -275,7 +273,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mouse event. */ private _getMouseBufferCoords(event: MouseEvent): [number, number] { - const coords = Mouse.getCoords(event, this._rowContainer, this._charMeasure, this._terminal.cols, this._terminal.rows, true); + const coords = Mouse.getCoords(event, this._terminal.element, this._charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows, true); if (!coords) { return null; } @@ -294,8 +292,8 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * @param event The mouse event. */ private _getMouseEventScrollAmount(event: MouseEvent): number { - let offset = Mouse.getCoordsRelativeToElement(event, this._rowContainer)[1]; - const terminalHeight = this._terminal.rows * this._charMeasure.height; + let offset = Mouse.getCoordsRelativeToElement(event, this._terminal.element)[1]; + const terminalHeight = this._terminal.rows * Math.ceil(this._charMeasure.height * this._terminal.options.lineHeight); if (offset >= 0 && offset <= terminalHeight) { return 0; } @@ -312,7 +310,7 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * Handles te mousedown event, setting up for a new selection. * @param event The mousedown event. */ - private _onMouseDown(event: MouseEvent): void { + public onMouseDown(event: MouseEvent): void { // If we have selection, we want the context menu on right click even if the // terminal is in mouse mode. if (event.button === 2 && this.hasSelection) { @@ -363,8 +361,8 @@ export class SelectionManager extends EventEmitter implements ISelectionManager */ private _addMouseDownListeners(): void { // Listen on the document so that dragging outside of viewport works - this._rowContainer.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); - this._rowContainer.ownerDocument.addEventListener('mouseup', this._mouseUpListener); + this._terminal.element.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); + this._terminal.element.ownerDocument.addEventListener('mouseup', this._mouseUpListener); this._dragScrollIntervalTimer = setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); } @@ -372,8 +370,8 @@ export class SelectionManager extends EventEmitter implements ISelectionManager * Removes the listeners that are registered when mousedown is triggered. */ private _removeMouseDownListeners(): void { - this._rowContainer.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); - this._rowContainer.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); + this._terminal.element.ownerDocument.removeEventListener('mousemove', this._mouseMoveListener); + this._terminal.element.ownerDocument.removeEventListener('mouseup', this._mouseUpListener); clearInterval(this._dragScrollIntervalTimer); this._dragScrollIntervalTimer = null; } @@ -412,6 +410,11 @@ export class SelectionManager extends EventEmitter implements ISelectionManager return; } + // Return early if the click event is not in the buffer (eg. in scroll bar) + if (line.length >= this._model.selectionStart[0]) { + return; + } + // If the mouse is over the second half of a wide character, adjust the // selection to cover the whole character const char = line[this._model.selectionStart[0]]; diff --git a/src/SelectionModel.test.ts b/src/SelectionModel.test.ts index 6c750ed240..e195d6bde1 100644 --- a/src/SelectionModel.test.ts +++ b/src/SelectionModel.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/SelectionModel.ts b/src/SelectionModel.ts index 8c599fd7ae..1fbf40fe89 100644 --- a/src/SelectionModel.ts +++ b/src/SelectionModel.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/Terminal.integration.ts b/src/Terminal.integration.ts index 124e89cb8e..00db4dd68c 100644 --- a/src/Terminal.integration.ts +++ b/src/Terminal.integration.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT * * This file contains integration tests for xterm.js. diff --git a/src/Terminal.test.ts b/src/Terminal.test.ts index 0c333741fc..fd173831fa 100644 --- a/src/Terminal.test.ts +++ b/src/Terminal.test.ts @@ -1,6 +1,11 @@ -import { assert, expect } from 'chai'; +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + + import { assert, expect } from 'chai'; import { Terminal } from './Terminal'; -import { MockViewport, MockCompositionHelper } from './utils/TestUtils.test'; +import { MockViewport, MockCompositionHelper, MockRenderer } from './utils/TestUtils.test'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX } from './Buffer'; const INIT_COLS = 80; @@ -21,6 +26,7 @@ describe('term.js addons', () => { rows: INIT_ROWS }); term.refresh = () => {}; + (term).renderer = new MockRenderer(); term.viewport = new MockViewport(); (term).compositionHelper = new MockCompositionHelper(); // Force synchronous writes diff --git a/src/Terminal.ts b/src/Terminal.ts index 827273ff52..e19887cbc2 100644 --- a/src/Terminal.ts +++ b/src/Terminal.ts @@ -1,5 +1,8 @@ /** - * xterm.js: xterm, in the browser + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * @license MIT + * * Originally forked from (with the author's permission): * Fabrice Bellard's javascript vt100 for jslinux: * http://bellard.org/jslinux/ @@ -7,7 +10,6 @@ * The original design remains. The terminal itself * has been extended to include xterm CSI codes, among * other features. - * @license MIT * * Terminal Emulation References: * http://vt100.net/ @@ -29,7 +31,7 @@ import { CircularList } from './utils/CircularList'; import { C0 } from './EscapeSequences'; import { InputHandler } from './InputHandler'; import { Parser } from './Parser'; -import { Renderer } from './Renderer'; +import { Renderer } from './renderer/Renderer'; import { Linkifier } from './Linkifier'; import { SelectionManager } from './SelectionManager'; import { CharMeasure } from './utils/CharMeasure'; @@ -38,8 +40,13 @@ import * as Mouse from './utils/Mouse'; import { CHARSETS } from './Charsets'; import { getRawByteCoords } from './utils/Mouse'; import { CustomKeyEventHandler, Charset, LinkMatcherHandler, LinkMatcherValidationCallback, CharData, LineData } from './Types'; -import { ITerminal, IBrowser, ITerminalOptions, IInputHandlingTerminal, ILinkMatcherOptions, IViewport, ICompositionHelper } from './Interfaces'; +import { ITerminal, IBrowser, ITerminalOptions, IInputHandlingTerminal, ILinkMatcherOptions, IViewport, ICompositionHelper, ITheme, ILinkifier } from './Interfaces'; import { BellSound } from './utils/Sounds'; +import { DEFAULT_ANSI_COLORS } from './renderer/ColorManager'; +import { IMouseZoneManager } from './input/Interfaces'; +import { MouseZoneManager } from './input/MouseZoneManager'; +import { initialize as initializeCharAtlas } from './renderer/CharAtlas'; +import { IRenderer } from './renderer/Interfaces'; // Declare for RequireJS in loadAddon declare var define: any; @@ -60,89 +67,7 @@ const WRITE_BUFFER_PAUSE_THRESHOLD = 5; */ const WRITE_BATCH_SIZE = 300; -/** - * The time between cursor blinks. This is driven by JS rather than a CSS - * animation due to a bug in Chromium that causes it to use excessive CPU time. - * See https://github.com/Microsoft/vscode/issues/22900 - */ -const CURSOR_BLINK_INTERVAL = 600; - -// TODO: Most of the color code should be removed after truecolor is implemented -// Colors 0-15 -const tangoColors: string[] = [ - // dark: - '#2e3436', - '#cc0000', - '#4e9a06', - '#c4a000', - '#3465a4', - '#75507b', - '#06989a', - '#d3d7cf', - // bright: - '#555753', - '#ef2929', - '#8ae234', - '#fce94f', - '#729fcf', - '#ad7fa8', - '#34e2e2', - '#eeeeec' -]; - -// Colors 0-15 + 16-255 -// Much thanks to TooTallNate for writing this. -const defaultColors: string[] = (function(): string[] { - let colors = tangoColors.slice(); - let r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; - let i; - - // 16-231 - i = 0; - for (; i < 216; i++) { - out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]); - } - - // 232-255 (grey) - i = 0; - let c: number; - for (; i < 24; i++) { - c = 8 + i * 10; - out(c, c, c); - } - - function out(r: number, g: number, b: number): void { - colors.push('#' + hex(r) + hex(g) + hex(b)); - } - - function hex(c: number): string { - let s = c.toString(16); - return s.length < 2 ? '0' + s : s; - } - - return colors; -})(); - -const _colors: string[] = defaultColors.slice(); - -const vcolors: number[][] = (function(): number[][] { - const out: number[][] = []; - let color; - - for (let i = 0; i < 256; i++) { - color = parseInt(defaultColors[i].substring(1), 16); - out.push([ - (color >> 16) & 0xff, - (color >> 8) & 0xff, - color & 0xff - ]); - } - - return out; -})(); - const DEFAULT_OPTIONS: ITerminalOptions = { - colors: defaultColors, convertEol: false, termName: 'xterm', geometry: [80, 24], @@ -150,13 +75,17 @@ const DEFAULT_OPTIONS: ITerminalOptions = { cursorStyle: 'block', bellSound: BellSound, bellStyle: 'none', + fontFamily: 'courier-new, courier, monospace', + fontSize: 15, + lineHeight: 1.0, scrollback: 1000, screenKeys: false, debug: false, cancelEvents: false, disableStdin: false, useFlowControl: false, - tabStopWidth: 8 + tabStopWidth: 8, + theme: null // programFeatures: false, // focusKeys: false, }; @@ -164,7 +93,6 @@ const DEFAULT_OPTIONS: ITerminalOptions = { export class Terminal extends EventEmitter implements ITerminal, IInputHandlingTerminal { public textarea: HTMLTextAreaElement; public element: HTMLElement; - public rowContainer: HTMLElement; /** * The HTMLElement that the terminal is created in, set by Terminal.open. @@ -175,7 +103,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private body: HTMLBodyElement; private viewportScrollArea: HTMLElement; private viewportElement: HTMLElement; - public selectionContainer: HTMLElement; private helperContainer: HTMLElement; private compositionView: HTMLElement; private charSizeStyleElement: HTMLStyleElement; @@ -194,10 +121,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private sendDataQueue: string; private customKeyEventHandler: CustomKeyEventHandler; - // The ID from a setInterval that tracks the blink animation. This animation - // is done in JS due to a Chromium bug with CSS animations that thrashed the - // CPU. - private cursorBlinkInterval: NodeJS.Timer; // modes public applicationKeypad: boolean; @@ -226,7 +149,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT public urxvtMouse: boolean; // misc - public children: HTMLElement[]; private refreshStart: number; private refreshEnd: number; public savedCols: number; @@ -266,14 +188,15 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT private inputHandler: InputHandler; private parser: Parser; - private renderer: Renderer; + private renderer: IRenderer; public selectionManager: SelectionManager; - private linkifier: Linkifier; + public linkifier: ILinkifier; public buffers: BufferSet; public buffer: Buffer; public viewport: IViewport; private compositionHelper: ICompositionHelper; public charMeasure: CharMeasure; + private _mouseZoneManager: IMouseZoneManager; public cols: number; public rows: number; @@ -308,19 +231,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this[key] = this.options[key]; }); - if (this.options.colors.length === 8) { - this.options.colors = this.options.colors.concat(_colors.slice(8)); - } else if (this.options.colors.length === 16) { - this.options.colors = this.options.colors.concat(_colors.slice(16)); - } else if (this.options.colors.length === 10) { - this.options.colors = this.options.colors.slice(0, -2).concat( - _colors.slice(8, -2), this.options.colors.slice(-2)); - } else if (this.options.colors.length === 18) { - this.options.colors = this.options.colors.concat( - _colors.slice(16, -2), this.options.colors.slice(-2)); - } - this.colors = this.options.colors; - // this.context = options.context || window; // this.document = options.document || document; // TODO: WHy not document.body? @@ -338,7 +248,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.cursorHidden = false; this.sendDataQueue = ''; this.customKeyEventHandler = null; - this.cursorBlinkInterval = null; // modes this.applicationKeypad = false; @@ -379,7 +288,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // Reuse renderer if the Terminal is being recreated via a reset call. this.renderer = this.renderer || null; this.selectionManager = this.selectionManager || null; - this.linkifier = this.linkifier || new Linkifier(); + this.linkifier = this.linkifier || new Linkifier(this); + this._mouseZoneManager = this._mouseZoneManager || null; // Create the terminal's buffers and set the current buffer this.buffers = new BufferSet(this); @@ -409,6 +319,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.textarea.focus(); } + public get isFocused(): boolean { + return document.activeElement === this.textarea; + } + /** * Retrieves an option's value from the terminal. * @param {string} key The option key. @@ -441,19 +355,32 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT } break; case 'cursorStyle': - if (!value) { - value = 'block'; - } - break; + if (!value) { + value = 'block'; + } + break; + case 'lineHeight': + if (value < 1) { + console.warn(`${key} cannot be less than 1, value: ${value}`); + return; + } case 'tabStopWidth': if (value < 1) { - console.warn(`tabStopWidth cannot be less than 1, value: ${value}`); + console.warn(`${key} cannot be less than 1, value: ${value}`); + return; + } + break; + case 'theme': + // If open has been called we do not want to set options.theme as the + // source of truth is owned by the renderer. + if (this.renderer) { + this._setTheme(value); return; } break; case 'scrollback': if (value < 0) { - console.warn(`scrollback cannot be less than 0, value: ${value}`); + console.warn(`${key} cannot be less than 0, value: ${value}`); return; } if (this.options[key] !== value) { @@ -474,12 +401,18 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this[key] = value; this.options[key] = value; switch (key) { - case 'cursorBlink': this.setCursorBlinking(value); break; - case 'cursorStyle': - this.element.classList.toggle(`xterm-cursor-style-block`, value === 'block'); - this.element.classList.toggle(`xterm-cursor-style-underline`, value === 'underline'); - this.element.classList.toggle(`xterm-cursor-style-bar`, value === 'bar'); + case 'fontFamily': + case 'fontSize': + // When the font changes the size of the cells may change which requires a renderer clear + this.renderer.clear(); + this.charMeasure.measure(this.options); break; + case 'lineHeight': + // When the font changes the size of the cells may change which requires a renderer clear + this.renderer.clear(); + this.renderer.onResize(this.cols, this.rows, false); + this.refresh(0, this.rows - 1); + // this.charMeasure.measure(this.options); case 'scrollback': this.buffers.resize(this.cols, this.rows); this.viewport.syncScrollArea(); @@ -488,44 +421,22 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT case 'bellSound': case 'bellStyle': this.syncBellSound(); break; } - } - - private restartCursorBlinking(): void { - this.setCursorBlinking(this.options.cursorBlink); - } - - private setCursorBlinking(enabled: boolean): void { - this.element.classList.toggle('xterm-cursor-blink', enabled); - this.clearCursorBlinkingInterval(); - if (enabled) { - this.cursorBlinkInterval = setInterval(() => { - this.element.classList.toggle('xterm-cursor-blink-on'); - }, CURSOR_BLINK_INTERVAL); - } - } - - private clearCursorBlinkingInterval(): void { - this.element.classList.remove('xterm-cursor-blink-on'); - if (this.cursorBlinkInterval) { - clearInterval(this.cursorBlinkInterval); - this.cursorBlinkInterval = null; + // Inform renderer of changes + if (this.renderer) { + this.renderer.onOptionsChanged(); } } /** * Binds the desired focus behavior on a given terminal object. */ - private bindFocus(): void { - globalOn(this.textarea, 'focus', (ev) => { - if (this.sendFocus) { - this.send(C0.ESC + '[I'); - } - this.element.classList.add('focus'); - this.showCursor(); - this.restartCursorBlinking.apply(this); - // TODO: Why pass terminal here? - this.emit('focus'); - }); + private _onTextAreaFocus(): void { + if (this.sendFocus) { + this.send(C0.ESC + '[I'); + } + this.element.classList.add('focus'); + this.showCursor(); + this.emit('focus'); }; /** @@ -539,17 +450,13 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT /** * Binds the desired blur behavior on a given terminal object. */ - private bindBlur(): void { - on(this.textarea, 'blur', (ev) => { - this.refresh(this.buffer.y, this.buffer.y); - if (this.sendFocus) { - this.send(C0.ESC + '[O'); - } - this.element.classList.remove('focus'); - this.clearCursorBlinkingInterval.apply(this); - // TODO: Why pass terminal here? - this.emit('blur'); - }); + private _onTextAreaBlur(): void { + this.refresh(this.buffer.y, this.buffer.y); + if (this.sendFocus) { + this.send(C0.ESC + '[O'); + } + this.element.classList.remove('focus'); + this.emit('blur'); } /** @@ -557,8 +464,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT */ private initGlobal(): void { this.bindKeys(); - this.bindFocus(); - this.bindBlur(); // Bind clipboard functionality on(this.element, 'copy', (event: ClipboardEvent) => { @@ -643,22 +548,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.on('refresh', (data) => this.queueLinkification(data.start, data.end)); } - /** - * Insert the given row to the terminal or produce a new one - * if no row argument is passed. Return the inserted row. - * @param {HTMLElement} row (optional) The row to append to the terminal. - */ - private insertRow(row?: HTMLElement): HTMLElement { - if (typeof row !== 'object') { - row = document.createElement('div'); - } - - this.rowContainer.appendChild(row); - this.children.push(row); - - return row; - }; - /** * Opens the terminal within an element. * @@ -679,12 +568,12 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.document = this.parent.ownerDocument; this.body = this.document.body; + initializeCharAtlas(this.document); + // Create main element container this.element = this.document.createElement('div'); this.element.classList.add('terminal'); this.element.classList.add('xterm'); - this.element.classList.add(`xterm-cursor-style-${this.options.cursorStyle}`); - this.setCursorBlinking(this.options.cursorBlink); this.element.setAttribute('tabindex', '0'); @@ -698,18 +587,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // preload audio this.syncBellSound(); - // Create the selection container. - this.selectionContainer = document.createElement('div'); - this.selectionContainer.classList.add('xterm-selection'); - this.element.appendChild(this.selectionContainer); - - // Create the container that will hold the lines of the terminal and then - // produce the lines the lines. - this.rowContainer = document.createElement('div'); - this.rowContainer.classList.add('xterm-rows'); - this.element.appendChild(this.rowContainer); - this.children = []; - this.linkifier.attachToDom(document, this.children); + this._mouseZoneManager = new MouseZoneManager(this); + this.linkifier.attachToDom(this._mouseZoneManager); // Create the container that will hold helpers like the textarea for // capturing DOM Events. Then produce the helpers. @@ -723,8 +602,8 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); this.textarea.tabIndex = 0; - this.textarea.addEventListener('focus', () => this.emit('focus')); - this.textarea.addEventListener('blur', () => this.emit('blur')); + this.textarea.addEventListener('focus', () => this._onTextAreaFocus()); + this.textarea.addEventListener('blur', () => this._onTextAreaBlur()); this.helperContainer.appendChild(this.textarea); this.compositionView = document.createElement('div'); @@ -735,23 +614,23 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.charSizeStyleElement = document.createElement('style'); this.helperContainer.appendChild(this.charSizeStyleElement); - for (; i < this.rows; i++) { - this.insertRow(); - } this.parent.appendChild(this.element); this.charMeasure = new CharMeasure(document, this.helperContainer); - this.charMeasure.on('charsizechanged', () => { - this.updateCharSizeStyles(); - }); - this.charMeasure.measure(); this.viewport = new Viewport(this, this.viewportElement, this.viewportScrollArea, this.charMeasure); + this.charMeasure.on('charsizechanged', () => this.viewport.syncScrollArea()); this.renderer = new Renderer(this); - this.selectionManager = new SelectionManager(this, this.buffer, this.rowContainer, this.charMeasure); - this.selectionManager.on('refresh', data => { - this.renderer.refreshSelection(data.start, data.end); - }); + this.on('cursormove', () => this.renderer.onCursorMove()); + this.on('resize', () => this.renderer.onResize(this.cols, this.rows, false)); + this.on('blur', () => this.renderer.onBlur()); + this.on('focus', () => this.renderer.onFocus()); + window.addEventListener('resize', () => this.renderer.onWindowResize(window.devicePixelRatio)); + this.charMeasure.on('charsizechanged', () => this.renderer.onResize(this.cols, this.rows, true)); + + this.selectionManager = new SelectionManager(this, this.buffer, this.charMeasure); + this.element.addEventListener('mousedown', (e: MouseEvent) => this.selectionManager.onMouseDown(e)); + this.selectionManager.on('refresh', data => this.renderer.onSelectionChanged(data.start, data.end)); this.selectionManager.on('newselection', text => { // If there's a new selection, put it into the textarea, focus and select it // in order to register it as a selection on the OS. This event is fired @@ -763,6 +642,18 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.on('scroll', () => this.selectionManager.refresh()); this.viewportElement.addEventListener('scroll', () => this.selectionManager.refresh()); + // Measure the character size + this.charMeasure.measure(this.options); + + // Set the theme if it was set via setOption/constructor before open. This + // must be run after CharMeasure.measure as it depends on char dimensions. + setTimeout(() => { + if (this.options.theme) { + this._setTheme(this.options.theme); + this.options.theme = null; + } + }, 0); + // Setup loop that draws to screen this.refresh(0, this.rows - 1); @@ -774,6 +665,17 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.bindMouse(); } + /** + * Sets the theme on the renderer. The renderer must have been initialized. + * @param theme The theme to ste. + */ + private _setTheme(theme: ITheme): void { + const colors = this.renderer.setTheme(theme); + if (this.viewport) { + this.viewport.onThemeChanged(colors); + } + } + /** * Attempts to load an add-on using CommonJS or RequireJS (whichever is available). * @param {string} addon The name of the addon to load @@ -793,17 +695,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT } } - /** - * Updates the helper CSS class with any changes necessary after the terminal's - * character width has been changed. - */ - public updateCharSizeStyles(): void { - this.charSizeStyleElement.textContent = - `.xterm-wide-char{width:${this.charMeasure.width * 2}px;}` + - `.xterm-normal-char{width:${this.charMeasure.width}px;}` + - `.xterm-rows > div{height:${this.charMeasure.height}px;}`; - } - /** * XTerm mouse events * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking @@ -830,7 +721,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT button = getButton(ev); // get mouse coordinates - pos = getRawByteCoords(ev, self.rowContainer, self.charMeasure, self.cols, self.rows); + pos = getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows); if (!pos) return; sendEvent(button, pos); @@ -856,7 +747,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT // ^[[M 3<^[[M@4<^[[M@5<^[[M@6<^[[M@7<^[[M#7< function sendMove(ev: MouseEvent): void { let button = pressed; - let pos = getRawByteCoords(ev, self.rowContainer, self.charMeasure, self.cols, self.rows); + let pos = getRawByteCoords(ev, self.element, self.charMeasure, self.options.lineHeight, self.cols, self.rows); if (!pos) return; // buttons marked as motions @@ -1143,9 +1034,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT */ private queueLinkification(start: number, end: number): void { if (this.linkifier) { - for (let i = start; i <= end; i++) { - this.linkifier.linkifyRow(i); - } + this.linkifier.linkifyRows(0, this.rows); } } @@ -1373,7 +1262,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT throw new Error('Cannot attach a hypertext validation callback before Terminal.open is called'); } this.linkifier.setHypertextValidationCallback(callback); - // Refresh to force links to refresh + // // Refresh to force links to refresh this.refresh(0, this.rows - 1); } @@ -1393,6 +1282,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.refresh(0, this.rows - 1); return matcherId; } + return 0; } /** @@ -1451,8 +1341,6 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT return false; } - this.restartCursorBlinking(); - if (!this.compositionHelper.keydown(ev)) { if (this.buffer.ybase !== this.buffer.ydisp) { this.scrollToBottom(); @@ -1904,17 +1792,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT return; } - let line; - let el; - let i; - let j; - let ch; - let addToY; - if (x === this.cols && y === this.rows) { // Check if we still need to measure the char size (fixes #785). if (!this.charMeasure.width || !this.charMeasure.height) { - this.charMeasure.measure(); + this.charMeasure.measure(this.options); } return; } @@ -1924,21 +1805,11 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.buffers.resize(x, y); - // Adjust rows in the DOM to accurately reflect the new dimensions - while (this.children.length < y) { - this.insertRow(); - } - while (this.children.length > y) { - el = this.children.shift(); - if (!el) continue; - el.parentNode.removeChild(el); - } - this.cols = x; this.rows = y; this.buffers.setupTabStops(this.cols); - this.charMeasure.measure(); + this.charMeasure.measure(this.options); this.refresh(0, this.rows - 1); @@ -1979,7 +1850,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT if (!line) { return; } - const ch: CharData = [this.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this.eraseAttr(), ' ', 1, 32 /* ' '.charCodeAt(0) */]; // xterm for (; x < this.cols; x++) { line[x] = ch; } @@ -1996,7 +1867,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT if (!line) { return; } - const ch: CharData = [this.eraseAttr(), ' ', 1]; // xterm + const ch: CharData = [this.eraseAttr(), ' ', 1, 32 /* ' '.charCodeAt(0) */]; // xterm x++; while (x--) { line[x] = ch; @@ -2042,7 +1913,7 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT public blankLine(cur?: boolean, isWrapped?: boolean, cols?: number): LineData { const attr = cur ? this.eraseAttr() : this.defAttr; - const ch: CharData = [attr, ' ', 1]; // width defaults to 1 halfwidth character + const ch: CharData = [attr, ' ', 1, 32 /* ' '.charCodeAt(0) */]; // width defaults to 1 halfwidth character const line: LineData = []; // TODO: It is not ideal that this is a property on an array, a buffer line @@ -2064,7 +1935,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT * @param cur */ public ch(cur?: boolean): CharData { - return cur ? [this.eraseAttr(), ' ', 1] : [this.defAttr, ' ', 1]; + if (cur) { + return [this.eraseAttr(), ' ', 1, 32 /* ' '.charCodeAt(0) */]; + } + return [this.defAttr, ' ', 1, 32 /* ' '.charCodeAt(0) */]; } /** @@ -2157,12 +2031,10 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT this.options.rows = this.rows; this.options.cols = this.cols; const customKeyEventHandler = this.customKeyEventHandler; - const cursorBlinkInterval = this.cursorBlinkInterval; const inputHandler = this.inputHandler; const buffers = this.buffers; this.setup(); this.customKeyEventHandler = customKeyEventHandler; - this.cursorBlinkInterval = cursorBlinkInterval; this.inputHandler = inputHandler; this.buffers = buffers; this.refresh(0, this.rows - 1); @@ -2187,44 +2059,9 @@ export class Terminal extends EventEmitter implements ITerminal, IInputHandlingT return false; } - // Expose to InputHandler - // TODO: Revise when truecolor is introduced. + // TODO: Remove when true color is implemented public matchColor(r1: number, g1: number, b1: number): number { - const hash = (r1 << 16) | (g1 << 8) | b1; - - if (matchColorCache[hash] != null) { - return matchColorCache[hash]; - } - - let ldiff = Infinity; - let li = -1; - let i = 0; - let c: number[]; - let r2: number; - let g2: number; - let b2: number; - let diff: number; - - for (; i < vcolors.length; i++) { - c = vcolors[i]; - r2 = c[0]; - g2 = c[1]; - b2 = c[2]; - - diff = matchColorDistance(r1, g1, b1, r2, g2, b2); - - if (diff === 0) { - li = i; - break; - } - - if (diff < ldiff) { - ldiff = diff; - li = i; - } - } - - return matchColorCache[hash] = li; + return matchColor_(r1, g1, b1); } private visualBell(): boolean { @@ -2283,17 +2120,95 @@ function isThirdLevelShift(browser: IBrowser, ev: KeyboardEvent): boolean { return thirdLevelKey && (!ev.keyCode || ev.keyCode > 47); } +function wasMondifierKeyOnlyEvent(ev: KeyboardEvent): boolean { + return ev.keyCode === 16 || // Shift + ev.keyCode === 17 || // Ctrl + ev.keyCode === 18; // Alt +} + +/** + * TODO: + * The below color-related code can be removed when true color is implemented. + * It's only purpose is to match true color requests with the closest matching + * ANSI color code. + */ + +// Colors 0-15 + 16-255 +// Much thanks to TooTallNate for writing this. +const vcolors: number[][] = (function(): number[][] { + const result = DEFAULT_ANSI_COLORS.map(c => { + c = c.substring(1); + return [ + parseInt(c.substring(0, 2), 16), + parseInt(c.substring(2, 4), 16), + parseInt(c.substring(4, 6), 16) + ]; + }); + const r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; + + // 16-231 + for (let i = 0; i < 216; i++) { + result.push([ + r[(i / 36) % 6 | 0], + r[(i / 6) % 6 | 0], + r[i % 6] + ]); + } + + // 232-255 (grey) + let c: number; + for (let i = 0; i < 24; i++) { + c = 8 + i * 10; + result.push([c, c, c]); + } + + return result; +})(); + const matchColorCache: {[colorRGBHash: number]: number} = {}; // http://stackoverflow.com/questions/1633828 -const matchColorDistance = function(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number { +function matchColorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number { return Math.pow(30 * (r1 - r2), 2) + Math.pow(59 * (g1 - g2), 2) + Math.pow(11 * (b1 - b2), 2); }; -function wasMondifierKeyOnlyEvent(ev: KeyboardEvent): boolean { - return ev.keyCode === 16 || // Shift - ev.keyCode === 17 || // Ctrl - ev.keyCode === 18; // Alt + +function matchColor_(r1: number, g1: number, b1: number): number { + const hash = (r1 << 16) | (g1 << 8) | b1; + + if (matchColorCache[hash] != null) { + return matchColorCache[hash]; + } + + let ldiff = Infinity; + let li = -1; + let i = 0; + let c: number[]; + let r2: number; + let g2: number; + let b2: number; + let diff: number; + + for (; i < vcolors.length; i++) { + c = vcolors[i]; + r2 = c[0]; + g2 = c[1]; + b2 = c[2]; + + diff = matchColorDistance(r1, g1, b1, r2, g2, b2); + + if (diff === 0) { + li = i; + break; + } + + if (diff < ldiff) { + ldiff = diff; + li = i; + } + } + + return matchColorCache[hash] = li; } diff --git a/src/Types.ts b/src/Types.ts index 0753d80e19..a0c03fdd7d 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ @@ -6,15 +7,29 @@ export type LinkMatcher = { id: number, regex: RegExp, handler: LinkMatcherHandler, + hoverTooltipCallback?: LinkMatcherHandler, + hoverLeaveCallback?: () => void, matchIndex?: number, validationCallback?: LinkMatcherValidationCallback, priority?: number }; export type LinkMatcherHandler = (event: MouseEvent, uri: string) => boolean | void; -export type LinkMatcherValidationCallback = (uri: string, element: HTMLElement, callback: (isValid: boolean) => void) => void; +export type LinkMatcherValidationCallback = (uri: string, callback: (isValid: boolean) => void) => void; export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean; export type Charset = {[key: string]: string}; -export type CharData = [number, string, number]; +export type CharData = [number, string, number, number]; export type LineData = CharData[]; + +export type LinkHoverEvent = { + x: number, + y: number, + length: number +}; + +export enum LinkHoverEventTypes { + HOVER = 'linkhover', + TOOLTIP = 'linktooltip', + LEAVE = 'linkleave' +}; diff --git a/src/Viewport.test.ts b/src/Viewport.test.ts index 428ef09871..6ce798d327 100644 --- a/src/Viewport.test.ts +++ b/src/Viewport.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ @@ -31,7 +32,8 @@ describe('Viewport', () => { } }, options: { - scrollback: 10 + scrollback: 10, + lineHeight: 1 } }; terminal.buffers = new BufferSet(terminal); @@ -55,18 +57,6 @@ describe('Viewport', () => { }); describe('refresh', () => { - it('should set the line-height of the terminal', done => { - // Allow CharMeasure to be initialized - setTimeout(() => { - assert.equal(viewportElement.style.lineHeight, CHARACTER_HEIGHT + 'px'); - assert.equal(terminal.rowContainer.style.lineHeight, CHARACTER_HEIGHT + 'px'); - charMeasure.height = 1; - viewport.refresh(); - assert.equal(viewportElement.style.lineHeight, '1px'); - assert.equal(terminal.rowContainer.style.lineHeight, '1px'); - done(); - }, 0); - }); it('should set the height of the viewport when the line-height changed', () => { terminal.buffer.lines.push(''); terminal.buffer.lines.push(''); diff --git a/src/Viewport.ts b/src/Viewport.ts index 4ad4ddec47..1f693cfeb6 100644 --- a/src/Viewport.ts +++ b/src/Viewport.ts @@ -1,9 +1,11 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ import { ITerminal, IViewport } from './Interfaces'; import { CharMeasure } from './utils/CharMeasure'; +import { IColorSet } from './renderer/Interfaces'; /** * Represents the viewport of a terminal, the visible area within the larger buffer of output. @@ -40,25 +42,28 @@ export class Viewport implements IViewport { setTimeout(() => this.syncScrollArea(), 0); } + public onThemeChanged(colors: IColorSet): void { + this.viewportElement.style.backgroundColor = colors.background; + } + /** * Refreshes row height, setting line-height, viewport height and scroll area height if * necessary. */ private refresh(): void { if (this.charMeasure.height > 0) { - const rowHeightChanged = this.charMeasure.height !== this.currentRowHeight; + const lineHeight = Math.ceil(this.charMeasure.height * this.terminal.options.lineHeight); + const rowHeightChanged = lineHeight !== this.currentRowHeight; if (rowHeightChanged) { - this.currentRowHeight = this.charMeasure.height; - this.viewportElement.style.lineHeight = this.charMeasure.height + 'px'; - this.terminal.rowContainer.style.lineHeight = this.charMeasure.height + 'px'; + this.currentRowHeight = lineHeight; + this.viewportElement.style.lineHeight = lineHeight + 'px'; } const viewportHeightChanged = this.lastRecordedViewportHeight !== this.terminal.rows; if (rowHeightChanged || viewportHeightChanged) { this.lastRecordedViewportHeight = this.terminal.rows; - this.viewportElement.style.height = this.charMeasure.height * this.terminal.rows + 'px'; - this.terminal.selectionContainer.style.height = this.viewportElement.style.height; + this.viewportElement.style.height = lineHeight * this.terminal.rows + 'px'; } - this.scrollArea.style.height = (this.charMeasure.height * this.lastRecordedBufferLength) + 'px'; + this.scrollArea.style.height = (lineHeight * this.lastRecordedBufferLength) + 'px'; } } @@ -75,7 +80,7 @@ export class Viewport implements IViewport { this.refresh(); } else { // If size has changed, refresh viewport - if (this.charMeasure.height !== this.currentRowHeight) { + if (Math.ceil(this.charMeasure.height * this.terminal.options.lineHeight) !== this.currentRowHeight) { this.refresh(); } } diff --git a/src/addons/attach/attach.js b/src/addons/attach/attach.js index 34dc4f681f..834ccd3c5d 100644 --- a/src/addons/attach/attach.js +++ b/src/addons/attach/attach.js @@ -1,7 +1,8 @@ /** - * Implements the attach method, that attaches the terminal to a WebSocket stream. - * @module xterm/addons/attach/attach + * Copyright (c) 2014 The xterm.js authors. All rights reserved. * @license MIT + * + * Implements the attach method, that attaches the terminal to a WebSocket stream. */ (function (attach) { diff --git a/src/addons/fit/fit.js b/src/addons/fit/fit.js index da66802d12..59f0fd52e0 100644 --- a/src/addons/fit/fit.js +++ b/src/addons/fit/fit.js @@ -1,14 +1,16 @@ /** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * @license MIT + * * Fit terminal columns and rows to the dimensions of its DOM element. * * ## Approach - * - Rows: Truncate the division of the terminal parent element height by the terminal row height. * - * - Columns: Truncate the division of the terminal parent element width by the terminal character - * width (apply display: inline at the terminal row and truncate its width with the current - * number of columns). - * @module xterm/addons/fit/fit - * @license MIT + * Rows: Truncate the division of the terminal parent element height by the + * terminal row height. + * Columns: Truncate the division of the terminal parent element width by the + * terminal character width (apply display: inline at the terminal + * row and truncate its width with the current number of columns). */ (function (fit) { @@ -35,43 +37,36 @@ if (!term.element.parentElement) { return null; } - var parentElementStyle = window.getComputedStyle(term.element.parentElement), - parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')), - parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')) - 17), - elementStyle = window.getComputedStyle(term.element), - elementPaddingVer = parseInt(elementStyle.getPropertyValue('padding-top')) + parseInt(elementStyle.getPropertyValue('padding-bottom')), - elementPaddingHor = parseInt(elementStyle.getPropertyValue('padding-right')) + parseInt(elementStyle.getPropertyValue('padding-left')), - availableHeight = parentElementHeight - elementPaddingVer, - availableWidth = parentElementWidth - elementPaddingHor, - container = term.rowContainer, - subjectRow = term.rowContainer.firstElementChild, - contentBuffer = subjectRow.innerHTML, - characterHeight, - rows, - characterWidth, - cols, - geometry; + var parentElementStyle = window.getComputedStyle(term.element.parentElement); + var parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); + var parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')) - 17); + var elementStyle = window.getComputedStyle(term.element); + var elementPaddingVer = parseInt(elementStyle.getPropertyValue('padding-top')) + parseInt(elementStyle.getPropertyValue('padding-bottom')); + var elementPaddingHor = parseInt(elementStyle.getPropertyValue('padding-right')) + parseInt(elementStyle.getPropertyValue('padding-left')); + var availableHeight = parentElementHeight - elementPaddingVer; + var availableWidth = parentElementWidth - elementPaddingHor; + var geometry = { + cols: Math.floor(availableWidth / term.charMeasure.width), + rows: Math.floor(availableHeight / Math.ceil(term.charMeasure.height * term.getOption('lineHeight'))) + }; - subjectRow.style.display = 'inline'; - subjectRow.innerHTML = 'W'; // Common character for measuring width, although on monospace - characterWidth = subjectRow.getBoundingClientRect().width; - subjectRow.style.display = ''; // Revert style before calculating height, since they differ. - characterHeight = subjectRow.getBoundingClientRect().height; - subjectRow.innerHTML = contentBuffer; - - rows = parseInt(availableHeight / characterHeight); - cols = parseInt(availableWidth / characterWidth); - - geometry = {cols: cols, rows: rows}; return geometry; }; exports.fit = function (term) { - var geometry = exports.proposeGeometry(term); + // Wrap fit in a setTimeout as charMeasure needs time to get initialized + // after calling Terminal.open + setTimeout(() => { + var geometry = exports.proposeGeometry(term); - if (geometry) { - term.resize(geometry.cols, geometry.rows); - } + if (geometry) { + // Force a full render + if (term.rows !== geometry.rows || term.cols !== geometry.cols) { + term.renderer.clear(); + term.resize(geometry.cols, geometry.rows); + } + } + }, 0); }; Terminal.prototype.proposeGeometry = function () { diff --git a/src/addons/fullscreen/fullscreen.js b/src/addons/fullscreen/fullscreen.js index 344669f6df..1474d2db6f 100644 --- a/src/addons/fullscreen/fullscreen.js +++ b/src/addons/fullscreen/fullscreen.js @@ -1,8 +1,8 @@ /** - * Fullscreen addon for xterm.js - * @module xterm/addons/fullscreen/fullscreen + * Copyright (c) 2014 The xterm.js authors. All rights reserved. * @license MIT */ + (function (fullscreen) { if (typeof exports === 'object' && typeof module === 'object') { /* diff --git a/src/addons/search/SearchHelper.ts b/src/addons/search/SearchHelper.ts index 26e4e8a7d2..1747e747e5 100644 --- a/src/addons/search/SearchHelper.ts +++ b/src/addons/search/SearchHelper.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/addons/search/search.ts b/src/addons/search/search.ts index 34223c4ea7..69c5737b53 100644 --- a/src/addons/search/search.ts +++ b/src/addons/search/search.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/addons/terminado/terminado.js b/src/addons/terminado/terminado.js index d1414c864b..54af76337a 100644 --- a/src/addons/terminado/terminado.js +++ b/src/addons/terminado/terminado.js @@ -1,8 +1,9 @@ /** - * This module provides methods for attaching a terminal to a terminado WebSocket stream. - * - * @module xterm/addons/terminado/terminado + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT + * + * This module provides methods for attaching a terminal to a terminado + * WebSocket stream. */ (function (attach) { diff --git a/src/handlers/Clipboard.test.ts b/src/handlers/Clipboard.test.ts index bd2cea0366..6901b73006 100644 --- a/src/handlers/Clipboard.test.ts +++ b/src/handlers/Clipboard.test.ts @@ -1,3 +1,8 @@ +/** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. + * @license MIT + */ + import { assert } from 'chai'; import * as Terminal from '../Terminal'; import * as Clipboard from './Clipboard'; diff --git a/src/handlers/Clipboard.ts b/src/handlers/Clipboard.ts index ac9a7b8714..a973535203 100644 --- a/src/handlers/Clipboard.ts +++ b/src/handlers/Clipboard.ts @@ -1,7 +1,5 @@ /** - * Clipboard handler module: exports methods for handling all clipboard-related events in the - * terminal. - * @module xterm/handlers/Clipboard + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/input/Interfaces.ts b/src/input/Interfaces.ts new file mode 100644 index 0000000000..e3ed4eef2c --- /dev/null +++ b/src/input/Interfaces.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export interface IMouseZoneManager { + add(zone: IMouseZone): void; + clearAll(): void; +} + +export interface IMouseZone { + x1: number; + x2: number; + y: number; + clickCallback: (e: MouseEvent) => any; + hoverCallback?: (e: MouseEvent) => any; + tooltipCallback?: (e: MouseEvent) => any; + leaveCallback?: () => any; +} diff --git a/src/input/MouseZoneManager.ts b/src/input/MouseZoneManager.ts new file mode 100644 index 0000000000..d1bc8a758b --- /dev/null +++ b/src/input/MouseZoneManager.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IMouseZoneManager, IMouseZone } from './Interfaces'; +import { ITerminal } from '../Interfaces'; +import { getCoords } from '../utils/Mouse'; + +const HOVER_DURATION = 500; + +/** + * The MouseZoneManager allows components to register zones within the terminal + * that trigger hover and click callbacks. + * + * This class was intentionally made not so robust initially as the only case it + * needed to support was single-line links which never overlap. Improvements can + * be made in the future. + */ +export class MouseZoneManager implements IMouseZoneManager { + private _zones: IMouseZone[] = []; + + private _areZonesActive: boolean = false; + private _mouseMoveListener: (e: MouseEvent) => any; + private _mouseDownListener: (e: MouseEvent) => any; + private _clickListener: (e: MouseEvent) => any; + + private _tooltipTimeout: number = null; + private _currentZone: IMouseZone = null; + private _lastHoverCoords: [number, number] = [null, null]; + + constructor( + private _terminal: ITerminal + ) { + // These events are expensive, only listen to it when mouse zones are active + this._mouseMoveListener = e => this._onMouseMove(e); + this._clickListener = e => this._onClick(e); + } + + public add(zone: IMouseZone): void { + this._zones.push(zone); + if (this._zones.length === 1) { + this._activate(); + } + } + + public clearAll(): void { + this._zones.length = 0; + this._deactivate(); + } + + private _activate(): void { + if (!this._areZonesActive) { + this._areZonesActive = true; + this._terminal.element.addEventListener('mousemove', this._mouseMoveListener); + this._terminal.element.addEventListener('click', this._clickListener); + } + } + + private _deactivate(): void { + if (this._areZonesActive) { + this._areZonesActive = false; + this._terminal.element.removeEventListener('mousemove', this._mouseMoveListener); + this._terminal.element.removeEventListener('click', this._clickListener); + } + } + + private _onMouseMove(e: MouseEvent): void { + // TODO: Ideally this would only clear the hover state when the mouse moves + // outside of the mouse zone + if (this._lastHoverCoords[0] !== e.pageX || this._lastHoverCoords[1] !== e.pageY) { + this._onHover(e); + // Record the current coordinates + this._lastHoverCoords = [e.pageX, e.pageY]; + } + } + + private _onHover(e: MouseEvent): void { + const zone = this._findZoneEventAt(e); + + // Do nothing if the zone is the same + if (zone === this._currentZone) { + return; + } + + // Fire the hover end callback if a zone was being hovered + if (this._currentZone) { + this._currentZone.leaveCallback(); + this._currentZone = null; + } + + // Exit if there is not zone + if (!zone) { + return; + } + this._currentZone = zone; + + // Trigger the hover callback + if (zone.hoverCallback) { + zone.hoverCallback(e); + } + + // Restart the timeout + if (this._tooltipTimeout) { + clearTimeout(this._tooltipTimeout); + } + this._tooltipTimeout = setTimeout(() => this._onTooltip(e), HOVER_DURATION); + } + + private _onTooltip(e: MouseEvent): void { + const zone = this._findZoneEventAt(e); + if (zone && zone.tooltipCallback) { + zone.tooltipCallback(e); + } + } + + private _onClick(e: MouseEvent): void { + const zone = this._findZoneEventAt(e); + if (zone) { + zone.clickCallback(e); + e.preventDefault(); + } + } + + private _findZoneEventAt(e: MouseEvent): IMouseZone { + const coords = getCoords(e, this._terminal.element, this._terminal.charMeasure, this._terminal.options.lineHeight, this._terminal.cols, this._terminal.rows); + for (let i = 0; i < this._zones.length; i++) { + const zone = this._zones[i]; + if (zone.y === coords[1] && zone.x1 <= coords[0] && zone.x2 > coords[0]) { + return zone; + } + }; + return null; + } +} + +export class MouseZone implements IMouseZone { + constructor( + public x1: number, + public x2: number, + public y: number, + public clickCallback: (e: MouseEvent) => any, + public hoverCallback?: (e: MouseEvent) => any, + public tooltipCallback?: (e: MouseEvent) => any, + public leaveCallback?: () => void + ) { + } +} diff --git a/src/renderer/BackgroundRenderLayer.ts b/src/renderer/BackgroundRenderLayer.ts new file mode 100644 index 0000000000..0c09727db7 --- /dev/null +++ b/src/renderer/BackgroundRenderLayer.ts @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColorSet } from './Interfaces'; +import { IBuffer, ICharMeasure, ITerminal } from '../Interfaces'; +import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; +import { GridCache } from './GridCache'; +import { FLAGS } from './Types'; +import { BaseRenderLayer, INVERTED_DEFAULT_COLOR } from './BaseRenderLayer'; + +export class BackgroundRenderLayer extends BaseRenderLayer { + private _state: GridCache; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { + super(container, 'bg', zIndex, colors); + this._state = new GridCache(); + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + // Resizing the canvas discards the contents of the canvas so clear state + this._state.clear(); + this._state.resize(terminal.cols, terminal.rows); + } + + public reset(terminal: ITerminal): void { + this._state.clear(); + this.clearAll(); + } + + public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void { + // Resize has not been called yet + if (this._state.cache.length === 0) { + return; + } + for (let y = startRow; y <= endRow; y++) { + let row = y + terminal.buffer.ydisp; + let line = terminal.buffer.lines.get(row); + for (let x = 0; x < terminal.cols; x++) { + const attr: number = line[x][CHAR_DATA_ATTR_INDEX]; + let bg = attr & 0x1ff; + const flags = attr >> 18; + + // If inverse flag is on, the background should become the foreground. + if (flags & FLAGS.INVERSE) { + bg = (attr >> 9) & 0x1ff; + if (bg === 257) { + bg = INVERTED_DEFAULT_COLOR; + } + } + + const cellState = this._state.cache[x][y]; + const needsRefresh = (bg < 256 && cellState !== bg) || cellState !== null; + if (needsRefresh) { + if (bg < 256) { + this._ctx.save(); + this._ctx.fillStyle = (bg === INVERTED_DEFAULT_COLOR ? this.colors.foreground : this.colors.ansi[bg]); + this.fillCells(x, y, 1, 1); + this._ctx.restore(); + this._state.cache[x][y] = bg; + } else { + this.clearCells(x, y, 1, 1); + this._state.cache[x][y] = null; + } + } + } + } + } +} diff --git a/src/renderer/BaseRenderLayer.ts b/src/renderer/BaseRenderLayer.ts new file mode 100644 index 0000000000..cc02bcea90 --- /dev/null +++ b/src/renderer/BaseRenderLayer.ts @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IRenderLayer, IColorSet } from './Interfaces'; +import { ITerminal, ITerminalOptions } from '../Interfaces'; +import { acquireCharAtlas, CHAR_ATLAS_CELL_SPACING } from './CharAtlas'; +import { CharData } from '../Types'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; + +export const INVERTED_DEFAULT_COLOR = -1; + +export abstract class BaseRenderLayer implements IRenderLayer { + private _canvas: HTMLCanvasElement; + protected _ctx: CanvasRenderingContext2D; + private scaledCharWidth: number; + private scaledCharHeight: number; + private scaledLineHeight: number; + private scaledLineDrawY: number; + + private _charAtlas: HTMLCanvasElement | ImageBitmap; + + constructor( + container: HTMLElement, + id: string, + zIndex: number, + protected colors: IColorSet + ) { + this._canvas = document.createElement('canvas'); + this._canvas.id = `xterm-${id}-layer`; + this._canvas.style.zIndex = zIndex.toString(); + this._ctx = this._canvas.getContext('2d'); + this._ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + container.appendChild(this._canvas); + } + + public onOptionsChanged(terminal: ITerminal): void {} + public onBlur(terminal: ITerminal): void {} + public onFocus(terminal: ITerminal): void {} + public onCursorMove(terminal: ITerminal): void {} + public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void {} + public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void {} + + public onThemeChanged(terminal: ITerminal, colorSet: IColorSet): void { + this._refreshCharAtlas(terminal, colorSet); + } + + /** + * Refreshes the char atlas, aquiring a new one if necessary. + * @param terminal The terminal. + * @param colorSet The color set to use for the char atlas. + */ + private _refreshCharAtlas(terminal: ITerminal, colorSet: IColorSet): void { + this._charAtlas = null; + const result = acquireCharAtlas(terminal, this.colors, this.scaledCharWidth, this.scaledCharHeight); + if (result instanceof HTMLCanvasElement) { + this._charAtlas = result; + } else { + result.then(bitmap => this._charAtlas = bitmap); + } + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + // Calculate the scaled character dimensions, if devicePixelRatio is a + // floating point number then the value is ceiled to ensure there is enough + // space to draw the character to the cell + this.scaledCharWidth = Math.ceil(terminal.charMeasure.width * window.devicePixelRatio); + this.scaledCharHeight = Math.ceil(terminal.charMeasure.height * window.devicePixelRatio); + + // Calculate the scaled line height, if lineHeight is not 1 then the value + // 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.scaledLineHeight = Math.floor(this.scaledCharHeight * 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.scaledLineDrawY = terminal.options.lineHeight === 1 ? 0 : Math.round((this.scaledLineHeight - this.scaledCharHeight) / 2); + + // Recalcualte the canvas dimensions; width/height define the actual number + // of pixels in the canvas, style.width/height define the size of the canvas + // on the page. It's very important that this rounds to nearest integer and + // not ceils as browsers often set window.devicePixelRatio as something like + // 1.100000023841858, when it's actually 1.1. Ceiling causes blurriness as + // the backing canvas image is 1 pixel too large for the canvas element + // size. + this._canvas.width = Math.round(canvasWidth * window.devicePixelRatio); + this._canvas.height = Math.round(canvasHeight * window.devicePixelRatio); + this._canvas.style.width = `${canvasWidth}px`; + this._canvas.style.height = `${canvasHeight}px`; + + if (charSizeChanged) { + this._refreshCharAtlas(terminal, this.colors); + } + } + + public abstract reset(terminal: ITerminal): void; + + /** + * Fills 1+ cells completely. This uses the existing fillStyle on the context. + * @param x The column to start at. + * @param y The row to start at + * @param width The number of columns to fill. + * @param height The number of rows to fill. + */ + protected fillCells(x: number, y: number, width: number, height: number): void { + this._ctx.fillRect(x * this.scaledCharWidth, y * this.scaledLineHeight, width * this.scaledCharWidth, height * this.scaledLineHeight); + } + + /** + * Fills a 1px line (2px on HDPI) at the bottom of the cell. This uses the + * existing fillStyle on the context. + * @param x The column to fill. + * @param y The row to fill. + */ + protected fillBottomLineAtCells(x: number, y: number, width: number = 1): void { + this._ctx.fillRect( + x * this.scaledCharWidth, + (y + 1) * this.scaledLineHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, + width * this.scaledCharWidth, + window.devicePixelRatio); + } + + /** + * Fills a 1px line (2px on HDPI) at the left of the cell. This uses the + * existing fillStyle on the context. + * @param x The column to fill. + * @param y The row to fill. + */ + protected fillLeftLineAtCell(x: number, y: number): void { + this._ctx.fillRect( + x * this.scaledCharWidth, + y * this.scaledLineHeight, + window.devicePixelRatio, + this.scaledLineHeight); + } + + /** + * Strokes a 1px rectangle (2px on HDPI) around a cell. This uses the existing + * strokeStyle on the context. + * @param x The column to fill. + * @param y The row to fill. + */ + protected strokeRectAtCell(x: number, y: number, width: number, height: number): void { + this._ctx.lineWidth = window.devicePixelRatio; + this._ctx.strokeRect( + x * this.scaledCharWidth + window.devicePixelRatio / 2, + y * this.scaledLineHeight + (window.devicePixelRatio / 2), + (width * this.scaledCharWidth) - window.devicePixelRatio, + (height * this.scaledLineHeight) - window.devicePixelRatio); + } + + /** + * Clears the entire canvas. + */ + protected clearAll(): void { + this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + } + + /** + * Clears 1+ cells completely. + * @param x The column to start at. + * @param y The row to start at. + * @param width The number of columns to clear. + * @param height The number of rows to clear. + */ + protected clearCells(x: number, y: number, width: number, height: number): void { + this._ctx.clearRect(x * this.scaledCharWidth, y * this.scaledLineHeight, width * this.scaledCharWidth, height * this.scaledLineHeight); + } + + /** + * Draws a truecolor character at the cell. The character will be clipped to + * ensure that it fits with the cell, including the cell to the right if it's + * a wide character. This uses the existing fillStyle on the context. + * @param terminal The terminal. + * @param charData The char data for the character to draw. + * @param x The column to draw at. + * @param y The row to draw at. + * @param color The color of the character. + */ + protected fillCharTrueColor(terminal: ITerminal, charData: CharData, x: number, y: number): void { + this._ctx.font = `${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; + this._ctx.textBaseline = 'top'; + + // Since uncached characters are not coming off the char atlas with source + // coordinates, it means that text drawn to the canvas (particularly '_') + // can bleed into other cells. This code will clip the following fillText, + // ensuring that its contents don't go beyond the cell bounds. + this._ctx.beginPath(); + this._ctx.rect(x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY, charData[CHAR_DATA_WIDTH_INDEX] * this.scaledCharWidth, this.scaledCharHeight); + this._ctx.clip(); + this._ctx.fillText(charData[CHAR_DATA_CHAR_INDEX], x * this.scaledCharWidth, y * this.scaledCharHeight); + } + + /** + * Draws a character at a cell. If possible this will draw using the character + * atlas to reduce draw time. + * @param terminal The terminal. + * @param char The character. + * @param code The character code. + * @param width The width of the character. + * @param x The column to draw at. + * @param y The row to draw at. + * @param fg The foreground color, in the format stored within the attributes. + * @param bold Whether the text is bold. + */ + protected drawChar(terminal: ITerminal, char: string, code: number, width: number, x: number, y: number, fg: number, bold: boolean): void { + // Clear the cell next to this character if it's wide + if (width === 2) { + this.clearCells(x + 1, y, 1, 1); + } + + let colorIndex = 0; + if (fg < 256) { + colorIndex = fg + 2; + } else { + // If default color and bold + if (bold) { + colorIndex = 1; + } + } + const isAscii = code < 256; + const isBasicColor = (colorIndex > 1 && fg < 16); + const isDefaultColor = fg >= 256; + if (isAscii && (isBasicColor || isDefaultColor)) { + // ImageBitmap's draw about twice as fast as from a canvas + const charAtlasCellWidth = this.scaledCharWidth + CHAR_ATLAS_CELL_SPACING; + const charAtlasCellHeight = this.scaledCharHeight + CHAR_ATLAS_CELL_SPACING; + this._ctx.drawImage(this._charAtlas, + code * charAtlasCellWidth, colorIndex * charAtlasCellHeight, this.scaledCharWidth, this.scaledCharHeight, + x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY, this.scaledCharWidth, this.scaledCharHeight); + } else { + this._drawUncachedChar(terminal, char, width, fg, x, y); + } + // This draws the atlas (for debugging purposes) + // this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + // this._ctx.drawImage(this._charAtlas, 0, 0); + } + + /** + * Draws a character at a cell. The character will be clipped to + * ensure that it fits with the cell, including the cell to the right if it's + * a wide character. + * @param terminal The terminal. + * @param char The character. + * @param width The width of the character. + * @param fg The foreground color, in the format stored within the attributes. + * @param x The column to draw at. + * @param y The row to draw at. + */ + private _drawUncachedChar(terminal: ITerminal, char: string, width: number, fg: number, x: number, y: number): void { + this._ctx.save(); + this._ctx.font = `${terminal.options.fontSize * window.devicePixelRatio}px ${terminal.options.fontFamily}`; + this._ctx.textBaseline = 'top'; + + if (fg === INVERTED_DEFAULT_COLOR) { + this._ctx.fillStyle = this.colors.background; + } else if (fg < 256) { + // 256 color support + this._ctx.fillStyle = this.colors.ansi[fg]; + } else { + this._ctx.fillStyle = this.colors.foreground; + } + + // Since uncached characters are not coming off the char atlas with source + // coordinates, it means that text drawn to the canvas (particularly '_') + // can bleed into other cells. This code will clip the following fillText, + // ensuring that its contents don't go beyond the cell bounds. + this._ctx.beginPath(); + this._ctx.rect(x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY, width * this.scaledCharWidth, this.scaledCharHeight); + this._ctx.clip(); + + // Draw the character + this._ctx.fillText(char, x * this.scaledCharWidth, y * this.scaledLineHeight + this.scaledLineDrawY); + this._ctx.restore(); + } +} + diff --git a/src/renderer/CharAtlas.ts b/src/renderer/CharAtlas.ts new file mode 100644 index 0000000000..bf080cce57 --- /dev/null +++ b/src/renderer/CharAtlas.ts @@ -0,0 +1,186 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal, ITheme } from '../Interfaces'; +import { IColorSet } from '../renderer/Interfaces'; +import { isFirefox } from '../utils/Browser'; + +export const CHAR_ATLAS_CELL_SPACING = 1; + +interface ICharAtlasConfig { + fontSize: number; + fontFamily: string; + scaledCharWidth: number; + scaledCharHeight: number; + colors: IColorSet; +} + +interface ICharAtlasCacheEntry { + bitmap: HTMLCanvasElement | Promise; + config: ICharAtlasConfig; + ownedBy: ITerminal[]; +} + +let charAtlasCache: ICharAtlasCacheEntry[] = []; + +/** + * Acquires a char atlas, either generating a new one or returning an existing + * one that is in use by another terminal. + * @param terminal The terminal. + * @param colors The colors to use. + */ +export function acquireCharAtlas(terminal: ITerminal, colors: IColorSet, scaledCharWidth: number, scaledCharHeight: number): HTMLCanvasElement | Promise { + const newConfig = generateConfig(scaledCharWidth, scaledCharHeight, terminal, colors); + + // Check to see if the terminal already owns this config + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + const ownedByIndex = entry.ownedBy.indexOf(terminal); + if (ownedByIndex >= 0) { + if (configEquals(entry.config, newConfig)) { + return entry.bitmap; + } else { + // The configs differ, release the terminal from the entry + if (entry.ownedBy.length === 1) { + charAtlasCache.splice(i, 1); + } else { + entry.ownedBy.splice(ownedByIndex, 1); + } + break; + } + } + } + + // Try match a char atlas from the cache + for (let i = 0; i < charAtlasCache.length; i++) { + const entry = charAtlasCache[i]; + if (configEquals(entry.config, newConfig)) { + // Add the terminal to the cache entry and return + entry.ownedBy.push(terminal); + return entry.bitmap; + } + } + + const newEntry: ICharAtlasCacheEntry = { + bitmap: generator.generate(scaledCharWidth, scaledCharHeight, terminal.options.fontSize, terminal.options.fontFamily, colors.foreground, colors.ansi), + config: newConfig, + ownedBy: [terminal] + }; + charAtlasCache.push(newEntry); + return newEntry.bitmap; +} + +function generateConfig(scaledCharWidth: number, scaledCharHeight: number, terminal: ITerminal, colors: IColorSet): ICharAtlasConfig { + const clonedColors = { + foreground: colors.foreground, + background: null, + cursor: null, + selection: null, + ansi: colors.ansi.slice(0, 16) + }; + return { + scaledCharWidth, + scaledCharHeight, + fontFamily: terminal.options.fontFamily, + fontSize: terminal.options.fontSize, + colors: clonedColors + }; +} + +function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean { + for (let i = 0; i < a.colors.ansi.length; i++) { + if (a.colors.ansi[i] !== b.colors.ansi[i]) { + return false; + } + } + return a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.scaledCharWidth === b.scaledCharWidth && + a.scaledCharHeight === b.scaledCharHeight && + a.colors.foreground === b.colors.foreground; +} + +let generator: CharAtlasGenerator; + +/** + * Initializes the char atlas generator. + * @param document The document. + */ +export function initialize(document: Document): void { + if (!generator) { + generator = new CharAtlasGenerator(document); + } +} + +class CharAtlasGenerator { + private _canvas: HTMLCanvasElement; + private _ctx: CanvasRenderingContext2D; + + constructor(private _document: Document) { + this._canvas = this._document.createElement('canvas'); + this._ctx = this._canvas.getContext('2d'); + this._ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + } + + public generate(scaledCharWidth: number, scaledCharHeight: number, fontSize: number, fontFamily: string, foreground: string, ansiColors: string[]): HTMLCanvasElement | Promise { + const cellWidth = scaledCharWidth + CHAR_ATLAS_CELL_SPACING; + const cellHeight = scaledCharHeight + CHAR_ATLAS_CELL_SPACING; + this._canvas.width = 255 * cellWidth; + this._canvas.height = (/*default+default bold*/2 + /*0-15*/16) * cellHeight; + + this._ctx.save(); + this._ctx.fillStyle = foreground; + this._ctx.font = `${fontSize * window.devicePixelRatio}px ${fontFamily}`; + this._ctx.textBaseline = 'top'; + + // Default color + for (let i = 0; i < 256; i++) { + this._ctx.fillText(String.fromCharCode(i), i * cellWidth, 0); + } + // Default color bold + this._ctx.save(); + this._ctx.font = `bold ${this._ctx.font}`; + for (let i = 0; i < 256; i++) { + this._ctx.fillText(String.fromCharCode(i), i * cellWidth, cellHeight); + } + this._ctx.restore(); + + // Colors 0-15 + this._ctx.font = `${fontSize * window.devicePixelRatio}px ${fontFamily}`; + for (let colorIndex = 0; colorIndex < 16; colorIndex++) { + // colors 8-15 are bold + if (colorIndex === 8) { + this._ctx.font = `bold ${this._ctx.font}`; + } + const y = (colorIndex + 2) * cellHeight; + // Draw ascii characters + for (let i = 0; i < 256; i++) { + this._ctx.fillStyle = ansiColors[colorIndex]; + this._ctx.fillText(String.fromCharCode(i), i * cellWidth, y); + } + } + this._ctx.restore(); + + const charAtlasImageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height); + + // Support is patchy for createImageBitmap at the moment, pass a canvas back + // if support is lacking as drawImage works there too. Firefox is also + // included here as ImageBitmap appears both buggy and has horrible + // performance (tested on v55). + if (!('createImageBitmap' in window) || isFirefox) { + // Regenerate canvas and context as they are now owned by the char atlas + const result = this._canvas; + this._canvas = this._document.createElement('canvas'); + this._ctx = this._canvas.getContext('2d'); + this._ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + return result; + } + + const promise = window.createImageBitmap(charAtlasImageData); + // Clear the rect while the promise is in progress + this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height); + return promise; + } +} diff --git a/src/renderer/ColorManager.test.ts b/src/renderer/ColorManager.test.ts new file mode 100644 index 0000000000..e00f6b6d31 --- /dev/null +++ b/src/renderer/ColorManager.test.ts @@ -0,0 +1,289 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { ColorManager } from './ColorManager'; + +describe('ColorManager', () => { + let cm: ColorManager; + + beforeEach(() => { + cm = new ColorManager(); + }); + + describe('constructor', () => { + it('should fill all colors with values', () => { + for (let key in cm.colors) { + if (typeof key === 'string') { + // A #rrggbb or rgba(...) + assert.ok(cm.colors[key].length >= 7); + } + } + assert.equal(cm.colors.ansi.length, 256); + }); + + it('should fill 240 colors with expected values', () => { + assert.equal(cm.colors.ansi[16], '#000000'); + assert.equal(cm.colors.ansi[17], '#00005f'); + assert.equal(cm.colors.ansi[18], '#000087'); + assert.equal(cm.colors.ansi[19], '#0000af'); + assert.equal(cm.colors.ansi[20], '#0000d7'); + assert.equal(cm.colors.ansi[21], '#0000ff'); + assert.equal(cm.colors.ansi[22], '#005f00'); + assert.equal(cm.colors.ansi[23], '#005f5f'); + assert.equal(cm.colors.ansi[24], '#005f87'); + assert.equal(cm.colors.ansi[25], '#005faf'); + assert.equal(cm.colors.ansi[26], '#005fd7'); + assert.equal(cm.colors.ansi[27], '#005fff'); + assert.equal(cm.colors.ansi[28], '#008700'); + assert.equal(cm.colors.ansi[29], '#00875f'); + assert.equal(cm.colors.ansi[30], '#008787'); + assert.equal(cm.colors.ansi[31], '#0087af'); + assert.equal(cm.colors.ansi[32], '#0087d7'); + assert.equal(cm.colors.ansi[33], '#0087ff'); + assert.equal(cm.colors.ansi[34], '#00af00'); + assert.equal(cm.colors.ansi[35], '#00af5f'); + assert.equal(cm.colors.ansi[36], '#00af87'); + assert.equal(cm.colors.ansi[37], '#00afaf'); + assert.equal(cm.colors.ansi[38], '#00afd7'); + assert.equal(cm.colors.ansi[39], '#00afff'); + assert.equal(cm.colors.ansi[40], '#00d700'); + assert.equal(cm.colors.ansi[41], '#00d75f'); + assert.equal(cm.colors.ansi[42], '#00d787'); + assert.equal(cm.colors.ansi[43], '#00d7af'); + assert.equal(cm.colors.ansi[44], '#00d7d7'); + assert.equal(cm.colors.ansi[45], '#00d7ff'); + assert.equal(cm.colors.ansi[46], '#00ff00'); + assert.equal(cm.colors.ansi[47], '#00ff5f'); + assert.equal(cm.colors.ansi[48], '#00ff87'); + assert.equal(cm.colors.ansi[49], '#00ffaf'); + assert.equal(cm.colors.ansi[50], '#00ffd7'); + assert.equal(cm.colors.ansi[51], '#00ffff'); + assert.equal(cm.colors.ansi[52], '#5f0000'); + assert.equal(cm.colors.ansi[53], '#5f005f'); + assert.equal(cm.colors.ansi[54], '#5f0087'); + assert.equal(cm.colors.ansi[55], '#5f00af'); + assert.equal(cm.colors.ansi[56], '#5f00d7'); + assert.equal(cm.colors.ansi[57], '#5f00ff'); + assert.equal(cm.colors.ansi[58], '#5f5f00'); + assert.equal(cm.colors.ansi[59], '#5f5f5f'); + assert.equal(cm.colors.ansi[60], '#5f5f87'); + assert.equal(cm.colors.ansi[61], '#5f5faf'); + assert.equal(cm.colors.ansi[62], '#5f5fd7'); + assert.equal(cm.colors.ansi[63], '#5f5fff'); + assert.equal(cm.colors.ansi[64], '#5f8700'); + assert.equal(cm.colors.ansi[65], '#5f875f'); + assert.equal(cm.colors.ansi[66], '#5f8787'); + assert.equal(cm.colors.ansi[67], '#5f87af'); + assert.equal(cm.colors.ansi[68], '#5f87d7'); + assert.equal(cm.colors.ansi[69], '#5f87ff'); + assert.equal(cm.colors.ansi[70], '#5faf00'); + assert.equal(cm.colors.ansi[71], '#5faf5f'); + assert.equal(cm.colors.ansi[72], '#5faf87'); + assert.equal(cm.colors.ansi[73], '#5fafaf'); + assert.equal(cm.colors.ansi[74], '#5fafd7'); + assert.equal(cm.colors.ansi[75], '#5fafff'); + assert.equal(cm.colors.ansi[76], '#5fd700'); + assert.equal(cm.colors.ansi[77], '#5fd75f'); + assert.equal(cm.colors.ansi[78], '#5fd787'); + assert.equal(cm.colors.ansi[79], '#5fd7af'); + assert.equal(cm.colors.ansi[80], '#5fd7d7'); + assert.equal(cm.colors.ansi[81], '#5fd7ff'); + assert.equal(cm.colors.ansi[82], '#5fff00'); + assert.equal(cm.colors.ansi[83], '#5fff5f'); + assert.equal(cm.colors.ansi[84], '#5fff87'); + assert.equal(cm.colors.ansi[85], '#5fffaf'); + assert.equal(cm.colors.ansi[86], '#5fffd7'); + assert.equal(cm.colors.ansi[87], '#5fffff'); + assert.equal(cm.colors.ansi[88], '#870000'); + assert.equal(cm.colors.ansi[89], '#87005f'); + assert.equal(cm.colors.ansi[90], '#870087'); + assert.equal(cm.colors.ansi[91], '#8700af'); + assert.equal(cm.colors.ansi[92], '#8700d7'); + assert.equal(cm.colors.ansi[93], '#8700ff'); + assert.equal(cm.colors.ansi[94], '#875f00'); + assert.equal(cm.colors.ansi[95], '#875f5f'); + assert.equal(cm.colors.ansi[96], '#875f87'); + assert.equal(cm.colors.ansi[97], '#875faf'); + assert.equal(cm.colors.ansi[98], '#875fd7'); + assert.equal(cm.colors.ansi[99], '#875fff'); + assert.equal(cm.colors.ansi[100], '#878700'); + assert.equal(cm.colors.ansi[101], '#87875f'); + assert.equal(cm.colors.ansi[102], '#878787'); + assert.equal(cm.colors.ansi[103], '#8787af'); + assert.equal(cm.colors.ansi[104], '#8787d7'); + assert.equal(cm.colors.ansi[105], '#8787ff'); + assert.equal(cm.colors.ansi[106], '#87af00'); + assert.equal(cm.colors.ansi[107], '#87af5f'); + assert.equal(cm.colors.ansi[108], '#87af87'); + assert.equal(cm.colors.ansi[109], '#87afaf'); + assert.equal(cm.colors.ansi[110], '#87afd7'); + assert.equal(cm.colors.ansi[111], '#87afff'); + assert.equal(cm.colors.ansi[112], '#87d700'); + assert.equal(cm.colors.ansi[113], '#87d75f'); + assert.equal(cm.colors.ansi[114], '#87d787'); + assert.equal(cm.colors.ansi[115], '#87d7af'); + assert.equal(cm.colors.ansi[116], '#87d7d7'); + assert.equal(cm.colors.ansi[117], '#87d7ff'); + assert.equal(cm.colors.ansi[118], '#87ff00'); + assert.equal(cm.colors.ansi[119], '#87ff5f'); + assert.equal(cm.colors.ansi[120], '#87ff87'); + assert.equal(cm.colors.ansi[121], '#87ffaf'); + assert.equal(cm.colors.ansi[122], '#87ffd7'); + assert.equal(cm.colors.ansi[123], '#87ffff'); + assert.equal(cm.colors.ansi[124], '#af0000'); + assert.equal(cm.colors.ansi[125], '#af005f'); + assert.equal(cm.colors.ansi[126], '#af0087'); + assert.equal(cm.colors.ansi[127], '#af00af'); + assert.equal(cm.colors.ansi[128], '#af00d7'); + assert.equal(cm.colors.ansi[129], '#af00ff'); + assert.equal(cm.colors.ansi[130], '#af5f00'); + assert.equal(cm.colors.ansi[131], '#af5f5f'); + assert.equal(cm.colors.ansi[132], '#af5f87'); + assert.equal(cm.colors.ansi[133], '#af5faf'); + assert.equal(cm.colors.ansi[134], '#af5fd7'); + assert.equal(cm.colors.ansi[135], '#af5fff'); + assert.equal(cm.colors.ansi[136], '#af8700'); + assert.equal(cm.colors.ansi[137], '#af875f'); + assert.equal(cm.colors.ansi[138], '#af8787'); + assert.equal(cm.colors.ansi[139], '#af87af'); + assert.equal(cm.colors.ansi[140], '#af87d7'); + assert.equal(cm.colors.ansi[141], '#af87ff'); + assert.equal(cm.colors.ansi[142], '#afaf00'); + assert.equal(cm.colors.ansi[143], '#afaf5f'); + assert.equal(cm.colors.ansi[144], '#afaf87'); + assert.equal(cm.colors.ansi[145], '#afafaf'); + assert.equal(cm.colors.ansi[146], '#afafd7'); + assert.equal(cm.colors.ansi[147], '#afafff'); + assert.equal(cm.colors.ansi[148], '#afd700'); + assert.equal(cm.colors.ansi[149], '#afd75f'); + assert.equal(cm.colors.ansi[150], '#afd787'); + assert.equal(cm.colors.ansi[151], '#afd7af'); + assert.equal(cm.colors.ansi[152], '#afd7d7'); + assert.equal(cm.colors.ansi[153], '#afd7ff'); + assert.equal(cm.colors.ansi[154], '#afff00'); + assert.equal(cm.colors.ansi[155], '#afff5f'); + assert.equal(cm.colors.ansi[156], '#afff87'); + assert.equal(cm.colors.ansi[157], '#afffaf'); + assert.equal(cm.colors.ansi[158], '#afffd7'); + assert.equal(cm.colors.ansi[159], '#afffff'); + assert.equal(cm.colors.ansi[160], '#d70000'); + assert.equal(cm.colors.ansi[161], '#d7005f'); + assert.equal(cm.colors.ansi[162], '#d70087'); + assert.equal(cm.colors.ansi[163], '#d700af'); + assert.equal(cm.colors.ansi[164], '#d700d7'); + assert.equal(cm.colors.ansi[165], '#d700ff'); + assert.equal(cm.colors.ansi[166], '#d75f00'); + assert.equal(cm.colors.ansi[167], '#d75f5f'); + assert.equal(cm.colors.ansi[168], '#d75f87'); + assert.equal(cm.colors.ansi[169], '#d75faf'); + assert.equal(cm.colors.ansi[170], '#d75fd7'); + assert.equal(cm.colors.ansi[171], '#d75fff'); + assert.equal(cm.colors.ansi[172], '#d78700'); + assert.equal(cm.colors.ansi[173], '#d7875f'); + assert.equal(cm.colors.ansi[174], '#d78787'); + assert.equal(cm.colors.ansi[175], '#d787af'); + assert.equal(cm.colors.ansi[176], '#d787d7'); + assert.equal(cm.colors.ansi[177], '#d787ff'); + assert.equal(cm.colors.ansi[178], '#d7af00'); + assert.equal(cm.colors.ansi[179], '#d7af5f'); + assert.equal(cm.colors.ansi[180], '#d7af87'); + assert.equal(cm.colors.ansi[181], '#d7afaf'); + assert.equal(cm.colors.ansi[182], '#d7afd7'); + assert.equal(cm.colors.ansi[183], '#d7afff'); + assert.equal(cm.colors.ansi[184], '#d7d700'); + assert.equal(cm.colors.ansi[185], '#d7d75f'); + assert.equal(cm.colors.ansi[186], '#d7d787'); + assert.equal(cm.colors.ansi[187], '#d7d7af'); + assert.equal(cm.colors.ansi[188], '#d7d7d7'); + assert.equal(cm.colors.ansi[189], '#d7d7ff'); + assert.equal(cm.colors.ansi[190], '#d7ff00'); + assert.equal(cm.colors.ansi[191], '#d7ff5f'); + assert.equal(cm.colors.ansi[192], '#d7ff87'); + assert.equal(cm.colors.ansi[193], '#d7ffaf'); + assert.equal(cm.colors.ansi[194], '#d7ffd7'); + assert.equal(cm.colors.ansi[195], '#d7ffff'); + assert.equal(cm.colors.ansi[196], '#ff0000'); + assert.equal(cm.colors.ansi[197], '#ff005f'); + assert.equal(cm.colors.ansi[198], '#ff0087'); + assert.equal(cm.colors.ansi[199], '#ff00af'); + assert.equal(cm.colors.ansi[200], '#ff00d7'); + assert.equal(cm.colors.ansi[201], '#ff00ff'); + assert.equal(cm.colors.ansi[202], '#ff5f00'); + assert.equal(cm.colors.ansi[203], '#ff5f5f'); + assert.equal(cm.colors.ansi[204], '#ff5f87'); + assert.equal(cm.colors.ansi[205], '#ff5faf'); + assert.equal(cm.colors.ansi[206], '#ff5fd7'); + assert.equal(cm.colors.ansi[207], '#ff5fff'); + assert.equal(cm.colors.ansi[208], '#ff8700'); + assert.equal(cm.colors.ansi[209], '#ff875f'); + assert.equal(cm.colors.ansi[210], '#ff8787'); + assert.equal(cm.colors.ansi[211], '#ff87af'); + assert.equal(cm.colors.ansi[212], '#ff87d7'); + assert.equal(cm.colors.ansi[213], '#ff87ff'); + assert.equal(cm.colors.ansi[214], '#ffaf00'); + assert.equal(cm.colors.ansi[215], '#ffaf5f'); + assert.equal(cm.colors.ansi[216], '#ffaf87'); + assert.equal(cm.colors.ansi[217], '#ffafaf'); + assert.equal(cm.colors.ansi[218], '#ffafd7'); + assert.equal(cm.colors.ansi[219], '#ffafff'); + assert.equal(cm.colors.ansi[220], '#ffd700'); + assert.equal(cm.colors.ansi[221], '#ffd75f'); + assert.equal(cm.colors.ansi[222], '#ffd787'); + assert.equal(cm.colors.ansi[223], '#ffd7af'); + assert.equal(cm.colors.ansi[224], '#ffd7d7'); + assert.equal(cm.colors.ansi[225], '#ffd7ff'); + assert.equal(cm.colors.ansi[226], '#ffff00'); + assert.equal(cm.colors.ansi[227], '#ffff5f'); + assert.equal(cm.colors.ansi[228], '#ffff87'); + assert.equal(cm.colors.ansi[229], '#ffffaf'); + assert.equal(cm.colors.ansi[230], '#ffffd7'); + assert.equal(cm.colors.ansi[231], '#ffffff'); + assert.equal(cm.colors.ansi[232], '#080808'); + assert.equal(cm.colors.ansi[233], '#121212'); + assert.equal(cm.colors.ansi[234], '#1c1c1c'); + assert.equal(cm.colors.ansi[235], '#262626'); + assert.equal(cm.colors.ansi[236], '#303030'); + assert.equal(cm.colors.ansi[237], '#3a3a3a'); + assert.equal(cm.colors.ansi[238], '#444444'); + assert.equal(cm.colors.ansi[239], '#4e4e4e'); + assert.equal(cm.colors.ansi[240], '#585858'); + assert.equal(cm.colors.ansi[241], '#626262'); + assert.equal(cm.colors.ansi[242], '#6c6c6c'); + assert.equal(cm.colors.ansi[243], '#767676'); + assert.equal(cm.colors.ansi[244], '#808080'); + assert.equal(cm.colors.ansi[245], '#8a8a8a'); + assert.equal(cm.colors.ansi[246], '#949494'); + assert.equal(cm.colors.ansi[247], '#9e9e9e'); + assert.equal(cm.colors.ansi[248], '#a8a8a8'); + assert.equal(cm.colors.ansi[249], '#b2b2b2'); + assert.equal(cm.colors.ansi[250], '#bcbcbc'); + assert.equal(cm.colors.ansi[251], '#c6c6c6'); + assert.equal(cm.colors.ansi[252], '#d0d0d0'); + assert.equal(cm.colors.ansi[253], '#dadada'); + assert.equal(cm.colors.ansi[254], '#e4e4e4'); + assert.equal(cm.colors.ansi[255], '#eeeeee'); + }); + }); + + describe('setTheme', () => { + it('should set a partial set of colors, using the default if not present', () => { + assert.equal(cm.colors.background, '#000000'); + assert.equal(cm.colors.foreground, '#ffffff'); + cm.setTheme({ + background: '#FF0000', + foreground: '#00FF00' + }); + assert.equal(cm.colors.background, '#FF0000'); + assert.equal(cm.colors.foreground, '#00FF00'); + cm.setTheme({ + background: '#0000FF' + }); + assert.equal(cm.colors.background, '#0000FF'); + // FG reverts back to default + assert.equal(cm.colors.foreground, '#ffffff'); + }); + }); +}); diff --git a/src/renderer/ColorManager.ts b/src/renderer/ColorManager.ts new file mode 100644 index 0000000000..b9afd0e1a6 --- /dev/null +++ b/src/renderer/ColorManager.ts @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColorSet } from './Interfaces'; +import { ITheme } from '../Interfaces'; + +const DEFAULT_FOREGROUND = '#ffffff'; +const DEFAULT_BACKGROUND = '#000000'; +const DEFAULT_CURSOR = '#ffffff'; +const DEFAULT_SELECTION = 'rgba(255, 255, 255, 0.3)'; +export const DEFAULT_ANSI_COLORS = [ + // dark: + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + // bright: + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +]; + +/** + * Fills an existing 16 length string with the remaining 240 ANSI colors. + * @param first16Colors The first 16 ANSI colors. + */ +function generate256Colors(first16Colors: string[]): string[] { + let colors = first16Colors.slice(); + + // Generate colors (16-231) + let v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]; + for (let i = 0; i < 216; i++) { + const r = toPaddedHex(v[(i / 36) % 6 | 0]); + const g = toPaddedHex(v[(i / 6) % 6 | 0]); + const b = toPaddedHex(v[i % 6]); + colors.push(`#${r}${g}${b}`); + } + + // Generate greys (232-255) + for (let i = 0; i < 24; i++) { + const c = toPaddedHex(8 + i * 10); + colors.push(`#${c}${c}${c}`); + } + + return colors; +} + +function toPaddedHex(c: number): string { + let s = c.toString(16); + return s.length < 2 ? '0' + s : s; +} + +/** + * Manages the source of truth for a terminal's colors. + */ +export class ColorManager { + public colors: IColorSet; + + constructor() { + this.colors = { + foreground: DEFAULT_FOREGROUND, + background: DEFAULT_BACKGROUND, + cursor: DEFAULT_CURSOR, + selection: DEFAULT_SELECTION, + ansi: generate256Colors(DEFAULT_ANSI_COLORS) + }; + } + + /** + * Sets the terminal's theme. + * @param theme The theme to use. If a partial theme is provided then default + * colors will be used where colors are not defined. + */ + public setTheme(theme: ITheme): void { + this.colors.foreground = theme.foreground || DEFAULT_FOREGROUND; + this.colors.background = theme.background || DEFAULT_BACKGROUND; + this.colors.cursor = theme.cursor || DEFAULT_CURSOR; + this.colors.selection = theme.selection || DEFAULT_SELECTION; + this.colors.ansi[0] = theme.black || DEFAULT_ANSI_COLORS[0]; + this.colors.ansi[1] = theme.red || DEFAULT_ANSI_COLORS[1]; + this.colors.ansi[2] = theme.green || DEFAULT_ANSI_COLORS[2]; + this.colors.ansi[3] = theme.yellow || DEFAULT_ANSI_COLORS[3]; + this.colors.ansi[4] = theme.blue || DEFAULT_ANSI_COLORS[4]; + this.colors.ansi[5] = theme.magenta || DEFAULT_ANSI_COLORS[5]; + this.colors.ansi[6] = theme.cyan || DEFAULT_ANSI_COLORS[6]; + this.colors.ansi[7] = theme.white || DEFAULT_ANSI_COLORS[7]; + this.colors.ansi[8] = theme.brightBlack || DEFAULT_ANSI_COLORS[8]; + this.colors.ansi[9] = theme.brightRed || DEFAULT_ANSI_COLORS[9]; + this.colors.ansi[10] = theme.brightGreen || DEFAULT_ANSI_COLORS[10]; + this.colors.ansi[11] = theme.brightYellow || DEFAULT_ANSI_COLORS[11]; + this.colors.ansi[12] = theme.brightBlue || DEFAULT_ANSI_COLORS[12]; + this.colors.ansi[13] = theme.brightMagenta || DEFAULT_ANSI_COLORS[13]; + this.colors.ansi[14] = theme.brightCyan || DEFAULT_ANSI_COLORS[14]; + this.colors.ansi[15] = theme.brightWhite || DEFAULT_ANSI_COLORS[15]; + } +} diff --git a/src/renderer/CursorRenderLayer.ts b/src/renderer/CursorRenderLayer.ts new file mode 100644 index 0000000000..b136a13a18 --- /dev/null +++ b/src/renderer/CursorRenderLayer.ts @@ -0,0 +1,351 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColorSet } from './Interfaces'; +import { IBuffer, ICharMeasure, ITerminal, ITerminalOptions } from '../Interfaces'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; +import { GridCache } from './GridCache'; +import { FLAGS } from './Types'; +import { BaseRenderLayer } from './BaseRenderLayer'; +import { CharData } from '../Types'; + +interface CursorState { + x: number; + y: number; + isFocused: boolean; + style: string; + width: number; +} + +/** + * The time between cursor blinks. + */ +const BLINK_INTERVAL = 600; + +export class CursorRenderLayer extends BaseRenderLayer { + private _state: CursorState; + private _cursorRenderers: {[key: string]: (terminal: ITerminal, x: number, y: number, charData: CharData) => void}; + private _cursorBlinkStateManager: CursorBlinkStateManager; + private _isFocused: boolean; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { + super(container, 'cursor', zIndex, colors); + this._state = { + x: null, + y: null, + isFocused: null, + style: null, + width: null, + }; + this._cursorRenderers = { + 'bar': this._renderBarCursor.bind(this), + 'block': this._renderBlockCursor.bind(this), + 'underline': this._renderUnderlineCursor.bind(this) + }; + // TODO: Consider initial options? Maybe onOptionsChanged should be called at the end of open? + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + // Resizing the canvas discards the contents of the canvas so clear state + this._state = { + x: null, + y: null, + isFocused: null, + style: null, + width: null, + }; + } + + public reset(terminal: ITerminal): void { + this._clearCursor(); + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.dispose(); + this._cursorBlinkStateManager = null; + this.onOptionsChanged(terminal); + } + } + + public onBlur(terminal: ITerminal): void { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.pause(); + } + terminal.refresh(terminal.buffer.y, terminal.buffer.y); + } + + public onFocus(terminal: ITerminal): void { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.resume(terminal); + } else { + terminal.refresh(terminal.buffer.y, terminal.buffer.y); + } + } + + public onOptionsChanged(terminal: ITerminal): void { + if (terminal.options.cursorBlink) { + if (!this._cursorBlinkStateManager) { + this._cursorBlinkStateManager = new CursorBlinkStateManager(terminal, () => { + this._render(terminal, true); + }); + } + } else { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.dispose(); + this._cursorBlinkStateManager = null; + } + // Request a refresh from the terminal as management of rendering is being + // moved back to the terminal + terminal.refresh(terminal.buffer.y, terminal.buffer.y); + } + } + + public onCursorMove(terminal: ITerminal): void { + if (this._cursorBlinkStateManager) { + this._cursorBlinkStateManager.restartBlinkAnimation(terminal); + } + } + + public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void { + // Only render if the animation frame is not active + if (!this._cursorBlinkStateManager || this._cursorBlinkStateManager.isPaused) { + this._render(terminal, false); + } + } + + private _render(terminal: ITerminal, triggeredByAnimationFrame: boolean): void { + // Don't draw the cursor if it's hidden + if (!terminal.cursorState || terminal.cursorHidden) { + this._clearCursor(); + return; + } + + const cursorY = terminal.buffer.ybase + terminal.buffer.y; + const viewportRelativeCursorY = cursorY - terminal.buffer.ydisp; + + // Don't draw the cursor if it's off-screen + if (viewportRelativeCursorY < 0 || viewportRelativeCursorY >= terminal.rows) { + this._clearCursor(); + return; + } + + const charData = terminal.buffer.lines.get(cursorY)[terminal.buffer.x]; + + if (!terminal.isFocused) { + this._clearCursor(); + this._ctx.save(); + this._ctx.fillStyle = this.colors.cursor; + this._renderBlurCursor(terminal, terminal.buffer.x, viewportRelativeCursorY, charData); + this._ctx.restore(); + this._state.x = terminal.buffer.x; + this._state.y = viewportRelativeCursorY; + this._state.isFocused = false; + this._state.style = terminal.options.cursorStyle; + this._state.width = charData[CHAR_DATA_WIDTH_INDEX]; + return; + } + + // Don't draw the cursor if it's blinking + if (this._cursorBlinkStateManager && !this._cursorBlinkStateManager.isCursorVisible) { + this._clearCursor(); + return; + } + + if (this._state) { + // The cursor is already in the correct spot, don't redraw + if (this._state.x === terminal.buffer.x && + this._state.y === viewportRelativeCursorY && + this._state.isFocused === terminal.isFocused && + this._state.style === terminal.options.cursorStyle && + this._state.width === charData[CHAR_DATA_WIDTH_INDEX]) { + return; + } + this._clearCursor(); + } + + this._ctx.save(); + this._cursorRenderers[terminal.options.cursorStyle || 'block'](terminal, terminal.buffer.x, viewportRelativeCursorY, charData); + this._ctx.restore(); + + this._state.x = terminal.buffer.x; + this._state.y = viewportRelativeCursorY; + this._state.isFocused = false; + this._state.style = terminal.options.cursorStyle; + this._state.width = charData[CHAR_DATA_WIDTH_INDEX]; + } + + private _clearCursor(): void { + if (this._state) { + this.clearCells(this._state.x, this._state.y, this._state.width, 1); + this._state = { + x: null, + y: null, + isFocused: null, + style: null, + width: null, + }; + } + } + + private _renderBarCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { + this._ctx.save(); + this._ctx.fillStyle = this.colors.cursor; + this.fillLeftLineAtCell(x, y); + this._ctx.restore(); + } + + private _renderBlockCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { + this._ctx.save(); + this._ctx.fillStyle = this.colors.cursor; + this.fillCells(x, y, charData[CHAR_DATA_WIDTH_INDEX], 1); + this._ctx.fillStyle = this.colors.background; + this.fillCharTrueColor(terminal, charData, x, y); + this._ctx.restore(); + } + + private _renderUnderlineCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { + this._ctx.save(); + this._ctx.fillStyle = this.colors.cursor; + this.fillBottomLineAtCells(x, y); + this._ctx.restore(); + } + + private _renderBlurCursor(terminal: ITerminal, x: number, y: number, charData: CharData): void { + this._ctx.save(); + this._ctx.strokeStyle = this.colors.cursor; + this.strokeRectAtCell(x, y, charData[CHAR_DATA_WIDTH_INDEX], 1); + this._ctx.restore(); + } +} + +class CursorBlinkStateManager { + public isCursorVisible: boolean; + + private _animationFrame: number; + private _blinkStartTimeout: number; + private _blinkInterval: number; + + /** + * The time at which the animation frame was restarted, this is used on the + * next render to restart the timers so they don't need to restart the timers + * multiple times over a short period. + */ + private _animationTimeRestarted: number; + + constructor( + terminal: ITerminal, + private renderCallback: () => void + ) { + this.isCursorVisible = true; + if (terminal.isFocused) { + this._restartInterval(); + } + } + + public get isPaused(): boolean { return !(this._blinkStartTimeout || this._blinkInterval); } + + public dispose(): void { + if (this._blinkInterval) { + window.clearInterval(this._blinkInterval); + this._blinkInterval = null; + } + if (this._blinkStartTimeout) { + window.clearTimeout(this._blinkStartTimeout); + this._blinkStartTimeout = null; + } + if (this._animationFrame) { + window.cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + } + + public restartBlinkAnimation(terminal: ITerminal): void { + if (this.isPaused) { + return; + } + // Save a timestamp so that the restart can be done on the next interval + this._animationTimeRestarted = Date.now(); + // Force a cursor render to ensure it's visible and in the correct position + this.isCursorVisible = true; + if (!this._animationFrame) { + this._animationFrame = window.requestAnimationFrame(() => { + this.renderCallback(); + this._animationFrame = null; + }); + } + } + + private _restartInterval(timeToStart: number = BLINK_INTERVAL): void { + // Clear any existing interval + if (this._blinkInterval) { + window.clearInterval(this._blinkInterval); + } + + // Setup the initial timeout which will hide the cursor, this is done before + // the regular interval is setup in order to support restarting the blink + // animation in a lightweight way (without thrashing clearInterval and + // setInterval). + this._blinkStartTimeout = setTimeout(() => { + // Check if another animation restart was requested while this was being + // started + if (this._animationTimeRestarted) { + const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted); + this._animationTimeRestarted = null; + if (time > 0) { + this._restartInterval(time); + return; + } + } + + // Hide the cursor + this.isCursorVisible = false; + this._animationFrame = window.requestAnimationFrame(() => { + this.renderCallback(); + this._animationFrame = null; + }); + + // Setup the blink interval + this._blinkInterval = setInterval(() => { + // Adjust the animation time if it was restarted + if (this._animationTimeRestarted) { + // calc time diff + // Make restart interval do a setTimeout initially? + const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted); + this._animationTimeRestarted = null; + this._restartInterval(time); + return; + } + + // Invert visibility and render + this.isCursorVisible = !this.isCursorVisible; + this._animationFrame = window.requestAnimationFrame(() => { + this.renderCallback(); + this._animationFrame = null; + }); + }, BLINK_INTERVAL); + }, timeToStart); + } + + public pause(): void { + this.isCursorVisible = true; + if (this._blinkInterval) { + window.clearInterval(this._blinkInterval); + this._blinkInterval = null; + } + if (this._blinkStartTimeout) { + window.clearTimeout(this._blinkStartTimeout); + this._blinkStartTimeout = null; + } + if (this._animationFrame) { + window.cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + } + + public resume(terminal: ITerminal): void { + this._animationTimeRestarted = null; + this._restartInterval(); + this.restartBlinkAnimation(terminal); + } +} diff --git a/src/renderer/ForegroundRenderLayer.ts b/src/renderer/ForegroundRenderLayer.ts new file mode 100644 index 0000000000..883be5d94a --- /dev/null +++ b/src/renderer/ForegroundRenderLayer.ts @@ -0,0 +1,179 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColorSet } from './Interfaces'; +import { IBuffer, ICharMeasure, ITerminal } from '../Interfaces'; +import { CHAR_DATA_ATTR_INDEX, CHAR_DATA_CODE_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX } from '../Buffer'; +import { FLAGS } from './Types'; +import { GridCache } from './GridCache'; +import { CharData } from '../Types'; +import { BaseRenderLayer, INVERTED_DEFAULT_COLOR } from './BaseRenderLayer'; + +/** + * This CharData looks like a null character, which will forc a clear and render + * when the character changes (a regular space ' ' character may not as it's + * drawn state is a cleared cell). + */ +const EMOJI_OWNED_CHAR_DATA: CharData = [null, '', 0, -1]; + +export class ForegroundRenderLayer extends BaseRenderLayer { + private _state: GridCache; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { + super(container, 'fg', zIndex, colors); + this._state = new GridCache(); + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + // Resizing the canvas discards the contents of the canvas so clear state + this._state.clear(); + this._state.resize(terminal.cols, terminal.rows); + } + + public reset(terminal: ITerminal): void { + this._state.clear(); + this.clearAll(); + } + + public onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void { + // Resize has not been called yet + if (this._state.cache.length === 0) { + return; + } + + for (let y = startRow; y <= endRow; y++) { + const row = y + terminal.buffer.ydisp; + const line = terminal.buffer.lines.get(row); + + for (let x = 0; x < terminal.cols; x++) { + const charData = line[x]; + const code: number = charData[CHAR_DATA_CODE_INDEX]; + const char: string = charData[CHAR_DATA_CHAR_INDEX]; + const attr: number = charData[CHAR_DATA_ATTR_INDEX]; + let width: number = charData[CHAR_DATA_WIDTH_INDEX]; + + // The character to the left is a wide character, drawing is owned by + // the char at x-1 + if (width === 0) { + this._state.cache[x][y] = null; + continue; + } + + // If the character is a space and the character to the left is an + // emoji, skip the character and allow the emoji char to take full + // control over this character's cell. + if (code === 32 /*' '*/) { + if (x > 0) { + const previousChar: CharData = line[x - 1]; + if (this._isEmoji(previousChar[CHAR_DATA_CHAR_INDEX])) { + continue; + } + } + } + + // Skip rendering if the character is identical + const state = this._state.cache[x][y]; + if (state && state[CHAR_DATA_CHAR_INDEX] === char && state[CHAR_DATA_ATTR_INDEX] === attr) { + // Skip render, contents are identical + this._state.cache[x][y] = charData; + continue; + } + + // Clear the old character if present + if (state && state[CHAR_DATA_CODE_INDEX] !== 32 /*' '*/) { + this._clearChar(x, y); + } + this._state.cache[x][y] = charData; + + const flags = attr >> 18; + + // Skip rendering if the character is invisible + if (!code || code === 32 /*' '*/ || (flags & FLAGS.INVISIBLE)) { + continue; + } + + // If the character is an emoji and the character to the right is a + // space, take ownership of the cell to the right. + if (this._isEmoji(char)) { + // If the character is an emoji, we want to force a re-render on every + // frame. This is specifically to work around the case where two + // emoji's `a` and `b` are adjacent, the cursor is moved to b and a + // space is added. Without this, the first half of `b` would never + // get removed, and `a` would not re-render because it thinks it's + // already in the correct state. + this._state.cache[x][y] = EMOJI_OWNED_CHAR_DATA; + if (x < line.length && line[x + 1][CHAR_DATA_CODE_INDEX] === 32 /*' '*/) { + width = 2; + this._clearChar(x + 1, y); + // The emoji owned char data will force a clear and render when the + // emoji is no longer to the left of the character and also when the + // space changes to another character. + this._state.cache[x + 1][y] = EMOJI_OWNED_CHAR_DATA; + } + } + + let fg = (attr >> 9) & 0x1ff; + + // If inverse flag is on, the foreground should become the background. + if (flags & FLAGS.INVERSE) { + fg = attr & 0x1ff; + // TODO: Is this case still needed + if (fg === 256) { + fg = INVERTED_DEFAULT_COLOR; + } + } + + this._ctx.save(); + if (flags & FLAGS.BOLD) { + this._ctx.font = `bold ${this._ctx.font}`; + // Convert the FG color to the bold variant + if (fg < 8) { + fg += 8; + } + } + + if (flags & FLAGS.UNDERLINE) { + if (fg === INVERTED_DEFAULT_COLOR) { + this._ctx.fillStyle = this.colors.background; + } else if (fg < 256) { + // 256 color support + this._ctx.fillStyle = this.colors.ansi[fg]; + } else { + this._ctx.fillStyle = this.colors.foreground; + } + this.fillBottomLineAtCells(x, y); + } + + this.drawChar(terminal, char, code, width, x, y, fg, !!(flags & FLAGS.BOLD)); + + this._ctx.restore(); + } + } + } + + /** + * Whether the character is an emoji. + * @param char The character to search. + */ + private _isEmoji(char: string): boolean { + return char.search(/([\uD800-\uDBFF][\uDC00-\uDFFF])/g) >= 0; + } + + /** + * Clear the charcater at the cell specified. + * @param x The column of the char. + * @param y The row of the char. + */ + private _clearChar(x: number, y: number): void { + let colsToClear = 1; + // Clear the adjacent character if it was wide + const state = this._state.cache[x][y]; + if (state && state[CHAR_DATA_WIDTH_INDEX] === 2) { + colsToClear = 2; + } + this.clearCells(x, y, colsToClear, 1); + } +} diff --git a/src/renderer/GridCache.test.ts b/src/renderer/GridCache.test.ts new file mode 100644 index 0000000000..c4b1c220ee --- /dev/null +++ b/src/renderer/GridCache.test.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { GridCache } from './GridCache'; + +describe('GridCache', () => { + let grid: GridCache; + + beforeEach(() => { + grid = new GridCache(); + }); + + describe('constructor', () => { + it('should create an empty cache', () => { + assert.equal(grid.cache.length, 0); + }); + }); + + describe('resize', () => { + it('should fill all new elements with null', () => { + grid.resize(2, 2); + assert.equal(grid.cache.length, 2); + assert.equal(grid.cache[0].length, 2); + assert.equal(grid.cache[0][0], null); + assert.equal(grid.cache[0][1], null); + assert.equal(grid.cache[1].length, 2); + assert.equal(grid.cache[1][0], null); + assert.equal(grid.cache[1][1], null); + grid.resize(3, 2); + assert.equal(grid.cache.length, 3); + assert.equal(grid.cache[2][0], null); + assert.equal(grid.cache[2][1], null); + }); + + it('should remove rows/cols from the cache when reduced', () => { + grid.resize(2, 2); + grid.resize(1, 1); + assert.equal(grid.cache.length, 1); + assert.equal(grid.cache[0].length, 1); + }); + + it('should not touch existing cache entries if they fit in the new cache', () => { + grid.resize(1, 1); + assert.equal(grid.cache[0][0], null); + grid.cache[0][0] = 1; + grid.resize(2, 1); + assert.equal(grid.cache[0][0], 1); + }); + }); + + describe('clear', () => { + it('should make all entries null', () => { + grid.resize(1, 1); + grid.cache[0][0] = 1; + grid.clear(); + assert.equal(grid.cache[0][0], null); + }); + }); +}); diff --git a/src/renderer/GridCache.ts b/src/renderer/GridCache.ts new file mode 100644 index 0000000000..dd188c6269 --- /dev/null +++ b/src/renderer/GridCache.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +export class GridCache { + public cache: T[][]; + + public constructor() { + this.cache = []; + } + + public resize(width: number, height: number): void { + for (let x = 0; x < width; x++) { + if (this.cache.length <= x) { + this.cache.push([]); + } + for (let y = this.cache[x].length; y < height; y++) { + this.cache[x].push(null); + } + this.cache[x].length = height; + } + this.cache.length = width; + } + + public clear(): void { + for (let x = 0; x < this.cache.length; x++) { + for (let y = 0; y < this.cache[x].length; y++) { + this.cache[x][y] = null; + } + } + } +} diff --git a/src/renderer/Interfaces.ts b/src/renderer/Interfaces.ts new file mode 100644 index 0000000000..05f98300d7 --- /dev/null +++ b/src/renderer/Interfaces.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal, ITerminalOptions, ITheme } from '../Interfaces'; + +export interface IRenderer { + setTheme(theme: ITheme): IColorSet; + onWindowResize(devicePixelRatio: number): void; + onResize(cols: number, rows: number, didCharSizeChange: boolean): void; + onCharSizeChanged(): void; + onBlur(): void; + onFocus(): void; + onSelectionChanged(start: [number, number], end: [number, number]): void; + onCursorMove(): void; + onOptionsChanged(): void; + clear(): void; + queueRefresh(start: number, end: number): void; +} + +export interface IRenderLayer { + /** + * Called when the terminal loses focus. + */ + onBlur(terminal: ITerminal): void; + + /** + * * Called when the terminal gets focus. + */ + onFocus(terminal: ITerminal): void; + + /** + * Called when the cursor is moved. + */ + onCursorMove(terminal: ITerminal): void; + + /** + * Called when options change. + */ + onOptionsChanged(terminal: ITerminal): void; + + /** + * Called when the theme changes. + */ + onThemeChanged(terminal: ITerminal, colorSet: IColorSet): void; + + /** + * Called when the data in the grid has changed (or needs to be rendered + * again). + */ + onGridChanged(terminal: ITerminal, startRow: number, endRow: number): void; + + /** + * Calls when the selection changes. + */ + onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void; + + /** + * Resize the render layer. + */ + resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void; + + /** + * Clear the state of the render layer. + */ + reset(terminal: ITerminal): void; +} + + +export interface IColorSet { + foreground: string; + background: string; + cursor: string; + selection: string; + ansi: string[]; +} diff --git a/src/renderer/LinkRenderLayer.ts b/src/renderer/LinkRenderLayer.ts new file mode 100644 index 0000000000..9b2cabb32d --- /dev/null +++ b/src/renderer/LinkRenderLayer.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColorSet } from './Interfaces'; +import { IBuffer, ICharMeasure, ITerminal, ILinkifierAccessor } from '../Interfaces'; +import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; +import { GridCache } from './GridCache'; +import { FLAGS } from './Types'; +import { BaseRenderLayer, INVERTED_DEFAULT_COLOR } from './BaseRenderLayer'; +import { LinkHoverEvent, LinkHoverEventTypes } from '../Types'; + +export class LinkRenderLayer extends BaseRenderLayer { + private _state: LinkHoverEvent = null; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet, terminal: ILinkifierAccessor) { + super(container, 'link', zIndex, colors); + terminal.linkifier.on(LinkHoverEventTypes.HOVER, (e: LinkHoverEvent) => this._onLinkHover(e)); + terminal.linkifier.on(LinkHoverEventTypes.LEAVE, (e: LinkHoverEvent) => this._onLinkLeave(e)); + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + // Resizing the canvas discards the contents of the canvas so clear state + this._state = null; + } + + public reset(terminal: ITerminal): void { + this._clearCurrentLink(); + } + + private _clearCurrentLink(): void { + if (this._state) { + this.clearCells(this._state.x, this._state.y, this._state.length, 1); + this._state = null; + } + } + + private _onLinkHover(e: LinkHoverEvent): void { + this._ctx.fillStyle = this.colors.foreground; + this.fillBottomLineAtCells(e.x, e.y, e.length); + this._state = e; + } + + private _onLinkLeave(e: LinkHoverEvent): void { + this._clearCurrentLink(); + } +} diff --git a/src/renderer/Renderer.ts b/src/renderer/Renderer.ts new file mode 100644 index 0000000000..131a8b7849 --- /dev/null +++ b/src/renderer/Renderer.ts @@ -0,0 +1,148 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { ITerminal, ITheme } from '../Interfaces'; +import { CHAR_DATA_WIDTH_INDEX, CHAR_DATA_CHAR_INDEX } from '../Buffer'; +import { BackgroundRenderLayer } from './BackgroundRenderLayer'; +import { ForegroundRenderLayer } from './ForegroundRenderLayer'; +import { SelectionRenderLayer } from './SelectionRenderLayer'; +import { CursorRenderLayer } from './CursorRenderLayer'; +import { ColorManager } from './ColorManager'; +import { BaseRenderLayer } from './BaseRenderLayer'; +import { IRenderLayer, IColorSet, IRenderer } from './Interfaces'; +import { LinkRenderLayer } from './LinkRenderLayer'; + +export class Renderer implements IRenderer { + /** A queue of the rows to be refreshed */ + private _refreshRowsQueue: {start: number, end: number}[] = []; + private _refreshAnimationFrame = null; + + private _renderLayers: IRenderLayer[]; + private _devicePixelRatio: number; + + private _colorManager: ColorManager; + + constructor(private _terminal: ITerminal) { + this._colorManager = new ColorManager(); + this._renderLayers = [ + new BackgroundRenderLayer(this._terminal.element, 0, this._colorManager.colors), + new SelectionRenderLayer(this._terminal.element, 1, this._colorManager.colors), + new ForegroundRenderLayer(this._terminal.element, 2, this._colorManager.colors), + new LinkRenderLayer(this._terminal.element, 3, this._colorManager.colors, this._terminal), + new CursorRenderLayer(this._terminal.element, 4, this._colorManager.colors) + ]; + this._devicePixelRatio = window.devicePixelRatio; + } + + public onWindowResize(devicePixelRatio: number): void { + // If the device pixel ratio changed, the char atlas needs to be regenerated + // and the terminal needs to refreshed + if (this._devicePixelRatio !== devicePixelRatio) { + this._devicePixelRatio = devicePixelRatio; + this.onResize(this._terminal.cols, this._terminal.rows, true); + } + } + + public setTheme(theme: ITheme): IColorSet { + this._colorManager.setTheme(theme); + + // Clear layers and force a full render + this._renderLayers.forEach(l => { + l.onThemeChanged(this._terminal, this._colorManager.colors); + l.reset(this._terminal); + }); + + this._terminal.refresh(0, this._terminal.rows - 1); + + return this._colorManager.colors; + } + + public onResize(cols: number, rows: number, didCharSizeChange: boolean): void { + if (!this._terminal.charMeasure.width || !this._terminal.charMeasure.height) { + return; + } + const width = this._terminal.charMeasure.width * cols; + const height = Math.floor(this._terminal.charMeasure.height * this._terminal.options.lineHeight) * rows; + // Resize all render layers + this._renderLayers.forEach(l => l.resize(this._terminal, width, height, didCharSizeChange)); + // Force a refresh + this._terminal.refresh(0, this._terminal.rows - 1); + } + + public onCharSizeChanged(): void { + this.onResize(this._terminal.cols, this._terminal.rows, true); + } + + public onBlur(): void { + this._renderLayers.forEach(l => l.onBlur(this._terminal)); + } + + public onFocus(): void { + this._renderLayers.forEach(l => l.onFocus(this._terminal)); + } + + public onSelectionChanged(start: [number, number], end: [number, number]): void { + this._renderLayers.forEach(l => l.onSelectionChanged(this._terminal, start, end)); + } + + public onCursorMove(): void { + this._renderLayers.forEach(l => l.onCursorMove(this._terminal)); + } + + public onOptionsChanged(): void { + this._renderLayers.forEach(l => l.onOptionsChanged(this._terminal)); + } + + public clear(): void { + this._renderLayers.forEach(l => l.reset(this._terminal)); + } + + /** + * 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 { + 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; + + // Render + start = Math.max(start, 0); + end = Math.min(end, this._terminal.rows - 1); + this._renderLayers.forEach(l => l.onGridChanged(this._terminal, start, end)); + this._terminal.emit('refresh', {start, end}); + } +} diff --git a/src/renderer/SelectionRenderLayer.ts b/src/renderer/SelectionRenderLayer.ts new file mode 100644 index 0000000000..39569c9beb --- /dev/null +++ b/src/renderer/SelectionRenderLayer.ts @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IColorSet } from './Interfaces'; +import { IBuffer, ICharMeasure, ITerminal } from '../Interfaces'; +import { CHAR_DATA_ATTR_INDEX } from '../Buffer'; +import { GridCache } from './GridCache'; +import { FLAGS } from './Types'; +import { BaseRenderLayer } from './BaseRenderLayer'; + +export class SelectionRenderLayer extends BaseRenderLayer { + private _state: {start: [number, number], end: [number, number]}; + + constructor(container: HTMLElement, zIndex: number, colors: IColorSet) { + super(container, 'selection', zIndex, colors); + this._state = { + start: null, + end: null + }; + } + + public resize(terminal: ITerminal, canvasWidth: number, canvasHeight: number, charSizeChanged: boolean): void { + super.resize(terminal, canvasWidth, canvasHeight, charSizeChanged); + // Resizing the canvas discards the contents of the canvas so clear state + this._state = { + start: null, + end: null + }; + } + + public reset(terminal: ITerminal): void { + if (this._state.start && this._state.end) { + this._state = { + start: null, + end: null + }; + this.clearAll(); + } + } + + public onSelectionChanged(terminal: ITerminal, start: [number, number], end: [number, number]): void { + // Selection has not changed + if (this._state.start === start || this._state.end === end) { + return; + } + + // Remove all selections + this.clearAll(); + + // Selection does not exist + if (!start || !end) { + return; + } + + // Translate from buffer position to viewport position + const viewportStartRow = start[1] - terminal.buffer.ydisp; + const viewportEndRow = end[1] - terminal.buffer.ydisp; + const viewportCappedStartRow = Math.max(viewportStartRow, 0); + const viewportCappedEndRow = Math.min(viewportEndRow, terminal.rows - 1); + + // No need to draw the selection + if (viewportCappedStartRow >= terminal.rows || viewportCappedEndRow < 0) { + return; + } + + // Draw first row + const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0; + const startRowEndCol = viewportCappedStartRow === viewportCappedEndRow ? end[0] : terminal.cols; + this._ctx.fillStyle = this.colors.selection; + this.fillCells(startCol, viewportCappedStartRow, startRowEndCol - startCol, 1); + + // Draw middle rows + const middleRowsCount = Math.max(viewportCappedEndRow - viewportCappedStartRow - 1, 0); + this.fillCells(0, viewportCappedStartRow + 1, terminal.cols, middleRowsCount); + + // Draw final row + if (viewportCappedStartRow !== viewportCappedEndRow) { + // Only draw viewportEndRow if it's not the same as viewporttartRow + const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : terminal.cols; + this.fillCells(0, viewportCappedEndRow, endCol, 1); + } + + // Save state for next render + this._state.start = [start[0], start[1]]; + this._state.end = [end[0], end[1]]; + } +} diff --git a/src/renderer/Types.ts b/src/renderer/Types.ts new file mode 100644 index 0000000000..b1a930f9d0 --- /dev/null +++ b/src/renderer/Types.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + + /** + * Flags used to render terminal text properly. + */ +export enum FLAGS { + BOLD = 1, + UNDERLINE = 2, + BLINK = 4, + INVERSE = 8, + INVISIBLE = 16 +}; diff --git a/src/utils/Browser.ts b/src/utils/Browser.ts index 68148aeb65..48c0c3748f 100644 --- a/src/utils/Browser.ts +++ b/src/utils/Browser.ts @@ -1,6 +1,5 @@ /** - * Attributes and methods to help with identifying the current browser and platform. - * @module xterm/utils/Browser + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/utils/CharMeasure.test.ts b/src/utils/CharMeasure.test.ts index 68e1b6a787..5d2358f4bf 100644 --- a/src/utils/CharMeasure.test.ts +++ b/src/utils/CharMeasure.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ @@ -25,13 +26,13 @@ describe('CharMeasure', () => { describe('measure', () => { it('should set _measureElement on first call', () => { - charMeasure.measure(); + charMeasure.measure({}); assert.isDefined((charMeasure)._measureElement, 'CharMeasure.measure should have created _measureElement'); }); it('should be performed async on first call', done => { assert.equal(charMeasure.width, null); - charMeasure.measure(); + charMeasure.measure({}); // Mock getBoundingClientRect since jsdom doesn't have a layout engine (charMeasure)._measureElement.getBoundingClientRect = () => { return { width: 1, height: 1 }; @@ -44,7 +45,7 @@ describe('CharMeasure', () => { }); it('should be performed sync on successive calls', done => { - charMeasure.measure(); + charMeasure.measure({}); // Mock getBoundingClientRect since jsdom doesn't have a layout engine (charMeasure)._measureElement.getBoundingClientRect = () => { return { width: 1, height: 1 }; @@ -55,19 +56,19 @@ describe('CharMeasure', () => { (charMeasure)._measureElement.getBoundingClientRect = () => { return { width: 2, height: 2 }; }; - charMeasure.measure(); + charMeasure.measure({}); assert.equal(charMeasure.width, firstWidth * 2); done(); }, 0); }); it('should NOT do a measure when the parent is hidden', done => { - charMeasure.measure(); + charMeasure.measure({}); setTimeout(() => { const firstWidth = charMeasure.width; container.style.display = 'none'; container.style.fontSize = '2em'; - charMeasure.measure(); + charMeasure.measure({}); assert.equal(charMeasure.width, firstWidth); done(); }, 0); diff --git a/src/utils/CharMeasure.ts b/src/utils/CharMeasure.ts index 45b06f5916..e8fcff8626 100644 --- a/src/utils/CharMeasure.ts +++ b/src/utils/CharMeasure.ts @@ -1,14 +1,17 @@ /** - * @module xterm/utils/CharMeasure + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ import { EventEmitter } from '../EventEmitter.js'; +import { ICharMeasure, ITerminal, ITerminalOptions } from '../Interfaces'; /** - * Utility class that measures the size of a character. + * Utility class that measures the size of a character. Measurements are done in + * the DOM rather than with a canvas context because support for extracting the + * height of characters is patchy across browsers. */ -export class CharMeasure extends EventEmitter { +export class CharMeasure extends EventEmitter implements ICharMeasure { private _document: Document; private _parentElement: HTMLElement; private _measureElement: HTMLElement; @@ -29,24 +32,27 @@ export class CharMeasure extends EventEmitter { return this._height; } - public measure(): void { + public measure(options: ITerminalOptions): void { if (!this._measureElement) { this._measureElement = this._document.createElement('span'); this._measureElement.style.position = 'absolute'; this._measureElement.style.top = '0'; this._measureElement.style.left = '-9999em'; + this._measureElement.style.lineHeight = 'normal'; this._measureElement.textContent = 'W'; this._measureElement.setAttribute('aria-hidden', 'true'); this._parentElement.appendChild(this._measureElement); // Perform _doMeasure async if the element was just attached as sometimes // getBoundingClientRect does not return accurate values without this. - setTimeout(() => this._doMeasure(), 0); + setTimeout(() => this._doMeasure(options), 0); } else { - this._doMeasure(); + this._doMeasure(options); } } - private _doMeasure(): void { + private _doMeasure(options: ITerminalOptions): void { + this._measureElement.style.fontFamily = options.fontFamily; + this._measureElement.style.fontSize = `${options.fontSize}px`; const geometry = this._measureElement.getBoundingClientRect(); // The element is likely currently display:none, we should retain the // previous value. @@ -54,8 +60,8 @@ export class CharMeasure extends EventEmitter { return; } if (this._width !== geometry.width || this._height !== geometry.height) { - this._width = geometry.width; - this._height = geometry.height; + this._width = Math.ceil(geometry.width); + this._height = Math.ceil(geometry.height); this.emit('charsizechanged'); } } diff --git a/src/utils/CircularList.test.ts b/src/utils/CircularList.test.ts index f404b6f28e..6bb35113ac 100644 --- a/src/utils/CircularList.test.ts +++ b/src/utils/CircularList.test.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/utils/CircularList.ts b/src/utils/CircularList.ts index e188fb5d86..97a32b790b 100644 --- a/src/utils/CircularList.ts +++ b/src/utils/CircularList.ts @@ -1,13 +1,15 @@ /** - * Represents a circular list; a list with a maximum size that wraps around when push is called, - * overriding values at the start of the list. - * @module xterm/utils/CircularList + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ import { EventEmitter } from '../EventEmitter'; import { ICircularList } from '../Interfaces'; +/** + * Represents a circular list; a list with a maximum size that wraps around when push is called, + * overriding values at the start of the list. + */ export class CircularList extends EventEmitter implements ICircularList { protected _array: T[]; private _startIndex: number; diff --git a/src/utils/DomElementObjectPool.test.ts b/src/utils/DomElementObjectPool.test.ts deleted file mode 100644 index 5a5d8a7841..0000000000 --- a/src/utils/DomElementObjectPool.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * @license MIT - */ - -import { assert } from 'chai'; -import { DomElementObjectPool } from './DomElementObjectPool'; - -class MockDocument { - private _attr: {[key: string]: string} = {}; - constructor() {} - public getAttribute(key: string): string { return this._attr[key]; }; - public setAttribute(key: string, value: string): void { this._attr[key] = value; } -} - -describe('DomElementObjectPool', () => { - let pool: DomElementObjectPool; - - beforeEach(() => { - pool = new DomElementObjectPool('span'); - (global).document = { - createElement: () => new MockDocument() - }; - }); - - it('should acquire distinct elements', () => { - const element1 = pool.acquire(); - const element2 = pool.acquire(); - assert.notEqual(element1, element2); - }); - - it('should acquire released elements', () => { - const element = pool.acquire(); - pool.release(element); - assert.equal(pool.acquire(), element); - }); - - it('should handle a series of acquisitions and releases', () => { - const element1 = pool.acquire(); - const element2 = pool.acquire(); - pool.release(element1); - assert.equal(pool.acquire(), element1); - pool.release(element1); - pool.release(element2); - assert.equal(pool.acquire(), element2); - assert.equal(pool.acquire(), element1); - }); - - it('should throw when releasing an element that was not acquired', () => { - assert.throws(() => pool.release(document.createElement('span'))); - }); -}); diff --git a/src/utils/DomElementObjectPool.ts b/src/utils/DomElementObjectPool.ts deleted file mode 100644 index 7707ad9ab6..0000000000 --- a/src/utils/DomElementObjectPool.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @module xterm/utils/DomElementObjectPool - * @license MIT - */ - -/** - * An object pool that manages acquisition and releasing of DOM elements for - * when reuse is desirable. - */ -export class DomElementObjectPool { - private static readonly OBJECT_ID_ATTRIBUTE = 'data-obj-id'; - - private static _objectCount = 0; - - private _type: string; - private _pool: HTMLElement[]; - private _inUse: {[key: string]: HTMLElement}; - - /** - * @param type The DOM element type (div, span, etc.). - */ - constructor(private type: string) { - this._type = type; - this._pool = []; - this._inUse = {}; - } - - /** - * Acquire an element from the pool, creating it if the pool is empty. - */ - public acquire(): HTMLElement { - let element: HTMLElement; - if (this._pool.length === 0) { - element = this._createNew(); - } else { - element = this._pool.pop(); - } - this._inUse[element.getAttribute(DomElementObjectPool.OBJECT_ID_ATTRIBUTE)] = element; - return element; - } - - /** - * Release an element back into the pool. It's up to the caller of this - * function to ensure that all external references to the element have been - * removed. - * @param element The element being released. - */ - public release(element: HTMLElement): void { - if (!this._inUse[element.getAttribute(DomElementObjectPool.OBJECT_ID_ATTRIBUTE)]) { - throw new Error('Could not release an element not yet acquired'); - } - delete this._inUse[element.getAttribute(DomElementObjectPool.OBJECT_ID_ATTRIBUTE)]; - this._cleanElement(element); - this._pool.push(element); - } - - /** - * Creates a new element for the pool. - */ - private _createNew(): HTMLElement { - const element = document.createElement(this._type); - const id = DomElementObjectPool._objectCount++; - element.setAttribute(DomElementObjectPool.OBJECT_ID_ATTRIBUTE, id.toString(10)); - return element; - } - - /** - * Resets an element back to a "clean state". - * @param element The element to be cleaned. - */ - private _cleanElement(element: HTMLElement): void { - element.className = ''; - element.innerHTML = ''; - } -} diff --git a/src/utils/Generic.ts b/src/utils/Generic.ts index 9807232605..823736178f 100644 --- a/src/utils/Generic.ts +++ b/src/utils/Generic.ts @@ -1,6 +1,5 @@ /** - * Generic utilities module with methods that can be helpful at different parts of the code base. - * @module xterm/utils/Generic + * Copyright (c) 2016 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/utils/Mouse.test.ts b/src/utils/Mouse.test.ts new file mode 100644 index 0000000000..4163263be3 --- /dev/null +++ b/src/utils/Mouse.test.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import jsdom = require('jsdom'); +import { assert } from 'chai'; +import { getCoords } from './Mouse'; +import { MockCharMeasure } from './TestUtils.test'; + +const CHAR_WIDTH = 10; +const CHAR_HEIGHT = 20; + +describe('getCoords', () => { + let dom: jsdom.JSDOM; + let window: Window; + let document: Document; + + let charMeasure: MockCharMeasure; + + beforeEach(() => { + dom = new jsdom.JSDOM(''); + window = dom.window; + document = window.document; + charMeasure = new MockCharMeasure(); + charMeasure.width = CHAR_WIDTH; + charMeasure.height = CHAR_HEIGHT; + }); + + describe('when charMeasure is not initialized', () => { + it('should return null', () => { + charMeasure = new MockCharMeasure(); + assert.equal(getCoords({ pageX: 0, pageY: 0 }, document.createElement('div'), charMeasure, 1, 10, 10), null); + }); + }); + + describe('when pageX/pageY are not supported', () => { + it('should return null', () => { + assert.equal(getCoords({ pageX: undefined, pageY: undefined }, document.createElement('div'), charMeasure, 1, 10, 10), null); + }); + }); + + it('should return the cell that was clicked', () => { + let coords: [number, number]; + coords = getCoords({ pageX: CHAR_WIDTH / 2, pageY: CHAR_HEIGHT / 2 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 1]); + coords = getCoords({ pageX: CHAR_WIDTH, pageY: CHAR_HEIGHT }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 1]); + coords = getCoords({ pageX: CHAR_WIDTH, pageY: CHAR_HEIGHT + 1 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 2]); + coords = getCoords({ pageX: CHAR_WIDTH + 1, pageY: CHAR_HEIGHT }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [2, 1]); + }); + + it('should ensure the coordinates are returned within the terminal bounds', () => { + let coords: [number, number]; + coords = getCoords({ pageX: -1, pageY: -1 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [1, 1]); + // Event are double the cols/rows + coords = getCoords({ pageX: CHAR_WIDTH * 20, pageY: CHAR_HEIGHT * 20 }, document.createElement('div'), charMeasure, 1, 10, 10); + assert.deepEqual(coords, [11, 11], 'coordinates should never come back as larger than the terminal'); + }); +}); diff --git a/src/utils/Mouse.ts b/src/utils/Mouse.ts index c61624e9ff..aef76d5bab 100644 --- a/src/utils/Mouse.ts +++ b/src/utils/Mouse.ts @@ -1,10 +1,11 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ -import { CharMeasure } from './CharMeasure'; +import { ICharMeasure } from '../Interfaces'; -export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLElement): [number, number] { +export function getCoordsRelativeToElement(event: {pageX: number, pageY: number}, element: HTMLElement): [number, number] { // Ignore browsers that don't support MouseEvent.pageX if (event.pageX == null) { return null; @@ -15,7 +16,7 @@ export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLEleme // Converts the coordinates from being relative to the document to being // relative to the terminal. - while (element && element !== self.document.documentElement) { + while (element) { x -= element.offsetLeft; y -= element.offsetTop; element = 'offsetParent' in element ? element.offsetParent : element.parentElement; @@ -28,7 +29,7 @@ export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLEleme * is returned as an array in the form [x, y] instead of an object as it's a * little faster and this function is used in some low level code. * @param event The mouse event. - * @param rowContainer The terminal's row container. + * @param element The terminal's container element. * @param charMeasure The char measure object used to determine character sizes. * @param colCount The number of columns in the terminal. * @param rowCount The number of rows n the terminal. @@ -36,20 +37,20 @@ export function getCoordsRelativeToElement(event: MouseEvent, element: HTMLEleme * apply an offset to the x value such that the left half of the cell will * select that cell and the right half will select the next cell. */ -export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number, isSelection?: boolean): [number, number] { +export function getCoords(event: {pageX: number, pageY: number}, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number, isSelection?: boolean): [number, number] { // Coordinates cannot be measured if charMeasure has not been initialized if (!charMeasure.width || !charMeasure.height) { return null; } - const coords = getCoordsRelativeToElement(event, rowContainer); + const coords = getCoordsRelativeToElement(event, element); if (!coords) { return null; } // Convert to cols/rows. coords[0] = Math.ceil((coords[0] + (isSelection ? charMeasure.width / 2 : 0)) / charMeasure.width); - coords[1] = Math.ceil(coords[1] / charMeasure.height); + coords[1] = Math.ceil(coords[1] / Math.ceil(charMeasure.height * lineHeight)); // Ensure coordinates are within the terminal viewport. coords[0] = Math.min(Math.max(coords[0], 1), colCount + 1); @@ -63,13 +64,13 @@ export function getCoords(event: MouseEvent, rowContainer: HTMLElement, charMeas * them to the bounds of the terminal and adding 32 to both the x and y values * as expected by xterm. * @param event The mouse event. - * @param rowContainer The terminal's row container. + * @param element The terminal's container element. * @param charMeasure The char measure object used to determine character sizes. * @param colCount The number of columns in the terminal. * @param rowCount The number of rows in the terminal. */ -export function getRawByteCoords(event: MouseEvent, rowContainer: HTMLElement, charMeasure: CharMeasure, colCount: number, rowCount: number): { x: number, y: number } { - const coords = getCoords(event, rowContainer, charMeasure, colCount, rowCount); +export function getRawByteCoords(event: MouseEvent, element: HTMLElement, charMeasure: ICharMeasure, lineHeight: number, colCount: number, rowCount: number): { x: number, y: number } { + const coords = getCoords(event, element, charMeasure, lineHeight, colCount, rowCount); let x = coords[0]; let y = coords[1]; diff --git a/src/utils/Sounds.ts b/src/utils/Sounds.ts index 6d20c67122..03f36f4267 100644 --- a/src/utils/Sounds.ts +++ b/src/utils/Sounds.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ diff --git a/src/utils/TestUtils.test.ts b/src/utils/TestUtils.test.ts index ae83aed4fb..eb2f25017b 100644 --- a/src/utils/TestUtils.test.ts +++ b/src/utils/TestUtils.test.ts @@ -1,12 +1,17 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT */ -import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper } from '../Interfaces'; +import { ITerminal, IBuffer, IBufferSet, IBrowser, ICharMeasure, ISelectionManager, ITerminalOptions, IListenerType, IInputHandlingTerminal, IViewport, ICircularList, ICompositionHelper, ITheme, ILinkifier } from '../Interfaces'; import { LineData } from '../Types'; +import { Buffer } from '../Buffer'; import * as Browser from './Browser'; +import { IColorSet, IRenderer } from '../renderer/Interfaces'; export class MockTerminal implements ITerminal { + linkifier: ILinkifier; + isFocused: boolean; options: ITerminalOptions = {}; element: HTMLElement; rowContainer: HTMLElement; @@ -52,16 +57,27 @@ export class MockTerminal implements ITerminal { showCursor(): void { throw new Error('Method not implemented.'); } + refresh(start: number, end: number): void { + throw new Error('Method not implemented.'); + } blankLine(cur?: boolean, isWrapped?: boolean, cols?: number): LineData { const line: LineData = []; cols = cols || this.cols; for (let i = 0; i < cols; i++) { - line.push([0, ' ', 1]); + line.push([0, ' ', 1, 32]); } return line; } } +export class MockCharMeasure implements ICharMeasure { + width: number; + height: number; + measure(options: ITerminalOptions): void { + throw new Error('Method not implemented.'); + } +} + export class MockInputHandlingTerminal implements IInputHandlingTerminal { element: HTMLElement; options: ITerminalOptions = {}; @@ -125,7 +141,7 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { eraseLeft(x: number, y: number): void { throw new Error('Method not implemented.'); } - blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number][] { + blankLine(cur?: boolean, isWrapped?: boolean): [number, string, number, number][] { throw new Error('Method not implemented.'); } prevStop(x?: number): number { @@ -176,7 +192,8 @@ export class MockInputHandlingTerminal implements IInputHandlingTerminal { } export class MockBuffer implements IBuffer { - lines: ICircularList<[number, string, number][]>; + isCursorInViewport: boolean; + lines: ICircularList<[number, string, number, number][]>; ydisp: number; ybase: number; y: number; @@ -187,7 +204,7 @@ export class MockBuffer implements IBuffer { savedY: number; savedX: number; translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string { - throw new Error('Method not implemented.'); + return Buffer.prototype.translateBufferLineToString.apply(this, arguments); } nextStop(x?: number): number { throw new Error('Method not implemented.'); @@ -197,7 +214,24 @@ export class MockBuffer implements IBuffer { } } +export class MockRenderer implements IRenderer { + setTheme(theme: ITheme): IColorSet { return {}; } + onResize(cols: number, rows: number, didCharSizeChange: boolean): void {} + onCharSizeChanged(): void {} + onBlur(): void {} + onFocus(): void {} + onSelectionChanged(start: [number, number], end: [number, number]): void {} + onCursorMove(): void {} + onOptionsChanged(): void {} + onWindowResize(devicePixelRatio: number): void {} + clear(): void {} + queueRefresh(start: number, end: number): void {} +} + export class MockViewport implements IViewport { + onThemeChanged(colors: IColorSet): void { + throw new Error('Method not implemented.'); + } onWheel(ev: WheelEvent): void { throw new Error('Method not implemented.'); } diff --git a/src/xterm.css b/src/xterm.css index 14b0a58d4e..399f56ceb5 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -1,8 +1,8 @@ /** - * xterm.js: xterm, in the browser - * Copyright (c) 2014-2016, SourceLair Private Company (www.sourcelair.com (MIT License) + * Copyright (c) 2014 The xterm.js authors. All rights reserved. * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) * https://github.com/chjj/term.js + * @license MIT * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -31,13 +31,11 @@ * other features. */ -/* - * Default style for xterm.js +/** + * Default styles for xterm.js */ .terminal { - background-color: #000; - color: #fff; font-family: courier-new, courier, monospace; font-feature-settings: "liga" 0; position: relative; @@ -54,6 +52,11 @@ .terminal .xterm-helpers { position: absolute; top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 10; } .terminal .xterm-helper-textarea { @@ -74,67 +77,8 @@ resize: none; } -.terminal a { - color: inherit; - text-decoration: none; -} - -.terminal a:hover { - cursor: pointer; - text-decoration: underline; -} - -.terminal a.xterm-invalid-link:hover { - cursor: text; - text-decoration: none; -} - -.terminal .terminal-cursor { - position: relative; - transition: opacity 150ms ease; -} - -.terminal:not(.focus) .terminal-cursor { - outline: 1px solid #fff; - outline-offset: -1px; -} - -.terminal.xterm-cursor-style-block.focus:not(.xterm-cursor-blink-on) .terminal-cursor { - background-color: #fff; - color: #000; -} - -.terminal.focus.xterm-cursor-style-bar:not(.xterm-cursor-blink-on) .terminal-cursor::before, -.terminal.focus.xterm-cursor-style-underline:not(.xterm-cursor-blink-on) .terminal-cursor::before { - content: ''; - position: absolute; - background-color: #fff; - transition: opacity 150ms ease; -} - -.terminal.focus.xterm-cursor-style-bar:not(.xterm-cursor-blink-on) .terminal-cursor::before { - top: 0; - left: 0; - bottom: 0; - width: 1px; -} - -.terminal.focus.xterm-cursor-style-underline:not(.xterm-cursor-blink-on) .terminal-cursor::before { - bottom: 0; - left: 0; - right: 0; - height: 1px; -} - -.terminal.focus.xterm-cursor-style-block.visual-bell-active .terminal-cursor { - opacity: 0.5; -} -.terminal.focus.xterm-cursor-style-bar.visual-bell-active .terminal-cursor::before, -.terminal.focus.xterm-cursor-style-underline.visual-bell-active .terminal-cursor::before { - opacity: 0; -} - .terminal .composition-view { + /* TODO: Composition position got messed up somewhere */ background: #000; color: #FFF; display: none; @@ -153,22 +97,12 @@ overflow-y: scroll; } -.terminal .xterm-wide-char, -.terminal .xterm-normal-char { - display: inline-block; -} - -.terminal .xterm-rows { +.terminal canvas { position: absolute; left: 0; top: 0; } -.terminal .xterm-rows > div { - /* Lines containing spans and text nodes ocassionally wrap despite being the same width (#327) */ - white-space: nowrap; -} - .terminal .xterm-scroll-area { visibility: hidden; } @@ -185,2087 +119,6 @@ cursor: default; } -.terminal .xterm-selection { - position: absolute; - top: 0; - left: 0; - z-index: 1; - opacity: 0.3; - pointer-events: none; -} - -.terminal .xterm-selection div { - position: absolute; - background-color: #fff; -} - -/* - * Determine default colors for xterm.js - */ -.terminal .xterm-bold { - font-weight: bold; -} - -.terminal .xterm-underline { - text-decoration: underline; -} - -.terminal .xterm-blink { - text-decoration: blink; -} - -.terminal .xterm-blink.xterm-underline { - text-decoration: blink underline; -} - -.terminal .xterm-hidden { - visibility: hidden; -} - -.terminal .xterm-color-0 { - color: #2e3436; -} - -.terminal .xterm-bg-color-0 { - background-color: #2e3436; -} - -.terminal .xterm-color-1 { - color: #cc0000; -} - -.terminal .xterm-bg-color-1 { - background-color: #cc0000; -} - -.terminal .xterm-color-2 { - color: #4e9a06; -} - -.terminal .xterm-bg-color-2 { - background-color: #4e9a06; -} - -.terminal .xterm-color-3 { - color: #c4a000; -} - -.terminal .xterm-bg-color-3 { - background-color: #c4a000; -} - -.terminal .xterm-color-4 { - color: #3465a4; -} - -.terminal .xterm-bg-color-4 { - background-color: #3465a4; -} - -.terminal .xterm-color-5 { - color: #75507b; -} - -.terminal .xterm-bg-color-5 { - background-color: #75507b; -} - -.terminal .xterm-color-6 { - color: #06989a; -} - -.terminal .xterm-bg-color-6 { - background-color: #06989a; -} - -.terminal .xterm-color-7 { - color: #d3d7cf; -} - -.terminal .xterm-bg-color-7 { - background-color: #d3d7cf; -} - -.terminal .xterm-color-8 { - color: #555753; -} - -.terminal .xterm-bg-color-8 { - background-color: #555753; -} - -.terminal .xterm-color-9 { - color: #ef2929; -} - -.terminal .xterm-bg-color-9 { - background-color: #ef2929; -} - -.terminal .xterm-color-10 { - color: #8ae234; -} - -.terminal .xterm-bg-color-10 { - background-color: #8ae234; -} - -.terminal .xterm-color-11 { - color: #fce94f; -} - -.terminal .xterm-bg-color-11 { - background-color: #fce94f; -} - -.terminal .xterm-color-12 { - color: #729fcf; -} - -.terminal .xterm-bg-color-12 { - background-color: #729fcf; -} - -.terminal .xterm-color-13 { - color: #ad7fa8; -} - -.terminal .xterm-bg-color-13 { - background-color: #ad7fa8; -} - -.terminal .xterm-color-14 { - color: #34e2e2; -} - -.terminal .xterm-bg-color-14 { - background-color: #34e2e2; -} - -.terminal .xterm-color-15 { - color: #eeeeec; -} - -.terminal .xterm-bg-color-15 { - background-color: #eeeeec; -} - -.terminal .xterm-color-16 { - color: #000000; -} - -.terminal .xterm-bg-color-16 { - background-color: #000000; -} - -.terminal .xterm-color-17 { - color: #00005f; -} - -.terminal .xterm-bg-color-17 { - background-color: #00005f; -} - -.terminal .xterm-color-18 { - color: #000087; -} - -.terminal .xterm-bg-color-18 { - background-color: #000087; -} - -.terminal .xterm-color-19 { - color: #0000af; -} - -.terminal .xterm-bg-color-19 { - background-color: #0000af; -} - -.terminal .xterm-color-20 { - color: #0000d7; -} - -.terminal .xterm-bg-color-20 { - background-color: #0000d7; -} - -.terminal .xterm-color-21 { - color: #0000ff; -} - -.terminal .xterm-bg-color-21 { - background-color: #0000ff; -} - -.terminal .xterm-color-22 { - color: #005f00; -} - -.terminal .xterm-bg-color-22 { - background-color: #005f00; -} - -.terminal .xterm-color-23 { - color: #005f5f; -} - -.terminal .xterm-bg-color-23 { - background-color: #005f5f; -} - -.terminal .xterm-color-24 { - color: #005f87; -} - -.terminal .xterm-bg-color-24 { - background-color: #005f87; -} - -.terminal .xterm-color-25 { - color: #005faf; -} - -.terminal .xterm-bg-color-25 { - background-color: #005faf; -} - -.terminal .xterm-color-26 { - color: #005fd7; -} - -.terminal .xterm-bg-color-26 { - background-color: #005fd7; -} - -.terminal .xterm-color-27 { - color: #005fff; -} - -.terminal .xterm-bg-color-27 { - background-color: #005fff; -} - -.terminal .xterm-color-28 { - color: #008700; -} - -.terminal .xterm-bg-color-28 { - background-color: #008700; -} - -.terminal .xterm-color-29 { - color: #00875f; -} - -.terminal .xterm-bg-color-29 { - background-color: #00875f; -} - -.terminal .xterm-color-30 { - color: #008787; -} - -.terminal .xterm-bg-color-30 { - background-color: #008787; -} - -.terminal .xterm-color-31 { - color: #0087af; -} - -.terminal .xterm-bg-color-31 { - background-color: #0087af; -} - -.terminal .xterm-color-32 { - color: #0087d7; -} - -.terminal .xterm-bg-color-32 { - background-color: #0087d7; -} - -.terminal .xterm-color-33 { - color: #0087ff; -} - -.terminal .xterm-bg-color-33 { - background-color: #0087ff; -} - -.terminal .xterm-color-34 { - color: #00af00; -} - -.terminal .xterm-bg-color-34 { - background-color: #00af00; -} - -.terminal .xterm-color-35 { - color: #00af5f; -} - -.terminal .xterm-bg-color-35 { - background-color: #00af5f; -} - -.terminal .xterm-color-36 { - color: #00af87; -} - -.terminal .xterm-bg-color-36 { - background-color: #00af87; -} - -.terminal .xterm-color-37 { - color: #00afaf; -} - -.terminal .xterm-bg-color-37 { - background-color: #00afaf; -} - -.terminal .xterm-color-38 { - color: #00afd7; -} - -.terminal .xterm-bg-color-38 { - background-color: #00afd7; -} - -.terminal .xterm-color-39 { - color: #00afff; -} - -.terminal .xterm-bg-color-39 { - background-color: #00afff; -} - -.terminal .xterm-color-40 { - color: #00d700; -} - -.terminal .xterm-bg-color-40 { - background-color: #00d700; -} - -.terminal .xterm-color-41 { - color: #00d75f; -} - -.terminal .xterm-bg-color-41 { - background-color: #00d75f; -} - -.terminal .xterm-color-42 { - color: #00d787; -} - -.terminal .xterm-bg-color-42 { - background-color: #00d787; -} - -.terminal .xterm-color-43 { - color: #00d7af; -} - -.terminal .xterm-bg-color-43 { - background-color: #00d7af; -} - -.terminal .xterm-color-44 { - color: #00d7d7; -} - -.terminal .xterm-bg-color-44 { - background-color: #00d7d7; -} - -.terminal .xterm-color-45 { - color: #00d7ff; -} - -.terminal .xterm-bg-color-45 { - background-color: #00d7ff; -} - -.terminal .xterm-color-46 { - color: #00ff00; -} - -.terminal .xterm-bg-color-46 { - background-color: #00ff00; -} - -.terminal .xterm-color-47 { - color: #00ff5f; -} - -.terminal .xterm-bg-color-47 { - background-color: #00ff5f; -} - -.terminal .xterm-color-48 { - color: #00ff87; -} - -.terminal .xterm-bg-color-48 { - background-color: #00ff87; -} - -.terminal .xterm-color-49 { - color: #00ffaf; -} - -.terminal .xterm-bg-color-49 { - background-color: #00ffaf; -} - -.terminal .xterm-color-50 { - color: #00ffd7; -} - -.terminal .xterm-bg-color-50 { - background-color: #00ffd7; -} - -.terminal .xterm-color-51 { - color: #00ffff; -} - -.terminal .xterm-bg-color-51 { - background-color: #00ffff; -} - -.terminal .xterm-color-52 { - color: #5f0000; -} - -.terminal .xterm-bg-color-52 { - background-color: #5f0000; -} - -.terminal .xterm-color-53 { - color: #5f005f; -} - -.terminal .xterm-bg-color-53 { - background-color: #5f005f; -} - -.terminal .xterm-color-54 { - color: #5f0087; -} - -.terminal .xterm-bg-color-54 { - background-color: #5f0087; -} - -.terminal .xterm-color-55 { - color: #5f00af; -} - -.terminal .xterm-bg-color-55 { - background-color: #5f00af; -} - -.terminal .xterm-color-56 { - color: #5f00d7; -} - -.terminal .xterm-bg-color-56 { - background-color: #5f00d7; -} - -.terminal .xterm-color-57 { - color: #5f00ff; -} - -.terminal .xterm-bg-color-57 { - background-color: #5f00ff; -} - -.terminal .xterm-color-58 { - color: #5f5f00; -} - -.terminal .xterm-bg-color-58 { - background-color: #5f5f00; -} - -.terminal .xterm-color-59 { - color: #5f5f5f; -} - -.terminal .xterm-bg-color-59 { - background-color: #5f5f5f; -} - -.terminal .xterm-color-60 { - color: #5f5f87; -} - -.terminal .xterm-bg-color-60 { - background-color: #5f5f87; -} - -.terminal .xterm-color-61 { - color: #5f5faf; -} - -.terminal .xterm-bg-color-61 { - background-color: #5f5faf; -} - -.terminal .xterm-color-62 { - color: #5f5fd7; -} - -.terminal .xterm-bg-color-62 { - background-color: #5f5fd7; -} - -.terminal .xterm-color-63 { - color: #5f5fff; -} - -.terminal .xterm-bg-color-63 { - background-color: #5f5fff; -} - -.terminal .xterm-color-64 { - color: #5f8700; -} - -.terminal .xterm-bg-color-64 { - background-color: #5f8700; -} - -.terminal .xterm-color-65 { - color: #5f875f; -} - -.terminal .xterm-bg-color-65 { - background-color: #5f875f; -} - -.terminal .xterm-color-66 { - color: #5f8787; -} - -.terminal .xterm-bg-color-66 { - background-color: #5f8787; -} - -.terminal .xterm-color-67 { - color: #5f87af; -} - -.terminal .xterm-bg-color-67 { - background-color: #5f87af; -} - -.terminal .xterm-color-68 { - color: #5f87d7; -} - -.terminal .xterm-bg-color-68 { - background-color: #5f87d7; -} - -.terminal .xterm-color-69 { - color: #5f87ff; -} - -.terminal .xterm-bg-color-69 { - background-color: #5f87ff; -} - -.terminal .xterm-color-70 { - color: #5faf00; -} - -.terminal .xterm-bg-color-70 { - background-color: #5faf00; -} - -.terminal .xterm-color-71 { - color: #5faf5f; -} - -.terminal .xterm-bg-color-71 { - background-color: #5faf5f; -} - -.terminal .xterm-color-72 { - color: #5faf87; -} - -.terminal .xterm-bg-color-72 { - background-color: #5faf87; -} - -.terminal .xterm-color-73 { - color: #5fafaf; -} - -.terminal .xterm-bg-color-73 { - background-color: #5fafaf; -} - -.terminal .xterm-color-74 { - color: #5fafd7; -} - -.terminal .xterm-bg-color-74 { - background-color: #5fafd7; -} - -.terminal .xterm-color-75 { - color: #5fafff; -} - -.terminal .xterm-bg-color-75 { - background-color: #5fafff; -} - -.terminal .xterm-color-76 { - color: #5fd700; -} - -.terminal .xterm-bg-color-76 { - background-color: #5fd700; -} - -.terminal .xterm-color-77 { - color: #5fd75f; -} - -.terminal .xterm-bg-color-77 { - background-color: #5fd75f; -} - -.terminal .xterm-color-78 { - color: #5fd787; -} - -.terminal .xterm-bg-color-78 { - background-color: #5fd787; -} - -.terminal .xterm-color-79 { - color: #5fd7af; -} - -.terminal .xterm-bg-color-79 { - background-color: #5fd7af; -} - -.terminal .xterm-color-80 { - color: #5fd7d7; -} - -.terminal .xterm-bg-color-80 { - background-color: #5fd7d7; -} - -.terminal .xterm-color-81 { - color: #5fd7ff; -} - -.terminal .xterm-bg-color-81 { - background-color: #5fd7ff; -} - -.terminal .xterm-color-82 { - color: #5fff00; -} - -.terminal .xterm-bg-color-82 { - background-color: #5fff00; -} - -.terminal .xterm-color-83 { - color: #5fff5f; -} - -.terminal .xterm-bg-color-83 { - background-color: #5fff5f; -} - -.terminal .xterm-color-84 { - color: #5fff87; -} - -.terminal .xterm-bg-color-84 { - background-color: #5fff87; -} - -.terminal .xterm-color-85 { - color: #5fffaf; -} - -.terminal .xterm-bg-color-85 { - background-color: #5fffaf; -} - -.terminal .xterm-color-86 { - color: #5fffd7; -} - -.terminal .xterm-bg-color-86 { - background-color: #5fffd7; -} - -.terminal .xterm-color-87 { - color: #5fffff; -} - -.terminal .xterm-bg-color-87 { - background-color: #5fffff; -} - -.terminal .xterm-color-88 { - color: #870000; -} - -.terminal .xterm-bg-color-88 { - background-color: #870000; -} - -.terminal .xterm-color-89 { - color: #87005f; -} - -.terminal .xterm-bg-color-89 { - background-color: #87005f; -} - -.terminal .xterm-color-90 { - color: #870087; -} - -.terminal .xterm-bg-color-90 { - background-color: #870087; -} - -.terminal .xterm-color-91 { - color: #8700af; -} - -.terminal .xterm-bg-color-91 { - background-color: #8700af; -} - -.terminal .xterm-color-92 { - color: #8700d7; -} - -.terminal .xterm-bg-color-92 { - background-color: #8700d7; -} - -.terminal .xterm-color-93 { - color: #8700ff; -} - -.terminal .xterm-bg-color-93 { - background-color: #8700ff; -} - -.terminal .xterm-color-94 { - color: #875f00; -} - -.terminal .xterm-bg-color-94 { - background-color: #875f00; -} - -.terminal .xterm-color-95 { - color: #875f5f; -} - -.terminal .xterm-bg-color-95 { - background-color: #875f5f; -} - -.terminal .xterm-color-96 { - color: #875f87; -} - -.terminal .xterm-bg-color-96 { - background-color: #875f87; -} - -.terminal .xterm-color-97 { - color: #875faf; -} - -.terminal .xterm-bg-color-97 { - background-color: #875faf; -} - -.terminal .xterm-color-98 { - color: #875fd7; -} - -.terminal .xterm-bg-color-98 { - background-color: #875fd7; -} - -.terminal .xterm-color-99 { - color: #875fff; -} - -.terminal .xterm-bg-color-99 { - background-color: #875fff; -} - -.terminal .xterm-color-100 { - color: #878700; -} - -.terminal .xterm-bg-color-100 { - background-color: #878700; -} - -.terminal .xterm-color-101 { - color: #87875f; -} - -.terminal .xterm-bg-color-101 { - background-color: #87875f; -} - -.terminal .xterm-color-102 { - color: #878787; -} - -.terminal .xterm-bg-color-102 { - background-color: #878787; -} - -.terminal .xterm-color-103 { - color: #8787af; -} - -.terminal .xterm-bg-color-103 { - background-color: #8787af; -} - -.terminal .xterm-color-104 { - color: #8787d7; -} - -.terminal .xterm-bg-color-104 { - background-color: #8787d7; -} - -.terminal .xterm-color-105 { - color: #8787ff; -} - -.terminal .xterm-bg-color-105 { - background-color: #8787ff; -} - -.terminal .xterm-color-106 { - color: #87af00; -} - -.terminal .xterm-bg-color-106 { - background-color: #87af00; -} - -.terminal .xterm-color-107 { - color: #87af5f; -} - -.terminal .xterm-bg-color-107 { - background-color: #87af5f; -} - -.terminal .xterm-color-108 { - color: #87af87; -} - -.terminal .xterm-bg-color-108 { - background-color: #87af87; -} - -.terminal .xterm-color-109 { - color: #87afaf; -} - -.terminal .xterm-bg-color-109 { - background-color: #87afaf; -} - -.terminal .xterm-color-110 { - color: #87afd7; -} - -.terminal .xterm-bg-color-110 { - background-color: #87afd7; -} - -.terminal .xterm-color-111 { - color: #87afff; -} - -.terminal .xterm-bg-color-111 { - background-color: #87afff; -} - -.terminal .xterm-color-112 { - color: #87d700; -} - -.terminal .xterm-bg-color-112 { - background-color: #87d700; -} - -.terminal .xterm-color-113 { - color: #87d75f; -} - -.terminal .xterm-bg-color-113 { - background-color: #87d75f; -} - -.terminal .xterm-color-114 { - color: #87d787; -} - -.terminal .xterm-bg-color-114 { - background-color: #87d787; -} - -.terminal .xterm-color-115 { - color: #87d7af; -} - -.terminal .xterm-bg-color-115 { - background-color: #87d7af; -} - -.terminal .xterm-color-116 { - color: #87d7d7; -} - -.terminal .xterm-bg-color-116 { - background-color: #87d7d7; -} - -.terminal .xterm-color-117 { - color: #87d7ff; -} - -.terminal .xterm-bg-color-117 { - background-color: #87d7ff; -} - -.terminal .xterm-color-118 { - color: #87ff00; -} - -.terminal .xterm-bg-color-118 { - background-color: #87ff00; -} - -.terminal .xterm-color-119 { - color: #87ff5f; -} - -.terminal .xterm-bg-color-119 { - background-color: #87ff5f; -} - -.terminal .xterm-color-120 { - color: #87ff87; -} - -.terminal .xterm-bg-color-120 { - background-color: #87ff87; -} - -.terminal .xterm-color-121 { - color: #87ffaf; -} - -.terminal .xterm-bg-color-121 { - background-color: #87ffaf; -} - -.terminal .xterm-color-122 { - color: #87ffd7; -} - -.terminal .xterm-bg-color-122 { - background-color: #87ffd7; -} - -.terminal .xterm-color-123 { - color: #87ffff; -} - -.terminal .xterm-bg-color-123 { - background-color: #87ffff; -} - -.terminal .xterm-color-124 { - color: #af0000; -} - -.terminal .xterm-bg-color-124 { - background-color: #af0000; -} - -.terminal .xterm-color-125 { - color: #af005f; -} - -.terminal .xterm-bg-color-125 { - background-color: #af005f; -} - -.terminal .xterm-color-126 { - color: #af0087; -} - -.terminal .xterm-bg-color-126 { - background-color: #af0087; -} - -.terminal .xterm-color-127 { - color: #af00af; -} - -.terminal .xterm-bg-color-127 { - background-color: #af00af; -} - -.terminal .xterm-color-128 { - color: #af00d7; -} - -.terminal .xterm-bg-color-128 { - background-color: #af00d7; -} - -.terminal .xterm-color-129 { - color: #af00ff; -} - -.terminal .xterm-bg-color-129 { - background-color: #af00ff; -} - -.terminal .xterm-color-130 { - color: #af5f00; -} - -.terminal .xterm-bg-color-130 { - background-color: #af5f00; -} - -.terminal .xterm-color-131 { - color: #af5f5f; -} - -.terminal .xterm-bg-color-131 { - background-color: #af5f5f; -} - -.terminal .xterm-color-132 { - color: #af5f87; -} - -.terminal .xterm-bg-color-132 { - background-color: #af5f87; -} - -.terminal .xterm-color-133 { - color: #af5faf; -} - -.terminal .xterm-bg-color-133 { - background-color: #af5faf; -} - -.terminal .xterm-color-134 { - color: #af5fd7; -} - -.terminal .xterm-bg-color-134 { - background-color: #af5fd7; -} - -.terminal .xterm-color-135 { - color: #af5fff; -} - -.terminal .xterm-bg-color-135 { - background-color: #af5fff; -} - -.terminal .xterm-color-136 { - color: #af8700; -} - -.terminal .xterm-bg-color-136 { - background-color: #af8700; -} - -.terminal .xterm-color-137 { - color: #af875f; -} - -.terminal .xterm-bg-color-137 { - background-color: #af875f; -} - -.terminal .xterm-color-138 { - color: #af8787; -} - -.terminal .xterm-bg-color-138 { - background-color: #af8787; -} - -.terminal .xterm-color-139 { - color: #af87af; -} - -.terminal .xterm-bg-color-139 { - background-color: #af87af; -} - -.terminal .xterm-color-140 { - color: #af87d7; -} - -.terminal .xterm-bg-color-140 { - background-color: #af87d7; -} - -.terminal .xterm-color-141 { - color: #af87ff; -} - -.terminal .xterm-bg-color-141 { - background-color: #af87ff; -} - -.terminal .xterm-color-142 { - color: #afaf00; -} - -.terminal .xterm-bg-color-142 { - background-color: #afaf00; -} - -.terminal .xterm-color-143 { - color: #afaf5f; -} - -.terminal .xterm-bg-color-143 { - background-color: #afaf5f; -} - -.terminal .xterm-color-144 { - color: #afaf87; -} - -.terminal .xterm-bg-color-144 { - background-color: #afaf87; -} - -.terminal .xterm-color-145 { - color: #afafaf; -} - -.terminal .xterm-bg-color-145 { - background-color: #afafaf; -} - -.terminal .xterm-color-146 { - color: #afafd7; -} - -.terminal .xterm-bg-color-146 { - background-color: #afafd7; -} - -.terminal .xterm-color-147 { - color: #afafff; -} - -.terminal .xterm-bg-color-147 { - background-color: #afafff; -} - -.terminal .xterm-color-148 { - color: #afd700; -} - -.terminal .xterm-bg-color-148 { - background-color: #afd700; -} - -.terminal .xterm-color-149 { - color: #afd75f; -} - -.terminal .xterm-bg-color-149 { - background-color: #afd75f; -} - -.terminal .xterm-color-150 { - color: #afd787; -} - -.terminal .xterm-bg-color-150 { - background-color: #afd787; -} - -.terminal .xterm-color-151 { - color: #afd7af; -} - -.terminal .xterm-bg-color-151 { - background-color: #afd7af; -} - -.terminal .xterm-color-152 { - color: #afd7d7; -} - -.terminal .xterm-bg-color-152 { - background-color: #afd7d7; -} - -.terminal .xterm-color-153 { - color: #afd7ff; -} - -.terminal .xterm-bg-color-153 { - background-color: #afd7ff; -} - -.terminal .xterm-color-154 { - color: #afff00; -} - -.terminal .xterm-bg-color-154 { - background-color: #afff00; -} - -.terminal .xterm-color-155 { - color: #afff5f; -} - -.terminal .xterm-bg-color-155 { - background-color: #afff5f; -} - -.terminal .xterm-color-156 { - color: #afff87; -} - -.terminal .xterm-bg-color-156 { - background-color: #afff87; -} - -.terminal .xterm-color-157 { - color: #afffaf; -} - -.terminal .xterm-bg-color-157 { - background-color: #afffaf; -} - -.terminal .xterm-color-158 { - color: #afffd7; -} - -.terminal .xterm-bg-color-158 { - background-color: #afffd7; -} - -.terminal .xterm-color-159 { - color: #afffff; -} - -.terminal .xterm-bg-color-159 { - background-color: #afffff; -} - -.terminal .xterm-color-160 { - color: #d70000; -} - -.terminal .xterm-bg-color-160 { - background-color: #d70000; -} - -.terminal .xterm-color-161 { - color: #d7005f; -} - -.terminal .xterm-bg-color-161 { - background-color: #d7005f; -} - -.terminal .xterm-color-162 { - color: #d70087; -} - -.terminal .xterm-bg-color-162 { - background-color: #d70087; -} - -.terminal .xterm-color-163 { - color: #d700af; -} - -.terminal .xterm-bg-color-163 { - background-color: #d700af; -} - -.terminal .xterm-color-164 { - color: #d700d7; -} - -.terminal .xterm-bg-color-164 { - background-color: #d700d7; -} - -.terminal .xterm-color-165 { - color: #d700ff; -} - -.terminal .xterm-bg-color-165 { - background-color: #d700ff; -} - -.terminal .xterm-color-166 { - color: #d75f00; -} - -.terminal .xterm-bg-color-166 { - background-color: #d75f00; -} - -.terminal .xterm-color-167 { - color: #d75f5f; -} - -.terminal .xterm-bg-color-167 { - background-color: #d75f5f; -} - -.terminal .xterm-color-168 { - color: #d75f87; -} - -.terminal .xterm-bg-color-168 { - background-color: #d75f87; -} - -.terminal .xterm-color-169 { - color: #d75faf; -} - -.terminal .xterm-bg-color-169 { - background-color: #d75faf; -} - -.terminal .xterm-color-170 { - color: #d75fd7; -} - -.terminal .xterm-bg-color-170 { - background-color: #d75fd7; -} - -.terminal .xterm-color-171 { - color: #d75fff; -} - -.terminal .xterm-bg-color-171 { - background-color: #d75fff; -} - -.terminal .xterm-color-172 { - color: #d78700; -} - -.terminal .xterm-bg-color-172 { - background-color: #d78700; -} - -.terminal .xterm-color-173 { - color: #d7875f; -} - -.terminal .xterm-bg-color-173 { - background-color: #d7875f; -} - -.terminal .xterm-color-174 { - color: #d78787; -} - -.terminal .xterm-bg-color-174 { - background-color: #d78787; -} - -.terminal .xterm-color-175 { - color: #d787af; -} - -.terminal .xterm-bg-color-175 { - background-color: #d787af; -} - -.terminal .xterm-color-176 { - color: #d787d7; -} - -.terminal .xterm-bg-color-176 { - background-color: #d787d7; -} - -.terminal .xterm-color-177 { - color: #d787ff; -} - -.terminal .xterm-bg-color-177 { - background-color: #d787ff; -} - -.terminal .xterm-color-178 { - color: #d7af00; -} - -.terminal .xterm-bg-color-178 { - background-color: #d7af00; -} - -.terminal .xterm-color-179 { - color: #d7af5f; -} - -.terminal .xterm-bg-color-179 { - background-color: #d7af5f; -} - -.terminal .xterm-color-180 { - color: #d7af87; -} - -.terminal .xterm-bg-color-180 { - background-color: #d7af87; -} - -.terminal .xterm-color-181 { - color: #d7afaf; -} - -.terminal .xterm-bg-color-181 { - background-color: #d7afaf; -} - -.terminal .xterm-color-182 { - color: #d7afd7; -} - -.terminal .xterm-bg-color-182 { - background-color: #d7afd7; -} - -.terminal .xterm-color-183 { - color: #d7afff; -} - -.terminal .xterm-bg-color-183 { - background-color: #d7afff; -} - -.terminal .xterm-color-184 { - color: #d7d700; -} - -.terminal .xterm-bg-color-184 { - background-color: #d7d700; -} - -.terminal .xterm-color-185 { - color: #d7d75f; -} - -.terminal .xterm-bg-color-185 { - background-color: #d7d75f; -} - -.terminal .xterm-color-186 { - color: #d7d787; -} - -.terminal .xterm-bg-color-186 { - background-color: #d7d787; -} - -.terminal .xterm-color-187 { - color: #d7d7af; -} - -.terminal .xterm-bg-color-187 { - background-color: #d7d7af; -} - -.terminal .xterm-color-188 { - color: #d7d7d7; -} - -.terminal .xterm-bg-color-188 { - background-color: #d7d7d7; -} - -.terminal .xterm-color-189 { - color: #d7d7ff; -} - -.terminal .xterm-bg-color-189 { - background-color: #d7d7ff; -} - -.terminal .xterm-color-190 { - color: #d7ff00; -} - -.terminal .xterm-bg-color-190 { - background-color: #d7ff00; -} - -.terminal .xterm-color-191 { - color: #d7ff5f; -} - -.terminal .xterm-bg-color-191 { - background-color: #d7ff5f; -} - -.terminal .xterm-color-192 { - color: #d7ff87; -} - -.terminal .xterm-bg-color-192 { - background-color: #d7ff87; -} - -.terminal .xterm-color-193 { - color: #d7ffaf; -} - -.terminal .xterm-bg-color-193 { - background-color: #d7ffaf; -} - -.terminal .xterm-color-194 { - color: #d7ffd7; -} - -.terminal .xterm-bg-color-194 { - background-color: #d7ffd7; -} - -.terminal .xterm-color-195 { - color: #d7ffff; -} - -.terminal .xterm-bg-color-195 { - background-color: #d7ffff; -} - -.terminal .xterm-color-196 { - color: #ff0000; -} - -.terminal .xterm-bg-color-196 { - background-color: #ff0000; -} - -.terminal .xterm-color-197 { - color: #ff005f; -} - -.terminal .xterm-bg-color-197 { - background-color: #ff005f; -} - -.terminal .xterm-color-198 { - color: #ff0087; -} - -.terminal .xterm-bg-color-198 { - background-color: #ff0087; -} - -.terminal .xterm-color-199 { - color: #ff00af; -} - -.terminal .xterm-bg-color-199 { - background-color: #ff00af; -} - -.terminal .xterm-color-200 { - color: #ff00d7; -} - -.terminal .xterm-bg-color-200 { - background-color: #ff00d7; -} - -.terminal .xterm-color-201 { - color: #ff00ff; -} - -.terminal .xterm-bg-color-201 { - background-color: #ff00ff; -} - -.terminal .xterm-color-202 { - color: #ff5f00; -} - -.terminal .xterm-bg-color-202 { - background-color: #ff5f00; -} - -.terminal .xterm-color-203 { - color: #ff5f5f; -} - -.terminal .xterm-bg-color-203 { - background-color: #ff5f5f; -} - -.terminal .xterm-color-204 { - color: #ff5f87; -} - -.terminal .xterm-bg-color-204 { - background-color: #ff5f87; -} - -.terminal .xterm-color-205 { - color: #ff5faf; -} - -.terminal .xterm-bg-color-205 { - background-color: #ff5faf; -} - -.terminal .xterm-color-206 { - color: #ff5fd7; -} - -.terminal .xterm-bg-color-206 { - background-color: #ff5fd7; -} - -.terminal .xterm-color-207 { - color: #ff5fff; -} - -.terminal .xterm-bg-color-207 { - background-color: #ff5fff; -} - -.terminal .xterm-color-208 { - color: #ff8700; -} - -.terminal .xterm-bg-color-208 { - background-color: #ff8700; -} - -.terminal .xterm-color-209 { - color: #ff875f; -} - -.terminal .xterm-bg-color-209 { - background-color: #ff875f; -} - -.terminal .xterm-color-210 { - color: #ff8787; -} - -.terminal .xterm-bg-color-210 { - background-color: #ff8787; -} - -.terminal .xterm-color-211 { - color: #ff87af; -} - -.terminal .xterm-bg-color-211 { - background-color: #ff87af; -} - -.terminal .xterm-color-212 { - color: #ff87d7; -} - -.terminal .xterm-bg-color-212 { - background-color: #ff87d7; -} - -.terminal .xterm-color-213 { - color: #ff87ff; -} - -.terminal .xterm-bg-color-213 { - background-color: #ff87ff; -} - -.terminal .xterm-color-214 { - color: #ffaf00; -} - -.terminal .xterm-bg-color-214 { - background-color: #ffaf00; -} - -.terminal .xterm-color-215 { - color: #ffaf5f; -} - -.terminal .xterm-bg-color-215 { - background-color: #ffaf5f; -} - -.terminal .xterm-color-216 { - color: #ffaf87; -} - -.terminal .xterm-bg-color-216 { - background-color: #ffaf87; -} - -.terminal .xterm-color-217 { - color: #ffafaf; -} - -.terminal .xterm-bg-color-217 { - background-color: #ffafaf; -} - -.terminal .xterm-color-218 { - color: #ffafd7; -} - -.terminal .xterm-bg-color-218 { - background-color: #ffafd7; -} - -.terminal .xterm-color-219 { - color: #ffafff; -} - -.terminal .xterm-bg-color-219 { - background-color: #ffafff; -} - -.terminal .xterm-color-220 { - color: #ffd700; -} - -.terminal .xterm-bg-color-220 { - background-color: #ffd700; -} - -.terminal .xterm-color-221 { - color: #ffd75f; -} - -.terminal .xterm-bg-color-221 { - background-color: #ffd75f; -} - -.terminal .xterm-color-222 { - color: #ffd787; -} - -.terminal .xterm-bg-color-222 { - background-color: #ffd787; -} - -.terminal .xterm-color-223 { - color: #ffd7af; -} - -.terminal .xterm-bg-color-223 { - background-color: #ffd7af; -} - -.terminal .xterm-color-224 { - color: #ffd7d7; -} - -.terminal .xterm-bg-color-224 { - background-color: #ffd7d7; -} - -.terminal .xterm-color-225 { - color: #ffd7ff; -} - -.terminal .xterm-bg-color-225 { - background-color: #ffd7ff; -} - -.terminal .xterm-color-226 { - color: #ffff00; -} - -.terminal .xterm-bg-color-226 { - background-color: #ffff00; -} - -.terminal .xterm-color-227 { - color: #ffff5f; -} - -.terminal .xterm-bg-color-227 { - background-color: #ffff5f; -} - -.terminal .xterm-color-228 { - color: #ffff87; -} - -.terminal .xterm-bg-color-228 { - background-color: #ffff87; -} - -.terminal .xterm-color-229 { - color: #ffffaf; -} - -.terminal .xterm-bg-color-229 { - background-color: #ffffaf; -} - -.terminal .xterm-color-230 { - color: #ffffd7; -} - -.terminal .xterm-bg-color-230 { - background-color: #ffffd7; -} - -.terminal .xterm-color-231 { - color: #ffffff; -} - -.terminal .xterm-bg-color-231 { - background-color: #ffffff; -} - -.terminal .xterm-color-232 { - color: #080808; -} - -.terminal .xterm-bg-color-232 { - background-color: #080808; -} - -.terminal .xterm-color-233 { - color: #121212; -} - -.terminal .xterm-bg-color-233 { - background-color: #121212; -} - -.terminal .xterm-color-234 { - color: #1c1c1c; -} - -.terminal .xterm-bg-color-234 { - background-color: #1c1c1c; -} - -.terminal .xterm-color-235 { - color: #262626; -} - -.terminal .xterm-bg-color-235 { - background-color: #262626; -} - -.terminal .xterm-color-236 { - color: #303030; -} - -.terminal .xterm-bg-color-236 { - background-color: #303030; -} - -.terminal .xterm-color-237 { - color: #3a3a3a; -} - -.terminal .xterm-bg-color-237 { - background-color: #3a3a3a; -} - -.terminal .xterm-color-238 { - color: #444444; -} - -.terminal .xterm-bg-color-238 { - background-color: #444444; -} - -.terminal .xterm-color-239 { - color: #4e4e4e; -} - -.terminal .xterm-bg-color-239 { - background-color: #4e4e4e; -} - -.terminal .xterm-color-240 { - color: #585858; -} - -.terminal .xterm-bg-color-240 { - background-color: #585858; -} - -.terminal .xterm-color-241 { - color: #626262; -} - -.terminal .xterm-bg-color-241 { - background-color: #626262; -} - -.terminal .xterm-color-242 { - color: #6c6c6c; -} - -.terminal .xterm-bg-color-242 { - background-color: #6c6c6c; -} - -.terminal .xterm-color-243 { - color: #767676; -} - -.terminal .xterm-bg-color-243 { - background-color: #767676; -} - -.terminal .xterm-color-244 { - color: #808080; -} - -.terminal .xterm-bg-color-244 { - background-color: #808080; -} - -.terminal .xterm-color-245 { - color: #8a8a8a; -} - -.terminal .xterm-bg-color-245 { - background-color: #8a8a8a; -} - -.terminal .xterm-color-246 { - color: #949494; -} - -.terminal .xterm-bg-color-246 { - background-color: #949494; -} - -.terminal .xterm-color-247 { - color: #9e9e9e; -} - -.terminal .xterm-bg-color-247 { - background-color: #9e9e9e; -} - -.terminal .xterm-color-248 { - color: #a8a8a8; -} - -.terminal .xterm-bg-color-248 { - background-color: #a8a8a8; -} - -.terminal .xterm-color-249 { - color: #b2b2b2; -} - -.terminal .xterm-bg-color-249 { - background-color: #b2b2b2; -} - -.terminal .xterm-color-250 { - color: #bcbcbc; -} - -.terminal .xterm-bg-color-250 { - background-color: #bcbcbc; -} - -.terminal .xterm-color-251 { - color: #c6c6c6; -} - -.terminal .xterm-bg-color-251 { - background-color: #c6c6c6; -} - -.terminal .xterm-color-252 { - color: #d0d0d0; -} - -.terminal .xterm-bg-color-252 { - background-color: #d0d0d0; -} - -.terminal .xterm-color-253 { - color: #dadada; -} - -.terminal .xterm-bg-color-253 { - background-color: #dadada; -} - -.terminal .xterm-color-254 { - color: #e4e4e4; -} - -.terminal .xterm-bg-color-254 { - background-color: #e4e4e4; -} - -.terminal .xterm-color-255 { - color: #eeeeee; -} - -.terminal .xterm-bg-color-255 { - background-color: #eeeeee; +.terminal:not(.enable-mouse-events) { + cursor: text; } diff --git a/src/xterm.ts b/src/xterm.ts index 50ac559287..6df3a4c065 100644 --- a/src/xterm.ts +++ b/src/xterm.ts @@ -1,4 +1,5 @@ /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. * @license MIT * * This file is the entry point for browserify. diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index ccc83ed90f..7a4d6940a5 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -41,6 +41,21 @@ interface ITerminalOptions { */ disableStdin?: boolean; + /** + * The font size used to render text. + */ + fontSize?: number; + + /** + * The font family used to render text. + */ + fontFamily?: string; + + /** + * The line height used to render text. + */ + lineHeight?: number; + /** * The number of rows in the terminal. */ @@ -56,6 +71,57 @@ interface ITerminalOptions { * The size of tab stops in the terminal. */ tabStopWidth?: number; + + /** + * The color theme of the terminal. + */ + theme?: ITheme; +} + +/** + * Contains colors to theme the terminal with. + */ +interface ITheme { + /** The default foreground color */ + foreground?: string, + /** The default background color */ + background?: string, + /** The cursor color */ + cursor?: string, + /** The selection color (can be transparent) */ + selection?: string, + /** ANSI black (eg. `\x1b[30m`) */ + black?: string, + /** ANSI red (eg. `\x1b[31m`) */ + red?: string, + /** ANSI green (eg. `\x1b[32m`) */ + green?: string, + /** ANSI yellow (eg. `\x1b[33m`) */ + yellow?: string, + /** ANSI blue (eg. `\x1b[34m`) */ + blue?: string, + /** ANSI magenta (eg. `\x1b[35m`) */ + magenta?: string, + /** ANSI cyan (eg. `\x1b[36m`) */ + cyan?: string, + /** ANSI white (eg. `\x1b[37m`) */ + white?: string, + /** ANSI bright black (eg. `\x1b[1;30m`) */ + brightBlack?: string, + /** ANSI bright red (eg. `\x1b[1;31m`) */ + brightRed?: string, + /** ANSI bright green (eg. `\x1b[1;32m`) */ + brightGreen?: string, + /** ANSI bright yellow (eg. `\x1b[1;33m`) */ + brightYellow?: string, + /** ANSI bright blue (eg. `\x1b[1;34m`) */ + brightBlue?: string, + /** ANSI bright magenta (eg. `\x1b[1;35m`) */ + brightMagenta?: string, + /** ANSI bright cyan (eg. `\x1b[1;36m`) */ + brightCyan?: string, + /** ANSI bright white (eg. `\x1b[1;37m`) */ + brightWhite?: string } /** @@ -72,7 +138,18 @@ interface ILinkMatcherOptions { * A callback that validates an individual link, returning true if valid and * false if invalid. */ - validationCallback?: (uri: string, element: HTMLElement, callback: (isValid: boolean) => void) => void; + validationCallback?: (uri: string, callback: (isValid: boolean) => void) => void; + + /** + * A callback that fires when the mouse hovers over a link for a moment. + */ + tooltipCallback?: (event: MouseEvent, uri: string) => boolean | void; + + /** + * A callback that fires when the mouse leaves a link. Note that this can + * happen even when tooltipCallback hasn't fired for the link yet. + */ + leaveCallback?: (event: MouseEvent, uri: string) => boolean | void; /** * The priority of the link matcher, this defines the order in which the link @@ -226,7 +303,7 @@ declare module 'xterm' { * @param options Options for the link matcher. * @return The ID of the new matcher, this can be used to deregister. */ - registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => boolean | void , options?: ILinkMatcherOptions): number; + registerLinkMatcher(regex: RegExp, handler: (event: MouseEvent, uri: string) => boolean | void, options?: ILinkMatcherOptions): number; /** * (EXPERIMENTAL) Deregisters a link matcher if it has been registered. @@ -313,7 +390,7 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'termName'): string; + getOption(key: 'bellSound' | 'bellStyle' | 'cursorStyle' | 'fontFamily' | 'termName'): string; /** * Retrieves an option's value from the terminal. * @param key The option key. @@ -328,7 +405,7 @@ declare module 'xterm' { * Retrieves an option's value from the terminal. * @param key The option key. */ - getOption(key: 'cols' | 'rows' | 'tabStopWidth' | 'scrollback'): number; + getOption(key: 'cols' | 'fontSize' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback'): number; /** * Retrieves an option's value from the terminal. * @param key The option key. @@ -350,7 +427,7 @@ declare module 'xterm' { * @param key The option key. * @param value The option value. */ - setOption(key: 'termName' | 'bellSound', value: string): void; + setOption(key: 'fontFamily' | 'termName' | 'bellSound', value: string): void; /** * Sets an option on the terminal. * @param key The option key. @@ -380,7 +457,7 @@ declare module 'xterm' { * @param key The option key. * @param value The option value. */ - setOption(key: 'cols' | 'rows' | 'tabStopWidth' | 'scrollback', value: number): void; + setOption(key: 'cols' | 'fontSize' | 'lineHeight' | 'rows' | 'tabStopWidth' | 'scrollback', value: number): void; /** * Sets an option on the terminal. * @param key The option key. @@ -393,6 +470,12 @@ declare module 'xterm' { * @param value The option value. */ setOption(key: 'handler', value: (data: string) => void): void; + /** + * Sets an option on the terminal. + * @param key The option key. + * @param value The option value. + */ + setOption(key: 'theme', value: ITheme): void; /** * Sets an option on the terminal. * @param key The option key.