Skip to content

Commit

Permalink
Fix advanced line wrap if decorations are present
Browse files Browse the repository at this point in the history
Decorations may affect line wrapping. For example, they make make the
text bold, or increase or decrease font size. This wasn’t originally
taken into account to calculate the the break offsets.
  • Loading branch information
remcohaszing committed Jul 5, 2024
1 parent 796dfbb commit 7e65ed2
Show file tree
Hide file tree
Showing 9 changed files with 144 additions and 54 deletions.
152 changes: 105 additions & 47 deletions src/vs/editor/browser/view/domLineBreaksComputer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { applyFontInfo } from 'vs/editor/browser/config/domFontInfo';
import { WrappingIndent } from 'vs/editor/common/config/editorOptions';
import { FontInfo } from 'vs/editor/common/config/fontInfo';
import { StringBuilder } from 'vs/editor/common/core/stringBuilder';
import { InjectedTextOptions } from 'vs/editor/common/model';
import { IModelDecoration, InjectedTextOptions } from 'vs/editor/common/model';
import { ILineBreaksComputer, ILineBreaksComputerFactory, ModelLineProjectionData } from 'vs/editor/common/modelLineProjectionData';
import { LineInjectedText } from 'vs/editor/common/textModelEvents';

Expand All @@ -34,14 +34,14 @@ export class DOMLineBreaksComputerFactory implements ILineBreaksComputerFactory
requests.push(lineText);
injectedTexts.push(injectedText);
},
finalize: () => {
return createLineBreaks(assertIsDefined(this.targetWindow.deref()), requests, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak, injectedTexts);
finalize: (decorations) => {
return createLineBreaks(assertIsDefined(this.targetWindow.deref()), requests, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak, injectedTexts, decorations);
}
};
}
}

