Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/editor/browser/viewParts/whitespace/whitespace.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './whitespace.css';
7
import { DynamicViewOverlay } from '../../view/dynamicViewOverlay.js';
8
import { Selection } from '../../../common/core/selection.js';
9
import { RenderingContext } from '../../view/renderingContext.js';
10
import { ViewContext } from '../../../common/viewModel/viewContext.js';
11
import * as viewEvents from '../../../common/viewEvents.js';
12
import { ViewLineRenderingData } from '../../../common/viewModel.js';
13
import { EditorOption } from '../../../common/config/editorOptions.js';
14
import { IEditorConfiguration } from '../../../common/config/editorConfiguration.js';
15
import * as strings from '../../../../base/common/strings.js';
16
import { CharCode } from '../../../../base/common/charCode.js';
17
import { Position } from '../../../common/core/position.js';
18
import { editorWhitespaces } from '../../../common/core/editorColorRegistry.js';
19
import { OffsetRange } from '../../../common/core/ranges/offsetRange.js';
20
21
/**
22
* The whitespace overlay will visual certain whitespace depending on the
23
* current editor configuration (boundary, selection, etc.).
24
*/
25
export class WhitespaceOverlay extends DynamicViewOverlay {
26
27
private readonly _context: ViewContext;
28
private _options: WhitespaceOptions;
29
private _selection: Selection[];
30
private _renderResult: string[] | null;
31
32
constructor(context: ViewContext) {
33
super();
34
this._context = context;
35
this._options = new WhitespaceOptions(this._context.configuration);
36
this._selection = [];
37
this._renderResult = null;
38
this._context.addEventHandler(this);
39
}
40
41
public override dispose(): void {
42
this._context.removeEventHandler(this);
43
this._renderResult = null;
44
super.dispose();
45
}
46
47
// --- begin event handlers
48
49
public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean {
50
const newOptions = new WhitespaceOptions(this._context.configuration);
51
if (this._options.equals(newOptions)) {
52
return e.hasChanged(EditorOption.layoutInfo);
53
}
54
this._options = newOptions;
55
return true;
56
}
57
public override onCursorStateChanged(e: viewEvents.ViewCursorStateChangedEvent): boolean {
58
this._selection = e.selections;
59
if (this._options.renderWhitespace === 'selection') {
60
return true;
61
}
62
return false;
63
}
64
public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean {
65
return true;
66
}
67
public override onFlushed(e: viewEvents.ViewFlushedEvent): boolean {
68
return true;
69
}
70
public override onLinesChanged(e: viewEvents.ViewLinesChangedEvent): boolean {
71
return true;
72
}
73
public override onLinesDeleted(e: viewEvents.ViewLinesDeletedEvent): boolean {
74
return true;
75
}
76
public override onLinesInserted(e: viewEvents.ViewLinesInsertedEvent): boolean {
77
return true;
78
}
79
public override onScrollChanged(e: viewEvents.ViewScrollChangedEvent): boolean {
80
return e.scrollTopChanged;
81
}
82
public override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean {
83
return true;
84
}
85
// --- end event handlers
86
87
public prepareRender(ctx: RenderingContext): void {
88
if (this._options.renderWhitespace === 'none') {
89
this._renderResult = null;
90
return;
91
}
92
93
const startLineNumber = ctx.visibleRange.startLineNumber;
94
const endLineNumber = ctx.visibleRange.endLineNumber;
95
const lineCount = endLineNumber - startLineNumber + 1;
96
const needed = new Array<boolean>(lineCount);
97
for (let i = 0; i < lineCount; i++) {
98
needed[i] = true;
99
}
100
101
this._renderResult = [];
102
for (let lineNumber = ctx.viewportData.startLineNumber; lineNumber <= ctx.viewportData.endLineNumber; lineNumber++) {
103
const lineIndex = lineNumber - ctx.viewportData.startLineNumber;
104
const lineData = this._context.viewModel.getViewLineRenderingData(lineNumber);
105
106
let selectionsOnLine: OffsetRange[] | null = null;
107
if (this._options.renderWhitespace === 'selection') {
108
const selections = this._selection;
109
for (const selection of selections) {
110
111
if (selection.endLineNumber < lineNumber || selection.startLineNumber > lineNumber) {
112
// Selection does not intersect line
113
continue;
114
}
115
116
const startColumn = (selection.startLineNumber === lineNumber ? selection.startColumn : lineData.minColumn);
117
const endColumn = (selection.endLineNumber === lineNumber ? selection.endColumn : lineData.maxColumn);
118
119
if (startColumn < endColumn) {
120
if (!selectionsOnLine) {
121
selectionsOnLine = [];
122
}
123
selectionsOnLine.push(new OffsetRange(startColumn - 1, endColumn - 1));
124
}
125
}
126
}
127
128
this._renderResult[lineIndex] = this._applyRenderWhitespace(ctx, lineNumber, selectionsOnLine, lineData);
129
}
130
}
131
132
private _applyRenderWhitespace(ctx: RenderingContext, lineNumber: number, selections: OffsetRange[] | null, lineData: ViewLineRenderingData): string {
133
if (lineData.hasVariableFonts) {
134
return '';
135
}
136
if (this._options.renderWhitespace === 'selection' && !selections) {
137
return '';
138
}
139
if (this._options.renderWhitespace === 'trailing' && lineData.continuesWithWrappedLine) {
140
return '';
141
}
142
const color = this._context.theme.getColor(editorWhitespaces);
143
const USE_SVG = this._options.renderWithSVG;
144
145
const lineContent = lineData.content;
146
const len = (this._options.stopRenderingLineAfter === -1 ? lineContent.length : Math.min(this._options.stopRenderingLineAfter, lineContent.length));
147
const continuesWithWrappedLine = lineData.continuesWithWrappedLine;
148
const fauxIndentLength = lineData.minColumn - 1;
149
const onlyBoundary = (this._options.renderWhitespace === 'boundary');
150
const onlyTrailing = (this._options.renderWhitespace === 'trailing');
151
const lineHeight = ctx.getLineHeightForLineNumber(lineNumber);
152
const middotWidth = this._options.middotWidth;
153
const wsmiddotWidth = this._options.wsmiddotWidth;
154
const spaceWidth = this._options.spaceWidth;
155
const wsmiddotDiff = Math.abs(wsmiddotWidth - spaceWidth);
156
const middotDiff = Math.abs(middotWidth - spaceWidth);
157
158
// U+2E31 - WORD SEPARATOR MIDDLE DOT
159
// U+00B7 - MIDDLE DOT
160
const renderSpaceCharCode = (wsmiddotDiff < middotDiff ? 0x2E31 : 0xB7);
161
162
const canUseHalfwidthRightwardsArrow = this._options.canUseHalfwidthRightwardsArrow;
163
164
let result: string = '';
165
166
let lineIsEmptyOrWhitespace = false;
167
let firstNonWhitespaceIndex = strings.firstNonWhitespaceIndex(lineContent);
168
let lastNonWhitespaceIndex: number;
169
if (firstNonWhitespaceIndex === -1) {
170
lineIsEmptyOrWhitespace = true;
171
firstNonWhitespaceIndex = len;
172
lastNonWhitespaceIndex = len;
173
} else {
174
lastNonWhitespaceIndex = strings.lastNonWhitespaceIndex(lineContent);
175
}
176
177
let currentSelectionIndex = 0;
178
let currentSelection = selections && selections[currentSelectionIndex];
179
let maxLeft = 0;
180
181
for (let charIndex = fauxIndentLength; charIndex < len; charIndex++) {
182
const chCode = lineContent.charCodeAt(charIndex);
183
184
if (currentSelection && currentSelection.endExclusive <= charIndex) {
185
currentSelectionIndex++;
186
currentSelection = selections && selections[currentSelectionIndex];
187
}
188
189
if (chCode !== CharCode.Tab && chCode !== CharCode.Space) {
190
continue;
191
}
192
193
if (onlyTrailing && !lineIsEmptyOrWhitespace && charIndex <= lastNonWhitespaceIndex) {
194
// If rendering only trailing whitespace, check that the charIndex points to trailing whitespace.
195
continue;
196
}
197
198
if (onlyBoundary && charIndex >= firstNonWhitespaceIndex && charIndex <= lastNonWhitespaceIndex && chCode === CharCode.Space) {
199
// rendering only boundary whitespace
200
const prevChCode = (charIndex - 1 >= 0 ? lineContent.charCodeAt(charIndex - 1) : CharCode.Null);
201
const nextChCode = (charIndex + 1 < len ? lineContent.charCodeAt(charIndex + 1) : CharCode.Null);
202
if (prevChCode !== CharCode.Space && nextChCode !== CharCode.Space) {
203
continue;
204
}
205
}
206
207
if (onlyBoundary && continuesWithWrappedLine && charIndex === len - 1) {
208
const prevCharCode = (charIndex - 1 >= 0 ? lineContent.charCodeAt(charIndex - 1) : CharCode.Null);
209
const isSingleTrailingSpace = (chCode === CharCode.Space && (prevCharCode !== CharCode.Space && prevCharCode !== CharCode.Tab));
210
if (isSingleTrailingSpace) {
211
continue;
212
}
213
}
214
215
if (selections && !(currentSelection && currentSelection.start <= charIndex && charIndex < currentSelection.endExclusive)) {
216
// If rendering whitespace on selection, check that the charIndex falls within a selection
217
continue;
218
}
219
220
const visibleRange = ctx.visibleRangeForPosition(new Position(lineNumber, charIndex + 1));
221
if (!visibleRange) {
222
continue;
223
}
224
225
if (USE_SVG) {
226
maxLeft = Math.max(maxLeft, visibleRange.left);
227
if (chCode === CharCode.Tab) {
228
result += this._renderArrow(lineHeight, spaceWidth, visibleRange.left);
229
} else {
230
result += `<circle cx="${(visibleRange.left + spaceWidth / 2).toFixed(2)}" cy="${(lineHeight / 2).toFixed(2)}" r="${(spaceWidth / 7).toFixed(2)}" />`;
231
}
232
} else {
233
if (chCode === CharCode.Tab) {
234
result += `<div class="mwh" style="left:${visibleRange.left}px;height:${lineHeight}px;">${canUseHalfwidthRightwardsArrow ? String.fromCharCode(0xFFEB) : String.fromCharCode(0x2192)}</div>`;
235
} else {
236
result += `<div class="mwh" style="left:${visibleRange.left}px;height:${lineHeight}px;">${String.fromCharCode(renderSpaceCharCode)}</div>`;
237
}
238
}
239
}
240
241
if (USE_SVG) {
242
maxLeft = Math.round(maxLeft + spaceWidth);
243
return (
244
`<svg style="bottom:0;position:absolute;width:${maxLeft}px;height:${lineHeight}px" viewBox="0 0 ${maxLeft} ${lineHeight}" xmlns="http://www.w3.org/2000/svg" fill="${color}">`
245
+ result
246
+ `</svg>`
247
);
248
}
249
250
return result;
251
}
252
253
private _renderArrow(lineHeight: number, spaceWidth: number, left: number): string {
254
const strokeWidth = spaceWidth / 7;
255
const width = spaceWidth;
256
const dy = lineHeight / 2;
257
const dx = left;
258
259
const p1 = { x: 0, y: strokeWidth / 2 };
260
const p2 = { x: 100 / 125 * width, y: p1.y };
261
const p3 = { x: p2.x - 0.2 * p2.x, y: p2.y + 0.2 * p2.x };
262
const p4 = { x: p3.x + 0.1 * p2.x, y: p3.y + 0.1 * p2.x };
263
const p5 = { x: p4.x + 0.35 * p2.x, y: p4.y - 0.35 * p2.x };
264
const p6 = { x: p5.x, y: -p5.y };
265
const p7 = { x: p4.x, y: -p4.y };
266
const p8 = { x: p3.x, y: -p3.y };
267
const p9 = { x: p2.x, y: -p2.y };
268
const p10 = { x: p1.x, y: -p1.y };
269
270
const p = [p1, p2, p3, p4, p5, p6, p7, p8, p9, p10];
271
const parts = p.map((p) => `${(dx + p.x).toFixed(2)} ${(dy + p.y).toFixed(2)}`).join(' L ');
272
return `<path d="M ${parts}" />`;
273
}
274
275
public render(startLineNumber: number, lineNumber: number): string {
276
if (!this._renderResult) {
277
return '';
278
}
279
const lineIndex = lineNumber - startLineNumber;
280
if (lineIndex < 0 || lineIndex >= this._renderResult.length) {
281
return '';
282
}
283
return this._renderResult[lineIndex];
284
}
285
}
286
287
class WhitespaceOptions {
288
289
public readonly renderWhitespace: 'none' | 'boundary' | 'selection' | 'trailing' | 'all';
290
public readonly renderWithSVG: boolean;
291
public readonly spaceWidth: number;
292
public readonly middotWidth: number;
293
public readonly wsmiddotWidth: number;
294
public readonly canUseHalfwidthRightwardsArrow: boolean;
295
public readonly lineHeight: number;
296
public readonly stopRenderingLineAfter: number;
297
298
constructor(config: IEditorConfiguration) {
299
const options = config.options;
300
const fontInfo = options.get(EditorOption.fontInfo);
301
const experimentalWhitespaceRendering = options.get(EditorOption.experimentalWhitespaceRendering);
302
if (experimentalWhitespaceRendering === 'off') {
303
// whitespace is rendered in the view line
304
this.renderWhitespace = 'none';
305
this.renderWithSVG = false;
306
} else if (experimentalWhitespaceRendering === 'svg') {
307
this.renderWhitespace = options.get(EditorOption.renderWhitespace);
308
this.renderWithSVG = true;
309
} else {
310
this.renderWhitespace = options.get(EditorOption.renderWhitespace);
311
this.renderWithSVG = false;
312
}
313
this.spaceWidth = fontInfo.spaceWidth;
314
this.middotWidth = fontInfo.middotWidth;
315
this.wsmiddotWidth = fontInfo.wsmiddotWidth;
316
this.canUseHalfwidthRightwardsArrow = fontInfo.canUseHalfwidthRightwardsArrow;
317
this.lineHeight = options.get(EditorOption.lineHeight);
318
this.stopRenderingLineAfter = options.get(EditorOption.stopRenderingLineAfter);
319
}
320
321
public equals(other: WhitespaceOptions): boolean {
322
return (
323
this.renderWhitespace === other.renderWhitespace
324
&& this.renderWithSVG === other.renderWithSVG
325
&& this.spaceWidth === other.spaceWidth
326
&& this.middotWidth === other.middotWidth
327
&& this.wsmiddotWidth === other.wsmiddotWidth
328
&& this.canUseHalfwidthRightwardsArrow === other.canUseHalfwidthRightwardsArrow
329
&& this.lineHeight === other.lineHeight
330
&& this.stopRenderingLineAfter === other.stopRenderingLineAfter
331
);
332
}
333
}
334
335