UI/PixelTooltip.razor

A UI Razor component that shows a pixel-styled tooltip bubble near the mouse cursor. It manages hover state, delayed show, size and placement to avoid screen edges and a centered play square, and computes bubble position and measured/estimated height.

File Access
@using System
@using Sandbox
@using Sandbox.UI
@inherits Panel
@namespace BlockParty
@attribute [StyleSheet( "PixelTooltip.razor.scss" )]

<root class="pixel-tooltip-layer">
	@if ( _visible && !string.IsNullOrWhiteSpace( _text ) )
	{
		<div class="bubble" @ref="Bubble" style="@TooltipStyle()">@_text</div>
	}
</root>

@code
{
	private const float SHOW_DELAY = 0.32f;
	private const float CURSOR_GAP = 8f;
	private const float FLIPPED_CURSOR_GAP = -24f;
	private const float EDGE_PAD = 8f;
	private const float MIN_WIDTH = 64f;
	private const float MAX_WIDTH = 340f;
	private const float TEXT_WIDTH = 9.5f;
	private const float HORIZONTAL_PADDING = 24f;
	// Bubble chrome (padding + border) and one wrapped text line, for the height estimate below.
	private const float VERTICAL_PADDING = 23f;
	private const float LINE_HEIGHT = 17f;

	private string _text = "";
	private bool _hovered;
	private bool _visible;
	private float _hoverTime;

	/// <summary>The live bubble, so placement can use its MEASURED height instead of the line estimate
	/// once it has been laid out (null while hidden).</summary>
	private Panel Bubble { get; set; }

	public void Show( string text )
	{
		if ( _hovered && _text == text ) return;
		_text = text ?? "";
		_hovered = !string.IsNullOrWhiteSpace( _text );
		_visible = false;
		_hoverTime = 0f;
		StateHasChanged();
	}

	public void Hide()
	{
		if ( !_hovered && !_visible && string.IsNullOrEmpty( _text ) ) return;
		_hovered = false;
		_visible = false;
		_hoverTime = 0f;
		_text = "";
		StateHasChanged();
	}

	public override void Tick()
	{
		if ( !_hovered ) return;

		_hoverTime += Time.Delta;
		if ( !_visible && _hoverTime >= SHOW_DELAY )
			_visible = true;

		if ( _visible )
			StateHasChanged();
	}

	private string TooltipStyle()
	{
		float scale = ScaleFromScreen;
		float viewportWidth = MathF.Max( 1f, Screen.Width * scale );
		float viewportHeight = MathF.Max( 1f, Screen.Height * scale );
		float cursorX = Mouse.Position.x * scale;
		float cursorY = Mouse.Position.y * scale;

		// The CRT pass only warps the centred play square (RetroArcadePostProcess.ScreenRegion — the
		// square is screen-height wide), so a bubble straddling its edge comes out half curved and half
		// straight, which reads as a broken panel. Treat that edge as a hard bound: the bubble stays
		// inside whichever band the cursor is in — the square, or the letterbox on either side of it.
		float squareWidth = MathF.Min( viewportHeight, viewportWidth );
		float squareLeft = ( viewportWidth - squareWidth ) * 0.5f;
		float squareRight = squareLeft + squareWidth;
		bool leftBox = cursorX < squareLeft;
		bool rightBox = cursorX >= squareRight;
		float bandLeft = leftBox ? 0f : rightBox ? squareRight : squareLeft;
		float bandRight = leftBox ? squareLeft : rightBox ? viewportWidth : squareRight;
		float available = MathF.Max( MIN_WIDTH, bandRight - bandLeft - EDGE_PAD * 2f );

		// Wrap width: the text's natural width, capped by MAX_WIDTH and by the band — so a tip too long
		// for a narrow letterbox wraps to more lines rather than reaching across into the square. This
		// goes out as the bubble's max-width, so what gets measured matches what's assumed here.
		float width = Math.Clamp( _text.Length * TEXT_WIDTH + HORIZONTAL_PADDING, MIN_WIDTH,
			MathF.Min( MAX_WIDTH, available ) );
		float height = BubbleHeight( width, scale );
		float x = cursorX + CURSOR_GAP;
		float y = cursorY - CURSOR_GAP - height;

		if ( x + width > bandRight - EDGE_PAD )
			x = cursorX - FLIPPED_CURSOR_GAP - width;
		if ( y < EDGE_PAD )
			y = cursorY + CURSOR_GAP;

		float xMin = bandLeft + EDGE_PAD;
		x = Math.Clamp( x, xMin, MathF.Max( xMin, bandRight - width - EDGE_PAD ) );
		y = Math.Clamp( y, EDGE_PAD, MathF.Max( EDGE_PAD, viewportHeight - height - EDGE_PAD ) );

		// Everything above is in SCREEN space, but the bubble's left/top are relative to this layer's own
		// box — which is only the screen origin when the layer's host fills the screen. Subtracting the
		// layer's own position keeps the bubble under the cursor inside a host that doesn't (the level
		// browser is clamped to the centred 1080 play square), and is a no-op for a full-screen host.
		return $"left:{x - Box.Rect.Left * scale:0}px;top:{y - Box.Rect.Top * scale:0}px;max-width:{width:0}px;";
	}

	/// <summary>Bubble height used to seat the bubble above the cursor: MEASURED off the live panel once
	/// it has been laid out, otherwise estimated from how many lines the text wraps to at
	/// <paramref name="width"/>. The estimate only has to carry the first frame of a hover.</summary>
	private float BubbleHeight( float width, float scale )
	{
		if ( Bubble.IsValid() && Bubble.Box.Rect.Height > 1f )
			return Bubble.Box.Rect.Height * scale;

		float lines = MathF.Max( 1f, MathF.Ceiling( _text.Length * TEXT_WIDTH / MathF.Max( 1f, width - HORIZONTAL_PADDING ) ) );
		return VERTICAL_PADDING + lines * LINE_HEIGHT;
	}
}