UI editor code for Prism. HoverPopup draws a non-focusable tooltip card showing symbol info, diagnostics, signature and notes; HoverController polls mouse position over a CodeEditorWidget and shows/hides the HoverPopup after a delay, using a CompletionEngine to resolve hover info.
using Editor.Prism.Core;
using Editor.Prism.Text.Completion;
using Editor.Prism.Ui;
namespace Editor.Prism.Text.Diagnostics;
/// <summary>
/// The card shown when the pointer rests on a symbol: what it is, how it is declared, one sentence
/// about it, where it came from, and — the part that earns its keep in s&box — why it will not
/// compile, when it will not.
/// <para>
/// It is a child of the editor rather than a popup window so it can never take focus, and it hides
/// itself the moment the pointer moves off the word.
/// </para>
/// </summary>
public sealed class HoverPopup : Widget
{
/// <summary>Widest the card is allowed to be.</summary>
public const float MaxWidth = 560f;
/// <summary>Narrowest the card is allowed to be.</summary>
public const float MinWidth = 220f;
const float Padding = 10f;
const float TitleHeight = 20f;
const float LineHeight = 16f;
const float Gap = 4f;
readonly List<Row> _rows = new();
float _monoWidth = 7f;
float _uiWidth = 6f;
readonly struct Row
{
public Row( string text, int size, int weight, Color colour, bool mono, bool italic, string icon )
{
Text = text;
Size = size;
Weight = weight;
Colour = colour;
Mono = mono;
Italic = italic;
Icon = icon;
}
public readonly string Text;
public readonly int Size;
public readonly int Weight;
public readonly Color Colour;
public readonly bool Mono;
public readonly bool Italic;
public readonly string Icon;
}
/// <summary>Creates the card as a child of an editor. It stays hidden until <see cref="Show"/>.</summary>
public HoverPopup( 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>True while the card is on screen.</summary>
public bool IsOpen => Visible && _rows.Count > 0;
/// <summary>Builds and shows the card at a point in editor-local pixels.</summary>
public void Show( HoverInfo info, Vector2 local )
{
if ( info is null || info.IsEmpty )
{
Dismiss();
return;
}
Build( info );
if ( _rows.Count == 0 )
{
Dismiss();
return;
}
Reposition( local );
Visible = true;
Raise();
Update();
}
/// <summary>Hides the card and drops its contents.</summary>
public void Dismiss()
{
if ( !Visible && _rows.Count == 0 )
return;
_rows.Clear();
Visible = false;
}
// ---- building ---------------------------------------------------------
void Build( HoverInfo info )
{
_rows.Clear();
var width = Wrap( MaxWidth );
if ( info.Diagnostic is not null )
{
var diagnostic = info.Diagnostic;
var colour = PrismTheme.ForSeverity( diagnostic.Severity );
var icon = diagnostic.Severity switch
{
DiagnosticSeverity.Error => "error",
DiagnosticSeverity.Warning => "warning",
_ => "info"
};
var head = string.IsNullOrEmpty( diagnostic.Code )
? diagnostic.Message
: $"{diagnostic.Code} {diagnostic.Message}";
foreach ( var line in WrapText( head, width, _uiWidth ) )
_rows.Add( new Row( line, 11, 600, colour, false, false, icon ) );
if ( !string.IsNullOrWhiteSpace( diagnostic.Detail ) )
{
foreach ( var line in WrapText( diagnostic.Detail, width, _uiWidth ) )
_rows.Add( new Row( line, 10, 400, PrismTheme.TextSecondary, false, false, null ) );
}
if ( string.IsNullOrEmpty( info.Title ) )
return;
_rows.Add( new Row( null, 0, 0, PrismTheme.BorderSubtle, false, false, null ) );
}
if ( !string.IsNullOrEmpty( info.Title ) )
_rows.Add( new Row( info.Title, PrismTheme.BodySize, 600, PrismTheme.TextPrimary, true, false, null ) );
if ( !string.IsNullOrEmpty( info.Signature ) )
{
foreach ( var raw in info.Signature.Split( '\n' ) )
{
if ( string.IsNullOrWhiteSpace( raw ) )
continue;
foreach ( var line in WrapText( raw.Trim(), width, _monoWidth ) )
_rows.Add( new Row( line, PrismTheme.BodySize - 1, 400, PrismTheme.Code.Intrinsic, true, false, null ) );
}
}
if ( !string.IsNullOrEmpty( info.Description ) )
{
foreach ( var line in WrapText( info.Description, width, _uiWidth ) )
_rows.Add( new Row( line, 11, 400, PrismTheme.TextSecondary, false, false, null ) );
}
if ( !string.IsNullOrEmpty( info.Note ) )
{
var colour = info.NoteIsError ? PrismTheme.Error : PrismTheme.Warning;
var icon = info.NoteIsError ? "error" : "warning";
foreach ( var line in WrapText( info.Note, width, _uiWidth ) )
{
_rows.Add( new Row( line, 11, 500, colour, false, false, icon ) );
icon = null;
}
}
if ( !string.IsNullOrEmpty( info.Origin ) )
_rows.Add( new Row( info.Origin, 10, 400, PrismTheme.TextDisabled, false, true, null ) );
}
static float Wrap( float width ) => width - Padding * 2f - 18f;
static IEnumerable<string> WrapText( string text, float width, float charWidth )
{
var columns = Math.Max( 24, (int)( width / Math.Max( 1f, charWidth ) ) );
if ( string.IsNullOrEmpty( text ) )
yield break;
var collapsed = text.Replace( "\r\n", " " ).Replace( '\n', ' ' ).Replace( '\t', ' ' );
while ( collapsed.Length > columns )
{
var cut = collapsed.LastIndexOf( ' ', Math.Min( columns, collapsed.Length - 1 ) );
if ( cut <= 0 )
cut = columns;
yield return collapsed.Substring( 0, cut ).TrimEnd();
collapsed = collapsed.Substring( cut ).TrimStart();
}
if ( collapsed.Length > 0 )
yield return collapsed;
}
void Measure()
{
PrismLog.Guard( "Prism.Text: measure hover font", () =>
{
using ( Paint.ToPixmap( new Pixmap( 8, 8 ) ) )
{
Paint.SetFont( PrismTheme.MonospaceFamily, PrismTheme.BodySize, 400, false, true );
var mono = Paint.MeasureText( new string( '0', 64 ) );
if ( mono.x > 1f )
_monoWidth = mono.x / 64f;
Paint.SetFont( PrismTheme.FontFamily, 11, 400, false, true );
var ui = Paint.MeasureText( new string( 'n', 64 ) );
if ( ui.x > 1f )
_uiWidth = ui.x / 64f;
}
} );
}
void Reposition( Vector2 local )
{
var height = Padding * 2f;
var widest = 0f;
for ( var i = 0; i < _rows.Count; i++ )
{
var row = _rows[i];
if ( row.Text is null )
{
height += Gap * 2f;
continue;
}
height += i == 0 && row.Mono ? TitleHeight : LineHeight;
var advance = row.Mono ? _monoWidth : _uiWidth;
var width = row.Text.Length * advance + ( row.Icon is null ? 0f : 18f );
if ( width > widest )
widest = width;
}
var size = new Vector2(
Math.Clamp( widest + Padding * 2f + 4f, MinWidth, MaxWidth ),
height );
if ( Editor is { IsValid: true } )
{
size.x = Math.Min( size.x, Math.Max( MinWidth, Editor.Width - 16f ) );
size.y = Math.Min( size.y, Math.Max( LineHeight * 2f, Editor.Height - 16f ) );
}
Size = size;
if ( Editor is not { IsValid: true } )
return;
var x = Math.Clamp( local.x + 12f, 4f, Math.Max( 4f, Editor.Width - Width - 4f ) );
var below = local.y + 20f;
var y = below + Height > Editor.Height - 4f ? local.y - Height - 8f : below;
Position = new Vector2( x, Math.Clamp( y, 4f, Math.Max( 4f, Editor.Height - Height - 4f ) ) );
}
// ---- painting ---------------------------------------------------------
protected override void OnPaint()
{
var rect = LocalRect;
PrismPaint.DropShadow( rect, PrismTheme.RadiusPopup, PrismTheme.Shadow );
Paint.SetBrushAndPen( PrismTheme.Elevated, PrismTheme.BorderStrong );
Paint.DrawRect( rect, PrismTheme.RadiusPopup );
var y = rect.Top + Padding;
for ( var i = 0; i < _rows.Count; i++ )
{
var row = _rows[i];
if ( row.Text is null )
{
var divider = new Rect( rect.Left + Padding, y + Gap, rect.Width - Padding * 2f, 1f );
PrismPaint.Divider( divider, row.Colour );
y += Gap * 2f;
continue;
}
var height = i == 0 && row.Mono ? TitleHeight : LineHeight;
var line = new Rect( rect.Left + Padding, y, rect.Width - Padding * 2f, height );
if ( !string.IsNullOrEmpty( row.Icon ) )
{
Paint.SetPen( row.Colour );
Paint.DrawIcon( new Rect( line.Left, line.Top, 14f, line.Height ), row.Icon, 12f,
TextFlag.LeftCenter );
line.Left += 18f;
}
Paint.SetFont( row.Mono ? PrismTheme.MonospaceFamily : PrismTheme.FontFamily,
row.Size, row.Weight, row.Italic, true );
Paint.SetPen( row.Colour );
Paint.DrawText( line, Paint.GetElidedText( row.Text, line.Width, ElideMode.Right,
TextFlag.LeftCenter ), TextFlag.LeftCenter );
y += height;
}
}
}
/// <summary>
/// Watches the pointer over a code editor and shows a <see cref="HoverPopup"/> when it rests on
/// something worth explaining.
/// <para>
/// There is no mouse-move event to hook on a foreign widget, so this polls once a frame — which is
/// cheap, because all it does until the pointer settles is compare two vectors. It stands down while
/// the completion list is open, so the two cards never fight for the same corner of the screen.
/// </para>
/// </summary>
public sealed class HoverController : IDisposable
{
CodeEditorWidget _editor;
HoverPopup _popup;
RealTimeSince _sinceMoved;
Vector2 _last;
TextPosition _shown = new( -1, -1 );
bool _hasShown;
/// <summary>Creates a watcher over an editor and registers it for frame ticks.</summary>
public HoverController( CodeEditorWidget editor, CompletionEngine engine = null )
{
_editor = editor;
Engine = engine ?? new CompletionEngine();
Engine.SetLanguage( editor?.Language );
EditorEvent.Register( this );
}
/// <summary>Creates and registers a watcher. Returns null for an invalid editor.</summary>
public static HoverController Attach( CodeEditorWidget editor, CompletionEngine engine = null ) =>
editor is { IsValid: true } ? new HoverController( editor, engine ) : null;
/// <summary>The engine that resolves the symbol under the pointer.</summary>
public CompletionEngine Engine { get; }
/// <summary>How long the pointer must rest before the card appears.</summary>
public float DelaySeconds { get; set; } = 0.45f;
/// <summary>Whether hovering shows anything at all.</summary>
public bool Enabled { get; set; } = true;
/// <summary>Hides the card.</summary>
public void Hide()
{
_popup?.Dismiss();
_hasShown = false;
}
[EditorEvent.Frame]
void Tick()
{
if ( _editor is not { IsValid: true } )
{
Dispose();
return;
}
if ( !Enabled || !_editor.Visible )
{
Hide();
return;
}
if ( _editor.Completion is CompletionHost { IsOpen: true } )
{
Hide();
return;
}
if ( !_editor.IsUnderMouse )
{
Hide();
return;
}
if ( !TryGetLocal( out var local ) )
{
Hide();
return;
}
if ( ( local - _last ).Length > 3f )
{
_last = local;
_sinceMoved = 0;
// Moving within the same word must not make the card flicker.
if ( _hasShown && !SameWord( local ) )
Hide();
return;
}
if ( _hasShown || _sinceMoved < DelaySeconds )
return;
PrismLog.Guard( "Prism.Text: hover", () => ShowAt( local ) );
}
bool SameWord( Vector2 local )
{
if ( _shown.Line < 0 || _editor.Document is null )
return false;
var position = _editor.LocalToPosition( local );
if ( position.Line != _shown.Line )
return false;
var line = _editor.Document.GetLine( position.Line );
var here = CompletionEngine.WordAt( line, position.Column );
return here.Length > 0 &&
string.Equals( here, CompletionEngine.WordAt( line, _shown.Column ), StringComparison.Ordinal );
}
bool TryGetLocal( out Vector2 local )
{
local = default;
var rect = _editor.LocalRect;
// Application.CursorPosition is DPI-scaled and UnscaledCursorPosition is not, and which one
// lines up with widget coordinates depends on the display. The pointer is known to be over the
// editor, so whichever candidate lands inside it is the right one.
var scaled = _editor.FromScreen( Application.CursorPosition );
if ( rect.IsInside( scaled ) )
{
local = scaled;
return true;
}
var unscaled = _editor.FromScreen( Application.UnscaledCursorPosition );
if ( rect.IsInside( unscaled ) )
{
local = unscaled;
return true;
}
return false;
}
void ShowAt( Vector2 local )
{
var position = _editor.LocalToPosition( local );
var diagnostic = _editor.DiagnosticAt( position );
HoverInfo info;
if ( local.x < _editor.GutterWidth )
{
// In the gutter there is no word to look up, only the line's problems.
if ( diagnostic is null )
return;
info = new HoverInfo( null, null, null ) { Diagnostic = diagnostic };
}
else
{
Engine.SetLanguage( _editor.Language );
Engine.FilePath = _editor.Document?.FilePath;
info = Engine.HoverAt( _editor.Document, position, diagnostic );
}
if ( info is null || info.IsEmpty )
return;
_popup ??= new HoverPopup( _editor );
_popup.Show( info, local );
// The editor sets a native tooltip for diagnostics on mouse move. Ours says more, and two
// tooltips over the same word is worse than either alone.
_editor.ToolTip = string.Empty;
_shown = position;
_hasShown = true;
}
/// <summary>Unregisters the watcher and destroys the card.</summary>
public void Dispose()
{
EditorEvent.Unregister( this );
if ( _popup is { IsValid: true } )
_popup.Destroy();
_popup = null;
_editor = null;
_hasShown = false;
_shown = new TextPosition( -1, -1 );
}
}