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.
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);
}
}