/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
'use strict';
import * as browser from 'vs/base/browser/browser';
import * as platform from 'vs/base/common/platform';
import * as strings from 'vs/base/common/strings';
import { FastDomNode, createFastDomNode } from 'vs/base/browser/styleMutator';
import { IConfiguration } from 'vs/editor/common/editorCommon';
import { Decoration } from 'vs/editor/common/viewLayout/viewLineParts';
import { renderViewLine, RenderLineInput, CharacterMapping } from 'vs/editor/common/viewLayout/viewLineRenderer';
import { ClassNames } from 'vs/editor/browser/editorBrowser';
import { IVisibleLine } from 'vs/editor/browser/view/viewLayer';
import { RangeUtil } from 'vs/editor/browser/viewParts/lines/rangeUtil';
import { HorizontalRange } from 'vs/editor/common/view/renderingContext';
import { ViewportData } from 'vs/editor/common/viewLayout/viewLinesViewportData';
const canUseFastRenderedViewLine = (function () {
if (platform.isNative) {
// In VSCode we know very well when the zoom level changes
return true;
}
if (platform.isLinux || browser.isFirefox || browser.isSafari) {
// On Linux, it appears that zooming affects char widths (in pixels), which is unexpected.
// --
// Even though we read character widths correctly, having read them at a specific zoom level
// does not mean they are the same at the current zoom level.
// --
// This could be improved if we ever figure out how to get an event when browsers zoom,
// but until then we have to stick with reading client rects.
// --
// The same has been observed with Firefox on Windows7
// --
// The same has been oversved with Safari
return false;
}
return true;
})();
export class DomReadingContext {
private readonly _domNode: HTMLElement;
private _clientRectDeltaLeft: number;
private _clientRectDeltaLeftRead: boolean;
public get clientRectDeltaLeft(): number {
if (!this._clientRectDeltaLeftRead) {
this._clientRectDeltaLeftRead = true;
this._clientRectDeltaLeft = this._domNode.getBoundingClientRect().left;
}
return this._clientRectDeltaLeft;
}
public readonly endNode: HTMLElement;
constructor(domNode: HTMLElement, endNode: HTMLElement) {
this._domNode = domNode;
this._clientRectDeltaLeft = 0;
this._clientRectDeltaLeftRead = false;
this.endNode = endNode;
}
}
export class ViewLineOptions {
public readonly renderWhitespace: 'none' | 'boundary' | 'all';
public readonly renderControlCharacters: boolean;
public readonly spaceWidth: number;
public readonly useMonospaceOptimizations: boolean;
public readonly lineHeight: number;
public readonly stopRenderingLineAfter: number;
constructor(config: IConfiguration) {
this.renderWhitespace = config.editor.viewInfo.renderWhitespace;
this.renderControlCharacters = config.editor.viewInfo.renderControlCharacters;
this.spaceWidth = config.editor.fontInfo.spaceWidth;
this.useMonospaceOptimizations = (
config.editor.fontInfo.isMonospace
&& !config.editor.viewInfo.disableMonospaceOptimizations
);
this.lineHeight = config.editor.lineHeight;
this.stopRenderingLineAfter = config.editor.viewInfo.stopRenderingLineAfter;
}
public equals(other: ViewLineOptions): boolean {
return (
this.renderWhitespace === other.renderWhitespace
&& this.renderControlCharacters === other.renderControlCharacters
&& this.spaceWidth === other.spaceWidth
&& this.useMonospaceOptimizations === other.useMonospaceOptimizations
&& this.lineHeight === other.lineHeight
&& this.stopRenderingLineAfter === other.stopRenderingLineAfter
);
}
}
export class ViewLine implements IVisibleLine {
private _options: ViewLineOptions;
private _isMaybeInvalid: boolean;
private _renderedViewLine: IRenderedViewLine;
constructor(options: ViewLineOptions) {
this._options = options;
this._isMaybeInvalid = true;
this._renderedViewLine = null;
}
// --- begin IVisibleLineData
public getDomNode(): HTMLElement {
if (this._renderedViewLine && this._renderedViewLine.domNode) {
return this._renderedViewLine.domNode.domNode;
}
return null;
}
public setDomNode(domNode: HTMLElement): void {
if (this._renderedViewLine) {
this._renderedViewLine.domNode = createFastDomNode(domNode);
} else {
throw new Error('I have no rendered view line to set the dom node to...');
}
}
public onContentChanged(): void {
this._isMaybeInvalid = true;
}
public onTokensChanged(): void {
this._isMaybeInvalid = true;
}
public onModelDecorationsChanged(): void {
this._isMaybeInvalid = true;
}
public onOptionsChanged(newOptions: ViewLineOptions): void {
this._isMaybeInvalid = true;
this._options = newOptions;
}
public renderLine(lineNumber: number, deltaTop: number, viewportData: ViewportData): string {
if (this._isMaybeInvalid === false) {
// it appears that nothing relevant has changed
return null;
}
this._isMaybeInvalid = false;
const lineData = viewportData.getViewLineRenderingData(lineNumber);
const options = this._options;
const actualInlineDecorations = Decoration.filter(lineData.inlineDecorations, lineNumber, lineData.minColumn, lineData.maxColumn);
let renderLineInput = new RenderLineInput(
options.useMonospaceOptimizations,
lineData.content,
lineData.mightContainRTL,
lineData.minColumn - 1,
lineData.tokens,
actualInlineDecorations,
lineData.tabSize,
options.spaceWidth,
options.stopRenderingLineAfter,
options.renderWhitespace,
options.renderControlCharacters
);
if (this._renderedViewLine && this._renderedViewLine.input.equals(renderLineInput)) {
// no need to do anything, we have the same render input
return null;
}
const output = renderViewLine(renderLineInput);
let renderedViewLine: IRenderedViewLine = null;
if (canUseFastRenderedViewLine && options.useMonospaceOptimizations && !output.containsForeignElements) {
let isRegularASCII = true;
if (lineData.mightContainNonBasicASCII) {
isRegularASCII = strings.isBasicASCII(lineData.content);
}
if (isRegularASCII && lineData.content.length < 1000) {
// Browser rounding errors have been observed in Chrome and IE, so using the fast
// view line only for short lines. Please test before removing the length check...
renderedViewLine = new FastRenderedViewLine(
this._renderedViewLine ? this._renderedViewLine.domNode : null,
renderLineInput,
output.characterMapping
);
}
}
if (!renderedViewLine) {
renderedViewLine = createRenderedLine(
this._renderedViewLine ? this._renderedViewLine.domNode : null,
renderLineInput,
output.characterMapping,
output.containsRTL
);
}
this._renderedViewLine = renderedViewLine;
return `
${output.html}
`;
}
public layoutLine(lineNumber: number, deltaTop: number): void {
if (this._renderedViewLine && this._renderedViewLine.domNode) {
this._renderedViewLine.domNode.setTop(deltaTop);
this._renderedViewLine.domNode.setHeight(this._options.lineHeight);
}
}
// --- end IVisibleLineData
public getWidth(): number {
if (!this._renderedViewLine) {
return 0;
}
return this._renderedViewLine.getWidth();
}
public getVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[] {
return this._renderedViewLine.getVisibleRangesForRange(startColumn, endColumn, context);
}
public getColumnOfNodeOffset(lineNumber: number, spanNode: HTMLElement, offset: number): number {
return this._renderedViewLine.getColumnOfNodeOffset(lineNumber, spanNode, offset);
}
}
interface IRenderedViewLine {
domNode: FastDomNode;
readonly input: RenderLineInput;
getWidth(): number;
getVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[];
getColumnOfNodeOffset(lineNumber: number, spanNode: HTMLElement, offset: number): number;
}
/**
* A rendered line which is guaranteed to contain only regular ASCII and is rendered with a monospace font.
*/
class FastRenderedViewLine implements IRenderedViewLine {
public domNode: FastDomNode;
public readonly input: RenderLineInput;
private readonly _characterMapping: CharacterMapping;
private readonly _charWidth: number;
private _charOffset: Uint32Array;
constructor(domNode: FastDomNode, renderLineInput: RenderLineInput, characterMapping: CharacterMapping) {
this.domNode = domNode;
this.input = renderLineInput;
this._characterMapping = characterMapping;
this._charWidth = renderLineInput.spaceWidth;
this._charOffset = null;
}
private static _createCharOffset(characterMapping: CharacterMapping): Uint32Array {
const partLengths = characterMapping.getPartLengths();
const len = characterMapping.length;
let result = new Uint32Array(len);
let currentPartIndex = 0;
let currentPartOffset = 0;
for (let ch = 0; ch < len; ch++) {
const partData = characterMapping.charOffsetToPartData(ch);
const partIndex = CharacterMapping.getPartIndex(partData);
const charIndex = CharacterMapping.getCharIndex(partData);
while (currentPartIndex < partIndex) {
currentPartOffset += partLengths[currentPartIndex];
currentPartIndex++;
}
result[ch] = currentPartOffset + charIndex;
}
return result;
}
private _getOrCreateCharOffset(): Uint32Array {
if (this._charOffset === null) {
this._charOffset = FastRenderedViewLine._createCharOffset(this._characterMapping);
}
return this._charOffset;
}
public getWidth(): number {
const charOffset = this._getOrCreateCharOffset();
return this._getCharPosition(charOffset.length);
}
public getVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[] {
startColumn = startColumn | 0; // @perf
endColumn = endColumn | 0; // @perf
const stopRenderingLineAfter = this.input.stopRenderingLineAfter | 0; // @perf
if (stopRenderingLineAfter !== -1 && startColumn > stopRenderingLineAfter && endColumn > stopRenderingLineAfter) {
// This range is obviously not visible
return null;
}
if (stopRenderingLineAfter !== -1 && startColumn > stopRenderingLineAfter) {
startColumn = stopRenderingLineAfter;
}
if (stopRenderingLineAfter !== -1 && endColumn > stopRenderingLineAfter) {
endColumn = stopRenderingLineAfter;
}
const startPosition = this._getCharPosition(startColumn);
const endPosition = this._getCharPosition(endColumn);
return [new HorizontalRange(startPosition, endPosition - startPosition)];
}
private _getCharPosition(column: number): number {
const charOffset = this._getOrCreateCharOffset();
if (charOffset.length === 0) {
// No characters on this line
return 0;
}
return Math.round(this._charWidth * charOffset[column - 1]);
}
public getColumnOfNodeOffset(lineNumber: number, spanNode: HTMLElement, offset: number): number {
let spanNodeTextContentLength = spanNode.textContent.length;
let spanIndex = -1;
while (spanNode) {
spanNode = spanNode.previousSibling;
spanIndex++;
}
let charOffset = this._characterMapping.partDataToCharOffset(spanIndex, spanNodeTextContentLength, offset);
return charOffset + 1;
}
}
/**
* Every time we render a line, we save what we have rendered in an instance of this class.
*/
class RenderedViewLine {
public domNode: FastDomNode;
public readonly input: RenderLineInput;
protected readonly _characterMapping: CharacterMapping;
private readonly _isWhitespaceOnly: boolean;
private _cachedWidth: number;
/**
* This is a map that is used only when the line is guaranteed to have no RTL text.
*/
private _pixelOffsetCache: Int32Array;
constructor(domNode: FastDomNode, renderLineInput: RenderLineInput, characterMapping: CharacterMapping, containsRTL: boolean) {
this.domNode = domNode;
this.input = renderLineInput;
this._characterMapping = characterMapping;
this._isWhitespaceOnly = /^\s*$/.test(renderLineInput.lineContent);
this._cachedWidth = -1;
this._pixelOffsetCache = null;
if (!containsRTL) {
this._pixelOffsetCache = new Int32Array(this._characterMapping.length + 1);
for (let column = 0, len = this._characterMapping.length; column <= len; column++) {
this._pixelOffsetCache[column] = -1;
}
}
}
// --- Reading from the DOM methods
protected _getReadingTarget(): HTMLElement {
return this.domNode.domNode.firstChild;
}
/**
* Width of the line in pixels
*/
public getWidth(): number {
if (this._cachedWidth === -1) {
this._cachedWidth = this._getReadingTarget().offsetWidth;
}
return this._cachedWidth;
}
/**
* Visible ranges for a model range
*/
public getVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[] {
startColumn = startColumn | 0; // @perf
endColumn = endColumn | 0; // @perf
const stopRenderingLineAfter = this.input.stopRenderingLineAfter | 0; // @perf
if (stopRenderingLineAfter !== -1 && startColumn > stopRenderingLineAfter && endColumn > stopRenderingLineAfter) {
// This range is obviously not visible
return null;
}
if (stopRenderingLineAfter !== -1 && startColumn > stopRenderingLineAfter) {
startColumn = stopRenderingLineAfter;
}
if (stopRenderingLineAfter !== -1 && endColumn > stopRenderingLineAfter) {
endColumn = stopRenderingLineAfter;
}
if (this._pixelOffsetCache !== null) {
// the text is LTR
let startOffset = this._readPixelOffset(startColumn, context);
if (startOffset === -1) {
return null;
}
let endOffset = this._readPixelOffset(endColumn, context);
if (endOffset === -1) {
return null;
}
return [new HorizontalRange(startOffset, endOffset - startOffset)];
}
return this._readVisibleRangesForRange(startColumn, endColumn, context);
}
protected _readVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[] {
if (startColumn === endColumn) {
let pixelOffset = this._readPixelOffset(startColumn, context);
if (pixelOffset === -1) {
return null;
} else {
return [new HorizontalRange(pixelOffset, 0)];
}
} else {
return this._readRawVisibleRangesForRange(startColumn, endColumn, context);
}
}
protected _readPixelOffset(column: number, context: DomReadingContext): number {
if (this._pixelOffsetCache !== null) {
// the text is LTR
let cachedPixelOffset = this._pixelOffsetCache[column];
if (cachedPixelOffset !== -1) {
return cachedPixelOffset;
}
let result = this._actualReadPixelOffset(column, context);
this._pixelOffsetCache[column] = result;
return result;
}
return this._actualReadPixelOffset(column, context);
}
private _actualReadPixelOffset(column: number, context: DomReadingContext): number {
if (this._characterMapping.length === 0) {
// This line is empty
return 0;
}
if (column === this._characterMapping.length && this._isWhitespaceOnly) {
// This branch helps in the case of whitespace only lines which have a width set
return this.getWidth();
}
let partData = this._characterMapping.charOffsetToPartData(column - 1);
let partIndex = CharacterMapping.getPartIndex(partData);
let charOffsetInPart = CharacterMapping.getCharIndex(partData);
let r = RangeUtil.readHorizontalRanges(this._getReadingTarget(), partIndex, charOffsetInPart, partIndex, charOffsetInPart, context.clientRectDeltaLeft, context.endNode);
if (!r || r.length === 0) {
return -1;
}
return r[0].left;
}
private _readRawVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[] {
if (startColumn === 1 && endColumn === this._characterMapping.length) {
// This branch helps IE with bidi text & gives a performance boost to other browsers when reading visible ranges for an entire line
return [new HorizontalRange(0, this.getWidth())];
}
let startPartData = this._characterMapping.charOffsetToPartData(startColumn - 1);
let startPartIndex = CharacterMapping.getPartIndex(startPartData);
let startCharOffsetInPart = CharacterMapping.getCharIndex(startPartData);
let endPartData = this._characterMapping.charOffsetToPartData(endColumn - 1);
let endPartIndex = CharacterMapping.getPartIndex(endPartData);
let endCharOffsetInPart = CharacterMapping.getCharIndex(endPartData);
return RangeUtil.readHorizontalRanges(this._getReadingTarget(), startPartIndex, startCharOffsetInPart, endPartIndex, endCharOffsetInPart, context.clientRectDeltaLeft, context.endNode);
}
/**
* Returns the column for the text found at a specific offset inside a rendered dom node
*/
public getColumnOfNodeOffset(lineNumber: number, spanNode: HTMLElement, offset: number): number {
let spanNodeTextContentLength = spanNode.textContent.length;
let spanIndex = -1;
while (spanNode) {
spanNode = spanNode.previousSibling;
spanIndex++;
}
let charOffset = this._characterMapping.partDataToCharOffset(spanIndex, spanNodeTextContentLength, offset);
return charOffset + 1;
}
}
class WebKitRenderedViewLine extends RenderedViewLine {
protected _readVisibleRangesForRange(startColumn: number, endColumn: number, context: DomReadingContext): HorizontalRange[] {
let output = super._readVisibleRangesForRange(startColumn, endColumn, context);
if (!output || output.length === 0 || startColumn === endColumn || (startColumn === 1 && endColumn === this._characterMapping.length)) {
return output;
}
// WebKit is buggy and returns an expanded range (to contain words in some cases)
// The last client rect is enlarged (I think)
// This is an attempt to patch things up
// Find position of previous column
let beforeEndPixelOffset = this._readPixelOffset(endColumn - 1, context);
// Find position of last column
let endPixelOffset = this._readPixelOffset(endColumn, context);
if (beforeEndPixelOffset !== -1 && endPixelOffset !== -1) {
let isLTR = (beforeEndPixelOffset <= endPixelOffset);
let lastRange = output[output.length - 1];
if (isLTR && lastRange.left < endPixelOffset) {
// Trim down the width of the last visible range to not go after the last column's position
lastRange.width = endPixelOffset - lastRange.left;
}
}
return output;
}
}
const createRenderedLine: (domNode: FastDomNode, renderLineInput: RenderLineInput, characterMapping: CharacterMapping, containsRTL: boolean) => RenderedViewLine = (function () {
if (browser.isWebKit) {
return createWebKitRenderedLine;
}
return createNormalRenderedLine;
})();
function createWebKitRenderedLine(domNode: FastDomNode, renderLineInput: RenderLineInput, characterMapping: CharacterMapping, containsRTL: boolean): RenderedViewLine {
return new WebKitRenderedViewLine(domNode, renderLineInput, characterMapping, containsRTL);
}
function createNormalRenderedLine(domNode: FastDomNode, renderLineInput: RenderLineInput, characterMapping: CharacterMapping, containsRTL: boolean): RenderedViewLine {
return new RenderedViewLine(domNode, renderLineInput, characterMapping, containsRTL);
}