UI/VirtualListPanel.cs
using System;
using System.Linq;
using Sandbox;
using Sandbox.UI;
namespace BlockParty;
/// <summary>
/// Shared virtual-scrolling list behaviour for the leaderboard / replay screens
/// (<see cref="HighscoreScreen"/>, <see cref="DailyChallengeScreen"/>, <see cref="LocalReplaysScreen"/>).
///
/// Rows are a fixed height, so the visible window is a simple slice: only the rows covering the
/// viewport (plus a little overscan) are emitted, each positioned absolutely by its index. This
/// base owns the inertial wheel scroll, the drag scrollbar, W/S selection with key-repeat, and the
/// smiling-cube selector; derived screens supply the item count and render the individual rows.
///
/// A derived screen must:
/// - override <see cref="ItemCount"/>;
/// - mark its scrollable container with class "list" (its inner height is measured for the
/// viewport) and its scroll thumb with class "scrollbar" (hit-tested to start a drag);
/// - render rows for indices [<see cref="FirstVisible"/>, <see cref="LastVisible"/>) at
/// <see cref="RowTop"/>, calling <see cref="SelectRow"/> on hover/click;
/// - fold <see cref="ScrollPos"/>, <see cref="Selected"/>, <see cref="SelectorPos"/> and
/// <see cref="SelectorFrame"/> into its BuildHash so scroll/selection repaint.
/// </summary>
public abstract class VirtualListPanel : PanelComponent
{
// Row height in 1080-reference units; must match the .row height in the stylesheet.
protected virtual float RowHeight => 64f;
// Scroll feel, in reference units per second so it's framerate independent.
protected virtual float WheelImpulse => 1600f; // speed added per wheel notch
protected virtual float MaxScrollSpeed => 6000f; // velocity clamp
protected virtual float ScrollDecay => 12f; // higher = comes to rest sooner
// Selector move blip is pitched down when landing on a list row (see RowBlipPitch): it sweeps by
// row index so scrolling down reads as a descending run, while moving onto a button (e.g. the
// BACK item) keeps the standard menu pitch.
private const float TopRowBlipPitch = 0.9f; // pitch at the top row (index 0)
/// <summary>Number of rows in the list. Read every frame, so it may change as the list loads/mutates.</summary>
protected abstract int ItemCount { get; }
/// <summary>
/// Viewport height (reference units) assumed until the list panel has actually laid out and
/// <see cref="MeasureViewport"/> can read its real height. Only affects the first pre-layout
/// frame's row count; override if a screen's list is a notably different size.
/// </summary>
protected virtual float InitialViewportHeight => 800f;
/// <summary>
/// When true, a "BACK" pseudo-item (index <c>-1</c>) joins the selection cycle, sitting between
/// the last row and the first: pressing up off the top row or down off the last row lands on it,
/// and it wraps on around. The screen renders its own control for it (e.g. a footer button) and
/// reads <see cref="BackSelected"/> to highlight it. Rows are never removed while it's selected,
/// so the removal/clamp helpers still assume a row is selected. Off by default.
/// </summary>
protected virtual bool HasBackItem => false;
// --- selection + scroll state (exposed for the derived screen's markup + BuildHash) ---
// Selected is -1 when the BACK pseudo-item is focused (only possible with HasBackItem); otherwise
// a row index in [0, ItemCount).
protected int Selected { get; private set; }
/// <summary>True while the BACK pseudo-item is focused rather than a row (see <see cref="HasBackItem"/>).</summary>
protected bool BackSelected => HasBackItem && Selected < 0;
protected float ScrollPos { get; private set; }
// -1 until measured, so the getter falls back to InitialViewportHeight (a virtual can't be
// read from a field initializer).
private float _viewportHeight = -1f;
protected float ViewportHeight => _viewportHeight >= 0f ? _viewportHeight : InitialViewportHeight;
private readonly MenuSelectorAnim _selector = new();
private readonly NavRepeater _nav = new(); // shared W/S hold-to-repeat cadence
private float _scrollVelocity; // current inertial speed, reference units/sec
private bool _draggingScrollbar;
private float _dragGrabOffset; // ref units from the thumb's top to where it was grabbed
private float TotalHeight => ItemCount * RowHeight;
// --- visible window ------------------------------------------------------
// Render only rows [FirstVisible, LastVisible): the window plus 2 overscan rows so fast scrolls
// don't flash empty gaps. Each row is placed at RowTop(i) so the slice can change without the
// rows appearing to shift under the cursor.
protected int FirstVisible => Math.Clamp( (int)MathF.Floor( ScrollPos / RowHeight ), 0, Math.Max( 0, ItemCount - 1 ) );
protected int LastVisible => Math.Min( ItemCount, FirstVisible + (int)MathF.Ceiling( ViewportHeight / RowHeight ) + 2 );
protected float RowTop( int index ) => index * RowHeight - ScrollPos;
// --- scrollbar thumb geometry (mirrors what the markup draws) ------------
// Proportional thumb: tall list -> short thumb, positioned by scroll fraction.
protected bool ShowScrollbar => TotalHeight > ViewportHeight;
protected float ThumbHeight => MathF.Max( 48f, (ViewportHeight / MathF.Max( 1f, TotalHeight )) * ViewportHeight );
protected float ThumbTop => Math.Clamp( ScrollPos / ScrollRange, 0f, 1f ) * ThumbTravel;
private float ThumbTravel => MathF.Max( 0f, ViewportHeight - ThumbHeight );
private float ScrollRange => MathF.Max( 1f, TotalHeight - ViewportHeight );
// --- selector cube -------------------------------------------------------
// Slides between rows (Pos in row units) and sits in the left lane. Frame drives the sprite sheet.
protected float SelectorPos => _selector.Pos;
protected int SelectorFrame => _selector.Frame;
protected float SelectorTop => _selector.Pos * RowHeight - ScrollPos;
// --- selection ops -------------------------------------------------------
/// <summary>Snap the selection to a starting row on open (no slide-in) and scroll it into view.</summary>
protected void SelectInitial( int index )
{
Selected = Math.Clamp( index, 0, Math.Max( 0, ItemCount - 1 ) );
_selector.Snap( Selected ); // spawn the cube on the row (no slide-in from row 0)
EnsureSelectedVisible( snap: true );
}
/// <summary>Open with the BACK pseudo-item focused (index -1). Falls back to the top row on a
/// screen that hasn't opted into <see cref="HasBackItem"/>. The list cube isn't shown while BACK
/// is focused, so its slide position is left where it is until the first move into the list.</summary>
protected void SelectBackInitial() => Selected = HasBackItem ? -1 : 0;
/// <summary>Move the focus to the BACK pseudo-item (e.g. hovering its button). No-op without a
/// BACK item or when it's already focused.</summary>
protected void SelectBack()
{
if ( !HasBackItem || Selected < 0 )
return;
Selected = -1;
_selector.PlayMove();
StateHasChanged();
}
/// <summary>Reset to the top with a fresh selection (e.g. after the list is replaced wholesale).</summary>
protected void ResetToTop()
{
ScrollPos = 0f;
_scrollVelocity = 0f;
Selected = 0;
_selector.Snap( 0 );
}
/// <summary>Re-clamp the selection after the list shrank (e.g. a row was deleted), keeping it valid.</summary>
protected void ClampSelectionAfterRemoval()
{
if ( BackSelected )
return; // focus is on the BACK item, not a row — nothing to re-clamp
// If the last row went away and there's a BACK item, fall back to it rather than a dead index.
if ( HasBackItem && ItemCount == 0 )
{
Selected = -1;
return;
}
Selected = Math.Clamp( Selected, 0, Math.Max( 0, ItemCount - 1 ) );
_selector.SnapWithin( Selected, 2f );
EnsureSelectedVisible();
}
/// <summary>Focus a row index directly (for screens driving a custom focus model, e.g. the daily
/// board's zone navigation). Clamps to a valid row, plays the move squash, and scrolls it into
/// view. Unlike <see cref="SelectRow"/> it doesn't early-out when the index is unchanged, so a
/// screen re-entering the list onto the current row still gets the animation.</summary>
protected void FocusRow( int index )
{
Selected = Math.Clamp( index, 0, Math.Max( 0, ItemCount - 1 ) );
_selector.PlayMove();
_selector.SnapWithin( Selected, 2f ); // long jumps into the list shouldn't crawl
EnsureSelectedVisible();
}
/// <summary>Mouse hover/click jumps the selection to a row; the cube slides over to follow.</summary>
protected void SelectRow( int index )
{
if ( index < 0 || index >= ItemCount || index == Selected )
return;
Selected = index;
_selector.PlayMove();
_selector.SnapWithin( Selected, 2f ); // far jumps shouldn't crawl; slide only the last bit
EnsureSelectedVisible();
StateHasChanged();
}
/// <summary>Play the selector's little "bump" wobble — e.g. to reject a Confirm on a non-replayable row.</summary>
protected void WobbleSelector() => _selector.PlayWobble();
/// <summary>Play the selector's "move" squash — for screens that focus off-list targets (their own
/// buttons) where the cube jumps rather than slides, but should still animate the frame.</summary>
protected void PlaySelectorMove() => _selector.PlayMove();
/// <summary>Per-frame navigation input. Default: held-repeat W/S over the item cycle (see
/// <see cref="MoveSelection"/> / <see cref="NavRepeater"/>). Overridable so a screen with extra
/// off-list focus targets (e.g. the daily board's day-nav / play buttons) can drive its own focus
/// model while still reusing this base's scrolling, virtualization, and selector cube.</summary>
protected virtual void HandleNavigation( float dt )
{
int step = _nav.Tick( dt, InputState.NavUp, InputState.NavDown );
if ( step != 0 )
MoveSelection( step );
}
/// <summary>Cancel active wheel inertia and scrollbar dragging when a modal takes input focus.</summary>
protected void StopScrolling()
{
_scrollVelocity = 0f;
_draggingScrollbar = false;
}
// Selector-blip pitch for landing on row <paramref name="index"/>: sweeps from TopRowBlipPitch at
// the top down to a size-dependent floor at the last row. The floor eases 0.85 -> 0.75 as the list
// grows from 0 to 100+ rows, so a longer board bottoms out lower. Protected so screens driving a
// custom focus model (the daily board) can pitch their row blips the same way.
protected float RowBlipPitch( int index )
{
if ( ItemCount <= 1 )
return TopRowBlipPitch;
float floor = MathX.Lerp( 0.85f, 0.75f, Math.Clamp( ItemCount / 100f, 0f, 1f ) );
float t = index / (float)(ItemCount - 1);
return MathX.Lerp( TopRowBlipPitch, floor, t );
}
/// <summary>Move the selection one row (dir -1 up / +1 down), keeping it on screen. Without a BACK
/// item it clamps and wobbles at the ends; with one it cycles through BACK ↔ rows (see
/// <see cref="HasBackItem"/>).</summary>
protected void MoveSelection( int dir )
{
if ( HasBackItem )
{
MoveSelectionCyclic( dir );
return;
}
if ( ItemCount == 0 )
return;
int next = Math.Clamp( Selected + dir, 0, ItemCount - 1 );
if ( next == Selected )
{
_selector.PlayWobble();
}
else
{
Selected = next;
_selector.PlayMove();
EnsureSelectedVisible();
}
// Every item here is a list row, so the blip takes the row pitch (swept by index).
Audio.PlaySfx( SfxType.MenuBlip, 1f, RowBlipPitch( Selected ) );
Haptics.MenuBlip();
StateHasChanged();
}
// Circular navigation over [BACK, row 0 … row N-1] for HasBackItem screens. BACK is a pseudo-item
// at index -1 that sits between the last row and the first, so up off the top row / down off the
// last row lands on it and it wraps on around. Shift by +1 so BACK (-1) maps to slot 0, rotate
// within [0, N], then shift back. With an empty board BACK is the sole item, so a move wobbles.
private void MoveSelectionCyclic( int dir )
{
int count = ItemCount + 1;
int slot = ((Selected + 1 + dir) % count + count) % count;
int next = slot - 1;
if ( next == Selected )
{
_selector.PlayWobble();
}
else
{
Selected = next;
_selector.PlayMove();
if ( Selected >= 0 )
{
_selector.SnapWithin( Selected, 2f ); // wrapping across the whole list shouldn't crawl
EnsureSelectedVisible();
}
}
// Landing on a row uses the index-swept row pitch; the BACK button (Selected < 0) keeps the
// standard menu pitch.
Audio.PlaySfx( SfxType.MenuBlip, 1f, Selected >= 0 ? RowBlipPitch( Selected ) : 1f );
Haptics.MenuBlip();
StateHasChanged();
}
// Scroll so the selected row is fully inside the viewport. snap kills inertia for an instant jump.
private void EnsureSelectedVisible( bool snap = false )
{
float top = Selected * RowHeight;
float bottom = top + RowHeight;
float maxPos = MathF.Max( 0f, TotalHeight - ViewportHeight );
if ( top < ScrollPos )
ScrollPos = top;
else if ( bottom > ScrollPos + ViewportHeight )
ScrollPos = bottom - ViewportHeight;
ScrollPos = Math.Clamp( ScrollPos, 0f, maxPos );
if ( snap )
_scrollVelocity = 0f;
}
// --- engine hooks --------------------------------------------------------
// Wheel adds inertia; OnUpdate integrates it. value.y > 0 is a downward scroll.
// Declared 'protected' (not 'protected internal') because the engine's member is in another
// assembly, so only its protected accessibility is visible to override here.
protected override void OnMouseWheel( Vector2 value )
{
_scrollVelocity = Math.Clamp( _scrollVelocity + value.y * WheelImpulse, -MaxScrollSpeed, MaxScrollSpeed );
}
protected override void OnUpdate()
{
MeasureViewport();
float dt = Time.Delta;
HandleNavigation( dt );
// Slide/squash the selector cube toward the chosen row every frame so it (and any
// scroll-to-selected motion) stays smooth even when there's no inertial scroll happening.
float prevPos = _selector.Pos;
int prevFrame = _selector.Frame;
_selector.Tick( dt, Selected );
bool selectorMoving = _selector.Pos != prevPos || _selector.Frame != prevFrame;
bool scrolling = MathF.Abs( _scrollVelocity ) > 1f;
if ( scrolling )
{
float maxPos = MathF.Max( 0f, TotalHeight - ViewportHeight );
ScrollPos = Math.Clamp( ScrollPos + _scrollVelocity * dt, 0f, maxPos );
// Exponential decay so the glide eases out the same at any framerate.
_scrollVelocity *= MathF.Exp( -ScrollDecay * dt );
// Don't "stick" against either end with leftover velocity.
if ( ScrollPos <= 0f || ScrollPos >= maxPos )
_scrollVelocity = 0f;
}
else
{
_scrollVelocity = 0f;
}
if ( scrolling || selectorMoving )
StateHasChanged();
}
// The list panel is the child marked with class "list"; its inner box drives virtualization.
private Panel ListPanel => Panel?.Children?.FirstOrDefault( c => c.HasClass( "list" ) );
// Reads the list panel's inner height (screen pixels) back into reference units so the
// virtualization math stays in stylesheet space. Keeps the last good value until laid out.
private void MeasureViewport()
{
var list = ListPanel;
if ( list is null )
return;
float h = list.Box.RectInner.Height * list.ScaleFromScreen;
if ( h > 1f )
_viewportHeight = h;
}
// --- scrollbar drag ------------------------------------------------------
// Click-and-drag the thumb to scroll. The drag only starts when the thumb itself is the event
// target, so per-row buttons (replay / delete) never hijack it.
protected override void OnMouseDown( MousePanelEvent e )
{
if ( e.MouseButton != MouseButtons.Left || e.Target is null || !e.Target.HasClass( "scrollbar" ) )
return;
// Remember where on the thumb we grabbed so it doesn't jump to the cursor.
float thumbTop = (ScrollPos / ScrollRange) * ThumbTravel;
_dragGrabOffset = Math.Clamp( ListLocalY( Mouse.Position ) - thumbTop, 0f, ThumbHeight );
_draggingScrollbar = true;
e.StopPropagation();
}
protected override void OnMouseMove( MousePanelEvent e )
{
if ( !_draggingScrollbar )
return;
if ( ThumbTravel > 0f )
{
float thumbTop = Math.Clamp( ListLocalY( Mouse.Position ) - _dragGrabOffset, 0f, ThumbTravel );
ScrollPos = Math.Clamp( (thumbTop / ThumbTravel) * ScrollRange, 0f, ScrollRange );
_scrollVelocity = 0f; // a drag overrides any inertial glide
StateHasChanged();
}
e.StopPropagation();
}
protected override void OnMouseUp( MousePanelEvent e )
{
_draggingScrollbar = false;
}
// Screen-space mouse Y -> reference-space Y within the list panel.
private float ListLocalY( Vector2 mouse )
{
var list = ListPanel;
return list is null ? 0f : (mouse.y - list.Box.Top) * list.ScaleFromScreen;
}
}