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
5297 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
this._renderResult = [];
94
for (let lineNumber = ctx.viewportData.startLineNumber; lineNumber <= ctx.viewportData.endLineNumber; lineNumber++) {
95
const lineIndex = lineNumber - ctx.viewportData.startLineNumber;
96
const lineData = this._context.viewModel.getViewLineRenderingData(lineNumber);
97
98
let selectionsOnLine: OffsetRange[] | null = null;
99
if (this._options.renderWhitespace === 'selection') {
100
const selections = this._selection;
101
for (const selection of selections) {
102
103
if (selection.endLineNumber < lineNumber || selection.startLineNumber > lineNumber) {
104
// Selection does not intersect line
105
continue;
106
}
107
108
const startColumn = (selection.startLineNumber === lineNumber ? selection.startColumn : lineData.minColumn);
109
const endColumn = (selection.endLineNumber === lineNumber ? selection.endColumn : lineData.maxColumn);
110
111
if (startColumn < endColumn) {
112
if (!selectionsOnLine) {
113
selectionsOnLine = [];
114
}
115
selectionsOnLine.push(new OffsetRange(startColumn - 1, endColumn - 1));
116
}
117
}
118
}
119
120
this._renderResult[lineIndex] = this._applyRenderWhitespace(ctx, lineNumber, selectionsOnLine, lineData);
121
}
122
}
123
124
private _applyRenderWhitespace(ctx: RenderingContext, lineNumber: number, selections: OffsetRange[] | null, lineData: ViewLineRenderingData): string {
125
if (lineData.hasVariableFonts) {
126
return '';
127
}
128
if (this._options.renderWhitespace === 'selection' && !selections) {
129
return '';
130
}
131
if (this._options.renderWhitespace === 'trailing' && lineData.continuesWithWrappedLine) {
132
return '';
133
}
134
const color = this._context.theme.getColor(editorWhitespaces);
135
const USE_SVG = this._options.renderWithSVG;
136
137
const lineContent = lineData.content;
138
const len = (this._options.stopRenderingLineAfter === -1 ? lineContent.length : Math.min(this._options.stopRenderingLineAfter, lineContent.length));
139
const continuesWithWrappedLine = lineData.continuesWithWrappedLine;
140
const fauxIndentLength = lineData.minColumn - 1;
141
const onlyBoundary = (this._options.renderWhitespace === 'boundary');
142
const onlyTrailing = (this._options.renderWhitespace === 'trailing');
143
const lineHeight = ctx.getLineHeightForLineNumber(lineNumber);
144
const middotWidth = this._options.middotWidth;
145
const wsmiddotWidth = this._options.wsmiddotWidth;
146
const spaceWidth = this._options.spaceWidth;
147
const wsmiddotDiff = Math.abs(wsmiddotWidth - spaceWidth);
148
const middotDiff = Math.abs(middotWidth - spaceWidth);
149
150
// U+2E31 - WORD SEPARATOR MIDDLE DOT
151
// U+00B7 - MIDDLE DOT
152
const renderSpaceCharCode = (wsmiddotDiff < middotDiff ? 0x2E31 : 0xB7);
153
154
const canUseHalfwidthRightwardsArrow = this._options.canUseHalfwidthRightwardsArrow;
155
156
let result: string = '';
157
158
let lineIsEmptyOrWhitespace = false;
159
let firstNonWhitespaceIndex = strings.firstNonWhitespaceIndex(lineContent);
160
let lastNonWhitespaceIndex: number;
161
if (firstNonWhitespaceIndex === -1) {
162
lineIsEmptyOrWhitespace = true;
163
firstNonWhitespaceIndex = len;
164
lastNonWhitespaceIndex = len;
165
} else {
166
lastNonWhitespaceIndex = strings.lastNonWhitespaceIndex(lineContent);
167
}
168
169
let currentSelectionIndex = 0;
170
let currentSelection = selections && selections[currentSelectionIndex];
171
let maxLeft = 0;
172
173
for (let charIndex = fauxIndentLength; charIndex < len; charIndex++) {
174
const chCode = lineContent.charCodeAt(charIndex);
175
176
if (currentSelection && currentSelection.endExclusive <= charIndex) {
177
currentSelectionIndex++;
178
currentSelection = selections && selections[currentSelectionIndex];
179
}
180
181
if (chCode !== CharCode.Tab && chCode !== CharCode.Space) {
182
continue;
183
}
184
185
if (onlyTrailing && !lineIsEmptyOrWhitespace && charIndex <= lastNonWhitespaceIndex) {
186
// If rendering only trailing whitespace, check that the charIndex points to trailing whitespace.
187
continue;
188
}
189
190
if (onlyBoundary && charIndex >= firstNonWhitespaceIndex && charIndex <= lastNonWhitespaceIndex && chCode === CharCode.Space) {
191
// rendering only boundary whitespace
192
const prevChCode = (charIndex - 1 >= 0 ? lineContent.charCodeAt(charIndex - 1) : CharCode.Null);
193
const nextChCode = (charIndex + 1 < len ? lineContent.charCodeAt(charIndex + 1) : CharCode.Null);
194
if (prevChCode !== CharCode.Space && nextChCode !== CharCode.Space) {
195
continue;
196
}
197
}
198
199
if (onlyBoundary && continuesWithWrappedLine && charIndex === len - 1) {
200
const prevCharCode = (charIndex - 1 >= 0 ? lineContent.charCodeAt(charIndex - 1) : CharCode.Null);
201
const isSingleTrailingSpace = (chCode === CharCode.Space && (prevCharCode !== CharCode.Space && prevCharCode !== CharCode.Tab));
202
if (isSingleTrailingSpace) {
203
continue;
204
}
205
}
206
207
if (selections && !(currentSelection && currentSelection.start <= charIndex && charIndex < currentSelection.endExclusive)) {
208
// If rendering whitespace on selection, check that the charIndex falls within a selection
209
continue;
210
}
211
212
const visibleRange = ctx.visibleRangeForPosition(new Position(lineNumber, charIndex + 1));
213
if (!visibleRange) {
214
continue;
215
}
216
217
if (USE_SVG) {
218
maxLeft = Math.max(maxLeft, visibleRange.left);
219
if (chCode === CharCode.Tab) {
220
result += this._renderArrow(lineHeight, spaceWidth, visibleRange.left);
221
} else {
222
result += `<circle cx="${(visibleRange.left + spaceWidth / 2).toFixed(2)}" cy="${(lineHeight / 2).toFixed(2)}" r="${(spaceWidth / 7).toFixed(2)}" />`;
223
}
224
} else {
225
if (chCode === CharCode.Tab) {
226
result += `<div class="mwh" style="left:${visibleRange.left}px;height:${lineHeight}px;">${canUseHalfwidthRightwardsArrow ? String.fromCharCode(0xFFEB) : String.fromCharCode(0x2192)}</div>`;
227
} else {
228
result += `<div class="mwh" style="left:${visibleRange.left}px;height:${lineHeight}px;">${String.fromCharCode(renderSpaceCharCode)}</div>`;
229
}
230
}
231
}
232
233
if (USE_SVG) {
234
maxLeft = Math.round(maxLeft + spaceWidth);
235
return (
236
`<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}">`
237
+ result
238
+ `</svg>`
239
);
240
}
241
242
return result;
243
}
244
245
private _renderArrow(lineHeight: number, spaceWidth: number, left: number): string {
246
const strokeWidth = spaceWidth / 7;
247
const width = spaceWidth;
248
const dy = lineHeight / 2;
249
const dx = left;
250
251
const p1 = { x: 0, y: strokeWidth / 2 };
252
const p2 = { x: 100 / 125 * width, y: p1.y };
253
const p3 = { x: p2.x - 0.2 * p2.x, y: p2.y + 0.2 * p2.x };
254
const p4 = { x: p3.x + 0.1 * p2.x, y: p3.y + 0.1 * p2.x };
255
const p5 = { x: p4.x + 0.35 * p2.x, y: p4.y - 0.35 * p2.x };
256
const p6 = { x: p5.x, y: -p5.y };
257
const p7 = { x: p4.x, y: -p4.y };
258
const p8 = { x: p3.x, y: -p3.y };
259
const p9 = { x: p2.x, y: -p2.y };
260
const p10 = { x: p1.x, y: -p1.y };
261
262
const p = [p1, p2, p3, p4, p5, p6, p7, p8, p9, p10];
263
const parts = p.map((p) => `${(dx + p.x).toFixed(2)} ${(dy + p.y).toFixed(2)}`).join(' L ');
264
return `<path d="M ${parts}" />`;
265
}
266
267
public render(startLineNumber: number, lineNumber: number): string {
268
if (!this._renderResult) {
269
return '';
270
}
271
const lineIndex = lineNumber - startLineNumber;
272
if (lineIndex < 0 || lineIndex >= this._renderResult.length) {
273
return '';
274
}
275
return this._renderResult[lineIndex];
276
}
277
}
278
279
class WhitespaceOptions {
280
281
public readonly renderWhitespace: 'none' | 'boundary' | 'selection' | 'trailing' | 'all';
282
public readonly renderWithSVG: boolean;
283
public readonly spaceWidth: number;
284
public readonly middotWidth: number;
285
public readonly wsmiddotWidth: number;
286
public readonly canUseHalfwidthRightwardsArrow: boolean;
287
public readonly lineHeight: number;
288
public readonly stopRenderingLineAfter: number;
289
290
constructor(config: IEditorConfiguration) {
291
const options = config.options;
292
const fontInfo = options.get(EditorOption.fontInfo);
293
const experimentalWhitespaceRendering = options.get(EditorOption.experimentalWhitespaceRendering);
294
if (experimentalWhitespaceRendering === 'off') {
295
// whitespace is rendered in the view line
296
this.renderWhitespace = 'none';
297
this.renderWithSVG = false;
298
} else if (experimentalWhitespaceRendering === 'svg') {
299
this.renderWhitespace = options.get(EditorOption.renderWhitespace);
300
this.renderWithSVG = true;
301
} else {
302
this.renderWhitespace = options.get(EditorOption.renderWhitespace);
303
this.renderWithSVG = false;
304
}
305
this.spaceWidth = fontInfo.spaceWidth;
306
this.middotWidth = fontInfo.middotWidth;
307
this.wsmiddotWidth = fontInfo.wsmiddotWidth;
308
this.canUseHalfwidthRightwardsArrow = fontInfo.canUseHalfwidthRightwardsArrow;
309
this.lineHeight = options.get(EditorOption.lineHeight);
310
this.stopRenderingLineAfter = options.get(EditorOption.stopRenderingLineAfter);
311
}
312
313
public equals(other: WhitespaceOptions): boolean {
314
return (
315
this.renderWhitespace === other.renderWhitespace
316
&& this.renderWithSVG === other.renderWithSVG
317
&& this.spaceWidth === other.spaceWidth
318
&& this.middotWidth === other.middotWidth
319
&& this.wsmiddotWidth === other.wsmiddotWidth
320
&& this.canUseHalfwidthRightwardsArrow === other.canUseHalfwidthRightwardsArrow
321
&& this.lineHeight === other.lineHeight
322
&& this.stopRenderingLineAfter === other.stopRenderingLineAfter
323
);
324
}
325
}
326
327