editor: support line number decorations

Supports setting a class name and hover message for line numbers
This commit is contained in:
Connor Peet
2024-01-11 14:02:19 -08:00
parent 273e4b0d7b
commit 8a44dee108
10 changed files with 91 additions and 47 deletions
@@ -8,6 +8,7 @@ import * as platform from 'vs/base/common/platform';
import { DynamicViewOverlay } from 'vs/editor/browser/view/dynamicViewOverlay';
import { RenderLineNumbersType, EditorOption } from 'vs/editor/common/config/editorOptions';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { RenderingContext } from 'vs/editor/browser/view/renderingContext';
import { ViewContext } from 'vs/editor/common/viewModel/viewContext';
import * as viewEvents from 'vs/editor/common/viewEvents';
@@ -98,6 +99,9 @@ export class LineNumbersOverlay extends DynamicViewOverlay {
public override onZonesChanged(e: viewEvents.ViewZonesChangedEvent): boolean {
return true;
}
public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean {
return e.affectsLineNumber;
}
// --- end event handlers
@@ -143,36 +147,50 @@ export class LineNumbersOverlay extends DynamicViewOverlay {
const visibleStartLineNumber = ctx.visibleRange.startLineNumber;
const visibleEndLineNumber = ctx.visibleRange.endLineNumber;
const lineNoDecorations = this._context.viewModel.getDecorationsInViewport(ctx.visibleRange).filter(d => !!d.options.lineNumberClassName);
lineNoDecorations.sort((a, b) => Range.compareRangesUsingEnds(a.range, b.range));
let decorationStartIndex = 0;
const lineCount = this._context.viewModel.getLineCount();
const output: string[] = [];
for (let lineNumber = visibleStartLineNumber; lineNumber <= visibleEndLineNumber; lineNumber++) {
const lineIndex = lineNumber - visibleStartLineNumber;
const renderLineNumber = this._getLineRenderLineNumber(lineNumber);
let renderLineNumber = this._getLineRenderLineNumber(lineNumber);
let extraClassNames = '';
if (!renderLineNumber) {
// skip decorations whose end positions we've already passed
while (decorationStartIndex < lineNoDecorations.length && lineNoDecorations[decorationStartIndex].range.endLineNumber < lineNumber) {
decorationStartIndex++;
}
for (let i = decorationStartIndex; i < lineNoDecorations.length; i++) {
const { range, options } = lineNoDecorations[i];
if (range.startLineNumber <= lineNumber) {
extraClassNames += ' ' + options.lineNumberClassName;
}
}
if (!renderLineNumber && !extraClassNames) {
output[lineIndex] = '';
continue;
}
let extraClassName = '';
if (lineNumber === lineCount && this._context.viewModel.getLineLength(lineNumber) === 0) {
// this is the last line
if (this._renderFinalNewline === 'off') {
output[lineIndex] = '';
continue;
renderLineNumber = '';
}
if (this._renderFinalNewline === 'dimmed') {
extraClassName = ' dimmed-line-number';
extraClassNames += ' dimmed-line-number';
}
}
if (lineNumber === this._activeLineNumber) {
extraClassName = ' active-line-number';
extraClassNames += ' active-line-number';
}
output[lineIndex] = (
`<div class="${LineNumbersOverlay.CLASS_NAME}${lineHeightClassName}${extraClassName}" style="left:${this._lineNumbersLeft}px;width:${this._lineNumbersWidth}px;">${renderLineNumber}</div>`
`<div class="${LineNumbersOverlay.CLASS_NAME}${lineHeightClassName}${extraClassNames}" style="left:${this._lineNumbersLeft}px;width:${this._lineNumbersWidth}px;">${renderLineNumber}</div>`
);
}
+8
View File
@@ -160,6 +160,10 @@ export interface IModelDecorationOptions {
* Array of MarkdownString to render as the decoration message.
*/
hoverMessage?: IMarkdownString | IMarkdownString[] | null;
/**
* Array of MarkdownString to render as the line number message.
*/
lineNumberHoverMessage?: IMarkdownString | IMarkdownString[] | null;
/**
* Should the decoration expand to encompass a whole line.
*/
@@ -204,6 +208,10 @@ export interface IModelDecorationOptions {
* Controls the tooltip text of the line decoration.
*/
linesDecorationsTooltip?: string | null;
/**
* If set, the decoration will be rendered on the line number.
*/
lineNumberClassName?: string | null;
/**
* If set, the decoration will be rendered in the lines decorations with this CSS class name, but only for the first line in case of line wrapping.
*/
+12 -10
View File
@@ -2299,6 +2299,8 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions {
readonly glyphMargin?: model.IModelDecorationGlyphMarginOptions | null | undefined;
readonly glyphMarginClassName: string | null;
readonly linesDecorationsClassName: string | null;
readonly lineNumberClassName: string | null;
readonly lineNumberHoverMessage: IMarkdownString | IMarkdownString[] | null;
readonly linesDecorationsTooltip: string | null;
readonly firstLineDecorationClassName: string | null;
readonly marginClassName: string | null;
@@ -2323,6 +2325,7 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions {
this.shouldFillLineOnLineBreak = options.shouldFillLineOnLineBreak ?? null;
this.hoverMessage = options.hoverMessage || null;
this.glyphMarginHoverMessage = options.glyphMarginHoverMessage || null;
this.lineNumberHoverMessage = options.lineNumberHoverMessage || null;
this.isWholeLine = options.isWholeLine || false;
this.showIfCollapsed = options.showIfCollapsed || false;
this.collapseOnReplaceEdit = options.collapseOnReplaceEdit || false;
@@ -2331,6 +2334,7 @@ export class ModelDecorationOptions implements model.IModelDecorationOptions {
this.glyphMargin = options.glyphMarginClassName ? new ModelDecorationGlyphMarginOptions(options.glyphMargin) : null;
this.glyphMarginClassName = options.glyphMarginClassName ? cleanClassName(options.glyphMarginClassName) : null;
this.linesDecorationsClassName = options.linesDecorationsClassName ? cleanClassName(options.linesDecorationsClassName) : null;
this.lineNumberClassName = options.lineNumberClassName ? cleanClassName(options.lineNumberClassName) : null;
this.linesDecorationsTooltip = options.linesDecorationsTooltip ? strings.htmlAttributeEncodeValue(options.linesDecorationsTooltip) : null;
this.firstLineDecorationClassName = options.firstLineDecorationClassName ? cleanClassName(options.firstLineDecorationClassName) : null;
this.marginClassName = options.marginClassName ? cleanClassName(options.marginClassName) : null;
@@ -2374,6 +2378,7 @@ class DidChangeDecorationsEmitter extends Disposable {
private _affectsOverviewRuler: boolean;
private _affectedInjectedTextLines: Set<number> | null = null;
private _affectsGlyphMargin: boolean;
private _affectsLineNumber: boolean;
constructor(private readonly handleBeforeFire: (affectedInjectedTextLines: Set<number> | null) => void) {
super();
@@ -2382,6 +2387,7 @@ class DidChangeDecorationsEmitter extends Disposable {
this._affectsMinimap = false;
this._affectsOverviewRuler = false;
this._affectsGlyphMargin = false;
this._affectsLineNumber = false;
}
hasListeners(): boolean {
@@ -2412,15 +2418,10 @@ class DidChangeDecorationsEmitter extends Disposable {
}
public checkAffectedAndFire(options: ModelDecorationOptions): void {
if (!this._affectsMinimap) {
this._affectsMinimap = options.minimap && options.minimap.position ? true : false;
}
if (!this._affectsOverviewRuler) {
this._affectsOverviewRuler = options.overviewRuler && options.overviewRuler.color ? true : false;
}
if (!this._affectsGlyphMargin) {
this._affectsGlyphMargin = options.glyphMarginClassName ? true : false;
}
this._affectsMinimap ||= !!options.minimap?.position;
this._affectsOverviewRuler ||= !!options.overviewRuler?.color;
this._affectsGlyphMargin ||= !!options.glyphMarginClassName;
this._affectsLineNumber ||= !!options.lineNumberClassName;
this.tryFire();
}
@@ -2445,7 +2446,8 @@ class DidChangeDecorationsEmitter extends Disposable {
const event: IModelDecorationsChangedEvent = {
affectsMinimap: this._affectsMinimap,
affectsOverviewRuler: this._affectsOverviewRuler,
affectsGlyphMargin: this._affectsGlyphMargin
affectsGlyphMargin: this._affectsGlyphMargin,
affectsLineNumber: this._affectsLineNumber,
};
this._shouldFireDeferred = false;
this._affectsMinimap = false;
+1
View File
@@ -91,6 +91,7 @@ export interface IModelDecorationsChangedEvent {
readonly affectsMinimap: boolean;
readonly affectsOverviewRuler: boolean;
readonly affectsGlyphMargin: boolean;
readonly affectsLineNumber: boolean;
}
/**
+3
View File
@@ -76,16 +76,19 @@ export class ViewDecorationsChangedEvent {
readonly affectsMinimap: boolean;
readonly affectsOverviewRuler: boolean;
readonly affectsGlyphMargin: boolean;
readonly affectsLineNumber: boolean;
constructor(source: IModelDecorationsChangedEvent | null) {
if (source) {
this.affectsMinimap = source.affectsMinimap;
this.affectsOverviewRuler = source.affectsOverviewRuler;
this.affectsGlyphMargin = source.affectsGlyphMargin;
this.affectsLineNumber = source.affectsLineNumber;
} else {
this.affectsMinimap = true;
this.affectsOverviewRuler = true;
this.affectsGlyphMargin = true;
this.affectsLineNumber = true;
}
}
}
@@ -300,6 +300,12 @@ export class HoverController extends Disposable implements IEditorContribution {
glyphWidget.startShowingAt(target.position.lineNumber, target.detail.glyphMarginLane);
return;
}
if (target.type === MouseTargetType.GUTTER_LINE_NUMBERS && target.position) {
this._contentWidget?.hide();
const glyphWidget = this._getOrCreateGlyphWidget();
glyphWidget.startShowingAt(target.position.lineNumber, 'lineNo');
return;
}
if (_sticky) {
return;
}
@@ -22,6 +22,8 @@ export interface IHoverMessage {
value: IMarkdownString;
}
type LaneOrLineNumber = GlyphMarginLane | 'lineNo';
export class MarginHoverWidget extends Disposable implements IOverlayWidget {
public static readonly ID = 'editor.contrib.modesGlyphHoverWidget';
@@ -99,8 +101,8 @@ export class MarginHoverWidget extends Disposable implements IOverlayWidget {
}
}
public startShowingAt(lineNumber: number, lane: GlyphMarginLane): void {
if (this._computer.lineNumber === lineNumber && this._computer.lane === lane) {
public startShowingAt(lineNumber: number, laneOrLine: LaneOrLineNumber): void {
if (this._computer.lineNumber === lineNumber && this._computer.lane === laneOrLine) {
// We have to show the widget at the exact same line number as before, so no work is needed
return;
}
@@ -110,7 +112,7 @@ export class MarginHoverWidget extends Disposable implements IOverlayWidget {
this.hide();
this._computer.lineNumber = lineNumber;
this._computer.lane = lane;
this._computer.lane = laneOrLine;
this._hoverOperation.start(HoverStartMode.Delayed);
}
@@ -169,8 +171,8 @@ export class MarginHoverWidget extends Disposable implements IOverlayWidget {
const lineHeight = this._editor.getOption(EditorOption.lineHeight);
const nodeHeight = this._hover.containerDomNode.clientHeight;
const top = topForLineNumber - editorScrollTop - ((nodeHeight - lineHeight) / 2);
this._hover.containerDomNode.style.left = `${editorLayout.glyphMarginLeft + editorLayout.glyphMarginWidth}px`;
const left = editorLayout.glyphMarginLeft + editorLayout.glyphMarginWidth + (this._computer.lane === 'lineNo' ? editorLayout.lineNumbersWidth : 0);
this._hover.containerDomNode.style.left = `${left}px`;
this._hover.containerDomNode.style.top = `${Math.max(Math.round(top), 0)}px`;
}
}
@@ -178,7 +180,7 @@ export class MarginHoverWidget extends Disposable implements IOverlayWidget {
class MarginHoverComputer implements IHoverComputer<IHoverMessage> {
private _lineNumber: number = -1;
private _lane = GlyphMarginLane.Center;
private _laneOrLine: LaneOrLineNumber = GlyphMarginLane.Center;
public get lineNumber(): number {
return this._lineNumber;
@@ -188,12 +190,12 @@ class MarginHoverComputer implements IHoverComputer<IHoverMessage> {
this._lineNumber = value;
}
public get lane(): number {
return this._lane;
public get lane(): LaneOrLineNumber {
return this._laneOrLine;
}
public set lane(value: GlyphMarginLane) {
this._lane = value;
public set lane(value: LaneOrLineNumber) {
this._laneOrLine = value;
}
constructor(
@@ -212,21 +214,18 @@ class MarginHoverComputer implements IHoverComputer<IHoverMessage> {
const lineDecorations = this._editor.getLineDecorations(this._lineNumber);
const result: IHoverMessage[] = [];
const isLineHover = this._laneOrLine === 'lineNo';
if (!lineDecorations) {
return result;
}
for (const d of lineDecorations) {
if (!d.options.glyphMarginClassName) {
continue;
}
const lane = d.options.glyphMargin?.position ?? GlyphMarginLane.Center;
if (lane !== this._lane) {
if (!isLineHover && lane !== this._laneOrLine) {
continue;
}
const hoverMessage = d.options.glyphMarginHoverMessage;
const hoverMessage = isLineHover ? d.options.lineNumberHoverMessage : d.options.glyphMarginHoverMessage;
if (!hoverMessage || isEmptyMarkdownString(hoverMessage)) {
continue;
}
+9
View File
@@ -1691,6 +1691,10 @@ declare namespace monaco.editor {
* Array of MarkdownString to render as the decoration message.
*/
hoverMessage?: IMarkdownString | IMarkdownString[] | null;
/**
* Array of MarkdownString to render as the line number message.
*/
lineNumberHoverMessage?: IMarkdownString | IMarkdownString[] | null;
/**
* Should the decoration expand to encompass a whole line.
*/
@@ -1730,6 +1734,10 @@ declare namespace monaco.editor {
* Controls the tooltip text of the line decoration.
*/
linesDecorationsTooltip?: string | null;
/**
* If set, the decoration will be rendered on the line number.
*/
lineNumberClassName?: string | null;
/**
* If set, the decoration will be rendered in the lines decorations with this CSS class name, but only for the first line in case of line wrapping.
*/
@@ -2930,6 +2938,7 @@ declare namespace monaco.editor {
readonly affectsMinimap: boolean;
readonly affectsOverviewRuler: boolean;
readonly affectsGlyphMargin: boolean;
readonly affectsLineNumber: boolean;
}
export interface IModelOptionsChangedEvent {
@@ -11,14 +11,13 @@ import { ICodeEditor, MouseTargetType } from 'vs/editor/browser/editorBrowser';
import { Position } from 'vs/editor/common/core/position';
import { Range } from 'vs/editor/common/core/range';
import { IEditorContribution } from 'vs/editor/common/editorCommon';
import { GlyphMarginLane, IModelDecorationOptions, ITextModel } from 'vs/editor/common/model';
import { IModelDecorationOptions, ITextModel } from 'vs/editor/common/model';
import { localize } from 'vs/nls';
import { ILogService } from 'vs/platform/log/common/log';
import { FileCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService';
import { CoverageDetails, DetailType, IStatementCoverage } from 'vs/workbench/contrib/testing/common/testTypes';
const GLYPH_LANE = GlyphMarginLane.Left;
const MAX_HOVERED_LINES = 30;
const CLASS_HIT = 'coverage-deco-hit';
const CLASS_MISS = 'coverage-deco-miss';
@@ -66,8 +65,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
}));
this._register(editor.onMouseMove(e => {
if (e.target.type === MouseTargetType.GUTTER_GLYPH_MARGIN
&& e.target.detail.glyphMarginLane === GLYPH_LANE) {
if (e.target.type === MouseTargetType.GUTTER_LINE_NUMBERS) {
this.hoverLineNumber(editor.getModel()!, e.target.position.lineNumber);
} else {
this.hoveredStore.clear();
@@ -143,11 +141,10 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
const opts: IModelDecorationOptions = {
showIfCollapsed: false,
description: 'coverage-gutter',
glyphMargin: { position: GlyphMarginLane.Left, persistLane: true },
glyphMarginHoverMessage: new MarkdownString()
lineNumberHoverMessage: new MarkdownString()
.appendCodeblock(model.getLanguageId(), model.getValueInRange(range))
.appendText(localize('testing.branchHitCount', 'Branch hit count: {0}', hits)),
glyphMarginClassName: `coverage-deco-gutter ${cls}`,
lineNumberClassName: `coverage-deco-gutter ${cls}`,
};
this.decorationIds.set(e.addDecoration(range, opts), {
@@ -161,11 +158,10 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri
const opts: IModelDecorationOptions = {
showIfCollapsed: false,
description: 'coverage-inline',
glyphMargin: { position: GlyphMarginLane.Left, persistLane: true },
glyphMarginHoverMessage: new MarkdownString()
lineNumberHoverMessage: new MarkdownString()
.appendCodeblock(model.getLanguageId(), model.getValueInRange(range))
.appendText(localize('testing.hitCount', 'Hit count: {0}', detail.count)),
glyphMarginClassName: `coverage-deco-gutter ${cls}`,
lineNumberClassName: `coverage-deco-gutter ${cls}`,
};
this.decorationIds.set(e.addDecoration(range, opts), {
@@ -417,12 +417,14 @@
/** -- coverage decorations */
.coverage-deco-gutter {
z-index: 0;
}
.coverage-deco-gutter::before {
content: '';
position: absolute;
inset: 0;
right: 25%;
left: 25%;
z-index: -1;
}
.coverage-deco-gutter.coverage-deco-hit::before {