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 Aug 13, 2024
1 parent 559107b commit bcc02a2
Show file tree
Hide file tree
Showing 13 changed files with 284 additions and 114 deletions.
135 changes: 87 additions & 48 deletions src/vs/editor/browser/view/domLineBreaksComputer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { FontInfo } from 'vs/editor/common/config/fontInfo';
import { StringBuilder } from 'vs/editor/common/core/stringBuilder';
import { InjectedTextOptions } from 'vs/editor/common/model';
import { ILineBreaksComputer, ILineBreaksComputerFactory, ModelLineProjectionData } from 'vs/editor/common/modelLineProjectionData';
import { LineInjectedText } from 'vs/editor/common/textModelEvents';
import { InlineClassName, LineInjectedText } from 'vs/editor/common/textModelEvents';

const ttPolicy = createTrustedTypesPolicy('domLineBreaksComputer', { createHTML: value => value });

Expand All @@ -29,19 +29,21 @@ export class DOMLineBreaksComputerFactory implements ILineBreaksComputerFactory
public createLineBreaksComputer(fontInfo: FontInfo, tabSize: number, wrappingColumn: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll'): ILineBreaksComputer {
const requests: string[] = [];
const injectedTexts: (LineInjectedText[] | null)[] = [];
const inlineClassNames: (InlineClassName[] | null)[] = [];
return {
addRequest: (lineText: string, injectedText: LineInjectedText[] | null, previousLineBreakData: ModelLineProjectionData | null) => {
addRequest: (lineText: string, injectedText: LineInjectedText[] | null, inlineClassName: InlineClassName[] | null, previousLineBreakData: ModelLineProjectionData | null) => {
requests.push(lineText);
injectedTexts.push(injectedText);
inlineClassNames.push(inlineClassName);
},
finalize: () => {
return createLineBreaks(assertIsDefined(this.targetWindow.deref()), requests, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak, injectedTexts);
return createLineBreaks(assertIsDefined(this.targetWindow.deref()), requests, fontInfo, tabSize, wrappingColumn, wrappingIndent, wordBreak, injectedTexts, inlineClassNames);
}
};
}
}

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)[], inlineClassNamesPerLine: (InlineClassName[] | null)[]): (ModelLineProjectionData | null)[] {
function createEmptyLineBreakWithPossiblyInjectedText(requestIdx: number): ModelLineProjectionData | null {
const injectedTexts = injectedTextsPerLine[requestIdx];
if (injectedTexts) {
Expand Down Expand Up @@ -78,10 +80,10 @@ 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]);
const lineContent = requests[i];

let firstNonWhitespaceIndex = 0;
let wrappedTextIndentLength = 0;
Expand Down Expand Up @@ -118,12 +120,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(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength, injectedTextsPerLine[i], inlineClassNamesPerLine[i]);
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 +148,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 +192,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(lineContent: string, initialVisibleColumn: number, tabSize: number, width: number, sb: StringBuilder, wrappingIndentLength: number, lineInjectedText: LineInjectedText[] | null, inlineClassNames: InlineClassName[] | null): number[] {
if (wrappingIndentLength !== 0) {
const hangingOffset = String(wrappingIndentLength);
sb.appendString('<div style="text-indent: -');
Expand All @@ -213,16 +212,47 @@ 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).fill('');
if (inlineClassNames) {
const inlineClassNamesLength = inlineClassNames.length;
for (let i = 0; i < inlineClassNamesLength; i++) {
const inlineClassName = inlineClassNames[i];
const className = inlineClassName.className;
const charStart = inlineClassName.startColumn - 1;
const charEnd = Math.min(inlineClassName.endColumn - 1, len);

for (let charIndex = charStart; charIndex < charEnd; charIndex++) {
classNames[charIndex] = classNames[charIndex] += ' ' + className;
}
}
}

// 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 +316,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 +355,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 @@ -170,7 +170,7 @@ export class DiffEditorViewZones extends Disposable {
if (i > originalModel.getLineCount()) {
return { orig: origViewZones, mod: modViewZones };
}
deletedCodeLineBreaksComputer?.addRequest(originalModel.getLineContent(i), null, null);
deletedCodeLineBreaksComputer?.addRequest(originalModel.getLineContent(i), null, null, null);
}
}
}
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
Loading

0 comments on commit bcc02a2

Please sign in to comment.