Repository: microsoft/vscode (TypeScript)
Date: 2022-10-18 21:40:52+00:00
Task type: Bug Fix

Issue:
Code action tool tip rendered too far from mouse, unable to hover
![Recording 2022-08-18 at 09 56 36](https://user-images.githubusercontent.com/4544166/185413398-a069264a-eaea-48e5-a8e1-11453cbec45f.gif)



Issue discussion:
@jrieken Assigning you as this looks like the normal hover widget

Solution patch:
diff --git a/src/vs/editor/browser/editorBrowser.ts b/src/vs/editor/browser/editorBrowser.ts
--- a/src/vs/editor/browser/editorBrowser.ts
+++ b/src/vs/editor/browser/editorBrowser.ts
@@ -132,10 +132,11 @@ export interface IContentWidgetPosition {
 	 */
 	position: IPosition | null;
 	/**
-	 * Optionally, a range can be provided to further
-	 * define the position of the content widget.
+	 * Optionally, a secondary position can be provided to further
+	 * define the position of the content widget. The secondary position
+	 * must have the same line number as the primary position.
 	 */
-	range?: IRange | null;
+	secondaryPosition?: IPosition | null;
 	/**
 	 * Placement preference for position, in order of preference.
 	 */
diff --git a/src/vs/editor/browser/view.ts b/src/vs/editor/browser/view.ts
--- a/src/vs/editor/browser/view.ts
+++ b/src/vs/editor/browser/view.ts
@@ -37,7 +37,6 @@ import { SelectionsOverlay } from 'vs/editor/browser/viewParts/selections/select
 import { ViewCursors } from 'vs/editor/browser/viewParts/viewCursors/viewCursors';
 import { ViewZones } from 'vs/editor/browser/viewParts/viewZones/viewZones';
 import { Position } from 'vs/editor/common/core/position';
-import { Range } from 'vs/editor/common/core/range';
 import { ScrollType } from 'vs/editor/common/editorCommon';
 import { IEditorConfiguration } from 'vs/editor/common/config/editorConfiguration';
 import { RenderingContext } from 'vs/editor/browser/view/renderingContext';
@@ -502,15 +501,13 @@ export class View extends ViewEventHandler {
 	}
 
 	public layoutContentWidget(widgetData: IContentWidgetData): void {
-		let newRange = widgetData.position ? widgetData.position.range || null : null;
-		if (newRange === null) {
-			const newPosition = widgetData.position ? widgetData.position.position : null;
-			if (newPosition !== null) {
-				newRange = new Range(newPosition.lineNumber, newPosition.column, newPosition.lineNumber, newPosition.column);
-			}
-		}
-		const newPreference = widgetData.position ? widgetData.position.preference : null;
-		this._contentWidgets.setWidgetPosition(widgetData.widget, newRange, newPreference, widgetData.position?.positionAffinity ?? null);
+		this._contentWidgets.setWidgetPosition(
+			widgetData.widget,
+			widgetData.position?.position ?? null,
+			widgetData.position?.secondaryPosition ?? null,
+			widgetData.position?.preference ?? null,
+			widgetData.position?.positionAffinity ?? null
+		);
 		this._scheduleRender();
 	}
 
diff --git a/src/vs/editor/browser/view/renderingContext.ts b/src/vs/editor/browser/view/renderingContext.ts
--- a/src/vs/editor/browser/view/renderingContext.ts
+++ b/src/vs/editor/browser/view/renderingContext.ts
@@ -87,6 +87,38 @@ export class RenderingContext extends RestrictedRenderingContext {
 }
 
 export class LineVisibleRanges {
+	/**
+	 * Returns the element with the smallest `lineNumber`.
+	 */
+	public static firstLine(ranges: LineVisibleRanges[] | null): LineVisibleRanges | null {
+		if (!ranges) {
+			return null;
+		}
+		let result: LineVisibleRanges | null = null;
+		for (const range of ranges) {
+			if (!result || range.lineNumber < result.lineNumber) {
+				result = range;
+			}
+		}
+		return result;
+	}
+
+	/**
+	 * Returns the element with the largest `lineNumber`.
+	 */
+	public static lastLine(ranges: LineVisibleRanges[] | null): LineVisibleRanges | null {
+		if (!ranges) {
+			return null;
+		}
+		let result: LineVisibleRanges | null = null;
+		for (const range of ranges) {
+			if (!result || range.lineNumber > result.lineNumber) {
+				result = range;
+			}
+		}
+		return result;
+	}
+
 	constructor(
 		public readonly outsideRenderedLine: boolean,
 		public readonly lineNumber: number,
diff --git a/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts b/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts
--- a/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts
+++ b/src/vs/editor/browser/viewParts/contentWidgets/contentWidgets.ts
@@ -7,8 +7,6 @@ import * as dom from 'vs/base/browser/dom';
 import { FastDomNode, createFastDomNode } from 'vs/base/browser/fastDomNode';
 import { ContentWidgetPositionPreference, IContentWidget } from 'vs/editor/browser/editorBrowser';
 import { PartFingerprint, PartFingerprints, ViewPart } from 'vs/editor/browser/view/viewPart';
-import { IRange, Range } from 'vs/editor/common/core/range';
-import { Constants } from 'vs/base/common/uint';
 import { RenderingContext, RestrictedRenderingContext } from 'vs/editor/browser/view/renderingContext';
 import { ViewContext } from 'vs/editor/common/viewModel/viewContext';
 import * as viewEvents from 'vs/editor/common/viewEvents';
@@ -16,19 +14,8 @@ import { ViewportData } from 'vs/editor/common/viewLayout/viewLinesViewportData'
 import { EditorOption } from 'vs/editor/common/config/editorOptions';
 import { IDimension } from 'vs/editor/common/core/dimension';
 import { PositionAffinity } from 'vs/editor/common/model';
-
-
-class Coordinate {
-	_coordinateBrand: void = undefined;
-
-	public readonly top: number;
-	public readonly left: number;
-
-	constructor(top: number, left: number) {
-		this.top = top;
-		this.left = left;
-	}
-}
+import { IPosition, Position } from 'vs/editor/common/core/position';
+import { IViewModel } from 'vs/editor/common/viewModel';
 
 export class ViewContentWidgets extends ViewPart {
 
@@ -113,9 +100,9 @@ export class ViewContentWidgets extends ViewPart {
 		this.setShouldRender();
 	}
 
-	public setWidgetPosition(widget: IContentWidget, range: IRange | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {
+	public setWidgetPosition(widget: IContentWidget, primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {
 		const myWidget = this._widgets[widget.getId()];
-		myWidget.setPosition(range, preference, affinity);
+		myWidget.setPosition(primaryAnchor, secondaryAnchor, preference, affinity);
 
 		this.setShouldRender();
 	}
@@ -166,11 +153,11 @@ export class ViewContentWidgets extends ViewPart {
 interface IBoxLayoutResult {
 	fitsAbove: boolean;
 	aboveTop: number;
-	aboveLeft: number;
 
 	fitsBelow: boolean;
 	belowTop: number;
-	belowLeft: number;
+
+	left: number;
 }
 
 interface IRenderData {
@@ -193,9 +180,9 @@ class Widget {
 	private _contentLeft: number;
 	private _lineHeight: number;
 
-	private _range: IRange | null;
+	private _primaryAnchor: PositionPair = new PositionPair(null, null);
+	private _secondaryAnchor: PositionPair = new PositionPair(null, null);
 	private _affinity: PositionAffinity | null;
-	private _viewRange: Range | null;
 	private _preference: ContentWidgetPositionPreference[] | null;
 	private _cachedDomNodeOffsetWidth: number;
 	private _cachedDomNodeOffsetHeight: number;
@@ -222,8 +209,6 @@ class Widget {
 		this._contentLeft = layoutInfo.contentLeft;
 		this._lineHeight = options.get(EditorOption.lineHeight);
 
-		this._range = null;
-		this._viewRange = null;
 		this._affinity = null;
 		this._preference = [];
 		this._cachedDomNodeOffsetWidth = -1;
@@ -251,20 +236,25 @@ class Widget {
 	}
 
 	public onLineMappingChanged(e: viewEvents.ViewLineMappingChangedEvent): void {
-		this._setPosition(this._range, this._affinity);
+		this._setPosition(this._affinity, this._primaryAnchor.modelPosition, this._secondaryAnchor.modelPosition);
 	}
 
-	private _setPosition(range: IRange | null, affinity: PositionAffinity | null): void {
-		this._range = range;
-		this._viewRange = null;
+	private _setPosition(affinity: PositionAffinity | null, primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null): void {
 		this._affinity = affinity;
+		this._primaryAnchor = getValidPositionPair(primaryAnchor, this._context.viewModel, this._affinity);
+		this._secondaryAnchor = getValidPositionPair(secondaryAnchor, this._context.viewModel, this._affinity);
 
-		if (this._range) {
+		function getValidPositionPair(position: IPosition | null, viewModel: IViewModel, affinity: PositionAffinity | null): PositionPair {
+			if (!position) {
+				return new PositionPair(null, null);
+			}
 			// Do not trust that widgets give a valid position
-			const validModelRange = this._context.viewModel.model.validateRange(this._range);
-			if (this._context.viewModel.coordinatesConverter.modelPositionIsVisible(validModelRange.getStartPosition()) || this._context.viewModel.coordinatesConverter.modelPositionIsVisible(validModelRange.getEndPosition())) {
-				this._viewRange = this._context.viewModel.coordinatesConverter.convertModelRangeToViewRange(validModelRange, this._affinity ?? undefined);
+			const validModelPosition = viewModel.model.validatePosition(position);
+			if (viewModel.coordinatesConverter.modelPositionIsVisible(validModelPosition)) {
+				const viewPosition = viewModel.coordinatesConverter.convertModelPositionToViewPosition(validModelPosition, affinity ?? undefined);
+				return new PositionPair(position, viewPosition);
 			}
+			return new PositionPair(position, null);
 		}
 	}
 
@@ -276,10 +266,10 @@ class Widget {
 		);
 	}
 
-	public setPosition(range: IRange | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {
-		this._setPosition(range, affinity);
+	public setPosition(primaryAnchor: IPosition | null, secondaryAnchor: IPosition | null, preference: ContentWidgetPositionPreference[] | null, affinity: PositionAffinity | null): void {
+		this._setPosition(affinity, primaryAnchor, secondaryAnchor);
 		this._preference = preference;
-		if (this._viewRange && this._preference && this._preference.length > 0) {
+		if (this._primaryAnchor.viewPosition && this._preference && this._preference.length > 0) {
 			// this content widget would like to be visible if possible
 			// we change it from `display:none` to `display:block` even if it
 			// might be outside the viewport such that we can measure its size
@@ -292,47 +282,32 @@ class Widget {
 		this._cachedDomNodeOffsetHeight = -1;
 	}
 
-	private _layoutBoxInViewport(topLeft: Coordinate, bottomLeft: Coordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult {
+	private _layoutBoxInViewport(anchor: AnchorCoordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult {
 		// Our visible box is split horizontally by the current line => 2 boxes
 
 		// a) the box above the line
-		const aboveLineTop = topLeft.top;
-		const heightAboveLine = aboveLineTop;
+		const aboveLineTop = anchor.top;
+		const heightAvailableAboveLine = aboveLineTop;
 
 		// b) the box under the line
-		const underLineTop = bottomLeft.top + this._lineHeight;
-		const heightUnderLine = ctx.viewportHeight - underLineTop;
+		const underLineTop = anchor.top + anchor.height;
+		const heightAvailableUnderLine = ctx.viewportHeight - underLineTop;
 
 		const aboveTop = aboveLineTop - height;
-		const fitsAbove = (heightAboveLine >= height);
+		const fitsAbove = (heightAvailableAboveLine >= height);
 		const belowTop = underLineTop;
-		const fitsBelow = (heightUnderLine >= height);
+		const fitsBelow = (heightAvailableUnderLine >= height);
 
 		// And its left
-		let actualAboveLeft = topLeft.left;
-		let actualBelowLeft = bottomLeft.left;
-		if (actualAboveLeft + width > ctx.scrollLeft + ctx.viewportWidth) {
-			actualAboveLeft = ctx.scrollLeft + ctx.viewportWidth - width;
-		}
-		if (actualBelowLeft + width > ctx.scrollLeft + ctx.viewportWidth) {
-			actualBelowLeft = ctx.scrollLeft + ctx.viewportWidth - width;
-		}
-		if (actualAboveLeft < ctx.scrollLeft) {
-			actualAboveLeft = ctx.scrollLeft;
+		let left = anchor.left;
+		if (left + width > ctx.scrollLeft + ctx.viewportWidth) {
+			left = ctx.scrollLeft + ctx.viewportWidth - width;
 		}
-		if (actualBelowLeft < ctx.scrollLeft) {
-			actualBelowLeft = ctx.scrollLeft;
+		if (left < ctx.scrollLeft) {
+			left = ctx.scrollLeft;
 		}
 
-		return {
-			fitsAbove: fitsAbove,
-			aboveTop: aboveTop,
-			aboveLeft: actualAboveLeft,
-
-			fitsBelow: fitsBelow,
-			belowTop: belowTop,
-			belowLeft: actualBelowLeft,
-		};
+		return { fitsAbove, aboveTop, fitsBelow, belowTop, left };
 	}
 
 	private _layoutHorizontalSegmentInPage(windowSize: dom.Dimension, domNodePosition: dom.IDomNodePagePosition, left: number, width: number): [number, number] {
@@ -357,17 +332,16 @@ class Widget {
 		return [left, absoluteLeft];
 	}
 
-	private _layoutBoxInPage(topLeft: Coordinate, bottomLeft: Coordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult | null {
-		const aboveTop = topLeft.top - height;
-		const belowTop = bottomLeft.top + this._lineHeight;
+	private _layoutBoxInPage(anchor: AnchorCoordinate, width: number, height: number, ctx: RenderingContext): IBoxLayoutResult | null {
+		const aboveTop = anchor.top - height;
+		const belowTop = anchor.top + anchor.height;
 
 		const domNodePosition = dom.getDomNodePagePosition(this._viewDomNode.domNode);
 		const absoluteAboveTop = domNodePosition.top + aboveTop - window.scrollY;
 		const absoluteBelowTop = domNodePosition.top + belowTop - window.scrollY;
 
 		const windowSize = dom.getClientArea(document.body);
-		const [aboveLeft, absoluteAboveLeft] = this._layoutHorizontalSegmentInPage(windowSize, domNodePosition, topLeft.left - ctx.scrollLeft + this._contentLeft, width);
-		const [belowLeft, absoluteBelowLeft] = this._layoutHorizontalSegmentInPage(windowSize, domNodePosition, bottomLeft.left - ctx.scrollLeft + this._contentLeft, width);
+		const [left, absoluteAboveLeft] = this._layoutHorizontalSegmentInPage(windowSize, domNodePosition, anchor.left - ctx.scrollLeft + this._contentLeft, width);
 
 		// Leave some clearance to the top/bottom
 		const TOP_PADDING = 22;
@@ -380,21 +354,13 @@ class Widget {
 			return {
 				fitsAbove,
 				aboveTop: Math.max(absoluteAboveTop, TOP_PADDING),
-				aboveLeft: absoluteAboveLeft,
 				fitsBelow,
 				belowTop: absoluteBelowTop,
-				belowLeft: absoluteBelowLeft
+				left: absoluteAboveLeft
 			};
 		}
 
-		return {
-			fitsAbove,
-			aboveTop: aboveTop,
-			aboveLeft,
-			fitsBelow,
-			belowTop,
-			belowLeft
-		};
+		return { fitsAbove, aboveTop, fitsBelow, belowTop, left };
 	}
 
 	private _prepareRenderWidgetAtExactPositionOverflowing(topLeft: Coordinate): Coordinate {
@@ -402,56 +368,47 @@ class Widget {
 	}
 
 	/**
-	 * Compute `this._topLeft`
+	 * Compute the coordinates above and below the primary and secondary anchors.
+	 * The content widget *must* touch the primary anchor.
+	 * The content widget should touch if possible the secondary anchor.
 	 */
-	private _getTopAndBottomLeft(ctx: RenderingContext): [Coordinate, Coordinate] | [null, null] {
-		if (!this._viewRange) {
-			return [null, null];
-		}
-
-		const visibleRangesForRange = ctx.linesVisibleRangesForRange(this._viewRange, false);
-		if (!visibleRangesForRange || visibleRangesForRange.length === 0) {
-			return [null, null];
-		}
-
-		let firstLine = visibleRangesForRange[0];
-		let lastLine = visibleRangesForRange[0];
-		for (const visibleRangesForLine of visibleRangesForRange) {
-			if (visibleRangesForLine.lineNumber < firstLine.lineNumber) {
-				firstLine = visibleRangesForLine;
-			}
-			if (visibleRangesForLine.lineNumber > lastLine.lineNumber) {
-				lastLine = visibleRangesForLine;
+	private _getAnchorsCoordinates(ctx: RenderingContext): { primary: AnchorCoordinate | null; secondary: AnchorCoordinate | null } {
+		const primary = getCoordinates(this._primaryAnchor.viewPosition, this._affinity, this._lineHeight);
+		const secondaryViewPosition = (this._secondaryAnchor.viewPosition?.lineNumber === this._primaryAnchor.viewPosition?.lineNumber ? this._secondaryAnchor.viewPosition : null);
+		const secondary = getCoordinates(secondaryViewPosition, this._affinity, this._lineHeight);
+		return { primary, secondary };
+
+		function getCoordinates(position: Position | null, affinity: PositionAffinity | null, lineHeight: number): AnchorCoordinate | null {
+			if (!position) {
+				return null;
 			}
-		}
 
-		let firstLineMinLeft = Constants.MAX_SAFE_SMALL_INTEGER;//firstLine.Constants.MAX_SAFE_SMALL_INTEGER;
-		for (const visibleRange of firstLine.ranges) {
-			if (visibleRange.left < firstLineMinLeft) {
-				firstLineMinLeft = visibleRange.left;
+			const horizontalPosition = ctx.visibleRangeForPosition(position);
+			if (!horizontalPosition) {
+				return null;
 			}
-		}
 
-		// Left-align widgets that should appear :before content
-		if (this._affinity === PositionAffinity.LeftOfInjectedText &&
-			this._viewRange.startColumn === 1) {
-			firstLineMinLeft = 0;
+			// Left-align widgets that should appear :before content
+			const left = (position.column === 1 && affinity === PositionAffinity.LeftOfInjectedText ? 0 : horizontalPosition.left);
+			const top = ctx.getVerticalOffsetForLineNumber(position.lineNumber) - ctx.scrollTop;
+			return new AnchorCoordinate(top, left, lineHeight);
 		}
+	}
 
-		let lastLineMinLeft = Constants.MAX_SAFE_SMALL_INTEGER;//lastLine.Constants.MAX_SAFE_SMALL_INTEGER;
-		for (const visibleRange of lastLine.ranges) {
-			if (visibleRange.left < lastLineMinLeft) {
-				lastLineMinLeft = visibleRange.left;
-			}
+	private _reduceAnchorCoordinates(primary: AnchorCoordinate, secondary: AnchorCoordinate | null, width: number): AnchorCoordinate {
+		if (!secondary) {
+			return primary;
 		}
 
-		const topForPosition = ctx.getVerticalOffsetForLineNumber(firstLine.lineNumber) - ctx.scrollTop;
-		const topLeft = new Coordinate(topForPosition, firstLineMinLeft);
-
-		const topForBottomLine = ctx.getVerticalOffsetForLineNumber(lastLine.lineNumber) - ctx.scrollTop;
-		const bottomLeft = new Coordinate(topForBottomLine, lastLineMinLeft);
+		const fontInfo = this._context.configuration.options.get(EditorOption.fontInfo);
 
-		return [topLeft, bottomLeft];
+		let left = secondary.left;
+		if (left < primary.left) {
+			left = Math.max(left, primary.left - width + fontInfo.typicalFullwidthCharacterWidth);
+		} else {
+			left = Math.min(left, primary.left + width - fontInfo.typicalFullwidthCharacterWidth);
+		}
+		return new AnchorCoordinate(primary.top, left, primary.height);
 	}
 
 	private _prepareRenderWidget(ctx: RenderingContext): IRenderData | null {
@@ -459,8 +416,8 @@ class Widget {
 			return null;
 		}
 
-		const [topLeft, bottomLeft] = this._getTopAndBottomLeft(ctx);
-		if (!topLeft || !bottomLeft) {
+		const { primary, secondary } = this._getAnchorsCoordinates(ctx);
+		if (!primary) {
 			return null;
 		}
 
@@ -481,11 +438,13 @@ class Widget {
 			}
 		}
 
+		const anchor = this._reduceAnchorCoordinates(primary, secondary, this._cachedDomNodeOffsetWidth);
+
 		let placement: IBoxLayoutResult | null;
 		if (this.allowEditorOverflow) {
-			placement = this._layoutBoxInPage(topLeft, bottomLeft, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);
+			placement = this._layoutBoxInPage(anchor, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);
 		} else {
-			placement = this._layoutBoxInViewport(topLeft, bottomLeft, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);
+			placement = this._layoutBoxInViewport(anchor, this._cachedDomNodeOffsetWidth, this._cachedDomNodeOffsetHeight, ctx);
 		}
 
 		// Do two passes, first for perfect fit, second picks first option
@@ -498,7 +457,7 @@ class Widget {
 						return null;
 					}
 					if (pass === 2 || placement.fitsAbove) {
-						return { coordinate: new Coordinate(placement.aboveTop, placement.aboveLeft), position: ContentWidgetPositionPreference.ABOVE };
+						return { coordinate: new Coordinate(placement.aboveTop, placement.left), position: ContentWidgetPositionPreference.ABOVE };
 					}
 				} else if (pref === ContentWidgetPositionPreference.BELOW) {
 					if (!placement) {
@@ -506,13 +465,13 @@ class Widget {
 						return null;
 					}
 					if (pass === 2 || placement.fitsBelow) {
-						return { coordinate: new Coordinate(placement.belowTop, placement.belowLeft), position: ContentWidgetPositionPreference.BELOW };
+						return { coordinate: new Coordinate(placement.belowTop, placement.left), position: ContentWidgetPositionPreference.BELOW };
 					}
 				} else {
 					if (this.allowEditorOverflow) {
-						return { coordinate: this._prepareRenderWidgetAtExactPositionOverflowing(topLeft), position: ContentWidgetPositionPreference.EXACT };
+						return { coordinate: this._prepareRenderWidgetAtExactPositionOverflowing(new Coordinate(anchor.top, anchor.left)), position: ContentWidgetPositionPreference.EXACT };
 					} else {
-						return { coordinate: topLeft, position: ContentWidgetPositionPreference.EXACT };
+						return { coordinate: new Coordinate(anchor.top, anchor.left), position: ContentWidgetPositionPreference.EXACT };
 					}
 				}
 			}
@@ -525,11 +484,11 @@ class Widget {
 	 * On this first pass, we ensure that the content widget (if it is in the viewport) has the max width set correctly.
 	 */
 	public onBeforeRender(viewportData: ViewportData): void {
-		if (!this._viewRange || !this._preference) {
+		if (!this._primaryAnchor.viewPosition || !this._preference) {
 			return;
 		}
 
-		if (this._viewRange.endLineNumber < viewportData.startLineNumber || this._viewRange.startLineNumber > viewportData.endLineNumber) {
+		if (this._primaryAnchor.viewPosition.lineNumber < viewportData.startLineNumber || this._primaryAnchor.viewPosition.lineNumber > viewportData.endLineNumber) {
 			// Outside of viewport
 			return;
 		}
@@ -577,6 +536,32 @@ class Widget {
 	}
 }
 
+class PositionPair {
+	constructor(
+		public readonly modelPosition: IPosition | null,
+		public readonly viewPosition: Position | null
+	) { }
+}
+
+class Coordinate {
+	_coordinateBrand: void = undefined;
+
+	constructor(
+		public readonly top: number,
+		public readonly left: number
+	) { }
+}
+
+class AnchorCoordinate {
+	_anchorCoordinateBrand: void = undefined;
+
+	constructor(
+		public readonly top: number,
+		public readonly left: number,
+		public readonly height: number
+	) { }
+}
+
 function safeInvoke<T extends (...args: any[]) => any>(fn: T, thisArg: ThisParameterType<T>, ...args: Parameters<T>): ReturnType<T> | null {
 	try {
 		return fn.call(thisArg, ...args);
diff --git a/src/vs/editor/contrib/hover/browser/contentHover.ts b/src/vs/editor/contrib/hover/browser/contentHover.ts
--- a/src/vs/editor/contrib/hover/browser/contentHover.ts
+++ b/src/vs/editor/contrib/hover/browser/contentHover.ts
@@ -24,7 +24,6 @@ import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
 import { Context as SuggestContext } from 'vs/editor/contrib/suggest/browser/suggest';
 import { AsyncIterableObject } from 'vs/base/common/async';
 import { EditorContextKeys } from 'vs/editor/common/editorContextKeys';
-import { Constants } from 'vs/base/common/uint';
 
 const $ = dom.$;
 
@@ -241,7 +240,7 @@ export class ContentHoverController extends Disposable {
 	}
 
 	private _renderMessages(anchor: HoverAnchor, messages: IHoverPart[]): void {
-		const { showAtPosition, showAtRange, highlightRange } = ContentHoverController.computeHoverRanges(this._editor, anchor.range, messages);
+		const { showAtPosition, showAtSecondaryPosition, highlightRange } = ContentHoverController.computeHoverRanges(this._editor, anchor.range, messages);
 
 		const disposables = new DisposableStore();
 		const statusBar = disposables.add(new EditorHoverStatusBar(this._keybindingService));
@@ -284,7 +283,7 @@ export class ContentHoverController extends Disposable {
 			this._widget.showAt(fragment, new ContentHoverVisibleData(
 				colorPicker,
 				showAtPosition,
-				showAtRange,
+				showAtSecondaryPosition,
 				this._editor.getOption(EditorOption.hover).above,
 				this._computer.shouldFocus,
 				isBeforeContent,
@@ -304,21 +303,17 @@ export class ContentHoverController extends Disposable {
 
 	public static computeHoverRanges(editor: ICodeEditor, anchorRange: Range, messages: IHoverPart[]) {
 		let startColumnBoundary = 1;
-		let endColumnBoundary = Constants.MAX_SAFE_SMALL_INTEGER;
 		if (editor.hasModel()) {
 			// Ensure the range is on the current view line
 			const viewModel = editor._getViewModel();
 			const coordinatesConverter = viewModel.coordinatesConverter;
 			const anchorViewRange = coordinatesConverter.convertModelRangeToViewRange(anchorRange);
 			const anchorViewRangeStart = new Position(anchorViewRange.startLineNumber, viewModel.getLineMinColumn(anchorViewRange.startLineNumber));
-			const anchorViewRangeEnd = new Position(anchorViewRange.endLineNumber, viewModel.getLineMaxColumn(anchorViewRange.endLineNumber));
 			startColumnBoundary = coordinatesConverter.convertViewPositionToModelPosition(anchorViewRangeStart).column;
-			endColumnBoundary = coordinatesConverter.convertViewPositionToModelPosition(anchorViewRangeEnd).column;
 		}
 		// The anchor range is always on a single line
 		const anchorLineNumber = anchorRange.startLineNumber;
 		let renderStartColumn = anchorRange.startColumn;
-		let renderEndColumn = anchorRange.endColumn;
 		let highlightRange: Range = messages[0].range;
 		let forceShowAtRange: Range | null = null;
 
@@ -327,7 +322,6 @@ export class ContentHoverController extends Disposable {
 			if (msg.range.startLineNumber === anchorLineNumber && msg.range.endLineNumber === anchorLineNumber) {
 				// this message has a range that is completely sitting on the line of the anchor
 				renderStartColumn = Math.max(Math.min(renderStartColumn, msg.range.startColumn), startColumnBoundary);
-				renderEndColumn = Math.min(Math.max(renderEndColumn, msg.range.endColumn), endColumnBoundary);
 			}
 			if (msg.forceShowAtRange) {
 				forceShowAtRange = msg.range;
@@ -335,8 +329,8 @@ export class ContentHoverController extends Disposable {
 		}
 
 		return {
-			showAtPosition: forceShowAtRange ? forceShowAtRange.getStartPosition() : new Position(anchorRange.startLineNumber, renderStartColumn),
-			showAtRange: forceShowAtRange ? forceShowAtRange : new Range(anchorLineNumber, renderStartColumn, anchorLineNumber, renderEndColumn),
+			showAtPosition: forceShowAtRange ? forceShowAtRange.getStartPosition() : new Position(anchorLineNumber, anchorRange.startColumn),
+			showAtSecondaryPosition: forceShowAtRange ? forceShowAtRange.getStartPosition() : new Position(anchorLineNumber, renderStartColumn),
 			highlightRange
 		};
 	}
@@ -382,7 +376,7 @@ class ContentHoverVisibleData {
 	constructor(
 		public readonly colorPicker: IEditorHoverColorPickerWidget | null,
 		public readonly showAtPosition: Position,
-		public readonly showAtRange: Range,
+		public readonly showAtSecondaryPosition: Position,
 		public readonly preferAbove: boolean,
 		public readonly stoleFocus: boolean,
 		public readonly isBeforeContent: boolean,
@@ -463,7 +457,7 @@ export class ContentHoverWidget extends Disposable implements IContentWidget {
 
 		return {
 			position: this._visibleData.showAtPosition,
-			range: this._visibleData.showAtRange,
+			secondaryPosition: this._visibleData.showAtSecondaryPosition,
 			preference: (
 				preferAbove
 					? [ContentWidgetPositionPreference.ABOVE, ContentWidgetPositionPreference.BELOW]
diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts
--- a/src/vs/monaco.d.ts
+++ b/src/vs/monaco.d.ts
@@ -4780,10 +4780,11 @@ declare namespace monaco.editor {
 		 */
 		position: IPosition | null;
 		/**
-		 * Optionally, a range can be provided to further
-		 * define the position of the content widget.
+		 * Optionally, a secondary position can be provided to further
+		 * define the position of the content widget. The secondary position
+		 * must have the same line number as the primary position.
 		 */
-		range?: IRange | null;
+		secondaryPosition?: IPosition | null;
 		/**
 		 * Placement preference for position, in order of preference.
 		 */


Test patch:
diff --git a/src/vs/editor/contrib/hover/test/browser/contentHover.test.ts b/src/vs/editor/contrib/hover/test/browser/contentHover.test.ts
--- a/src/vs/editor/contrib/hover/test/browser/contentHover.test.ts
+++ b/src/vs/editor/contrib/hover/test/browser/contentHover.test.ts
@@ -23,7 +23,7 @@ suite('Content Hover', () => {
 				actual,
 				{
 					showAtPosition: new Position(5, 5),
-					showAtRange: new Range(5, 5, 5, 5),
+					showAtSecondaryPosition: new Position(5, 5),
 					highlightRange: new Range(4, 1, 5, 6)
 				}
 			);
@@ -42,8 +42,8 @@ suite('Content Hover', () => {
 			assert.deepStrictEqual(
 				actual,
 				{
-					showAtPosition: new Position(1, 6),
-					showAtRange: new Range(1, 6, 1, 11),
+					showAtPosition: new Position(1, 8),
+					showAtSecondaryPosition: new Position(1, 6),
 					highlightRange: new Range(1, 1, 1, 15)
 				}
 			);