Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion src/vs/editor/browser/gpu/css/decorationCssRuleExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { $, getActiveDocument } from '../../../../base/browser/dom.js';
import { $, getActiveDocument, getActiveWindow } from '../../../../base/browser/dom.js';
import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';
import './media/decorationCssRuleExtractor.css';

Expand Down Expand Up @@ -81,4 +81,14 @@ export class DecorationCssRuleExtractor extends Disposable {

return rules;
}

/**
* Resolves a CSS variable to its computed value using the container element.
*/
resolveCssVariable(canvas: HTMLCanvasElement, variableName: string): string {
canvas.appendChild(this._container);
const result = getActiveWindow().getComputedStyle(this._container).getPropertyValue(variableName).trim();
canvas.removeChild(this._container);
return result;
}
}
44 changes: 38 additions & 6 deletions src/vs/editor/browser/gpu/css/decorationStyleCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ export interface IDecorationStyleSet {
* A number between 0 and 1 representing the opacity of the text.
*/
opacity: number | undefined;
/**
* Whether the text should be rendered with a strikethrough.
*/
strikethrough: boolean | undefined;
/**
* The thickness of the strikethrough line in pixels (CSS pixels, not device pixels).
*/
strikethroughThickness: number | undefined;
/**
* A 32-bit number representing the strikethrough color.
*/
strikethroughColor: number | undefined;
}

