diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 79a40ba40876f..be4da5877daaf 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -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", diff --git a/src/vs/editor/browser/view/domLineBreaksComputer.ts b/src/vs/editor/browser/view/domLineBreaksComputer.ts index 1c88653206fac..abac9d8a123d1 100644 --- a/src/vs/editor/browser/view/domLineBreaksComputer.ts +++ b/src/vs/editor/browser/view/domLineBreaksComputer.ts @@ -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 }); @@ -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; @@ -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 @@ -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(); @@ -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; @@ -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); @@ -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(''); 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(''); + } + // 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(''); + sb.appendString(''); + 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(''); + } else { + sb.appendString(''); + } + spanStartOffsets.push(charOffset); + spanOpen = true; + } else if (!spanOpen) { + sb.appendString(''); + spanStartOffsets.push(charOffset); + spanOpen = true; + } else if ((!fixedWidthRange || charIndex < fixedWidthRange.startOffset) && charIndex !== 0 && charIndex % Constants.SPAN_MODULO_LIMIT === 0) { sb.appendString(''); + spanStartOffsets.push(charOffset); } charOffsets[charIndex] = charOffset; visibleColumns[charIndex] = visibleColumn; @@ -286,18 +340,30 @@ 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(''); + spanOpen = false; + fixedWidthRangeIndex++; + } + } + if (spanOpen) { + sb.appendString(''); } - sb.appendString(''); + // 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(''); - 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; } @@ -305,7 +371,7 @@ function readLineBreaks(range: Range, lineDomNode: HTMLDivElement, lineContent: 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; @@ -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 @@ -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; +} diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 2fc027b34a231..34c57f255760c 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -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. diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 6d7e5a6be5503..ee6ad23b353b8 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -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; @@ -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; } diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index 1f504db5852e6..561feefda7705 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -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 @@ -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, @@ -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, @@ -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, diff --git a/src/vs/editor/common/viewLayout/lineDecorations.ts b/src/vs/editor/common/viewLayout/lineDecorations.ts index 3439b945aacc2..521d2668face0 100644 --- a/src/vs/editor/common/viewLayout/lineDecorations.ts +++ b/src/vs/editor/common/viewLayout/lineDecorations.ts @@ -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]; } diff --git a/src/vs/editor/common/viewLayout/viewLineRenderer.ts b/src/vs/editor/common/viewLayout/viewLineRenderer.ts index 11aca36bbb9a7..3616af6c04d18 100644 --- a/src/vs/editor/common/viewLayout/viewLineRenderer.ts +++ b/src/vs/editor/common/viewLayout/viewLineRenderer.ts @@ -382,18 +382,19 @@ export function renderViewLine(input: RenderLineInput, sb: StringBuilder): Rende let afterCount = 0; let containsForeignElements = ForeignElementType.None; for (const lineDecoration of input.lineDecorations) { - if (lineDecoration.type === InlineDecorationType.Before || lineDecoration.type === InlineDecorationType.After) { + if (lineDecoration.type === InlineDecorationType.Before || lineDecoration.type === InlineDecorationType.After || lineDecoration.type === InlineDecorationType.WidthOnly) { sb.appendString(``); - if (lineDecoration.type === InlineDecorationType.Before) { - containsForeignElements |= ForeignElementType.Before; - beforeCount++; - } if (lineDecoration.type === InlineDecorationType.After) { containsForeignElements |= ForeignElementType.After; afterCount++; + } else { + // Width only decorations sit before the character at their column, just like + // before content decorations. + containsForeignElements |= ForeignElementType.Before; + beforeCount++; } } } @@ -502,6 +503,9 @@ function resolveRenderLineInput(input: RenderLineInput): ResolvedRenderLineInput containsForeignElements |= ForeignElementType.Before; } else if (lineDecoration.type === InlineDecorationType.After) { containsForeignElements |= ForeignElementType.After; + } else if (lineDecoration.type === InlineDecorationType.WidthOnly) { + // Pretend there are foreign elements... although not 100% accurate. See above. + containsForeignElements |= ForeignElementType.Before; } } tokens = _applyInlineDecorations(lineContent, len, tokens, input.lineDecorations); diff --git a/src/vs/editor/common/viewModel/inlineDecorations.ts b/src/vs/editor/common/viewModel/inlineDecorations.ts index f6869c57dd334..5e8a25204e644 100644 --- a/src/vs/editor/common/viewModel/inlineDecorations.ts +++ b/src/vs/editor/common/viewModel/inlineDecorations.ts @@ -13,7 +13,15 @@ export const enum InlineDecorationType { Regular = 0, Before = 1, After = 2, - RegularAffectingLetterSpacing = 3 + RegularAffectingLetterSpacing = 3, + /** + * A decoration that covers no character, yet is rendered as an element of its own. + * Used for injected text that only reserves horizontal space (e.g. `{ content: '', widthInEm: 1 }`): + * there is no character to decorate, so the width comes from the decoration's class instead. + * Unlike {@link InlineDecorationType.Before}/{@link InlineDecorationType.After}, this is not a + * pseudo element attached to a surrounding decoration, but the injected content itself. + */ + WidthOnly = 4 } export class InlineDecoration { @@ -230,27 +238,40 @@ export class InjectedTextInlineDecorationsComputer implements IInlineDecorations const lineEndOffsetInInputWithInjections = breakOffsets[outputLineIndex]; while (currentInjectedOffset < injectionOffsets.length) { - const length = injectionOptions![currentInjectedOffset].content.length; + const options = injectionOptions![currentInjectedOffset]; + const length = options.content.length; const injectedTextStartOffsetInInputWithInjections = injectionOffsets[currentInjectedOffset] + totalInjectedTextLengthBefore; const injectedTextEndOffsetInInputWithInjections = injectedTextStartOffsetInInputWithInjections + length; + const isWidthOnly = (length === 0 && options.widthInEm !== undefined); + const isLastOutputLine = outputLineIndex === breakOffsets.length - 1; + const isAtInternalWrapBoundary = injectedTextStartOffsetInInputWithInjections === lineEndOffsetInInputWithInjections && !isLastOutputLine; - if (injectedTextStartOffsetInInputWithInjections > lineEndOffsetInInputWithInjections) { + if (injectedTextStartOffsetInInputWithInjections > lineEndOffsetInInputWithInjections || (isWidthOnly && isAtInternalWrapBoundary)) { // Injected text only starts in later wrapped lines. break; } - if (lineStartOffsetInInputWithInjections < injectedTextEndOffsetInInputWithInjections) { + const isInLine = isWidthOnly + ? lineStartOffsetInInputWithInjections <= injectedTextStartOffsetInInputWithInjections + && (injectedTextStartOffsetInInputWithInjections < lineEndOffsetInInputWithInjections || isLastOutputLine) + : lineStartOffsetInInputWithInjections < injectedTextEndOffsetInInputWithInjections; + if (isInLine) { // Injected text ends after or in this line (but also starts in or before this line). - const options = injectionOptions![currentInjectedOffset]; if (options.inlineClassName) { const wrappedTextIndentLength = this.context.getWrappedTextIndentLength(modelLineNumber); const offset = (outputLineIndex > 0 ? wrappedTextIndentLength : 0); const start = offset + Math.max(injectedTextStartOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, 0); const end = offset + Math.min(injectedTextEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections, lineEndOffsetInInputWithInjections - lineStartOffsetInInputWithInjections); - if (start !== end) { + if (start !== end || isWidthOnly) { const viewLineNumber = this.context.getBaseViewLineNumber(modelLineNumber) + outputLineIndex; const range = new Range(viewLineNumber, start + 1, viewLineNumber, end + 1); - const type: InlineDecorationType = options.inlineClassNameAffectsLetterSpacing ? InlineDecorationType.RegularAffectingLetterSpacing : InlineDecorationType.Regular; + const type: InlineDecorationType = ( + isWidthOnly + ? InlineDecorationType.WidthOnly + : options.inlineClassNameAffectsLetterSpacing + ? InlineDecorationType.RegularAffectingLetterSpacing + : InlineDecorationType.Regular + ); inlineDecorations.push(new InlineDecoration(range, options.inlineClassName, type)); } } diff --git a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts index 678c350916de2..0882655482027 100644 --- a/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts +++ b/src/vs/editor/common/viewModel/monospaceLineBreaksComputer.ts @@ -8,7 +8,7 @@ import * as strings from '../../../base/common/strings.js'; import { WrappingIndent, IComputedEditorOptions, EditorOption } from '../config/editorOptions.js'; import { CharacterClassifier } from '../core/characterClassifier.js'; import { FontInfo } from '../config/fontInfo.js'; -import { LineInjectedText } from '../textModelEvents.js'; +import { FixedWidthInjectedTextRange, LineInjectedText } from '../textModelEvents.js'; import { InjectedTextOptions } from '../model.js'; import { ILineBreaksComputerFactory, ILineBreaksComputer, ModelLineProjectionData, ILineBreaksComputerContext } from '../modelLineProjectionData.js'; @@ -46,7 +46,7 @@ export class MonospaceLineBreaksComputerFactory implements ILineBreaksComputerFa if (previousLineBreakData && !previousLineBreakData.injectionOptions && !injectedText && !isLineFeedWrappingEnabled) { result[i] = createLineBreaksFromPreviousLineBreaks(this.classifier, previousLineBreakData, lineText, tabSize, wrappingColumn, columnsForFullWidthChar, wrappingIndent, wordBreak); } else { - result[i] = createLineBreaks(this.classifier, lineText, injectedText, tabSize, wrappingColumn, columnsForFullWidthChar, wrappingIndent, wordBreak, isLineFeedWrappingEnabled); + result[i] = createLineBreaks(this.classifier, lineText, injectedText, tabSize, wrappingColumn, columnsForFullWidthChar, fontInfo, wrappingIndent, wordBreak, isLineFeedWrappingEnabled); } } arrPool1.length = 0; @@ -356,8 +356,9 @@ function createLineBreaksFromPreviousLineBreaks(classifier: WrappingCharacterCla return previousBreakingData; } -function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: string, injectedTexts: LineInjectedText[] | null, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ModelLineProjectionData | null { +function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: string, injectedTexts: LineInjectedText[] | null, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, fontInfo: FontInfo, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean): ModelLineProjectionData | null { const lineText = LineInjectedText.applyInjectedText(_lineText, injectedTexts); + const fixedWidthRanges = LineInjectedText.getFixedWidthInjectedTextRanges(injectedTexts); let injectionOptions: InjectedTextOptions[] | null; let injectionOffsets: number[] | null; @@ -389,73 +390,117 @@ function createLineBreaks(classifier: WrappingCharacterClassifier, _lineText: st } const isKeepAll = (wordBreak === 'keepAll'); - const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, columnsForFullWidthChar, wrappingIndent); - const wrappedLineBreakColumn = firstLineBreakColumn - wrappedTextIndentLength; + const wrappedTextIndentLength = computeWrappedTextIndentLength(lineText, tabSize, firstLineBreakColumn, columnsForFullWidthChar, wrappingIndent, fixedWidthRanges); + + // The wrapping decision is taken in pixels, because injected text can request an arbitrary + // width via `widthInEm` which does not map to a whole number of columns. + const typicalHalfwidthCharacterWidth = fontInfo.typicalHalfwidthCharacterWidth; + const wrappedLineBreakPixelWidth = (firstLineBreakColumn - wrappedTextIndentLength) * typicalHalfwidthCharacterWidth; const breakingOffsets: number[] = []; const breakingOffsetsVisibleColumn: number[] = []; let breakingOffsetsCount: number = 0; let breakOffset = 0; let breakOffsetVisibleColumn = 0; - - let breakingColumn = firstLineBreakColumn; - let prevCharCode = lineText.charCodeAt(0); - let prevCharCodeClass = classifier.get(prevCharCode); - let visibleColumn = computeCharWidth(prevCharCode, 0, tabSize, columnsForFullWidthChar); - - let startOffset = 1; - if (strings.isHighSurrogate(prevCharCode)) { - // A surrogate pair must always be considered as a single unit, so it is never to be broken - visibleColumn += 1; - prevCharCode = lineText.charCodeAt(1); + let breakOffsetPixelWidth = 0; + + let breakingPixelWidth = firstLineBreakColumn * typicalHalfwidthCharacterWidth; + let fixedWidthRangeIndex = 0; + const firstFixedWidthRange = fixedWidthRanges.length > 0 ? fixedWidthRanges[0] : null; + const startsWithFixedWidth = firstFixedWidthRange && firstFixedWidthRange.startOffset === 0; + + let prevCharCode: number; + let prevCharCodeClass: CharacterClass; + let visibleColumn: number; + let currentLinePixelWidth: number; + let startOffset: number; + if (startsWithFixedWidth) { + prevCharCode = CharCode.Null; + prevCharCodeClass = CharacterClass.NONE; + visibleColumn = computeFixedWidthRangeColumnWidth(lineText, firstFixedWidthRange, 0, tabSize, columnsForFullWidthChar); + currentLinePixelWidth = firstFixedWidthRange.widthInEm * fontInfo.fontSize; + startOffset = firstFixedWidthRange.endOffset; + fixedWidthRangeIndex++; + } else { + prevCharCode = lineText.charCodeAt(0); + visibleColumn = computeCharWidth(prevCharCode, 0, tabSize, columnsForFullWidthChar); + currentLinePixelWidth = computeCharPixelWidth(prevCharCode, 0, tabSize, fontInfo); + startOffset = 1; + if (strings.isHighSurrogate(prevCharCode)) { + // A surrogate pair must always be considered as a single unit, so it is never to be broken + visibleColumn += 1; + currentLinePixelWidth += typicalHalfwidthCharacterWidth; + prevCharCode = lineText.charCodeAt(1); + startOffset++; + } prevCharCodeClass = classifier.get(prevCharCode); - startOffset++; } for (let i = startOffset; i < len; i++) { + const fixedWidthRange = fixedWidthRanges.length > 0 && fixedWidthRangeIndex < fixedWidthRanges.length ? fixedWidthRanges[fixedWidthRangeIndex] : null; const charStartOffset = i; - const charCode = lineText.charCodeAt(i); + let charCode = lineText.charCodeAt(i); let charCodeClass: CharacterClass; let charWidth: number; + let charPixelWidth: number; let wrapEscapedLineFeed = false; - if (strings.isHighSurrogate(charCode)) { + if (fixedWidthRange && fixedWidthRange.startOffset === i) { + charCode = CharCode.Null; + charCodeClass = CharacterClass.NONE; + charWidth = computeFixedWidthRangeColumnWidth(lineText, fixedWidthRange, visibleColumn, tabSize, columnsForFullWidthChar); + charPixelWidth = fixedWidthRange.widthInEm * fontInfo.fontSize; + i = fixedWidthRange.endOffset - 1; + fixedWidthRangeIndex++; + } else if (strings.isHighSurrogate(charCode)) { // A surrogate pair must always be considered as a single unit, so it is never to be broken i++; charCodeClass = CharacterClass.NONE; charWidth = 2; + charPixelWidth = 2 * typicalHalfwidthCharacterWidth; } else { charCodeClass = classifier.get(charCode); charWidth = computeCharWidth(charCode, visibleColumn, tabSize, columnsForFullWidthChar); + charPixelWidth = computeCharPixelWidth(charCode, visibleColumn, tabSize, fontInfo); } // literal \n shall trigger a softwrap - if (wrapOnEscapedLineFeeds && isEscapedLineBreakAtPosition(lineText, i)) { + if (wrapOnEscapedLineFeeds && isEscapedLineBreakAtPosition(lineText, charStartOffset)) { breakOffset = charStartOffset; breakOffsetVisibleColumn = visibleColumn; + breakOffsetPixelWidth = currentLinePixelWidth; wrapEscapedLineFeed = true; } else if (canBreak(prevCharCode, prevCharCodeClass, charCode, charCodeClass, isKeepAll)) { breakOffset = charStartOffset; breakOffsetVisibleColumn = visibleColumn; + breakOffsetPixelWidth = currentLinePixelWidth; } visibleColumn += charWidth; + currentLinePixelWidth += charPixelWidth; - // check if adding character at `i` will go over the breaking column - if (visibleColumn > breakingColumn || wrapEscapedLineFeed) { + // check if adding character at `i` will go over the breaking width + if (currentLinePixelWidth > breakingPixelWidth || wrapEscapedLineFeed) { // We need to break at least before character at `i`: - if (breakOffset === 0 || visibleColumn - breakOffsetVisibleColumn > wrappedLineBreakColumn) { + if (breakOffset === 0 || currentLinePixelWidth - breakOffsetPixelWidth > wrappedLineBreakPixelWidth) { // Cannot break at `breakOffset`, must break at `i` breakOffset = charStartOffset; breakOffsetVisibleColumn = visibleColumn - charWidth; + breakOffsetPixelWidth = currentLinePixelWidth - charPixelWidth; } - breakingOffsets[breakingOffsetsCount] = breakOffset; - breakingOffsetsVisibleColumn[breakingOffsetsCount] = breakOffsetVisibleColumn; - breakingOffsetsCount++; - breakingColumn = breakOffsetVisibleColumn + wrappedLineBreakColumn; - breakOffset = 0; + const currentLineStartOffset = breakingOffsetsCount > 0 ? breakingOffsets[breakingOffsetsCount - 1] : 0; + if (breakOffset > currentLineStartOffset) { + // Breaking at the start of the current output line would emit an empty line, which + // happens when an oversized leading width-only injection cannot fit before the first + // character. In that case keep the content on the current line and defer the break. + breakingOffsets[breakingOffsetsCount] = breakOffset; + breakingOffsetsVisibleColumn[breakingOffsetsCount] = breakOffsetVisibleColumn; + breakingOffsetsCount++; + breakingPixelWidth = breakOffsetPixelWidth + wrappedLineBreakPixelWidth; + breakOffset = 0; + } } prevCharCode = charCode; @@ -491,6 +536,38 @@ function tabCharacterWidth(visibleColumn: number, tabSize: number): number { return (tabSize - (visibleColumn % tabSize)); } +/** + * The width in pixels a character occupies. Used for the wrapping decision, which must reason + * in real widths because injected text can request an arbitrary width via `widthInEm`. + */ +function computeCharPixelWidth(charCode: number, visibleColumn: number, tabSize: number, fontInfo: FontInfo): number { + if (charCode === CharCode.Tab) { + return tabCharacterWidth(visibleColumn, tabSize) * fontInfo.typicalHalfwidthCharacterWidth; + } + if (strings.isFullWidthCharacter(charCode)) { + return fontInfo.typicalFullwidthCharacterWidth; + } + if (charCode < 32) { + // when using `editor.renderControlCharacters`, the substitutions are often wide + return fontInfo.typicalFullwidthCharacterWidth; + } + return fontInfo.typicalHalfwidthCharacterWidth; +} + +/** + * The number of columns the characters of a fixed width injected text range occupy, which is 0 for + * a width-only injection. `widthInEm` is deliberately ignored here: `visibleColumn` only drives tab + * expansion, which must stay in sync with the line rendering, and the rendering does not know about + * `widthInEm`. + */ +function computeFixedWidthRangeColumnWidth(lineText: string, range: FixedWidthInjectedTextRange, visibleColumn: number, tabSize: number, columnsForFullWidthChar: number): number { + let width = 0; + for (let i = range.startOffset; i < range.endOffset; i++) { + width += computeCharWidth(lineText.charCodeAt(i), visibleColumn + width, tabSize, columnsForFullWidthChar); + } + return width; +} + /** * Checks if the current position in the text should trigger a soft wrap due to escaped line feeds. * This handles the wrapOnEscapedLineFeeds feature which allows \n sequences in strings to trigger wrapping. @@ -526,7 +603,7 @@ function canBreak(prevCharCode: number, prevCharCodeClass: CharacterClass, charC ); } -function computeWrappedTextIndentLength(lineText: string, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent): number { +function computeWrappedTextIndentLength(lineText: string, tabSize: number, firstLineBreakColumn: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, fixedWidthRanges?: readonly FixedWidthInjectedTextRange[]): number { let wrappedTextIndentLength = 0; if (wrappingIndent !== WrappingIndent.None) { const firstNonWhitespaceIndex = strings.firstNonWhitespaceIndex(lineText); @@ -534,6 +611,9 @@ function computeWrappedTextIndentLength(lineText: string, tabSize: number, first // Track existing indent for (let i = 0; i < firstNonWhitespaceIndex; i++) { + if (fixedWidthRanges?.[0]?.startOffset === i) { + break; + } const charWidth = (lineText.charCodeAt(i) === CharCode.Tab ? tabCharacterWidth(wrappedTextIndentLength, tabSize) : 1); wrappedTextIndentLength += charWidth; } diff --git a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts index 506be419193df..a605d3c978965 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorDetector.ts @@ -7,7 +7,7 @@ import { CancelablePromise, createCancelablePromise, TimeoutTimer } from '../../ import { RGBA } from '../../../../base/common/color.js'; import { onUnexpectedError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { noBreakWhitespace } from '../../../../base/common/strings.js'; import { ICodeEditor } from '../../../browser/editorBrowser.js'; @@ -49,6 +49,9 @@ export class ColorDetector extends Disposable implements IEditorContribution { private readonly _decoratorLimitReporter = this._register(new DecoratorLimitReporter()); + private static readonly colorDecoratorInnerWidthInEm = 0.8; + private static readonly colorDecoratorMarginInEm = 0.2; + constructor( private readonly _editor: ICodeEditor, @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -58,6 +61,13 @@ export class ColorDetector extends Disposable implements IEditorContribution { super(); this._colorDecoratorIds = this._editor.createDecorationsCollection(); this._ruleFactory = this._register(new DynamicCssRules(this._editor)); + const editorDomNode = this._editor.getContainerDomNode(); + editorDomNode.style.setProperty('--vscode-colorPicker-colorDecoratorWidth', `${ColorDetector.colorDecoratorInnerWidthInEm}em`); + editorDomNode.style.setProperty('--vscode-colorPicker-colorDecoratorMargin', `${ColorDetector.colorDecoratorMarginInEm}em`); + this._register(toDisposable(() => { + editorDomNode.style.removeProperty('--vscode-colorPicker-colorDecoratorWidth'); + editorDomNode.style.removeProperty('--vscode-colorPicker-colorDecoratorMargin'); + })); this._debounceInformation = languageFeatureDebounceService.for(_languageFeaturesService.colorProvider, 'Document Colors', { min: ColorDetector.RECOMPUTE_TIME }); this._register(_editor.onDidChangeModel(() => { this._isColorDecoratorsEnabled = this.isEnabled(); @@ -204,6 +214,7 @@ export class ColorDetector extends Disposable implements IEditorContribution { this._colorDecorationClassRefs.clear(); const decorations: IModelDeltaDecoration[] = []; + const widthInEm = ColorDetector.colorDecoratorInnerWidthInEm + 2 * ColorDetector.colorDecoratorMarginInEm; const limit = this._editor.getOption(EditorOption.colorDecoratorsLimit); @@ -231,7 +242,8 @@ export class ColorDetector extends Disposable implements IEditorContribution { content: noBreakWhitespace, inlineClassName: `${ref.className} colorpicker-color-decoration`, inlineClassNameAffectsLetterSpacing: true, - attachedData: ColorDecorationInjectedTextMarker + attachedData: ColorDecorationInjectedTextMarker, + widthInEm, } } }); diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPicker.css b/src/vs/editor/contrib/colorPicker/browser/colorPicker.css index f484517caba97..7f09b70cb98b0 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPicker.css +++ b/src/vs/editor/contrib/colorPicker/browser/colorPicker.css @@ -15,8 +15,8 @@ .hc-light .colorpicker-color-decoration { border: solid 0.1em #000; box-sizing: border-box; - margin: 0.1em 0.2em 0 0.2em; - width: 0.8em; + margin: 0.1em var(--vscode-colorPicker-colorDecoratorMargin) 0; + width: var(--vscode-colorPicker-colorDecoratorWidth); height: 0.8em; line-height: 0.8em; display: inline-block; @@ -206,6 +206,6 @@ cursor: pointer; } -.colorpicker-body .insert-button:hover{ +.colorpicker-body .insert-button:hover { background: var(--vscode-button-hoverBackground); } diff --git a/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts b/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts index 6c1c58c4f8ac1..e09a3807fbd9e 100644 --- a/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts +++ b/src/vs/editor/contrib/inlayHints/browser/inlayHintsController.ts @@ -532,14 +532,17 @@ export class InlayHintsController implements IEditorContribution { } // utils to collect/create injected text decorations + const { fontSize, fontFamily, padding, isUniform } = this._getLayoutInfo(); + const editorFontSize = this._editor.getOption(EditorOption.fontSize); const newDecorationsData: InlayHintDecorationRenderInfo[] = []; - const addInjectedText = (item: InlayHintItem, ref: ClassNameReference, content: string, cursorStops: InjectedTextCursorStops, attachedData?: RenderedInlayHintLabelPart | object): void => { + const addInjectedText = (item: InlayHintItem, ref: ClassNameReference, content: string, cursorStops: InjectedTextCursorStops, attachedData?: RenderedInlayHintLabelPart | object, widthInEm?: number): void => { const opts: InjectedTextOptions = { content, inlineClassNameAffectsLetterSpacing: true, inlineClassName: ref.className, cursorStops, - attachedData + attachedData, + widthInEm }; newDecorationsData.push({ item, @@ -559,16 +562,17 @@ export class InlayHintsController implements IEditorContribution { }; const addInjectedWhitespace = (item: InlayHintItem, isLast: boolean): void => { + const widthInPixels = (fontSize / 3) | 0; + const widthInEm = widthInPixels / editorFontSize; const marginRule = this._ruleFactory.createClassNameRef({ - width: `${(fontSize / 3) | 0}px`, + width: `${widthInPixels}px`, display: 'inline-block' }); - addInjectedText(item, marginRule, '\u200a', isLast ? InjectedTextCursorStops.Right : InjectedTextCursorStops.None, InlayHintsController._whitespaceData); + addInjectedText(item, marginRule, '', isLast ? InjectedTextCursorStops.Right : InjectedTextCursorStops.None, InlayHintsController._whitespaceData, widthInEm); }; // - const { fontSize, fontFamily, padding, isUniform } = this._getLayoutInfo(); const maxLength = this._editor.getOption(EditorOption.inlayHints).maximumLength; const fontFamilyVar = '--code-editorInlayHintsFontFamily'; this._editor.getContainerDomNode().style.setProperty(fontFamilyVar, fontFamily); diff --git a/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts b/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts index 5b238c214768d..e111516e10f82 100644 --- a/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts +++ b/src/vs/editor/contrib/inlineProgress/browser/inlineProgress.ts @@ -27,6 +27,7 @@ const inlineProgressDecoration = ModelDecorationOptions.register({ content: noBreakWhitespace, inlineClassName: 'inline-editor-progress-decoration', inlineClassNameAffectsLetterSpacing: true, + widthInEm: 1, } }); diff --git a/src/vs/editor/test/common/model/modelDecorations.test.ts b/src/vs/editor/test/common/model/modelDecorations.test.ts index 142bbead17a39..cf786fd4fe17e 100644 --- a/src/vs/editor/test/common/model/modelDecorations.test.ts +++ b/src/vs/editor/test/common/model/modelDecorations.test.ts @@ -113,6 +113,26 @@ suite('Editor Model - Model Decorations', () => { lineHasNoDecorations(thisModel, 5); }); + test('injected text width must be finite and non-negative', () => { + const widths = [NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, 0, 1]; + const decorationIds = thisModel.deltaDecorations([], widths.map(widthInEm => ({ + range: new Range(1, 1, 1, 1), + options: { + description: 'test', + after: { content: 'x', widthInEm } + } + }))); + + assert.deepStrictEqual(decorationIds.map(id => thisModel.getDecorationOptions(id)?.after?.widthInEm), [ + undefined, + undefined, + undefined, + undefined, + 0, + 1 + ]); + }); + test('line decoration', () => { addDecoration(thisModel, 1, 1, 1, 14, 'myType'); lineHasDecoration(thisModel, 1, 1, 14, 'myType'); diff --git a/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts b/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts index efe951f345715..f19d4fcd65315 100644 --- a/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineDecorations.test.ts @@ -63,6 +63,40 @@ suite('Editor ViewLayout - ViewLineParts', () => { ]); }); + test('empty width only decorations are kept, empty regular ones are not', () => { + const result = LineDecoration.filter([ + new InlineDecoration(new Range(4, 3, 4, 3), 'spacer', InlineDecorationType.WidthOnly), + new InlineDecoration(new Range(4, 3, 4, 3), 'regular', InlineDecorationType.Regular), + ], 4, 1, 500); + + assert.deepStrictEqual(result, [ + new LineDecoration(3, 3, 'spacer', InlineDecorationType.WidthOnly), + ]); + }); + + test('width only decorations come before other decorations at the same position', () => { + const decorations = [ + new LineDecoration(3, 3, 'regular', InlineDecorationType.Regular), + new LineDecoration(3, 3, 'after', InlineDecorationType.After), + new LineDecoration(3, 3, 'spacer', InlineDecorationType.WidthOnly), + new LineDecoration(3, 3, 'before', InlineDecorationType.Before), + ]; + decorations.sort(LineDecoration.compare); + + assert.deepStrictEqual(decorations.map(d => d.className), ['spacer', 'before', 'after', 'regular']); + }); + + test('width only decorations are not turned into pseudo elements', () => { + const result = LineDecorationsNormalizer.normalize('abc', [ + new LineDecoration(2, 2, 'spacer', InlineDecorationType.WidthOnly), + ]); + + // Metadata stays `0`: unlike a before or after decoration, this is the injected content itself. + assert.deepStrictEqual(result, [ + new DecorationSegment(1, 0, 'spacer', 0), + ]); + }); + test('ViewLineParts', () => { assert.deepStrictEqual(LineDecorationsNormalizer.normalize('abcabcabcabcabcabcabcabcabcabc', [ diff --git a/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts b/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts index efc948f669e3c..28b01611eb066 100644 --- a/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts +++ b/src/vs/editor/test/common/viewModel/inlineDecorations.test.ts @@ -316,6 +316,24 @@ suite('InjectedTextInlineDecorationsComputer', () => { ]); }); + test('fixed width injection uses its regular inline decoration', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '\xa0', inlineClassName: 'fixed-width', widthInEm: 3 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [11], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 6, 1, 7), 'fixed-width', InlineDecorationType.Regular)] + ]); + }); + test('injection with inlineClassNameAffectsLetterSpacing', () => { const injectionOptions: InjectedTextOptions[] = [ { content: 'abc', inlineClassName: 'ls-class', inlineClassNameAffectsLetterSpacing: true } @@ -488,4 +506,137 @@ suite('InjectedTextInlineDecorationsComputer', () => { [new InlineDecoration(new Range(6, 1, 6, 3), 'wrap-class', InlineDecorationType.Regular)], ]); }); + + test('spacing-only injection in the middle of a line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 6, 1, 6), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection at the beginning of a line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 1), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection on an empty line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [0], + getBreakOffsets: () => [0], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 1, 1, 1), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection at a wrap boundary belongs to the following line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [5, 10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [], + [new InlineDecoration(new Range(2, 1, 2, 1), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection at the end of a line', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [10], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // There is no following output line to move it to, so it stays at the end of this one. + assert.deepStrictEqual(result, [ + [new InlineDecoration(new Range(1, 11, 1, 11), 'spacer', InlineDecorationType.WidthOnly)] + ]); + }); + + test('spacing-only injection next to an injection with content', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', inlineClassName: 'spacer', widthInEm: 1 }, + { content: 'AB', inlineClassName: 'text-class' } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [2, 5], + getBreakOffsets: () => [12], // 10 (original) + 0 + 2 + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + // The spacing-only injection must not shift the offsets of the injections that follow it. + assert.deepStrictEqual(result, [ + [ + new InlineDecoration(new Range(1, 3, 1, 3), 'spacer', InlineDecorationType.WidthOnly), + new InlineDecoration(new Range(1, 6, 1, 8), 'text-class', InlineDecorationType.Regular), + ] + ]); + }); + + test('spacing-only injection without inlineClassName produces no inline decorations', () => { + const injectionOptions: InjectedTextOptions[] = [ + { content: '', widthInEm: 1 } + ]; + const context: IInjectedTextInlineDecorationsComputerContext = { + getInjectionOptions: () => injectionOptions, + getInjectionOffsets: () => [5], + getBreakOffsets: () => [10], + getWrappedTextIndentLength: () => 0, + getBaseViewLineNumber: () => 1, + }; + const computer = new InjectedTextInlineDecorationsComputer(context); + const result = computer.getInlineDecorations(1); + assert.deepStrictEqual(result, [ + [] + ]); + }); }); diff --git a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts index 70d687d30e442..70019f302fea8 100644 --- a/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/monospaceLineBreaksComputer.test.ts @@ -7,6 +7,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { EditorOptions, WrappingIndent } from '../../../common/config/editorOptions.js'; import { FontInfo } from '../../../common/config/fontInfo.js'; import { ILineBreaksComputerContext, ILineBreaksComputerFactory, ModelLineProjectionData } from '../../../common/modelLineProjectionData.js'; +import { LineInjectedText } from '../../../common/textModelEvents.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; function parseAnnotatedText(annotatedText: string): { text: string; indices: number[] } { @@ -44,7 +45,7 @@ function toAnnotatedText(text: string, lineBreakData: ModelLineProjectionData | return actualAnnotatedText; } -function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, text: string, previousLineBreakData: ModelLineProjectionData | null): ModelLineProjectionData | null { +function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, breakAfter: number, columnsForFullWidthChar: number, wrappingIndent: WrappingIndent, wordBreak: 'normal' | 'keepAll', wrapOnEscapedLineFeeds: boolean, text: string, previousLineBreakData: ModelLineProjectionData | null, injectedText: LineInjectedText[] | null = null): ModelLineProjectionData | null { const fontInfo = new FontInfo({ pixelRatio: 1, fontFamily: 'testFontFamily', @@ -68,7 +69,7 @@ function getLineBreakData(factory: ILineBreaksComputerFactory, tabSize: number, return text; }, getLineInjectedText(lineNumber) { - return null; + return injectedText; } }; const lineBreaksComputer = factory.createLineBreaksComputer(context, fontInfo, tabSize, breakAfter, wrappingIndent, wordBreak, wrapOnEscapedLineFeeds); @@ -134,6 +135,211 @@ suite('Editor ViewModel - MonospaceLineBreaksComputer', () => { assertLineBreaks(factory, 4, 5, 'aa.|(.).|aaa'); }); + test('accounts for fixed injected text width when wrapping', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 5, { content: '\xa0', widthInEm: 1 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [4, 7], + breakOffsetsVisibleColumn: [4, 7] + }); + }); + + test('treats multi-character fixed-width injected text as an atomic span', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 4, { content: 'hello', widthInEm: 1 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [8, 11], + breakOffsetsVisibleColumn: [8, 11] + }); + }); + + test('wraps after an escaped line feed followed by fixed-width injected text', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 100, 2, WrappingIndent.None, 'normal', true, '"a\\nb"', null, [ + new LineInjectedText(0, 1, 5, { content: 'hint', widthInEm: 1 }, 0) + ]); + + assert.deepStrictEqual(lineBreakData?.breakOffsets, [4, 10]); + }); + + test('treats adjacent fixed-width injected texts as separate atomic spans', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 4, { content: 'x', widthInEm: 0.5 }, 0), + new LineInjectedText(0, 1, 4, { content: 'y', widthInEm: 1 }, 1) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [4, 8], + breakOffsetsVisibleColumn: [4, 8] + }); + }); + + test('keeps fixed-width injected text wider than the wrap column atomic', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 4, { content: 'x', widthInEm: 3 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 4, 7], + breakOffsetsVisibleColumn: [3, 4, 7] + }); + }); + + test('wraps before a tab that no longer fits after fixed-width injected text', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 4, 2, WrappingIndent.None, 'normal', false, 'ab\tcd', null, [ + new LineInjectedText(0, 1, 3, { content: '\xa0', widthInEm: 0.75 }, 0) + ]); + + // `ab` plus a 0.75em wide injection fills 2.75 columns worth of pixels, so the tab (which + // expands from character column 3 to character column 4) no longer fits and the line wraps + // right before it. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 6], + breakOffsetsVisibleColumn: [3, 6] + }); + }); + + test('expands a tab following fixed-width injected text from the character column', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 10, 2, WrappingIndent.None, 'normal', false, 'ab\tcdefgh', null, [ + new LineInjectedText(0, 1, 3, { content: '\xa0', widthInEm: 1.5 }, 0) + ]); + + // The injection counts as a single character column, so the tab at character column 3 expands + // to character column 4 and the whole line spans 10 columns. Its 1.5em is spent on the wrapping + // decision alone, which runs out of room at `g`. Were that width to leak into the column + // accumulator, the injection would count as 3 columns, the tab would expand to character + // column 8, and the line would already run out of room at `e`. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [8, 10], + breakOffsetsVisibleColumn: [8, 10] + }); + }); + + test('reserves width for a spacing-only injection at the beginning of a line', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 6, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 1, { content: '', widthInEm: 1 }, 0) + ]); + + // 1em is two columns wide here, so the spacer pushes the last two characters onto a second + // line. It consumes no character, so the offsets still refer to the original text. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [4, 6], + breakOffsetsVisibleColumn: [4, 6] + }); + }); + + test('does not create empty lines for oversized spacing-only injected text', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 2, 2, WrappingIndent.None, 'normal', false, 'abc', null, [ + new LineInjectedText(0, 1, 1, { content: '', widthInEm: 2 }, 0) + ]); + + // The spacer alone already overflows the wrap width, but there is no character before it to + // wrap, so it must share the first line with `a` rather than emit an empty leading line. + assert.deepStrictEqual(lineBreakData?.breakOffsets, [1, 3]); + }); + + test('lets a spacing-only injection in the middle of a line inherit surrounding break opportunities', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ' '); + const lineBreakData = getLineBreakData(factory, 4, 8, 2, WrappingIndent.None, 'normal', false, 'ab cdef', null, [ + new LineInjectedText(0, 1, 4, { content: '', widthInEm: 1 }, 0) + ]); + + // The spacer is transparent for break opportunities: it neither creates nor suppresses one, so + // the break after the space is still recorded even though the spacer sits on top of it, and the + // line breaks there rather than at `f` where it runs out of room. Its 1em is what makes the line + // run out of room at all: without it the seven characters fit in the eight available columns. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 7], + breakOffsetsVisibleColumn: [3, 7] + }); + }); + + test('ignores a spacing-only injection at the end of a line', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 6, 2, WrappingIndent.None, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 7, { content: '', widthInEm: 1 }, 0) + ]); + + // The line is exactly full, and there is no character after the spacer that could be moved to a + // following line, so the spacer cannot introduce a break. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [6], + breakOffsetsVisibleColumn: [6] + }); + }); + + test('expands a tab following a spacing-only injection from the character column', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 6, 2, WrappingIndent.None, 'normal', false, 'ab\tcd', null, [ + new LineInjectedText(0, 1, 3, { content: '', widthInEm: 1 }, 0) + ]); + + // The spacer occupies no character column, so the tab still expands from character column 2 to + // character column 4. Were its 1em width to leak into the column accumulator, the tab would + // start at character column 4 and expand to character column 8 instead. + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + breakOffsetsVisibleColumn: lineBreakData?.breakOffsetsVisibleColumn + }, { + breakOffsets: [3, 5], + breakOffsetsVisibleColumn: [4, 6] + }); + }); + + test('does not use fixed-width injected whitespace as continuation indentation', () => { + const factory = new MonospaceLineBreaksComputerFactory('', ''); + const lineBreakData = getLineBreakData(factory, 4, 5, 2, WrappingIndent.Same, 'normal', false, 'abcdef', null, [ + new LineInjectedText(0, 1, 1, { content: ' ', widthInEm: 0.75 }, 0) + ]); + + assert.deepStrictEqual({ + breakOffsets: lineBreakData?.breakOffsets, + wrappedTextIndentLength: lineBreakData?.wrappedTextIndentLength + }, { + breakOffsets: [4, 7], + wrappedTextIndentLength: 0 + }); + }); + function assertLineBreakDataEqual(a: ModelLineProjectionData | null, b: ModelLineProjectionData | null): void { if (!a || !b) { assert.deepStrictEqual(a, b); @@ -142,7 +348,7 @@ suite('Editor ViewModel - MonospaceLineBreaksComputer', () => { assert.deepStrictEqual(a.breakOffsets, b.breakOffsets); assert.deepStrictEqual(a.wrappedTextIndentLength, b.wrappedTextIndentLength); for (let i = 0; i < a.breakOffsetsVisibleColumn.length; i++) { - const diff = a.breakOffsetsVisibleColumn[i] - b.breakOffsetsVisibleColumn[i]; + const diff = Math.abs(a.breakOffsetsVisibleColumn[i] - b.breakOffsetsVisibleColumn[i]); assert.ok(diff < 0.001); } } diff --git a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts index 6ee10c8b3f260..a4b9d2ea45585 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts @@ -142,7 +142,8 @@ function getBreakpointDecorationOptions(accessor: ServicesAccessor, model: IText before: renderInline ? { content: noBreakWhitespace, inlineClassName: `debug-breakpoint-placeholder`, - inlineClassNameAffectsLetterSpacing: true + inlineClassNameAffectsLetterSpacing: true, + widthInEm: 0.9 } : undefined, overviewRuler: overviewRulerDecoration, zIndex: 9999 @@ -195,7 +196,8 @@ function createCandidateDecorations(model: ITextModel, breakpointDecorations: IB before: breakpointAtPosition ? undefined : { content: noBreakWhitespace, inlineClassName: `debug-breakpoint-placeholder`, - inlineClassNameAffectsLetterSpacing: true + inlineClassNameAffectsLetterSpacing: true, + widthInEm: 0.9 }, }, breakpoint: breakpointAtPosition ? breakpointAtPosition.breakpoint : undefined diff --git a/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts b/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts index c53b662bf176f..842a53873c060 100644 --- a/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/breakpoints.test.ts @@ -433,6 +433,7 @@ suite('Debug - Breakpoints', () => { assert.deepStrictEqual(decorations[2].range, new Range(3, 5, 3, 6)); assert.strictEqual(decorations[0].options.beforeContentClassName, undefined); assert.strictEqual(decorations[1].options.before?.inlineClassName, `debug-breakpoint-placeholder`); + assert.strictEqual(decorations[1].options.before?.widthInEm, 0.9); assert.strictEqual(decorations[0].options.overviewRuler?.position, OverviewRulerLane.Left); const expected = new MarkdownString(undefined, { isTrusted: true, supportThemeIcons: true }).appendCodeblock(languageId, 'Condition: x > 5'); assert.deepStrictEqual(decorations[0].options.glyphMarginHoverMessage, expected);