From 8a44dee10866f478c4e6347d4372dd67b28b3b19 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 11 Jan 2024 12:58:12 -0800 Subject: [PATCH] editor: support line number decorations Supports setting a class name and hover message for line numbers --- .../viewParts/lineNumbers/lineNumbers.ts | 36 ++++++++++++++----- src/vs/editor/common/model.ts | 8 +++++ src/vs/editor/common/model/textModel.ts | 22 ++++++------ src/vs/editor/common/textModelEvents.ts | 1 + src/vs/editor/common/viewEvents.ts | 3 ++ src/vs/editor/contrib/hover/browser/hover.ts | 6 ++++ .../contrib/hover/browser/marginHover.ts | 31 ++++++++-------- src/vs/monaco.d.ts | 9 +++++ .../browser/codeCoverageDecorations.ts | 16 ++++----- .../contrib/testing/browser/media/testing.css | 6 ++-- 10 files changed, 91 insertions(+), 47 deletions(-) diff --git a/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts b/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts index 92537ee51db..03336d34278 100644 --- a/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts +++ b/src/vs/editor/browser/viewParts/lineNumbers/lineNumbers.ts @@ -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] = ( - `
${renderLineNumber}
` + `
${renderLineNumber}
` ); } diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index 3d84860632f..ad0d31765ff 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -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. */ diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index 8b91f6ae75e..e3e8239cb55 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -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 | null = null; private _affectsGlyphMargin: boolean; + private _affectsLineNumber: boolean; constructor(private readonly handleBeforeFire: (affectedInjectedTextLines: Set | 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; diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index 8c25e06776d..58c720ac87c 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -91,6 +91,7 @@ export interface IModelDecorationsChangedEvent { readonly affectsMinimap: boolean; readonly affectsOverviewRuler: boolean; readonly affectsGlyphMargin: boolean; + readonly affectsLineNumber: boolean; } /** diff --git a/src/vs/editor/common/viewEvents.ts b/src/vs/editor/common/viewEvents.ts index 0e6cb0fa166..f607afe73e9 100644 --- a/src/vs/editor/common/viewEvents.ts +++ b/src/vs/editor/common/viewEvents.ts @@ -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; } } } diff --git a/src/vs/editor/contrib/hover/browser/hover.ts b/src/vs/editor/contrib/hover/browser/hover.ts index 08b2a7bb68d..509eb67e324 100644 --- a/src/vs/editor/contrib/hover/browser/hover.ts +++ b/src/vs/editor/contrib/hover/browser/hover.ts @@ -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; } diff --git a/src/vs/editor/contrib/hover/browser/marginHover.ts b/src/vs/editor/contrib/hover/browser/marginHover.ts index 98af0a06cda..e9eb33604c1 100644 --- a/src/vs/editor/contrib/hover/browser/marginHover.ts +++ b/src/vs/editor/contrib/hover/browser/marginHover.ts @@ -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 { 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 { 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 { 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; } diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index ac4a31bd48e..f0028ac35dc 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -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 { diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts index 856c7d4e6ae..7fb11557806 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts @@ -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), { diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index 6168c064211..d40e482a1c7 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -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 {