export interface IDecorationStyleCacheEntry extends IDecorationStyleSet {
Expand All @@ -32,29 +44,49 @@ export class DecorationStyleCache {
private _nextId = 1;

private readonly _cacheById = new Map<number, IDecorationStyleCacheEntry>();
private readonly _cacheByStyle = new NKeyMap<IDecorationStyleCacheEntry, [number, number, string]>();
private readonly _cacheByStyle = new NKeyMap<IDecorationStyleCacheEntry, [number, number, string, number, string, number]>();

getOrCreateEntry(
color: number | undefined,
bold: boolean | undefined,
opacity: number | undefined
opacity: number | undefined,
strikethrough: boolean | undefined,
strikethroughThickness: number | undefined,
strikethroughColor: number | undefined
): number {
if (color === undefined && bold === undefined && opacity === undefined) {
if (color === undefined && bold === undefined && opacity === undefined && strikethrough === undefined && strikethroughThickness === undefined && strikethroughColor === undefined) {
return 0;
}
const result = this._cacheByStyle.get(color ?? 0, bold ? 1 : 0, opacity === undefined ? '' : opacity.toFixed(2));
const result = this._cacheByStyle.get(
color ?? 0,
bold ? 1 : 0,
opacity === undefined ? '' : opacity.toFixed(2),
strikethrough ? 1 : 0,
strikethroughThickness === undefined ? '' : strikethroughThickness.toFixed(2),
strikethroughColor ?? 0
);
if (result) {
return result.id;
}
const id = this._nextId++;
const entry = {
const entry: IDecorationStyleCacheEntry = {
id,
color,
bold,
opacity,
strikethrough,
strikethroughThickness,
strikethroughColor,
};
this._cacheById.set(id, entry);
this._cacheByStyle.set(entry, color ?? 0, bold ? 1 : 0, opacity === undefined ? '' : opacity.toFixed(2));
this._cacheByStyle.set(entry,
color ?? 0,
bold ? 1 : 0,
opacity === undefined ? '' : opacity.toFixed(2),
strikethrough ? 1 : 0,
strikethroughThickness === undefined ? '' : strikethroughThickness.toFixed(2),
strikethroughColor ?? 0
);
return id;
}

Expand Down
43 changes: 37 additions & 6 deletions src/vs/editor/browser/gpu/raster/glyphRasterizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {

// The sub-pixel x offset is the fractional part of the x pixel coordinate of the cell, this
// is used to improve the spacing between rendered characters.
const xSubPixelXOffset = (tokenMetadata & 0b1111) / 10;
const subPixelXOffset = (tokenMetadata & 0b1111) / 10;

const bgId = TokenMetadata.getBackground(tokenMetadata);
const bg = colorMap[bgId];
Expand Down Expand Up @@ -145,26 +145,54 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {
fontSb.appendString(`${devicePixelFontSize}px ${this.fontFamily}`);
this._ctx.font = fontSb.build();

// TODO: Support FontStyle.Strikethrough and FontStyle.Underline text decorations, these
// need to be drawn manually to the canvas. See xterm.js for "dodging" the text for
// underlines.
// TODO: Support FontStyle.Underline text decorations, these need to be drawn manually to
// the canvas. See xterm.js for "dodging" the text for underlines.

const originX = devicePixelFontSize;
const originY = devicePixelFontSize;

// Apply text color
if (decorationStyleSet?.color !== undefined) {
this._ctx.fillStyle = `#${decorationStyleSet.color.toString(16).padStart(8, '0')}`;
} else {
this._ctx.fillStyle = colorMap[TokenMetadata.getForeground(tokenMetadata)];
}
this._ctx.textBaseline = 'top';

// Apply opacity
if (decorationStyleSet?.opacity !== undefined) {
this._ctx.globalAlpha = decorationStyleSet.opacity;
}

this._ctx.fillText(chars, originX + xSubPixelXOffset, originY);
// The glyph baseline is top, meaning it's drawn at the top-left of the
// cell. Add `TextMetrics.alphabeticBaseline` to the drawn position to
// get the alphabetic baseline.
this._ctx.textBaseline = 'top';

// Draw the text
this._ctx.fillText(chars, originX + subPixelXOffset, originY);

// Draw strikethrough
if (decorationStyleSet?.strikethrough) {
// TODO: This position could be refined further by checking
// TextMetrics of lowercase letters.
// Position strikethrough at approximately the vertical center of
// lowercase letters.
const strikethroughY = Math.round(originY - this._textMetrics.alphabeticBaseline * 0.65);
const lineWidth = decorationStyleSet?.strikethroughThickness !== undefined
? Math.round(decorationStyleSet.strikethroughThickness * this.devicePixelRatio)
: Math.max(1, Math.floor(devicePixelFontSize / 10));
// Apply strikethrough color if specified
if (decorationStyleSet?.strikethroughColor !== undefined) {
this._ctx.fillStyle = `#${decorationStyleSet.strikethroughColor.toString(16).padStart(8, '0')}`;
}
// Intentionally do not apply the sub pixel x offset to
// strikethrough to ensure successive glyphs form a contiguous line.
this._ctx.fillRect(originX, strikethroughY - Math.floor(lineWidth / 2), Math.ceil(this._textMetrics.width), lineWidth);
}

this._ctx.restore();

// Extract the image data and clear the background color
const imageData = this._ctx.getImageData(0, 0, this._canvas.width, this._canvas.height);
if (this._antiAliasing === 'subpixel') {
const bgR = parseInt(bg.substring(1, 3), 16);
Expand All @@ -173,7 +201,10 @@ export class GlyphRasterizer extends Disposable implements IGlyphRasterizer {
this._clearColor(imageData, bgR, bgG, bgB);
this._ctx.putImageData(imageData, 0, 0);
}

// Find the bounding box
this._findGlyphBoundingBox(imageData, this._workGlyph.boundingBox);

// const offset = {
// x: textMetrics.actualBoundingBoxLeft,
// y: textMetrics.actualBoundingBoxAscent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,9 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
let decorationStyleSetBold: boolean | undefined;
let decorationStyleSetColor: number | undefined;
let decorationStyleSetOpacity: number | undefined;
let decorationStyleSetStrikethrough: boolean | undefined;
let decorationStyleSetStrikethroughThickness: number | undefined;
let decorationStyleSetStrikethroughColor: number | undefined;

let lineData: ViewLineRenderingData;
let decoration: InlineDecoration;
Expand Down Expand Up @@ -375,6 +378,9 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
decorationStyleSetColor = undefined;
decorationStyleSetBold = undefined;
decorationStyleSetOpacity = undefined;
decorationStyleSetStrikethrough = undefined;
decorationStyleSetStrikethroughThickness = undefined;
decorationStyleSetStrikethroughColor = undefined;

// Apply supported inline decoration styles to the cell metadata
for (decoration of lineData.inlineDecorations) {
Expand Down Expand Up @@ -419,6 +425,36 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
decorationStyleSetOpacity = parsedValue;
break;
}
case 'text-decoration':
case 'text-decoration-line': {
if (value === 'line-through') {
decorationStyleSetStrikethrough = true;
}
break;
}
case 'text-decoration-thickness': {
const match = value.match(/^(\d+(?:\.\d+)?)px$/);
if (match) {
decorationStyleSetStrikethroughThickness = parseFloat(match[1]);
}
break;
}
case 'text-decoration-color': {
let colorValue = value;
const varMatch = value.match(/^var\((--[^,]+),\s*(?:initial|inherit)\)$/);
if (varMatch) {
colorValue = ViewGpuContext.decorationCssRuleExtractor.resolveCssVariable(this._viewGpuContext.canvas.domNode, varMatch[1]);
}
const parsedColor = Color.Format.CSS.parse(colorValue);
if (parsedColor) {
decorationStyleSetStrikethroughColor = parsedColor.toNumber32Bit();
}
break;
}
case 'text-decoration-style': {
// These are validated in canRender and use default behavior
break;
}
default: throw new BugIndicatingError('Unexpected inline decoration style');
}
}
Expand All @@ -443,7 +479,7 @@ export class FullFileRenderStrategy extends BaseRenderStrategy {
continue;
}

const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity);
const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity, decorationStyleSetStrikethrough, decorationStyleSetStrikethroughThickness, decorationStyleSetStrikethroughColor);
glyph = this._viewGpuContext.atlas.getGlyph(this.glyphRasterizer, chars, tokenMetadata, decorationStyleSetId, absoluteOffsetX);

absoluteOffsetY = Math.round(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
let decorationStyleSetBold: boolean | undefined;
let decorationStyleSetColor: number | undefined;
let decorationStyleSetOpacity: number | undefined;
let decorationStyleSetStrikethrough: boolean | undefined;
let decorationStyleSetStrikethroughThickness: number | undefined;
let decorationStyleSetStrikethroughColor: number | undefined;

let lineData: ViewLineRenderingData;
let decoration: InlineDecoration;
Expand Down Expand Up @@ -278,6 +281,9 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
decorationStyleSetColor = undefined;
decorationStyleSetBold = undefined;
decorationStyleSetOpacity = undefined;
decorationStyleSetStrikethrough = undefined;
decorationStyleSetStrikethroughThickness = undefined;
decorationStyleSetStrikethroughColor = undefined;

// Apply supported inline decoration styles to the cell metadata
for (decoration of lineData.inlineDecorations) {
Expand Down Expand Up @@ -322,6 +328,36 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
decorationStyleSetOpacity = parsedValue;
break;
}
case 'text-decoration':
case 'text-decoration-line': {
if (value === 'line-through') {
decorationStyleSetStrikethrough = true;
}
break;
}
case 'text-decoration-thickness': {
const match = value.match(/^(\d+(?:\.\d+)?)px$/);
if (match) {
decorationStyleSetStrikethroughThickness = parseFloat(match[1]);
}
break;
}
case 'text-decoration-color': {
let colorValue = value;
const varMatch = value.match(/^var\((--[^,]+),\s*(?:initial|inherit)\)$/);
if (varMatch) {
colorValue = ViewGpuContext.decorationCssRuleExtractor.resolveCssVariable(this._viewGpuContext.canvas.domNode, varMatch[1]);
}
const parsedColor = Color.Format.CSS.parse(colorValue);
if (parsedColor) {
decorationStyleSetStrikethroughColor = parsedColor.toNumber32Bit();
}
break;
}
case 'text-decoration-style': {
// These are validated in canRender and use default behavior
break;
}
default: throw new BugIndicatingError('Unexpected inline decoration style');
}
}
Expand All @@ -346,7 +382,7 @@ export class ViewportRenderStrategy extends BaseRenderStrategy {
continue;
}

const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity);
const decorationStyleSetId = ViewGpuContext.decorationStyleCache.getOrCreateEntry(decorationStyleSetColor, decorationStyleSetBold, decorationStyleSetOpacity, decorationStyleSetStrikethrough, decorationStyleSetStrikethroughThickness, decorationStyleSetStrikethroughColor);
glyph = this._viewGpuContext.atlas.getGlyph(this.glyphRasterizer, chars, tokenMetadata, decorationStyleSetId, absoluteOffsetX);

absoluteOffsetY = Math.round(
Expand Down
31 changes: 31 additions & 0 deletions src/vs/editor/browser/gpu/viewGpuContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import * as nls from '../../../nls.js';
import { addDisposableListener, getActiveWindow } from '../../../base/browser/dom.js';
import { createFastDomNode, type FastDomNode } from '../../../base/browser/fastDomNode.js';
import { Color } from '../../../base/common/color.js';
import { BugIndicatingError } from '../../../base/common/errors.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import type { ViewportData } from '../../common/viewLayout/viewLinesViewportData.js';
Expand Down Expand Up @@ -255,6 +256,11 @@ const gpuSupportedDecorationCssRules = [
'color',
'font-weight',
'opacity',
'text-decoration',
'text-decoration-color',
'text-decoration-line',
'text-decoration-style',
'text-decoration-thickness',
];

function supportsCssRule(rule: string, style: CSSStyleDeclaration) {
Expand All @@ -263,6 +269,31 @@ function supportsCssRule(rule: string, style: CSSStyleDeclaration) {
}
// Check for values that aren't supported
switch (rule) {
case 'text-decoration':
case 'text-decoration-line': {
const value = style.getPropertyValue(rule);
// Only line-through is supported currently
return value === 'line-through';
}
case 'text-decoration-color': {
const value = style.getPropertyValue(rule);
// Support var(--something, initial/inherit) which falls back to currentcolor
if (/^var\(--[^,]+,\s*(?:initial|inherit)\)$/.test(value)) {
return true;
}
// Support parsed color values
return Color.Format.CSS.parse(value) !== null;
}
case 'text-decoration-style': {
const value = style.getPropertyValue(rule);
// Only 'initial' (solid) is supported
return value === 'initial';
}
case 'text-decoration-thickness': {
const value = style.getPropertyValue(rule);
// Only pixel values and 'initial' are supported
return value === 'initial' || /^\d+(\.\d+)?px$/.test(value);
}
default: return true;
}
}
Loading