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