Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
a7cad9d
Add injected text decoration fixtures
aiday-mar Aug 21, 2026
2c853b8
Refactor line break test helper for injected text
aiday-mar Aug 21, 2026
691e462
Stabilize injected text decoration fixtures
aiday-mar Aug 21, 2026
32f3b94
Carry fixed width metadata through editor rendering
aiday-mar Aug 21, 2026
85e97b6
Add failing tests for fixed-width injected text
aiday-mar Aug 21, 2026
a0b9cd9
Add proportional-font injected text fixture
aiday-mar Aug 21, 2026
fd06444
Exercise advanced injected text wrapping
aiday-mar Aug 21, 2026
9f31f53
Support fixed-width injected text wrapping
aiday-mar Aug 21, 2026
ab76b1a
Account for inline progress decoration width
aiday-mar Aug 21, 2026
be99315
Account for inlay hint spacer width
aiday-mar Aug 21, 2026
e3f5e3b
Account for color decorator width when wrapping
aiday-mar Aug 21, 2026
4d66db5
Add fixed-width injected text coverage
aiday-mar Aug 21, 2026
02e2de0
Fix fixed-width injected text rendering
aiday-mar Aug 21, 2026
9d48d56
wip
aiday-mar Aug 21, 2026
90f81cb
wip
aiday-mar Aug 21, 2026
6e9fc0d
review changes
aiday-mar Aug 24, 2026
63a762f
wip
aiday-mar Aug 24, 2026
d7c05c2
removing rendering code
aiday-mar Aug 24, 2026
8a2c199
wip
aiday-mar Aug 24, 2026
023ca8a
polishing
aiday-mar Aug 24, 2026
cf50a3e
polishing
aiday-mar Aug 24, 2026
e68430b
merging main
aiday-mar Aug 24, 2026
0cea148
updating screenshots
aiday-mar Aug 24, 2026
cae296f
Remove injected text component fixtures
aiday-mar Aug 25, 2026
c61b938
Merge branch 'main' into elated-minnow
alexdima Aug 25, 2026
9739fb7
checking widthInEm is defined, finite and positive
aiday-mar Aug 26, 2026
8b47b84
removing change to inlayhintscontroller
aiday-mar Aug 26, 2026
007cfe5
making firstFixedWidthRange as being potentially undefined more explicit
aiday-mar Aug 26, 2026
5584b63
adding check on fixedWidthRangeIndex index
aiday-mar Aug 26, 2026
6793336
using one accumulator
aiday-mar Aug 26, 2026
4f3b31f
using pixels for wrapping
aiday-mar Aug 26, 2026
f55144e
allowing to set empty content
aiday-mar Aug 26, 2026
146580c
polish
aiday-mar Aug 26, 2026
972ac33
putting back inline progress nbsp
aiday-mar Aug 26, 2026
eca03a1
updating color decorator screenshots
aiday-mar Aug 26, 2026
9c5aca8
fix 'the wrapping algorithm now wraps immediately after color boxes'
aiday-mar Aug 27, 2026
557720b
fixing https://github.com/microsoft/vscode/pull/332340#discussion_r38…
aiday-mar Aug 27, 2026
56a4122
fixing https://github.com/microsoft/vscode/pull/332340#discussion_r38…
aiday-mar Aug 27, 2026
661ec86
fixing https://github.com/microsoft/vscode/pull/332340#discussion_r38…
aiday-mar Aug 27, 2026
92eac28
align the color decorator bottom
aiday-mar Aug 27, 2026
d5a1f60
restoring non breaking space
aiday-mar Aug 27, 2026
16cc4c7
checking is escaped line break at offset charStartOffset
aiday-mar Aug 28, 2026
c7060f4
fixing 'Oversized width-only injected text creates an empty output line'
aiday-mar Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build/lib/stylelint/vscode-known-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -1115,6 +1115,8 @@
"--scroll-shadow-surface",
"--vscode-chat-list-background",
"--vscode-chat-persistent-content-height",
"--vscode-colorPicker-colorDecoratorMargin",
"--vscode-colorPicker-colorDecoratorWidth",
"--vscode-editorCodeLens-fontFamily",
"--vscode-editorCodeLens-fontFamilyDefault",
"--vscode-editorCodeLens-fontFeatureSettings",
Expand Down
139 changes: 113 additions & 26 deletions src/vs/editor/browser/view/domLineBreaksComputer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { WrappingIndent } from '../../common/config/editorOptions.js';
import { StringBuilder } from '../../common/core/stringBuilder.js';
import { InjectedTextOptions } from '../../common/model.js';
import { ILineBreaksComputer, ILineBreaksComputerContext, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../common/modelLineProjectionData.js';
import { LineInjectedText } from '../../common/textModelEvents.js';
import { FixedWidthInjectedTextRange, LineInjectedText } from '../../common/textModelEvents.js';
import { FontInfo } from '../../common/config/fontInfo.js';

const ttPolicy = createTrustedTypesPolicy('domLineBreaksComputer', { createHTML: value => value });
Expand Down Expand Up @@ -78,10 +78,13 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont
const wrappedTextIndentLengths: number[] = [];
const renderLineContents: string[] = [];
const allCharOffsets: number[][] = [];
const allSpanStartOffsets: number[][] = [];
const allVisibleColumns: number[][] = [];
for (let i = 0; i < lineNumbers.length; i++) {
const lineNumber = lineNumbers[i];
const lineContent = LineInjectedText.applyInjectedText(context.getLineContent(lineNumber), context.getLineInjectedText(lineNumber));
const injectedTexts = context.getLineInjectedText(lineNumber);
const lineContent = LineInjectedText.applyInjectedText(context.getLineContent(lineNumber), injectedTexts);
const fixedWidthRanges = LineInjectedText.getFixedWidthInjectedTextRanges(injectedTexts);

let firstNonWhitespaceIndex = 0;
let wrappedTextIndentLength = 0;
Expand All @@ -97,14 +100,20 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont
// Track existing indent

for (let i = 0; i < firstNonWhitespaceIndex; i++) {
const charWidth = (
lineContent.charCodeAt(i) === CharCode.Tab
? (tabSize - (wrappedTextIndentLength % tabSize))
: 1
);
wrappedTextIndentLength += charWidth;
const fixedWidthRange = fixedWidthRanges[0];
const isFixedWidthStart = fixedWidthRange && fixedWidthRange.startOffset === i;
if (isFixedWidthStart) {
firstNonWhitespaceIndex = i;
break;
} else {
const charWidth = (
lineContent.charCodeAt(i) === CharCode.Tab
? (tabSize - (wrappedTextIndentLength % tabSize))
: 1
);
wrappedTextIndentLength += charWidth;
}
}

const indentWidth = Math.ceil(fontInfo.spaceWidth * wrappedTextIndentLength);

// Force sticking to beginning of line if no character would fit except for the indentation
Expand All @@ -118,11 +127,19 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont
}

const renderLineContent = lineContent.substr(firstNonWhitespaceIndex);
const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength);
const shiftedFixedWidthRanges = firstNonWhitespaceIndex === 0
? fixedWidthRanges
: fixedWidthRanges.map(range => ({
startOffset: Math.max(0, range.startOffset - firstNonWhitespaceIndex),
endOffset: range.endOffset - firstNonWhitespaceIndex,
widthInEm: range.widthInEm
}));
const tmp = renderLine(renderLineContent, wrappedTextIndentLength, tabSize, width, sb, additionalIndentLength, shiftedFixedWidthRanges);
firstNonWhitespaceIndices[i] = firstNonWhitespaceIndex;
wrappedTextIndentLengths[i] = wrappedTextIndentLength;
renderLineContents[i] = renderLineContent;
allCharOffsets[i] = tmp[0];
allSpanStartOffsets[i] = tmp[2];
allVisibleColumns[i] = tmp[1];
}
const html = sb.build();
Expand All @@ -149,7 +166,7 @@ function createLineBreaks(targetWindow: Window, context: ILineBreaksComputerCont
for (let i = 0; i < lineNumbers.length; i++) {
const lineNumber = lineNumbers[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], allCharOffsets[i], allSpanStartOffsets[i]);
if (breakOffsets === null) {
result[i] = createEmptyLineBreakWithPossiblyInjectedText(lineNumber);
continue;
Expand Down Expand Up @@ -193,7 +210,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, fixedWidthRanges: readonly FixedWidthInjectedTextRange[]): [number[], number[], number[]] {

if (wrappingIndentLength !== 0) {
const hangingOffset = String(wrappingIndentLength);
Expand All @@ -214,14 +231,51 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize:
const len = lineContent.length;
let visibleColumn = initialVisibleColumn;
let charOffset = 0;
let fixedWidthRangeIndex = 0;
const charOffsets: number[] = [];
const spanStartOffsets: number[] = [0];
const visibleColumns: number[] = [];
let nextCharCode = (0 < len ? lineContent.charCodeAt(0) : CharCode.Null);
let spanOpen = true;

sb.appendString('<span>');
for (let charIndex = 0; charIndex < len; charIndex++) {
if (charIndex !== 0 && charIndex % Constants.SPAN_MODULO_LIMIT === 0) {
let fixedWidthRange = fixedWidthRanges[fixedWidthRangeIndex];
const startsFixedWidth = fixedWidthRange && fixedWidthRange.startOffset === charIndex;
if (startsFixedWidth) {
if (spanOpen) {
sb.appendString('</span>');
}
// Injected text that only reserves horizontal space covers no character, so it gets a span of
// its own. Rendering it inside the span of the character below would make that character fixed
// width as well. Several such injections can sit at the same offset.
while (fixedWidthRange && fixedWidthRange.startOffset === charIndex && fixedWidthRange.endOffset === charIndex) {
sb.appendString('<span style="display:inline-block;box-sizing:border-box;white-space:nowrap;width:');
sb.appendString(String(fixedWidthRange.widthInEm));
sb.appendString('em;">');
sb.appendString('</span>');
spanStartOffsets.push(charOffset);
fixedWidthRange = fixedWidthRanges[++fixedWidthRangeIndex];
}
// The character below goes into a fixed width span if one still covers it, a normal one
// otherwise. At most one such range can start here: injections at the same column are laid
// out one after the other, so only an empty one leaves the next starting at the same offset.
if (fixedWidthRange && fixedWidthRange.startOffset === charIndex) {
sb.appendString('<span style="display:inline-block;box-sizing:border-box;white-space:nowrap;width:');
sb.appendString(String(fixedWidthRange.widthInEm));
sb.appendString('em;">');
Comment thread
aiday-mar marked this conversation as resolved.
} else {
sb.appendString('<span>');
}
spanStartOffsets.push(charOffset);
spanOpen = true;
} else if (!spanOpen) {
sb.appendString('<span>');
spanStartOffsets.push(charOffset);
spanOpen = true;
} else if ((!fixedWidthRange || charIndex < fixedWidthRange.startOffset) && charIndex !== 0 && charIndex % Constants.SPAN_MODULO_LIMIT === 0) {
sb.appendString('</span><span>');
spanStartOffsets.push(charOffset);
}
charOffsets[charIndex] = charOffset;
visibleColumns[charIndex] = visibleColumn;
Expand Down Expand Up @@ -286,26 +340,38 @@ function renderLine(lineContent: string, initialVisibleColumn: number, tabSize:

charOffset += producedCharacters;
visibleColumn += charWidth;

// A range that covers no character has already been closed above, and must not be consumed here:
// its `endOffset` equals its `startOffset`, so this condition would hold one character too early.
if (fixedWidthRange && fixedWidthRange.startOffset < fixedWidthRange.endOffset && charIndex + 1 === fixedWidthRange.endOffset) {
sb.appendString('</span>');
spanOpen = false;
fixedWidthRangeIndex++;
}
}
if (spanOpen) {
sb.appendString('</span>');
}
sb.appendString('</span>');
// A spacing-only injection at the very end of the line is left out on purpose: nothing follows it,
// so it cannot move a break point. `MonospaceLineBreaksComputer` ignores it for the same reason.

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

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

return [charOffsets, visibleColumns];
return [charOffsets, visibleColumns, spanStartOffsets];
}

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

const breakOffsets: number[] = [];
try {
discoverBreaks(range, spans, charOffsets, 0, null, lineContent.length - 1, null, breakOffsets);
discoverBreaks(range, spans, charOffsets, spanStartOffsets, 0, null, lineContent.length - 1, null, breakOffsets);
} catch (err) {
console.error(err);
return null;
Expand All @@ -319,13 +385,13 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent:
return breakOffsets;
}

function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: number[], low: number, lowRects: DOMRectList | null, high: number, highRects: DOMRectList | null, result: number[]): void {
function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: number[], spanStartOffsets: 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]);
lowRects = lowRects || readClientRect(range, spans, charOffsets[low], charOffsets[low + 1], spanStartOffsets);
highRects = highRects || readClientRect(range, spans, charOffsets[high], charOffsets[high + 1], spanStartOffsets);

if (Math.abs(lowRects[0].top - highRects[0].top) <= 0.1) {
// same line
Expand All @@ -340,13 +406,34 @@ function discoverBreaks(range: Range, spans: HTMLSpanElement[], charOffsets: num
}

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);
const midRects = readClientRect(range, spans, charOffsets[mid], charOffsets[mid + 1], spanStartOffsets);
discoverBreaks(range, spans, charOffsets, spanStartOffsets, low, lowRects, mid, midRects, result);
discoverBreaks(range, spans, charOffsets, spanStartOffsets, mid, midRects, high, highRects, result);
}

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);
function readClientRect(range: Range, spans: HTMLSpanElement[], startOffset: number, endOffset: number, spanStartOffsets: number[]): DOMRectList {
if (!spanStartOffsets) {
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();
}
const startSpanIndex = findSpanIndex(spanStartOffsets, startOffset);
const endSpanIndex = findSpanIndex(spanStartOffsets, endOffset);
range.setStart(spans[startSpanIndex].firstChild!, startOffset - spanStartOffsets[startSpanIndex]);
range.setEnd(spans[endSpanIndex].firstChild!, endOffset - spanStartOffsets[endSpanIndex]);
return range.getClientRects();
}

