diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts index eb084bf8ac3e4..f174bbfdf666d 100644 --- a/src/vs/editor/browser/editorBrowser.ts +++ b/src/vs/editor/browser/editorBrowser.ts @@ -12,7 +12,7 @@ import { IPosition, Position } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; import * as editorCommon from 'vs/editor/common/editorCommon'; -import { IIdentifiedSingleEditOperation, IModelDecoration, IModelDeltaDecoration, ITextModel, ICursorStateComputer, PositionAffinity } from 'vs/editor/common/model'; +import { IIdentifiedSingleEditOperation, IModelDecoration, IModelDeltaDecoration, ITextModel, ICursorStateComputer, PositionAffinity, GlyphMarginLane } from 'vs/editor/common/model'; import { IWordAtPosition } from 'vs/editor/common/core/wordHelper'; import { IModelContentChangedEvent, IModelDecorationsChangedEvent, IModelLanguageChangedEvent, IModelLanguageConfigurationChangedEvent, IModelOptionsChangedEvent, IModelTokensChangedEvent } from 'vs/editor/common/textModelEvents'; import { OverviewRulerZone } from 'vs/editor/common/viewModel/overviewZoneManager'; @@ -251,6 +251,43 @@ export interface IOverlayWidget { getPosition(): IOverlayWidgetPosition | null; } +/** + * A glyph margin widget renders in the editor glyph margin. + */ +export interface IGlyphMarginWidget { + /** + * Get a unique identifier of the glyph widget. + */ + getId(): string; + /** + * Get the dom node of the glyph widget. + */ + getDomNode(): HTMLElement; + /** + * Get the placement of the glyph widget. + */ + getPosition(): IGlyphMarginWidgetPosition; +} + +/** + * A position for rendering glyph margin widgets. + */ +export interface IGlyphMarginWidgetPosition { + /** + * The glyph margin lane where the widget should be shown. + */ + lane: GlyphMarginLane; + /** + * The priority order of the widget, used for determining which widget + * to render when there are multiple. + */ + zIndex: number; + /** + * The editor range that this widget applies to. + */ + range: IRange; +} + /** * Type of hit element with the mouse in the editor. */ @@ -993,6 +1030,20 @@ export interface ICodeEditor extends editorCommon.IEditor { */ removeOverlayWidget(widget: IOverlayWidget): void; + /** + * Add a glyph margin widget. Widgets must have unique ids, otherwise they will be overwritten. + */ + addGlyphMarginWidget(widget: IGlyphMarginWidget): void; + /** + * Layout/Reposition a glyph margin widget. This is a ping to the editor to call widget.getPosition() + * and update appropriately. + */ + layoutGlyphMarginWidget(widget: IGlyphMarginWidget): void; + /** + * Remove a glyph margin widget. + */ + removeGlyphMarginWidget(widget: IGlyphMarginWidget): void; + /** * Change the view zones. View zones are lost when a new model is attached to the editor. */ diff --git a/src/vs/editor/browser/services/abstractCodeEditorService.ts b/src/vs/editor/browser/services/abstractCodeEditorService.ts index 62db35d5a0ccf..cc0f8669a0343 100644 --- a/src/vs/editor/browser/services/abstractCodeEditorService.ts +++ b/src/vs/editor/browser/services/abstractCodeEditorService.ts @@ -786,7 +786,7 @@ class DecorationCSSRules { } /** - * Build the CSS for decorations styled via `glpyhMarginClassName`. + * Build the CSS for decorations styled via `glyphMarginClassName`. */ private getCSSTextForModelDecorationGlyphMarginClassName(opts: IThemeDecorationRenderOptions | undefined): string { if (!opts) { diff --git a/src/vs/editor/browser/view.ts b/src/vs/editor/browser/view.ts index 922e0dd6e52ff..82d1fb7a26969 100644 --- a/src/vs/editor/browser/view.ts +++ b/src/vs/editor/browser/view.ts @@ -5,13 +5,14 @@ import * as dom from 'vs/base/browser/dom'; import { Selection } from 'vs/editor/common/core/selection'; +import { Range } from 'vs/editor/common/core/range'; import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; import { onUnexpectedError } from 'vs/base/common/errors'; import { IDisposable } from 'vs/base/common/lifecycle'; import { IPointerHandlerHelper } from 'vs/editor/browser/controller/mouseHandler'; import { PointerHandler } from 'vs/editor/browser/controller/pointerHandler'; import { IVisibleRangeProvider, TextAreaHandler } from 'vs/editor/browser/controller/textAreaHandler'; -import { IContentWidget, IContentWidgetPosition, IOverlayWidget, IOverlayWidgetPosition, IMouseTarget, IViewZoneChangeAccessor, IEditorAriaOptions } from 'vs/editor/browser/editorBrowser'; +import { IContentWidget, IContentWidgetPosition, IOverlayWidget, IOverlayWidgetPosition, IMouseTarget, IViewZoneChangeAccessor, IEditorAriaOptions, IGlyphMarginWidget, IGlyphMarginWidgetPosition } from 'vs/editor/browser/editorBrowser'; import { ICommandDelegate, ViewController } from 'vs/editor/browser/view/viewController'; import { ViewUserInputEvents } from 'vs/editor/browser/view/viewUserInputEvents'; import { ContentViewOverlays, MarginViewOverlays } from 'vs/editor/browser/view/viewOverlays'; @@ -20,7 +21,6 @@ import { ViewContentWidgets } from 'vs/editor/browser/viewParts/contentWidgets/c import { CurrentLineHighlightOverlay, CurrentLineMarginHighlightOverlay } from 'vs/editor/browser/viewParts/currentLineHighlight/currentLineHighlight'; import { DecorationsOverlay } from 'vs/editor/browser/viewParts/decorations/decorations'; import { EditorScrollbar } from 'vs/editor/browser/viewParts/editorScrollbar/editorScrollbar'; -import { GlyphMarginOverlay } from 'vs/editor/browser/viewParts/glyphMargin/glyphMargin'; import { IndentGuidesOverlay } from 'vs/editor/browser/viewParts/indentGuides/indentGuides'; import { LineNumbersOverlay } from 'vs/editor/browser/viewParts/lineNumbers/lineNumbers'; import { ViewLines } from 'vs/editor/browser/viewParts/lines/viewLines'; @@ -52,6 +52,8 @@ import { BlockDecorations } from 'vs/editor/browser/viewParts/blockDecorations/b import { inputLatency } from 'vs/base/browser/performance'; import { IMouseWheelEvent } from 'vs/base/browser/mouseEvent'; import { WhitespaceOverlay } from 'vs/editor/browser/viewParts/whitespace/whitespace'; +import { GlyphMarginWidgets } from 'vs/editor/browser/viewParts/glyphMargin/glyphMargin'; +import { GlyphMarginLane } from 'vs/editor/common/model'; export interface IContentWidgetData { @@ -64,6 +66,11 @@ export interface IOverlayWidgetData { position: IOverlayWidgetPosition | null; } +export interface IGlyphMarginWidgetData { + widget: IGlyphMarginWidget; + position: IGlyphMarginWidgetPosition; +} + export class View extends ViewEventHandler { private readonly _scrollbar: EditorScrollbar; @@ -77,6 +84,7 @@ export class View extends ViewEventHandler { private readonly _viewZones: ViewZones; private readonly _contentWidgets: ViewContentWidgets; private readonly _overlayWidgets: ViewOverlayWidgets; + private readonly _glyphMarginWidgets: GlyphMarginWidgets; private readonly _viewCursors: ViewCursors; private readonly _viewParts: ViewPart[]; @@ -89,6 +97,7 @@ export class View extends ViewEventHandler { private readonly _overflowGuardContainer: FastDomNode; // Actual mutable state + private _shouldRecomputeGlyphMarginLanes: boolean = false; private _renderAnimationFrame: IDisposable | null; constructor( @@ -160,14 +169,18 @@ export class View extends ViewEventHandler { const marginViewOverlays = new MarginViewOverlays(this._context); this._viewParts.push(marginViewOverlays); marginViewOverlays.addDynamicOverlay(new CurrentLineMarginHighlightOverlay(this._context)); - marginViewOverlays.addDynamicOverlay(new GlyphMarginOverlay(this._context)); marginViewOverlays.addDynamicOverlay(new MarginViewLineDecorationsOverlay(this._context)); marginViewOverlays.addDynamicOverlay(new LinesDecorationsOverlay(this._context)); marginViewOverlays.addDynamicOverlay(new LineNumbersOverlay(this._context)); + // Glyph margin widgets + this._glyphMarginWidgets = new GlyphMarginWidgets(this._context); + this._viewParts.push(this._glyphMarginWidgets); + const margin = new Margin(this._context); margin.getDomNode().appendChild(this._viewZones.marginDomNode); margin.getDomNode().appendChild(marginViewOverlays.getDomNode()); + margin.getDomNode().appendChild(this._glyphMarginWidgets.domNode); this._viewParts.push(margin); // Content widgets @@ -226,10 +239,70 @@ export class View extends ViewEventHandler { } private _flushAccumulatedAndRenderNow(): void { + if (this._shouldRecomputeGlyphMarginLanes) { + this._shouldRecomputeGlyphMarginLanes = false; + this._context.configuration.setGlyphMarginDecorationLaneCount(this._computeGlyphMarginLaneCount()); + } inputLatency.onRenderStart(); this._renderNow(); } + private _computeGlyphMarginLaneCount(): number { + const model = this._context.viewModel.model; + type Glyph = { range: Range; lane: GlyphMarginLane }; + let glyphs: Glyph[] = []; + + // Add all margin decorations + glyphs = glyphs.concat(model.getAllMarginDecorations().map((decoration) => { + const lane = decoration.options.glyphMargin?.position ?? GlyphMarginLane.Left; + return { range: decoration.range, lane }; + })); + + // Add all glyph margin widgets + glyphs = glyphs.concat(this._glyphMarginWidgets.getWidgets().map((widget) => { + const range = model.validateRange(widget.preference.range); + return { range, lane: widget.preference.lane }; + })); + + // Sorted by their start position + glyphs.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)); + + let leftDecRange: Range | null = null; + let rightDecRange: Range | null = null; + for (const decoration of glyphs) { + + if (decoration.lane === GlyphMarginLane.Left && (!leftDecRange || Range.compareRangesUsingEnds(leftDecRange, decoration.range) < 0)) { + // assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane + leftDecRange = decoration.range; + } + + if (decoration.lane === GlyphMarginLane.Right && (!rightDecRange || Range.compareRangesUsingEnds(rightDecRange, decoration.range) < 0)) { + // assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane + rightDecRange = decoration.range; + } + + if (leftDecRange && rightDecRange) { + + if (leftDecRange.endLineNumber < rightDecRange.startLineNumber) { + // there's no chance for `leftDecRange` to ever intersect something going further + leftDecRange = null; + continue; + } + + if (rightDecRange.endLineNumber < leftDecRange.startLineNumber) { + // there's no chance for `rightDecRange` to ever intersect something going further + rightDecRange = null; + continue; + } + + // leftDecRange and rightDecRange are intersecting or touching => we need two lanes + return 2; + } + } + + return 1; + } + private _createPointerHandlerHelper(): IPointerHandlerHelper { return { viewDomNode: this.domNode.domNode, @@ -317,6 +390,12 @@ export class View extends ViewEventHandler { this._selections = e.selections; return false; } + public override onDecorationsChanged(e: viewEvents.ViewDecorationsChangedEvent): boolean { + if (e.affectsGlyphMargin) { + this._shouldRecomputeGlyphMarginLanes = true; + } + return false; + } public override onFocusChanged(e: viewEvents.ViewFocusChangedEvent): boolean { this.domNode.setClassName(this._getEditorClassName()); return false; @@ -548,6 +627,27 @@ export class View extends ViewEventHandler { this._scheduleRender(); } + public addGlyphMarginWidget(widgetData: IGlyphMarginWidgetData): void { + this._glyphMarginWidgets.addWidget(widgetData.widget); + this._shouldRecomputeGlyphMarginLanes = true; + this._scheduleRender(); + } + + public layoutGlyphMarginWidget(widgetData: IGlyphMarginWidgetData): void { + const newPreference = widgetData.position; + const shouldRender = this._glyphMarginWidgets.setWidgetPosition(widgetData.widget, newPreference); + if (shouldRender) { + this._shouldRecomputeGlyphMarginLanes = true; + this._scheduleRender(); + } + } + + public removeGlyphMarginWidget(widgetData: IGlyphMarginWidgetData): void { + this._glyphMarginWidgets.removeWidget(widgetData.widget); + this._shouldRecomputeGlyphMarginLanes = true; + this._scheduleRender(); + } + // --- END CodeEditor helpers } diff --git a/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts b/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts index 0b35f4b06edc5..919d0aad1f982 100644 --- a/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts +++ b/src/vs/editor/browser/viewParts/glyphMargin/glyphMargin.ts @@ -3,14 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode'; +import { ArrayQueue } from 'vs/base/common/arrays'; import 'vs/css!./glyphMargin'; +import { IGlyphMarginWidget, IGlyphMarginWidgetPosition } from 'vs/editor/browser/editorBrowser'; import { DynamicViewOverlay } from 'vs/editor/browser/view/dynamicViewOverlay'; -import { RenderingContext } from 'vs/editor/browser/view/renderingContext'; -import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; -import * as viewEvents from 'vs/editor/common/viewEvents'; +import { RenderingContext, RestrictedRenderingContext } from 'vs/editor/browser/view/renderingContext'; +import { ViewPart } from 'vs/editor/browser/view/viewPart'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { Range } from 'vs/editor/common/core/range'; +import * as viewEvents from 'vs/editor/common/viewEvents'; +import { ViewContext } from 'vs/editor/common/viewModel/viewContext'; - +/** + * Represents a decoration that should be shown along the lines from `startLineNumber` to `endLineNumber`. + * This can end up producing multiple `LineDecorationToRender`. + */ export class DecorationToRender { _decorationToRenderBrand: void = undefined; @@ -18,66 +26,59 @@ export class DecorationToRender { public endLineNumber: number; public className: string; public readonly zIndex: number; - public readonly decorationLane: number; - constructor(startLineNumber: number, endLineNumber: number, className: string, zIndex?: number, decorationLane?: number) { + constructor(startLineNumber: number, endLineNumber: number, className: string, zIndex: number | undefined) { this.startLineNumber = +startLineNumber; this.endLineNumber = +endLineNumber; this.className = String(className); this.zIndex = zIndex ?? 0; - this.decorationLane = decorationLane ?? 1; } } -export class RenderedDecoration { +/** + * A decoration that should be shown along a line. + */ +export class LineDecorationToRender { constructor( public readonly className: string, public readonly zIndex: number, ) { } } -export class LineRenderedDecorations { +/** + * Decorations to render on a visible line. + */ +export class VisibleLineDecorationsToRender { - private readonly lanes: RenderedDecoration[][] = []; + private readonly decorations: LineDecorationToRender[] = []; - public add(lane: number, decoration: RenderedDecoration) { - while (lane >= this.lanes.length) { - this.lanes.push([]); - } - this.lanes[lane].push(decoration); + public add(decoration: LineDecorationToRender) { + this.decorations.push(decoration); } - public getLaneDecorations(laneIndex: number): RenderedDecoration[] { - if (laneIndex < this.lanes.length) { - return this.lanes[laneIndex]; - } - return []; - } - - public isEmpty(): boolean { - for (const lane of this.lanes) { - if (lane.length > 0) { - return false; - } - } - return true; + public getDecorations(): LineDecorationToRender[] { + return this.decorations; } } export abstract class DedupOverlay extends DynamicViewOverlay { - protected _render(visibleStartLineNumber: number, visibleEndLineNumber: number, decorations: DecorationToRender[], decorationLaneCount: number): LineRenderedDecorations[] { + /** + * Returns an array with an element for each visible line number. + */ + protected _render(visibleStartLineNumber: number, visibleEndLineNumber: number, decorations: DecorationToRender[]): VisibleLineDecorationsToRender[] { - const output: LineRenderedDecorations[] = []; + const output: VisibleLineDecorationsToRender[] = []; for (let lineNumber = visibleStartLineNumber; lineNumber <= visibleEndLineNumber; lineNumber++) { const lineIndex = lineNumber - visibleStartLineNumber; - output[lineIndex] = new LineRenderedDecorations(); + output[lineIndex] = new VisibleLineDecorationsToRender(); } if (decorations.length === 0) { return output; } + // Sort decorations by className, then by startLineNumber and then by endLineNumber decorations.sort((a, b) => { if (a.className === b.className) { if (a.startLineNumber === b.startLineNumber) { @@ -96,9 +97,9 @@ export abstract class DedupOverlay extends DynamicViewOverlay { const zIndex = d.zIndex; let startLineIndex = Math.max(d.startLineNumber, visibleStartLineNumber) - visibleStartLineNumber; const endLineIndex = Math.min(d.endLineNumber, visibleEndLineNumber) - visibleStartLineNumber; - const lane = Math.min(d.decorationLane, decorationLaneCount); if (prevClassName === className) { + // Here we avoid rendering the same className multiple times on the same line startLineIndex = Math.max(prevEndLineIndex + 1, startLineIndex); prevEndLineIndex = Math.max(prevEndLineIndex, endLineIndex); } else { @@ -107,7 +108,7 @@ export abstract class DedupOverlay extends DynamicViewOverlay { } for (let i = startLineIndex; i <= prevEndLineIndex; i++) { - output[i].add(lane, new RenderedDecoration(className, zIndex)); + output[i].add(new LineDecorationToRender(className, zIndex)); } } @@ -115,40 +116,54 @@ export abstract class DedupOverlay extends DynamicViewOverlay { } } -export class GlyphMarginOverlay extends DedupOverlay { +export class GlyphMarginWidgets extends ViewPart { + + public domNode: FastDomNode; - private readonly _context: ViewContext; private _lineHeight: number; private _glyphMargin: boolean; private _glyphMarginLeft: number; private _glyphMarginWidth: number; private _glyphMarginDecorationLaneCount: number; - private _renderResult: string[] | null; + + private _managedDomNodes: FastDomNode[]; + private _decorationGlyphsToRender: DecorationBasedGlyph[]; + + private _widgets: { [key: string]: IWidgetData } = {}; constructor(context: ViewContext) { - super(); + super(context); this._context = context; const options = this._context.configuration.options; const layoutInfo = options.get(EditorOption.layoutInfo); + this.domNode = createFastDomNode(document.createElement('div')); + this.domNode.setClassName('glyphMarginWidgets'); + this.domNode.setPosition('absolute'); + this.domNode.setTop(0); + this._lineHeight = options.get(EditorOption.lineHeight); this._glyphMargin = options.get(EditorOption.glyphMargin); this._glyphMarginLeft = layoutInfo.glyphMarginLeft; this._glyphMarginWidth = layoutInfo.glyphMarginWidth; this._glyphMarginDecorationLaneCount = layoutInfo.glyphMarginDecorationLaneCount; - this._renderResult = null; - this._context.addEventHandler(this); + this._managedDomNodes = []; + this._decorationGlyphsToRender = []; } public override dispose(): void { - this._context.removeEventHandler(this); - this._renderResult = null; + this._managedDomNodes = []; + this._decorationGlyphsToRender = []; + this._widgets = {}; super.dispose(); } - // --- begin event handlers + public getWidgets(): IWidgetData[] { + return Object.values(this._widgets); + } + // --- begin event handlers public override onConfigurationChanged(e: viewEvents.ViewConfigurationChangedEvent): boolean { const options = this._context.configuration.options; const layoutInfo = options.get(EditorOption.layoutInfo); @@ -184,85 +199,302 @@ export class GlyphMarginOverlay extends DedupOverlay { // --- end event handlers - protected _getDecorations(ctx: RenderingContext): DecorationToRender[] { + // --- begin widget management + + public addWidget(widget: IGlyphMarginWidget): void { + const domNode = createFastDomNode(widget.getDomNode()); + + this._widgets[widget.getId()] = { + widget: widget, + preference: widget.getPosition(), + domNode: domNode, + renderInfo: null + }; + + domNode.setPosition('absolute'); + domNode.setDisplay('none'); + domNode.setAttribute('widgetId', widget.getId()); + this.domNode.appendChild(domNode); + + this.setShouldRender(); + } + + public setWidgetPosition(widget: IGlyphMarginWidget, preference: IGlyphMarginWidgetPosition): boolean { + const myWidget = this._widgets[widget.getId()]; + if (myWidget.preference.lane === preference.lane + && myWidget.preference.zIndex === preference.zIndex + && Range.equalsRange(myWidget.preference.range, preference.range)) { + return false; + } + + myWidget.preference = preference; + this.setShouldRender(); + + return true; + } + + public removeWidget(widget: IGlyphMarginWidget): void { + const widgetId = widget.getId(); + if (this._widgets[widgetId]) { + const widgetData = this._widgets[widgetId]; + const domNode = widgetData.domNode.domNode; + delete this._widgets[widgetId]; + + domNode.parentNode?.removeChild(domNode); + this.setShouldRender(); + } + } + + // --- end widget management + + private _collectDecorationBasedGlyphRenderRequest(ctx: RenderingContext, requests: GlyphRenderRequest[]): void { + const visibleStartLineNumber = ctx.visibleRange.startLineNumber; + const visibleEndLineNumber = ctx.visibleRange.endLineNumber; const decorations = ctx.getDecorationsInViewport(); - const r: DecorationToRender[] = []; - let rLen = 0; - for (let i = 0, len = decorations.length; i < len; i++) { - const d = decorations[i]; + + for (const d of decorations) { const glyphMarginClassName = d.options.glyphMarginClassName; - const zIndex = d.options.zIndex; - const lane = d.options.glyphMargin?.position; - if (glyphMarginClassName) { - r[rLen++] = new DecorationToRender(d.range.startLineNumber, d.range.endLineNumber, glyphMarginClassName, zIndex, lane); + if (!glyphMarginClassName) { + continue; + } + + const startLineNumber = Math.max(d.range.startLineNumber, visibleStartLineNumber); + const endLineNumber = Math.min(d.range.endLineNumber, visibleEndLineNumber); + const lane = Math.min(d.options.glyphMargin?.position ?? 1, this._glyphMarginDecorationLaneCount); + const zIndex = d.options.zIndex ?? 0; + + for (let lineNumber = startLineNumber; lineNumber <= endLineNumber; lineNumber++) { + requests.push(new DecorationBasedGlyphRenderRequest(lineNumber, lane, zIndex, glyphMarginClassName)); } } - return r; } + private _collectWidgetBasedGlyphRenderRequest(ctx: RenderingContext, requests: GlyphRenderRequest[]): void { + const visibleStartLineNumber = ctx.visibleRange.startLineNumber; + const visibleEndLineNumber = ctx.visibleRange.endLineNumber; + + for (const widget of Object.values(this._widgets)) { + const range = widget.preference.range; + if (range.endLineNumber < visibleStartLineNumber || range.startLineNumber > visibleEndLineNumber) { + // The widget is not in the viewport + continue; + } + + // The widget is in the viewport, find a good line for it + const widgetLineNumber = Math.max(range.startLineNumber, visibleStartLineNumber); + const lane = Math.min(widget.preference.lane, this._glyphMarginDecorationLaneCount); + requests.push(new WidgetBasedGlyphRenderRequest(widgetLineNumber, lane, widget.preference.zIndex, widget)); + } + } + + private _collectSortedGlyphRenderRequests(ctx: RenderingContext): GlyphRenderRequest[] { + + const requests: GlyphRenderRequest[] = []; + + this._collectDecorationBasedGlyphRenderRequest(ctx, requests); + this._collectWidgetBasedGlyphRenderRequest(ctx, requests); + + // sort requests by lineNumber ASC, lane ASC, zIndex DESC, type DESC (widgets first), className ASC + // don't change this sort unless you understand `prepareRender` below. + requests.sort((a, b) => { + if (a.lineNumber === b.lineNumber) { + if (a.lane === b.lane) { + if (a.zIndex === b.zIndex) { + if (b.type === a.type) { + if (a.type === GlyphRenderRequestType.Decoration && b.type === GlyphRenderRequestType.Decoration) { + return (a.className < b.className ? -1 : 1); + } + return 0; + } + return b.type - a.type; + } + return b.zIndex - a.zIndex; + } + return a.lane - b.lane; + } + return a.lineNumber - b.lineNumber; + }); + + return requests; + } + + /** + * Will store render information in each widget's renderInfo and in `_decorationGlyphsToRender`. + */ public prepareRender(ctx: RenderingContext): void { if (!this._glyphMargin) { - this._renderResult = null; + this._decorationGlyphsToRender = []; return; } - const visibleStartLineNumber = ctx.visibleRange.startLineNumber; - const visibleEndLineNumber = ctx.visibleRange.endLineNumber; - const decorationsToRender = this._getDecorations(ctx); - const toRender = this._render(visibleStartLineNumber, visibleEndLineNumber, decorationsToRender, this._glyphMarginDecorationLaneCount); + for (const widget of Object.values(this._widgets)) { + widget.renderInfo = null; + } - const lineHeight = this._lineHeight.toString(); - const width = (Math.round(this._glyphMarginWidth / this._glyphMarginDecorationLaneCount)).toString(); - const common = '" style="width:' + width + 'px' + ';height:' + lineHeight + 'px;'; + const requests = new ArrayQueue(this._collectSortedGlyphRenderRequests(ctx)); + const decorationGlyphsToRender: DecorationBasedGlyph[] = []; + while (requests.length > 0) { + const first = requests.peek(); + if (!first) { + // not possible + break; + } - const output: string[] = []; - for (let lineNumber = visibleStartLineNumber; lineNumber <= visibleEndLineNumber; lineNumber++) { - const lineIndex = lineNumber - visibleStartLineNumber; - const renderInfo = toRender[lineIndex]; + // Requests are sorted by lineNumber and lane, so we read all requests for this particular location + const requestsAtLocation = requests.takeWhile((el) => el.lineNumber === first.lineNumber && el.lane === first.lane); + if (!requestsAtLocation || requestsAtLocation.length === 0) { + // not possible + break; + } - if (renderInfo.isEmpty()) { - output[lineIndex] = ''; - } else { - let css = ''; - for (let lane = 1; lane <= this._glyphMarginDecorationLaneCount; lane += 1) { - const decorations = renderInfo.getLaneDecorations(lane); - if (decorations.length === 0) { - continue; + const winner = requestsAtLocation[0]; + if (winner.type === GlyphRenderRequestType.Decoration) { + // combine all decorations with the same z-index + + const classNames: string[] = []; + // requests are sorted by zIndex, type, and className so we can dedup className by looking at the previous one + for (const request of requestsAtLocation) { + if (request.zIndex !== winner.zIndex || request.type !== winner.type) { + break; } - decorations.sort((a, b) => b.zIndex - a.zIndex); - // Render winning decorations with the same zIndex together - const winningDecoration: RenderedDecoration = decorations[0]; - const winningDecorationClassNames = [winningDecoration.className]; - for (let i = 1; i < decorations.length; i += 1) { - const decoration = decorations[i]; - if (decoration.zIndex !== winningDecoration.zIndex) { - break; - } - winningDecorationClassNames.push(decoration.className); + if (classNames.length === 0 || classNames[classNames.length - 1] !== request.className) { + classNames.push(request.className); } - const left = (this._glyphMarginLeft + (lane - 1) * this._lineHeight).toString(); - css += ( - '
' - ); } - output[lineIndex] = css; + + decorationGlyphsToRender.push(winner.accept(classNames.join(' '))); // TODO@joyceerhl Implement overflow for remaining decorations + } else { + // widgets cannot be combined + winner.widget.renderInfo = { + lineNumber: winner.lineNumber, + lane: winner.lane, + }; } } - - this._renderResult = output; + this._decorationGlyphsToRender = decorationGlyphsToRender; } - public render(startLineNumber: number, lineNumber: number): string { - if (!this._renderResult) { - return ''; + public render(ctx: RestrictedRenderingContext): void { + if (!this._glyphMargin) { + for (const widget of Object.values(this._widgets)) { + widget.domNode.setDisplay('none'); + } + while (this._managedDomNodes.length > 0) { + const domNode = this._managedDomNodes.pop(); + domNode?.domNode.remove(); + } + return; } - const lineIndex = lineNumber - startLineNumber; - if (lineIndex < 0 || lineIndex >= this._renderResult.length) { - return ''; + + const width = (Math.round(this._glyphMarginWidth / this._glyphMarginDecorationLaneCount)); + + // Render widgets + for (const widget of Object.values(this._widgets)) { + if (!widget.renderInfo) { + // this widget is not visible + widget.domNode.setDisplay('none'); + } else { + const top = ctx.viewportData.relativeVerticalOffset[widget.renderInfo.lineNumber - ctx.viewportData.startLineNumber]; + const left = this._glyphMarginLeft + (widget.renderInfo.lane - 1) * this._lineHeight; + + widget.domNode.setDisplay('block'); + widget.domNode.setTop(top); + widget.domNode.setLeft(left); + widget.domNode.setWidth(width); + widget.domNode.setHeight(this._lineHeight); + } + } + + // Render decorations, reusing previous dom nodes as possible + for (let i = 0; i < this._decorationGlyphsToRender.length; i++) { + const dec = this._decorationGlyphsToRender[i]; + const top = ctx.viewportData.relativeVerticalOffset[dec.lineNumber - ctx.viewportData.startLineNumber]; + const left = this._glyphMarginLeft + (dec.lane - 1) * this._lineHeight; + + let domNode: FastDomNode; + if (i < this._managedDomNodes.length) { + domNode = this._managedDomNodes[i]; + } else { + domNode = createFastDomNode(document.createElement('div')); + this._managedDomNodes.push(domNode); + this.domNode.appendChild(domNode); + } + + domNode.setClassName(`cgmr codicon ` + dec.combinedClassName); + domNode.setPosition(`absolute`); + domNode.setTop(top); + domNode.setLeft(left); + domNode.setWidth(width); + domNode.setHeight(this._lineHeight); + } + + // remove extra dom nodes + while (this._managedDomNodes.length > this._decorationGlyphsToRender.length) { + const domNode = this._managedDomNodes.pop(); + domNode?.domNode.remove(); } - return this._renderResult[lineIndex]; } } + +export interface IWidgetData { + widget: IGlyphMarginWidget; + preference: IGlyphMarginWidgetPosition; + domNode: FastDomNode; + /** + * it will contain the location where to render the widget + * or null if the widget is not visible + */ + renderInfo: IRenderInfo | null; +} + +export interface IRenderInfo { + lineNumber: number; + lane: number; +} + +const enum GlyphRenderRequestType { + Decoration = 0, + Widget = 1 +} + +/** + * A request to render a decoration in the glyph margin at a certain location. + */ +class DecorationBasedGlyphRenderRequest { + public readonly type = GlyphRenderRequestType.Decoration; + + constructor( + public readonly lineNumber: number, + public readonly lane: number, + public readonly zIndex: number, + public readonly className: string, + ) { } + + accept(combinedClassName: string): DecorationBasedGlyph { + return new DecorationBasedGlyph(this.lineNumber, this.lane, combinedClassName); + } +} + +/** + * A request to render a widget in the glyph margin at a certain location. + */ +class WidgetBasedGlyphRenderRequest { + public readonly type = GlyphRenderRequestType.Widget; + + constructor( + public readonly lineNumber: number, + public readonly lane: number, + public readonly zIndex: number, + public readonly widget: IWidgetData, + ) { } +} + +type GlyphRenderRequest = DecorationBasedGlyphRenderRequest | WidgetBasedGlyphRenderRequest; + +class DecorationBasedGlyph { + constructor( + public readonly lineNumber: number, + public readonly lane: number, + public readonly combinedClassName: string + ) { } +} diff --git a/src/vs/editor/browser/viewParts/linesDecorations/linesDecorations.ts b/src/vs/editor/browser/viewParts/linesDecorations/linesDecorations.ts index 9ed72e6acf0bf..95d8caaed5123 100644 --- a/src/vs/editor/browser/viewParts/linesDecorations/linesDecorations.ts +++ b/src/vs/editor/browser/viewParts/linesDecorations/linesDecorations.ts @@ -91,7 +91,7 @@ export class LinesDecorationsOverlay extends DedupOverlay { public prepareRender(ctx: RenderingContext): void { const visibleStartLineNumber = ctx.visibleRange.startLineNumber; const visibleEndLineNumber = ctx.visibleRange.endLineNumber; - const toRender = this._render(visibleStartLineNumber, visibleEndLineNumber, this._getDecorations(ctx), 1); + const toRender = this._render(visibleStartLineNumber, visibleEndLineNumber, this._getDecorations(ctx)); const left = this._decorationsLeft.toString(); const width = this._decorationsWidth.toString(); @@ -100,7 +100,7 @@ export class LinesDecorationsOverlay extends DedupOverlay { const output: string[] = []; for (let lineNumber = visibleStartLineNumber; lineNumber <= visibleEndLineNumber; lineNumber++) { const lineIndex = lineNumber - visibleStartLineNumber; - const decorations = toRender[lineIndex].getLaneDecorations(1); // there is only one lane, see _render call above + const decorations = toRender[lineIndex].getDecorations(); let lineOutput = ''; for (const decoration of decorations) { lineOutput += '
'; diff --git a/src/vs/editor/browser/widget/codeEditorWidget.ts b/src/vs/editor/browser/widget/codeEditorWidget.ts index 181b2c279e4c2..14eac1ff18612 100644 --- a/src/vs/editor/browser/widget/codeEditorWidget.ts +++ b/src/vs/editor/browser/widget/codeEditorWidget.ts @@ -21,7 +21,7 @@ import * as editorBrowser from 'vs/editor/browser/editorBrowser'; import { EditorExtensionsRegistry, IEditorContributionDescription } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { ICommandDelegate } from 'vs/editor/browser/view/viewController'; -import { IContentWidgetData, IOverlayWidgetData, View } from 'vs/editor/browser/view'; +import { IContentWidgetData, IGlyphMarginWidgetData, IOverlayWidgetData, View } from 'vs/editor/browser/view'; import { ViewUserInputEvents } from 'vs/editor/browser/view/viewUserInputEvents'; import { ConfigurationChangedEvent, EditorLayoutInfo, IEditorOptions, EditorOption, IComputedEditorOptions, FindComputedEditorOptionValueById, filterValidationDecorations } from 'vs/editor/common/config/editorOptions'; import { CursorColumns } from 'vs/editor/common/core/cursorColumns'; @@ -255,6 +255,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE private _contentWidgets: { [key: string]: IContentWidgetData }; private _overlayWidgets: { [key: string]: IOverlayWidgetData }; + private _glyphMarginWidgets: { [key: string]: IGlyphMarginWidgetData }; /** * map from "parent" decoration type to live decoration ids. @@ -323,6 +324,7 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE this._contentWidgets = {}; this._overlayWidgets = {}; + this._glyphMarginWidgets = {}; let contributions: IEditorContributionDescription[]; if (Array.isArray(codeEditorWidgetOptions.contributions)) { @@ -1510,6 +1512,45 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE } } + public addGlyphMarginWidget(widget: editorBrowser.IGlyphMarginWidget): void { + const widgetData: IGlyphMarginWidgetData = { + widget: widget, + position: widget.getPosition() + }; + + if (this._glyphMarginWidgets.hasOwnProperty(widget.getId())) { + console.warn('Overwriting a glyph margin widget with the same id.'); + } + + this._glyphMarginWidgets[widget.getId()] = widgetData; + + if (this._modelData && this._modelData.hasRealView) { + this._modelData.view.addGlyphMarginWidget(widgetData); + } + } + + public layoutGlyphMarginWidget(widget: editorBrowser.IGlyphMarginWidget): void { + const widgetId = widget.getId(); + if (this._glyphMarginWidgets.hasOwnProperty(widgetId)) { + const widgetData = this._glyphMarginWidgets[widgetId]; + widgetData.position = widget.getPosition(); + if (this._modelData && this._modelData.hasRealView) { + this._modelData.view.layoutGlyphMarginWidget(widgetData); + } + } + } + + public removeGlyphMarginWidget(widget: editorBrowser.IGlyphMarginWidget): void { + const widgetId = widget.getId(); + if (this._glyphMarginWidgets.hasOwnProperty(widgetId)) { + const widgetData = this._glyphMarginWidgets[widgetId]; + delete this._glyphMarginWidgets[widgetId]; + if (this._modelData && this._modelData.hasRealView) { + this._modelData.view.removeGlyphMarginWidget(widgetData); + } + } + } + public changeViewZones(callback: (accessor: editorBrowser.IViewZoneChangeAccessor) => void): void { if (!this._modelData || !this._modelData.hasRealView) { return; @@ -1718,6 +1759,12 @@ export class CodeEditorWidget extends Disposable implements editorBrowser.ICodeE view.addOverlayWidget(this._overlayWidgets[widgetId]); } + keys = Object.keys(this._glyphMarginWidgets); + for (let i = 0, len = keys.length; i < len; i++) { + const widgetId = keys[i]; + view.addGlyphMarginWidget(this._glyphMarginWidgets[widgetId]); + } + view.render(false, true); view.domNode.domNode.setAttribute('data-uri', model.uri.toString()); } diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index cf61c0bac20b5..eb18dced3c1f6 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -19,7 +19,7 @@ import { Range } from 'vs/editor/common/core/range'; import { ISelection, Selection } from 'vs/editor/common/core/selection'; import { ICommand, ICursorState, IViewState, ScrollType } from 'vs/editor/common/editorCommon'; import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration'; -import { EndOfLinePreference, GlyphMarginLane, IAttachedView, ICursorStateComputer, IIdentifiedSingleEditOperation, ITextModel, PositionAffinity, TrackedRangeStickiness } from 'vs/editor/common/model'; +import { EndOfLinePreference, IAttachedView, ICursorStateComputer, IIdentifiedSingleEditOperation, ITextModel, PositionAffinity, TrackedRangeStickiness } from 'vs/editor/common/model'; import { IActiveIndentGuideInfo, BracketGuideOptions, IndentGuide } from 'vs/editor/common/textModelGuides'; import { ModelDecorationMinimapOptions, ModelDecorationOptions, ModelDecorationOverviewRulerOptions } from 'vs/editor/common/model/textModel'; import * as textModelEvents from 'vs/editor/common/textModelEvents'; @@ -459,54 +459,6 @@ export class ViewModel extends Disposable implements IViewModel { this._register(this.model.onDidChangeDecorations((e) => { this._decorations.onModelDecorationsChanged(); - - // Determine whether we need to resize the glyph margin - if (e.affectsGlyphMargin) { - const decorations = this.model.getAllMarginDecorations(); - - let hasTwoLanes = false; - - // Decorations are already sorted by their start position, but protect against future changes - decorations.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)); - - let leftDecRange: Range | null = null; - let rightDecRange: Range | null = null; - for (const decoration of decorations) { - const position = decoration.options.glyphMargin?.position ?? GlyphMarginLane.Left; - - if (position === GlyphMarginLane.Left && (!leftDecRange || Range.compareRangesUsingEnds(leftDecRange, decoration.range) < 0)) { - // assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane - leftDecRange = decoration.range; - } - - if (position === GlyphMarginLane.Right && (!rightDecRange || Range.compareRangesUsingEnds(rightDecRange, decoration.range) < 0)) { - // assign only if the range of `decoration` ends after, which means it has a higher chance to overlap with the other lane - rightDecRange = decoration.range; - } - - if (leftDecRange && rightDecRange) { - - if (leftDecRange.endLineNumber < rightDecRange.startLineNumber) { - // there's no chance for `leftDecRange` to ever intersect something going further - leftDecRange = null; - continue; - } - - if (rightDecRange.endLineNumber < leftDecRange.startLineNumber) { - // there's no chance for `rightDecRange` to ever intersect something going further - rightDecRange = null; - continue; - } - - // leftDecRange and rightDecRange are intersecting or touching => we need two lanes - hasTwoLanes = true; - break; - } - } - - this._configuration.setGlyphMarginDecorationLaneCount(hasTwoLanes ? 2 : 1); - } - this._eventDispatcher.emitSingleViewEvent(new viewEvents.ViewDecorationsChangedEvent(e)); this._eventDispatcher.emitOutgoingEvent(new ModelDecorationsChangedEvent(e)); })); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 1aeebd607deaa..a3e9b38bfe495 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -5379,6 +5379,43 @@ declare namespace monaco.editor { getPosition(): IOverlayWidgetPosition | null; } + /** + * A glyph margin widget renders in the editor glyph margin. + */ + export interface IGlyphMarginWidget { + /** + * Get a unique identifier of the glyph widget. + */ + getId(): string; + /** + * Get the dom node of the glyph widget. + */ + getDomNode(): HTMLElement; + /** + * Get the placement of the glyph widget. + */ + getPosition(): IGlyphMarginWidgetPosition; + } + + /** + * A position for rendering glyph margin widgets. + */ + export interface IGlyphMarginWidgetPosition { + /** + * The glyph margin lane where the widget should be shown. + */ + lane: GlyphMarginLane; + /** + * The priority order of the widget, used for determining which widget + * to render when there are multiple. + */ + zIndex: number; + /** + * The editor range that this widget applies to. + */ + range: IRange; + } + /** * Type of hit element with the mouse in the editor. */ @@ -5951,6 +5988,19 @@ declare namespace monaco.editor { * Remove an overlay widget. */ removeOverlayWidget(widget: IOverlayWidget): void; + /** + * Add a glyph margin widget. Widgets must have unique ids, otherwise they will be overwritten. + */ + addGlyphMarginWidget(widget: IGlyphMarginWidget): void; + /** + * Layout/Reposition a glyph margin widget. This is a ping to the editor to call widget.getPosition() + * and update appropriately. + */ + layoutGlyphMarginWidget(widget: IGlyphMarginWidget): void; + /** + * Remove a glyph margin widget. + */ + removeGlyphMarginWidget(widget: IGlyphMarginWidget): void; /** * Change the view zones. View zones are lost when a new model is attached to the editor. */