Editor/Prism/Text/Completion/SignatureHelpPopup.cs

A UI widget for the editor that displays a floating signature help card showing the current overload, active parameter highlight, optional documentation, and an overload counter. It anchors to a document position, follows scrolling each frame, measures font metrics, lays out and paints the signature and documentation, and supports cycling overloads and dismissal.

Native Interop
using Editor.Prism.Core;
using Editor.Prism.Ui;

namespace Editor.Prism.Text.Completion;

/// <summary>
/// The floating signature card: the overload being called with the argument under the caret picked
/// out, an overload counter, and one line about what the function does.
/// <para>
/// Like <see cref="CompletionPopup"/> this is a child of the editor rather than a popup window, so it
/// cannot steal focus mid-typing. It sits above the caret by preference, because the interesting text
/// while you are filling in arguments is the line you are on.
/// </para>
/// </summary>
public sealed class SignatureHelpPopup : Widget
{
	/// <summary>Height of the signature line.</summary>
	public const float SignatureHeight = 22f;

	/// <summary>Height of the documentation line under the signature.</summary>
	public const float DocumentationHeight = 20f;

	/// <summary>Widest the card is allowed to be.</summary>
	public const float MaxWidth = 720f;

	const float Padding = 9f;

	float _charWidth = 7f;
	SignatureHelp _help;

	TextPosition _anchor;
	Vector2 _anchorLocal;

	/// <summary>Creates the card as a child of an editor. It stays hidden until <see cref="Show"/>.</summary>
	public SignatureHelpPopup( CodeEditorWidget editor ) : base( editor )
	{
		Editor = editor;
		FocusMode = FocusMode.None;
		MouseTracking = true;
		Visible = false;

		Measure();
	}

	/// <summary>The editor this card belongs to.</summary>
	public CodeEditorWidget Editor { get; }

	/// <summary>The call being described, or null when the card is hidden.</summary>
	public SignatureHelp Help => _help;

	/// <summary>True while the card is on screen.</summary>
	public bool IsOpen => Visible && _help is { IsValid: true };

	/// <summary>The overload on show.</summary>
	public SignatureInfo Current
	{
		get
		{
			if ( _help is not { IsValid: true } )
				return null;

			var index = Math.Clamp( _help.ActiveSignature, 0, _help.Signatures.Count - 1 );

			return _help.Signatures[index];
		}
	}

	/// <summary>Shows a call, anchored to a document position so it follows the text when the view scrolls.</summary>
	public void Show( SignatureHelp help, TextPosition anchor )
	{
		if ( help is not { IsValid: true } )
		{
			Dismiss();
			return;
		}

		// Keep the overload the user cycled to when the same call is simply being refreshed.
		if ( _help is not null && string.Equals( _help.Name, help.Name, StringComparison.Ordinal ) &&
			 _help.Signatures.Count == help.Signatures.Count )
		{
			help.ActiveSignature = _help.ActiveSignature;
		}

		_help = help;
		_anchor = anchor;
		_anchorLocal = default;

		if ( Editor is { IsValid: true } )
			Reposition( Editor.PositionToLocal( anchor ), Editor.LineHeight );

		Visible = true;
		Raise();
		Update();
	}

	/// <summary>Repositions the card under its anchor. Runs every frame so scrolling cannot detach it.</summary>
	[EditorEvent.Frame]
	void Reanchor()
	{
		if ( !Visible || _help is null || Editor is not { IsValid: true } )
			return;

		var local = Editor.PositionToLocal( _anchor );

		if ( local == _anchorLocal )
			return;

		_anchorLocal = local;

		if ( local.y < -Editor.LineHeight || local.y > Editor.Height )
		{
			Visible = false;
			return;
		}

		Reposition( local, Editor.LineHeight );
		Update();
	}

	/// <summary>Hides the card.</summary>
	public void Dismiss()
	{
		if ( !Visible && _help is null )
			return;

		_help = null;
		Visible = false;
	}

	/// <summary>Steps through the overload set, wrapping at both ends.</summary>
	public void Cycle( int delta )
	{
		if ( _help is not { IsValid: true } || _help.Signatures.Count < 2 )
			return;

		var count = _help.Signatures.Count;

		_help.ActiveSignature = ( ( _help.ActiveSignature + delta ) % count + count ) % count;
		Update();
	}

	// ---- geometry ---------------------------------------------------------