function findSpanIndex(spanStartOffsets: readonly number[], offset: number): number {
let low = 0;
let high = spanStartOffsets.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (spanStartOffsets[mid] <= offset) {
low = mid + 1;
} else {
high = mid;
}
}
return low - 1;
}
6 changes: 6 additions & 0 deletions src/vs/editor/common/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,12 @@ export interface InjectedTextOptions {
*/
readonly inlineClassNameAffectsLetterSpacing?: boolean;

/**
* Sets the width used to wrap this injected text in editor-font em units.
* @internal
*/
readonly widthInEm?: number;

/**
* This field allows to attach data to this injected text.
* The data can be read when injected texts at a given position are queried.
Expand Down
2 changes: 2 additions & 0 deletions src/vs/editor/common/model/textModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2463,6 +2463,7 @@ export class ModelDecorationInjectedTextOptions implements model.InjectedTextOpt
public readonly tokens: TokenArray | null;
readonly inlineClassName: string | null;
readonly inlineClassNameAffectsLetterSpacing: boolean;
readonly widthInEm: number | undefined;
readonly attachedData: unknown | null;
readonly cursorStops: model.InjectedTextCursorStops | null;

Expand All @@ -2471,6 +2472,7 @@ export class ModelDecorationInjectedTextOptions implements model.InjectedTextOpt
this.tokens = options.tokens ?? null;
this.inlineClassName = options.inlineClassName || null;
this.inlineClassNameAffectsLetterSpacing = options.inlineClassNameAffectsLetterSpacing || false;
this.widthInEm = options.widthInEm !== undefined && Number.isFinite(options.widthInEm) && options.widthInEm >= 0 ? options.widthInEm : undefined;
this.attachedData = options.attachedData || null;
this.cursorStops = options.cursorStops || null;
}
Expand Down
48 changes: 46 additions & 2 deletions src/vs/editor/common/textModelEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,24 @@ export class ModelRawFlush {
public readonly changeType = RawContentChangedType.Flush;
}

/**
* Represents a fixed-width injected text range within a line.
* @internal
*/
export interface FixedWidthInjectedTextRange {
readonly startOffset: number;
readonly endOffset: number;
readonly widthInEm: number;
}

/**
* Whether injected text takes up space on a line, either through its content or, when it is
* width-only (e.g. `{ content: '', widthInEm: 1 }`), through the horizontal space it reserves.
*/
function occupiesHorizontalSpace(options: InjectedTextOptions): boolean {
return options.content.length > 0 || (options.widthInEm !== undefined && options.widthInEm > 0);
}

/**
* Represents text injected on a line
* @internal
Expand All @@ -257,7 +275,7 @@ export class LineInjectedText {
public static fromDecorations(decorations: IModelDecoration[]): LineInjectedText[] {
const result: LineInjectedText[] = [];
for (const decoration of decorations) {
if (decoration.options.before && decoration.options.before.content.length > 0) {
if (decoration.options.before && occupiesHorizontalSpace(decoration.options.before)) {
result.push(new LineInjectedText(
decoration.ownerId,
decoration.range.startLineNumber,
Expand All @@ -266,7 +284,7 @@ export class LineInjectedText {
0,
));
}
if (decoration.options.after && decoration.options.after.content.length > 0) {
if (decoration.options.after && occupiesHorizontalSpace(decoration.options.after)) {
result.push(new LineInjectedText(
decoration.ownerId,
decoration.range.endLineNumber,
Expand All @@ -288,6 +306,32 @@ export class LineInjectedText {
return result;
}

/**
* The ranges of `applyInjectedText(...)` that are rendered at a fixed width. Width-only injected
* text produces an empty range (`startOffset === endOffset`) which reserves horizontal space
* without covering any character.
*
* `injectedTexts` must be sorted by column, which is what `fromDecorations` produces and what
* `applyInjectedText` already requires. The result is then sorted by `startOffset` and never
* overlaps: injections at the same column are laid out one after the other, so only an injection
* with empty content leaves the next one starting at the same offset.
*/
public static getFixedWidthInjectedTextRanges(injectedTexts: readonly LineInjectedText[] | null): FixedWidthInjectedTextRange[] {
const result: FixedWidthInjectedTextRange[] = [];
let injectedTextLength = 0;
for (const injectedText of injectedTexts ?? []) {
const length = injectedText.options.content.length;
const startOffset = injectedText.column - 1 + injectedTextLength;
const endOffset = startOffset + length;
const widthInEm = injectedText.options.widthInEm;
if (widthInEm !== undefined) {
result.push({ startOffset, endOffset, widthInEm });
}
injectedTextLength += length;
}
return result;
}

constructor(
public readonly ownerId: number,
public readonly lineNumber: number,
Expand Down
4 changes: 3 additions & 1 deletion src/vs/editor/common/viewLayout/lineDecorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export class LineDecoration {
}

private static _typeCompare(a: InlineDecorationType, b: InlineDecorationType): number {
const ORDER = [2, 0, 1, 3];
// WidthOnly, Before, After, Regular, RegularAffectingLetterSpacing.
// Width only decorations come from injected text, which renders before any other decoration.
const ORDER = [3, 1, 2, 4, 0];
return ORDER[a] - ORDER[b];
}

Expand Down
Loading
Loading