function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: FontInfo, tabSize: number, firstLineBreakColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', injectedTextsPerLine: (LineInjectedText[] | null)[]): (ModelLineProjectionData | null)[] {
function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: FontInfo, tabSize: number, firstLineBreakColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', injectedTextsPerLine: (LineInjectedText[] | null)[], decorations: IModelDecoration[]): (ModelLineProjectionData | null)[] {
function createEmptyLineBreakWithPossiblyInjectedText(requestIdx: number): ModelLineProjectionData | null {
const injectedTexts = injectedTextsPerLine[requestIdx];
if (injectedTexts) {
Expand Down Expand Up @@ -78,7 +78,6 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo
const firstNonWhitespaceIndices: number[] = [];
const wrappedTextIndentLengths: number[] = [];
const renderLineContents: string[] = [];
const allCharOffsets: number[][] = [];
const allVisibleColumns: number[][] = [];
for (let i = 0; i < requests.length; i++) {
const lineContent = LineInjectedText.applyInjectedText(requests[i], injectedTextsPerLine[i]);
Expand Down Expand Up @@ -118,12 +117,10 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo
}

const renderLineContent = lineContent.substr(firstNonWhitespaceIndex);
const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength);
allVisibleColumns[i] = renderLine(i, renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength, decorations);
firstNonWhitespaceIndices[i] = firstNonWhitespaceIndex;
wrappedTextIndentLengths[i] = wrappedTextIndentLength;
renderLineContents[i] = renderLineContent;
allCharOffsets[i] = tmp[0];
allVisibleColumns[i] = tmp[1];
}
const html = sb.build();
const trustedhtml = ttPolicy?.createHTML(html) ?? html;
Expand All @@ -148,7 +145,7 @@ function createLineBreaks(targetWindow: Window, requests: string[], fontInfo: Fo
const result: (ModelLineProjectionData | null)[] = [];
for (let i = 0; i < requests.length; i++) {
const lineDomNode = lineDomNodes[i];
const breakOffsets: number[] | null = readLineBreaks(range, lineDomNode, renderLineContents[i], allCharOffsets[i]);
const breakOffsets: number[] | null = readLineBreaks(range, lineDomNode, renderLineContents[i]);
if (breakOffsets === null) {
result[i] = createEmptyLineBreakWithPossiblyInjectedText(i);
continue;
Expand Down Expand Up @@ -192,8 +189,7 @@ const enum Constants {
SPAN_MODULO_LIMIT = 16384
}

function renderLine(lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number): [number[], number[]] {

function renderLine(index: number, lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number, decorations: IModelDecoration[]): number[] {
if (wrappingIndentLength !== 0) {
const hangingOffset = String(wrappingIndentLength);
sb.appendString('<div style="text-indent: -');
Expand All @@ -213,16 +209,69 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize:
const len = lineContent.length;
let visibleColumn = initialVisibleColumn;
let charOffset = 0;
const charOffsets: number[] = [];
const visibleColumns: number[] = [];
let nextCharCode = (0 < len ? lineContent.charCodeAt(0) : CharCode.Null);

const classNames = new Array<string>(len);
const lineNumber = index + 1;

for (let i = 0; i < decorations.length; i++) {
const decoration = decorations[i];
const inlineClassName = decoration.options.inlineClassName;
const isWholeLine = decoration.options.isWholeLine;

if (!inlineClassName) {
continue;
}

const range = decoration.range;
let startCharIndex = range.startColumn - 1;
let endCharIndex = range.endColumn - 1;

if (range.endLineNumber < lineNumber) {
continue;
}

if (range.startLineNumber > lineNumber) {
continue;
}

if (isWholeLine || range.endLineNumber !== lineNumber) {
endCharIndex = len;
}

if (isWholeLine || range.startLineNumber !== lineNumber) {
startCharIndex = 0;
}

for (let charIndex = startCharIndex; charIndex < endCharIndex; charIndex++) {
classNames[charIndex] = (classNames[charIndex] || '') + ' ' + inlineClassName;
}
}

// Forxe the text to be aligned vertically. We use the middle alignment to
// calculate whether or not a line is wrapped.
const style = '<span style="vertical-align:middle !important"';
sb.appendString('<span>');
sb.appendString(style);
sb.appendString('>');
let previousClassName: string | undefined;
for (let charIndex = 0; charIndex < len; charIndex++) {
const className = classNames[charIndex];
if (className !== previousClassName) {
sb.appendString('</span>');
sb.appendString(style);
if (className) {
sb.appendString(' class="');
sb.appendString(className);
sb.appendString('"');
}
sb.appendString('>');
previousClassName = className;
}
if (charIndex !== 0 && charIndex % Constants.SPAN_MODULO_LIMIT === 0) {
sb.appendString('</span><span>');
sb.appendString('</span></span><span><span>');
}
charOffsets[charIndex] = charOffset;
visibleColumns[charIndex] = visibleColumn;
const charCode = nextCharCode;
nextCharCode = (charIndex + 1 < len ? lineContent.charCodeAt(charIndex + 1) : CharCode.Null);
Expand Down Expand Up @@ -286,25 +335,34 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize:
charOffset += producedCharacters;
visibleColumn += charWidth;
}
sb.appendString('</span>');
sb.appendString('</span></span>');

charOffsets[lineContent.length] = charOffset;
visibleColumns[lineContent.length] = visibleColumn;

sb.appendString('</div>');

return [charOffsets, visibleColumns];
return visibleColumns;
}

function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: string, charOffsets: number[]): number[] | null {
function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: string): number[] | null {
if (lineContent.length <= 1) {
return null;
}
const spans = <HTMLSpanElement[]>Array.prototype.slice.call(lineDomNode.children, 0);

const breakOffsets: number[] = [];
let lineOffset = 0;
let previousMiddle: number | undefined;

try {
discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets);
for (const wrapper of lineDomNode.children) {
for (const child of wrapper.children) {
const textNode = child.firstChild;
const length = textNode?.textContent?.length;
if (length) {
discoverBreaks(textNode, 0, length);
}
}
}
} catch (err) {
console.log(err);
return null;
Expand All @@ -316,36 +374,36 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent:

breakOffsets.push(lineContent.length);
return breakOffsets;
}

function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: number[], low: number, lowRects: DOMRectList | null, high: number, highRects: DOMRectList | null, result: number[]): void {
if (low === high) {
return;
}

lowRects = lowRects || readClientRect(range, spans, charOffsets[low], charOffsets[low + 1]);
highRects = highRects || readClientRect(range, spans, charOffsets[high], charOffsets[high + 1]);
function discoverBreaks(node: ChildNode, low: number, high: number) {
if (low === high) {
return;
}

if (Math.abs(lowRects[0].top - highRects[0].top) <= 0.1) {
// same line
return;
}
range.setStart(node, low);
range.setEnd(node, high);

// there is at least one line break between these two offsets
if (low + 1 === high) {
// the two characters are adjacent, so the line break must be exactly between them
result.push(high);
return;
}
const chunkSize = high - low;
const rects = range.getClientRects();
if (rects.length === 0) {
lineOffset += chunkSize;
} else if (rects.length === 1) {
const rect = rects[0];
const middle = (rect.top + rect.bottom) / 2;

const mid = low + ((high - low) / 2) | 0;
const midRects = readClientRect(range, spans, charOffsets[mid], charOffsets[mid + 1]);
discoverBreaks(range, spans, charOffsets, low, lowRects, mid, midRects, result);
discoverBreaks(range, spans, charOffsets, mid, midRects, high, highRects, result);
}
if (previousMiddle !== undefined && Math.abs(previousMiddle - middle) > 0.5) {
breakOffsets.push(lineOffset);
}

function readClientRect(range: Range, spans: HTMLSpanElement[], startOffset: number, endOffset: number): DOMRectList {
range.setStart(spans[(startOffset / Constants.SPAN_MODULO_LIMIT) | 0].firstChild!, startOffset % Constants.SPAN_MODULO_LIMIT);
range.setEnd(spans[(endOffset / Constants.SPAN_MODULO_LIMIT) | 0].firstChild!, endOffset % Constants.SPAN_MODULO_LIMIT);
return range.getClientRects();
previousMiddle = middle;
lineOffset += chunkSize;
} else {
let middle = low + ((chunkSize / 2) | 0);
if (node.textContent!.charCodeAt(middle) === CharCode.Space) {
middle += 1;
}
discoverBreaks(node, low, middle);
discoverBreaks(node, middle, high);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export class DiffEditorViewZones extends Disposable {
}
}

const lineBreakData = deletedCodeLineBreaksComputer?.finalize() ?? [];
const lineBreakData = deletedCodeLineBreaksComputer?.finalize(this._editors.original.getModel()?.getAllTextDecorations() ?? []) ?? [];
let lineBreakDataIdx = 0;

const modLineHeight = this._editors.modified.getOption(EditorOption.lineHeight);
Expand Down
6 changes: 6 additions & 0 deletions src/vs/editor/common/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,6 +1083,12 @@ export interface ITextModel {
*/
getAllMarginDecorations(ownerId?: number): IModelDecoration[];

/**
* Gets all decorations that apply to text.
* @param ownerId If set, it will ignore decorations belonging to other owners.
*/
getAllTextDecorations(ownerId?: number): IModelDecoration[];

/**
* Gets all the decorations that should be rendered in the overview ruler as an array.
* @param ownerId If set, it will ignore decorations belonging to other owners.
Expand Down
21 changes: 21 additions & 0 deletions src/vs/editor/common/model/textModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,10 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati
return this._decorationsTree.getAll(this, ownerId, false, false, true);
}

public getAllTextDecorations(ownerId: number = 0): model.IModelDecoration[] {
return this._decorationsTree.getAllTextDecorations(this, ownerId);
}

private _getDecorationsInRange(filterRange: Range, filterOwnerId: number, filterOutValidation: boolean, onlyMarginDecorations: boolean): model.IModelDecoration[] {
const startOffset = this._buffer.getOffsetAt(filterRange.startLineNumber, filterRange.startColumn);
const endOffset = this._buffer.getOffsetAt(filterRange.endLineNumber, filterRange.endColumn);
Expand Down Expand Up @@ -1880,6 +1884,12 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati
const nodeRange = this._decorationsTree.getNodeRange(this, node);
this._onDidChangeDecorations.recordLineAffectedByInjectedText(nodeRange.startLineNumber);
}
if (node.options.inlineClassNameAffectsLetterSpacing) {
const nodeRange = this._decorationsTree.getNodeRange(this, node);
for (let lineNumber = nodeRange.startLineNumber; lineNumber <= nodeRange.endLineNumber; lineNumber++) {
this._onDidChangeDecorations.recordLineAffectedByInjectedText(lineNumber);
}
}

this._decorationsTree.delete(node);

Expand Down Expand Up @@ -1915,6 +1925,11 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati
if (node.options.before) {
this._onDidChangeDecorations.recordLineAffectedByInjectedText(range.startLineNumber);
}
if (node.options.inlineClassNameAffectsLetterSpacing) {
for (let lineNumber = range.startLineNumber; lineNumber <= range.endLineNumber; lineNumber++) {
this._onDidChangeDecorations.recordLineAffectedByInjectedText(lineNumber);
}
}

if (!suppressEvents) {
this._onDidChangeDecorations.checkAffectedAndFire(options);
Expand Down Expand Up @@ -2081,6 +2096,12 @@ class DecorationsTrees {
return this._ensureNodesHaveRanges(host, result).filter((i) => i.options.showIfCollapsed || !i.range.isEmpty());
}

public getAllTextDecorations(host: IDecorationsTreesHost, filterOwnerId: number): model.IModelDecoration[] {
const versionId = host.getVersionId();
const result = this._decorationsTree0.search(filterOwnerId, true, versionId, false);
return this._ensureNodesHaveRanges(host, result);
}

public getAll(host: IDecorationsTreesHost, filterOwnerId: number, filterOutValidation: boolean, overviewRulerOnly: boolean, onlyMarginDecorations: boolean): model.IModelDecoration[] {
const versionId = host.getVersionId();
const result = this._search(filterOwnerId, filterOutValidation, overviewRulerOnly, versionId, onlyMarginDecorations);
Expand Down
4 changes: 2 additions & 2 deletions src/vs/editor/common/modelLineProjectionData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { assertNever } from 'vs/base/common/assert';
import { WrappingIndent } from 'vs/editor/common/config/editorOptions';
import { FontInfo } from 'vs/editor/common/config/fontInfo';
import { Position } from 'vs/editor/common/core/position';
import { InjectedTextCursorStops, InjectedTextOptions, PositionAffinity } from 'vs/editor/common/model';
import { IModelDecoration, InjectedTextCursorStops, InjectedTextOptions, PositionAffinity } from 'vs/editor/common/model';
import { LineInjectedText } from 'vs/editor/common/textModelEvents';

/**
Expand Down Expand Up @@ -337,5 +337,5 @@ export interface ILineBreaksComputer {
* Pass in `previousLineBreakData` if the only difference is in breaking columns!!!
*/
addRequest(lineText: string, injectedText: LineInjectedText[] | null, previousLineBreakData: ModelLineProjectionData | null): void;
finalize(): (ModelLineProjectionData | null)[];
finalize(decorations: IModelDecoration[]): (ModelLineProjectionData | null)[];
}
2 changes: 1 addition & 1 deletion src/vs/editor/common/viewModel/viewModelImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ export class ViewModel extends Disposable implements IViewModel {
}
}
}
const lineBreaks = lineBreaksComputer.finalize();
const lineBreaks = lineBreaksComputer.finalize(this.model.getAllTextDecorations());
const lineBreakQueue = new ArrayQueue(lineBreaks);

for (const change of changes) {
Expand Down
4 changes: 2 additions & 2 deletions src/vs/editor/common/viewModel/viewModelLines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines {
}

const linesContent = this.model.getLinesContent();
const injectedTextDecorations = this.model.getInjectedTextDecorations(this._editorId);
const injectedTextDecorations = this.model.getAllTextDecorations(this._editorId);
const lineCount = linesContent.length;
const lineBreaksComputer = this.createLineBreaksComputer();

Expand All @@ -133,7 +133,7 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines {
const lineInjectedText = injectedTextQueue.takeWhile(t => t.lineNumber === i + 1);
lineBreaksComputer.addRequest(linesContent[i], lineInjectedText, previousLineBreaks ? previousLineBreaks[i] : null);
}
const linesBreaks = lineBreaksComputer.finalize();
const linesBreaks = lineBreaksComputer.finalize(injectedTextDecorations);

const values: number[] = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number,
const lineBreaksComputer = factory.createLineBreaksComputer(fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak);
const previousLineBreakDataClone = previousLineBreakData ? new ModelLineProjectionData(null, null, previousLineBreakData.breakOffsets.slice(0), previousLineBreakData.breakOffsetsVisibleColumn.slice(0), previousLineBreakData.wrappedTextIndentLength) : null;
lineBreaksComputer.addRequest(text, null, previousLineBreakDataClone);
return lineBreaksComputer.finalize()[0];
return lineBreaksComputer.finalize([])[0];
}

function assertLineBreaks(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, annotatedText: string, wrappingIndent = WrappingIndent.None, wordBreak: 'normal' | 'keepAll' = 'normal'): ModelLineProjectionData | null {
Expand Down
5 changes: 5 additions & 0 deletions src/vs/monaco.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2261,6 +2261,11 @@ declare namespace monaco.editor {
* @param ownerId If set, it will ignore decorations belonging to other owners.
*/
getAllMarginDecorations(ownerId?: number): IModelDecoration[];
/**
* Gets all decorations that apply to text.
* @param ownerId If set, it will ignore decorations belonging to other owners.
*/
getAllTextDecorations(ownerId?: number): IModelDecoration[];
/**
* Gets all the decorations that should be rendered in the overview ruler as an array.
* @param ownerId If set, it will ignore decorations belonging to other owners.
Expand Down

0 comments on commit 7e65ed2

Please sign in to comment.