	void Measure()
	{
		PrismLog.Guard( "Prism.Text: measure signature font", () =>
		{
			using ( Paint.ToPixmap( new Pixmap( 8, 8 ) ) )
			{
				Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize, 400, false, true );

				var size = Paint.MeasureText( new string( '0', 64 ) );

				if ( size.x > 1f )
					_charWidth = size.x / 64f;
			}
		} );
	}

	void Reposition( Vector2 caretLocal, float caretHeight )
	{
		var signature = Current;
		var label = signature?.Label ?? string.Empty;
		var counter = _help.Signatures.Count > 1 ? 44f : 0f;

		var width = Math.Clamp( Padding * 2f + counter + label.Length * _charWidth + 8f, 180f, MaxWidth );
		var height = SignatureHeight + ( HasDocumentation ? DocumentationHeight : 0f ) + 6f;

		if ( Editor is { IsValid: true } )
			width = Math.Min( width, Math.Max( 180f, Editor.Width - 16f ) );

		Size = new Vector2( width, height );

		if ( Editor is not { IsValid: true } )
			return;

		var x = Math.Clamp( caretLocal.x - Padding, 4f, Math.Max( 4f, Editor.Width - Width - 4f ) );
		var above = caretLocal.y - Height - 4f;
		var y = above >= 4f ? above : caretLocal.y + caretHeight + 4f;

		Position = new Vector2( x, Math.Clamp( y, 4f, Math.Max( 4f, Editor.Height - Height - 4f ) ) );
	}

	bool HasDocumentation
	{
		get
		{
			var signature = Current;

			return !string.IsNullOrEmpty( signature?.Documentation ) || !string.IsNullOrEmpty( signature?.Origin );
		}
	}

	// ---- painting ---------------------------------------------------------

	protected override void OnPaint()
	{
		var signature = Current;

		if ( signature is null )
			return;

		var rect = LocalRect;

		PrismPaint.DropShadow( rect, PrismTheme.RadiusPopup, PrismTheme.Shadow );

		Paint.SetBrushAndPen( PrismTheme.Elevated, PrismTheme.BorderStrong );
		Paint.DrawRect( rect, PrismTheme.RadiusPopup );

		var inner = rect.Shrink( Padding, 3f, Padding, 3f );
		var line = new Rect( inner.Left, inner.Top, inner.Width, SignatureHeight );

		if ( _help.Signatures.Count > 1 )
		{
			var counter = new Rect( line.Left, line.Top, 40f, line.Height );

			PrismPaint.Pill( counter.Shrink( 0f, 3f ), PrismTheme.PanelAlt, PrismTheme.RadiusChip );
			PrismPaint.Text( counter, $"{_help.ActiveSignature + 1}/{_help.Signatures.Count}",
				PrismTheme.TextSecondary, 10, 600, TextFlag.Center );

			line.Left += 46f;
		}

		PaintSignature( signature, line );

		if ( !HasDocumentation )
			return;

		var doc = new Rect( inner.Left, inner.Top + SignatureHeight, inner.Width, DocumentationHeight );
		var text = signature.Documentation;

		if ( string.IsNullOrEmpty( text ) )
		{
			PrismPaint.Text( doc, signature.Origin, PrismTheme.TextDisabled, 10, 400, TextFlag.LeftCenter, true );
			return;
		}

		PrismPaint.Text( doc, text, PrismTheme.TextSecondary, 10, 400 );
	}

	void PaintSignature( SignatureInfo signature, Rect line )
	{
		var label = signature.Label ?? string.Empty;
		var active = ActiveRange( label, _help.ActiveParameter );

		Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize, 400, false, true );

		if ( active.Length <= 0 )
		{
			Paint.SetPen( PrismTheme.TextPrimary );
			Paint.DrawText( line, Paint.GetElidedText( label, line.Width, ElideMode.Right, TextFlag.LeftCenter ),
				TextFlag.LeftCenter );

			return;
		}

		var before = label.Substring( 0, active.Start );
		var middle = label.Substring( active.Start, active.Length );
		var after = label.Substring( active.Start + active.Length );

		var x = line.Left;

		x += Run( before, x, line, PrismTheme.TextSecondary, 400 );

		var highlight = new Rect( x - 2f, line.Top + 3f, Paint.MeasureText( middle ).x + 4f, line.Height - 6f );

		PrismPaint.Pill( highlight, PrismTheme.AccentSoft, PrismTheme.RadiusChip );

		x += Run( middle, x, line, PrismTheme.Accent, 600 );

		Run( after, x, line, PrismTheme.TextSecondary, 400 );
	}

	float Run( string text, float x, Rect line, Color colour, int weight )
	{
		if ( string.IsNullOrEmpty( text ) )
			return 0f;

		Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize, weight, false, true );
		Paint.SetPen( colour );

		var width = Paint.MeasureText( text ).x;

		Paint.DrawText( new Rect( x, line.Top, Math.Max( 0f, line.Right - x ), line.Height ), text,
			TextFlag.LeftCenter );

		return width;
	}

	/// <summary>
	/// Where the active argument sits inside the signature text. Found by splitting on top-level commas
	/// between the outermost parentheses, so a nested <c>float3(1,2,3)</c> default does not shift the
	/// highlight one parameter to the right.
	/// </summary>
	public static (int Start, int Length) ActiveRange( string label, int index )
	{
		if ( string.IsNullOrEmpty( label ) || index < 0 )
			return (0, 0);

		var open = label.IndexOf( '(' );
		var close = label.LastIndexOf( ')' );

		if ( open < 0 || close <= open )
			return (0, 0);

		var depth = 0;
		var start = open + 1;
		var found = 0;

		for ( var i = open + 1; i <= close; i++ )
		{
			var c = i < close ? label[i] : ',';

			if ( c is '(' or '<' or '[' )
			{
				depth++;
				continue;
			}

			if ( c is ')' or '>' or ']' )
			{
				depth--;
				continue;
			}

			if ( c != ',' || depth != 0 )
				continue;

			if ( found == index )
			{
				while ( start < i && char.IsWhiteSpace( label[start] ) )
					start++;

				var end = i;

				while ( end > start && char.IsWhiteSpace( label[end - 1] ) )
					end--;

				return (start, Math.Max( 0, end - start ));
			}

			found++;
			start = i + 1;
		}

		// A variadic call can pass more arguments than the signature names; highlight nothing rather
		// than lying about which one is active.
		return (0, 0);
	}
}