PadNavigation.cs
using System;
using Sandbox.UI;
namespace Bimp;
/// <summary>
/// Moves a highlight around a panel's controls with a gamepad. The D-pad or left stick moves it to the next control
/// that way, A presses it, and left/right adjusts a control that can be adjusted (a slider) instead of moving. While a
/// popup menu is open (a drop down's), up/down moves through its options, A picks one and B closes it. Controls opt in
/// with the <c>nav</c> class (<c>nav-default</c> is highlighted first); the highlighted one gets <c>pad-focus</c>.
/// <para>
/// Pads only reach games through input actions, so each button is read as whatever action the game binds to it (A is
/// "Jump" in the default set) - it works with any game's bindings, and a button nothing is bound to just does nothing.
/// Call <see cref="Update"/> first thing in the frame (<see cref="Component.ISceneStage"/>), then suppress the frame's
/// input, so the player doesn't also jump or walk.
/// </para>
/// </summary>
public sealed class PadNavigation
{
/// <summary> Stick past this counts as a direction. </summary>
const float StickThreshold = 0.5f;
/// <summary> Holding a direction repeats it: first after this long, then this often. </summary>
const float RepeatDelay = 0.4f;
const float RepeatInterval = 0.12f;
/// <summary> Press the control, e.g. a button. </summary>
public Action<Panel> Press { get; set; }
/// <summary> Adjust the control by a step (-1 left, +1 right). Returns false if it can't be adjusted - left/right then moves. </summary>
public Func<Panel, int, bool> Adjust { get; set; }
/// <summary> The highlighted control, if any. </summary>
public Panel Focused { get; private set; }
/// <summary> The actions bound to A (press) and B (back), for glyphs. Null if the game binds nothing to them. </summary>
public string SelectAction { get; }
public string BackAction { get; }
readonly string up, down, left, right;
Vector2 heldDirection;
RealTimeSince sinceMoved;
bool repeating;
Vector2 lastFocusCenter;
public PadNavigation()
{
SelectAction = ActionFor( GamepadCode.A );
BackAction = ActionFor( GamepadCode.B );
up = ActionFor( GamepadCode.DpadNorth );
down = ActionFor( GamepadCode.DpadSouth );
left = ActionFor( GamepadCode.DpadWest );
right = ActionFor( GamepadCode.DpadEast );
}
static string ActionFor( GamepadCode code ) => Input.GetActions().FirstOrDefault( a => a.GamepadCode == code )?.Name;
static bool Down( string action ) => action is not null && Input.Down( action, false );
static bool Pressed( string action ) => action is not null && Input.Pressed( action );
/// <summary> B pressed this frame (and no popup menu took it). </summary>
public bool BackPressed { get; private set; }
/// <summary> A popup menu that's open (a drop down's options), if any - it takes the pad until it closes. </summary>
static Popup OpenPopup( Panel root )
{
var top = root;
while ( top.Parent is not null ) top = top.Parent;
return top.Descendants.OfType<Popup>().FirstOrDefault( p => p.IsValid() && !p.IsDeleting && p.IsVisible );
}
/// <summary> Up/down through a popup's options, starting on the current one; A picks, B closes it. </summary>
void UpdatePopup( Popup popup )
{
if ( popup.SelectedChild is null || !popup.SelectedChild.IsValid() )
{
// start on the current choice: the option showing the drop down's text (the engine's own "active" mark
// compares the values by reference, and misses)
var current = (popup.PopupSource as Button)?.Text;
popup.SelectedChild = popup.Children.FirstOrDefault( c => c is Button b && b.Text == current )
?? popup.Children.FirstOrDefault();
}
var direction = Direction();
var step = direction.y != 0 && Repeat( direction );
if ( direction != heldDirection ) Hold( direction );
if ( step ) popup.MoveSelection( (int)direction.y );
// our own mark for the option it's on, every frame: a Button resets its "active" class from its Active property
// each tick, so the one MoveSelection sets never shows
foreach ( var option in popup.Children ) option.SetClass( "pad-selected", option == popup.SelectedChild );
if ( Pressed( SelectAction ) && popup.SelectedChild is { } selected )
selected.CreateEvent( new MousePanelEvent( "onclick", selected, "mouseleft" ) );
else if ( Pressed( BackAction ) )
popup.Delete();
}
/// <summary>
/// Read the pad and move, press or adjust. Keeps the highlight on a control that's still there (the panel may have
/// rebuilt), or on the nearest one to where it was.
/// </summary>
public void Update( Panel root )
{
BackPressed = false;
if ( !root.IsValid() ) return;
if ( OpenPopup( root ) is { } popup )
{
UpdatePopup( popup );
return;
}
BackPressed = Pressed( BackAction );
var controls = root.Descendants.Where( p => p.HasClass( "nav" ) && p.IsVisible ).ToList();
if ( Focused is null || !Focused.IsValid() || !controls.Contains( Focused ) )
Focus( controls.FirstOrDefault( p => p.HasClass( "nav-default" ) && Focused is null ) ?? Nearest( controls, lastFocusCenter ) ?? controls.FirstOrDefault() );
// the highlight is a class, re-applied every frame - a rebuild of the panel can reset classes
foreach ( var p in controls ) p.SetClass( "pad-focus", p == Focused );
if ( Focused is null ) return;
lastFocusCenter = Focused.Box.Rect.Center;
var direction = Direction();
var step = Repeat( direction );
if ( direction != heldDirection ) Hold( direction );
if ( step ) Step( controls, direction );
if ( Pressed( SelectAction ) ) Press?.Invoke( Focused );
}
/// <summary> Should a direction act this frame: when it's first pushed, then repeatedly while it's held. </summary>
bool Repeat( Vector2 direction )
{
if ( direction == Vector2.Zero ) return false;
if ( direction != heldDirection ) return true;
if ( sinceMoved <= (repeating ? RepeatInterval : RepeatDelay) ) return false;
repeating = true;
sinceMoved = 0;
return true;
}
void Hold( Vector2 direction )
{
heldDirection = direction;
repeating = false;
sinceMoved = 0;
}
/// <summary> The D-pad, else the left stick, as one of the four directions (y down, like the screen). </summary>
Vector2 Direction()
{
if ( Down( up ) ) return new Vector2( 0, -1 );
if ( Down( down ) ) return new Vector2( 0, 1 );
if ( Down( left ) ) return new Vector2( -1, 0 );
if ( Down( right ) ) return new Vector2( 1, 0 );
var x = Input.GetAnalog( InputAnalog.LeftStickX );
var y = Input.GetAnalog( InputAnalog.LeftStickY );
if ( MathF.Max( MathF.Abs( x ), MathF.Abs( y ) ) < StickThreshold ) return Vector2.Zero;
return MathF.Abs( x ) > MathF.Abs( y ) ? new Vector2( MathF.Sign( x ), 0 ) : new Vector2( 0, MathF.Sign( y ) );
}
/// <summary> For probes: one move as if the D-pad was pushed that way (left/right adjusts first, as on the pad). </summary>
internal void ProbeStep( Panel root, Vector2 direction )
{
var controls = root.Descendants.Where( p => p.HasClass( "nav" ) && p.IsVisible ).ToList();
if ( Focused is null || !controls.Contains( Focused ) ) Focus( controls.FirstOrDefault() );
if ( Focused is not null ) Step( controls, direction );
}
void Step( List<Panel> controls, Vector2 direction )
{
// left/right on an adjustable control changes it rather than leaving it
if ( direction.y == 0 && Adjust is not null && Adjust( Focused, (int)direction.x ) ) return;
// Up/down: the nearest row that way, then the control in it closest to straight ahead - so down from the play
// button lands on the row below it, not on a control further down that happens to be under it.
// Left/right: the next control in the same row (so right from the mute button is the volume slider, not a tab
// above that's only a little to the right); nothing further that way in the row, it stays.
// A control counts as "that way" only if it's wholly past our edge: centres alone put a smaller button beside the
// big play button "above" it.
var rect = Focused.Box.Rect;
var from = rect.Center;
const float slack = 4;
bool Beyond( Rect r ) =>
direction.y < 0 ? r.Bottom <= rect.Top + slack :
direction.y > 0 ? r.Top >= rect.Bottom - slack :
direction.x < 0 ? r.Right <= rect.Left + slack :
r.Left >= rect.Right - slack;
var ahead = controls.Where( p => p != Focused && Beyond( p.Box.Rect ) )
.Select( p => (panel: p, d: p.Box.Rect.Center - from) )
.Select( x => (x.panel, along: Vector2.Dot( x.d, direction ), across: MathF.Abs( x.d.x * direction.y - x.d.y * direction.x )) )
.ToList();
if ( direction.y == 0 )
{
var sameRow = ahead.Where( x => x.panel.Box.Rect.Top < rect.Bottom && x.panel.Box.Rect.Bottom > rect.Top ).ToList();
if ( sameRow.Count > 0 ) Focus( sameRow.OrderBy( x => x.along ).First().panel );
return;
}
if ( ahead.Count == 0 ) return;
var nearest = ahead.Min( x => x.along );
var rowSlack = Math.Max( 8, (direction.y != 0 ? Focused.Box.Rect.Height : Focused.Box.Rect.Width) * 0.5f );
Focus( ahead.Where( x => x.along <= nearest + rowSlack ).OrderBy( x => x.across ).First().panel );
}
void Focus( Panel panel )
{
Focused = panel;
if ( panel is null ) return;
// bring it into view in a scrolling list
for ( var parent = panel.Parent; parent is not null; parent = parent.Parent )
if ( parent.ScrollIntoView( panel.Box.Rect ) ) break;
}
static Panel Nearest( List<Panel> controls, Vector2 point )
=> point == default ? null : controls.OrderBy( p => (p.Box.Rect.Center - point).LengthSquared ).FirstOrDefault();
}