Editor/Prism/Text/Diagnostics/HoverPopup.cs

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.

ReflectionFile Access
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&amp;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 );
	}
}