2025-01-10 19:55:53 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
using System.Collections.Generic ;
using System.Diagnostics ;
using System.Linq ;
using System.Threading ;
using System.Threading.Tasks ;
using osu.Framework.Bindables ;
2025-01-28 21:53:17 +08:00
using osu.Framework.Caching ;
2025-01-10 19:55:53 +08:00
using osu.Framework.Extensions.TypeExtensions ;
using osu.Framework.Graphics ;
using osu.Framework.Graphics.Containers ;
2025-01-16 19:25:16 +08:00
using osu.Framework.Graphics.Cursor ;
2025-01-10 19:55:53 +08:00
using osu.Framework.Graphics.Pooling ;
2025-01-16 19:25:16 +08:00
using osu.Framework.Input.Bindings ;
using osu.Framework.Input.Events ;
2025-01-10 19:55:53 +08:00
using osu.Framework.Logging ;
2025-01-15 16:01:07 +08:00
using osu.Framework.Utils ;
2025-01-10 19:55:53 +08:00
using osu.Game.Graphics.Containers ;
2025-01-16 19:25:16 +08:00
using osu.Game.Input.Bindings ;
2025-01-10 19:55:53 +08:00
using osuTK ;
2025-01-16 19:25:16 +08:00
using osuTK.Input ;
2025-01-10 19:55:53 +08:00
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// A highly efficient vertical list display that is used primarily for the song select screen,
/// but flexible enough to be used for other use cases.
/// </summary>
2025-01-23 15:11:02 +08:00
public abstract partial class Carousel < T > : CompositeDrawable , IKeyBindingHandler < GlobalAction >
where T : notnull
2025-01-10 19:55:53 +08:00
{
2025-01-17 17:49:12 +08:00
#region Properties and methods for external usage
2025-01-10 19:55:53 +08:00
/// <summary>
/// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
/// </summary>
public float BleedTop { get ; set ; } = 0 ;
/// <summary>
/// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
/// </summary>
public float BleedBottom { get ; set ; } = 0 ;
/// <summary>
/// The number of pixels outside the carousel's vertical bounds to manifest drawables.
/// This allows preloading content before it scrolls into view.
/// </summary>
2025-01-14 19:43:03 +08:00
public float DistanceOffscreenToPreload { get ; set ; }
2025-01-10 19:55:53 +08:00
2025-01-15 18:02:27 +08:00
/// <summary>
/// Vertical space between panel layout. Negative value can be used to create an overlapping effect.
/// </summary>
protected float SpacingBetweenPanels { get ; set ; } = - 5 ;
2025-01-10 19:55:53 +08:00
/// <summary>
/// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter.
/// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations.
/// </summary>
2025-01-14 19:43:03 +08:00
public int DebounceDelay { get ; set ; }
2025-01-10 19:55:53 +08:00
/// <summary>
/// Whether an asynchronous filter / group operation is currently underway.
/// </summary>
public bool IsFiltering = > ! filterTask . IsCompleted ;
/// <summary>
/// The number of displayable items currently being tracked (before filtering).
/// </summary>
public int ItemsTracked = > Items . Count ;
/// <summary>
/// The number of carousel items currently in rotation for display.
/// </summary>
2025-01-23 14:58:35 +08:00
public int DisplayableItems = > carouselItems ? . Count ? ? 0 ;
2025-01-10 19:55:53 +08:00
/// <summary>
/// The number of items currently actualised into drawables.
/// </summary>
public int VisibleItems = > scroll . Panels . Count ;
2025-01-10 18:34:56 +08:00
/// <summary>
2025-01-23 15:11:02 +08:00
/// The currently selected model. Generally of type T.
2025-01-10 18:34:56 +08:00
/// </summary>
/// <remarks>
2025-01-23 15:11:02 +08:00
/// A carousel may create panels for non-T types.
/// To keep things simple, we therefore avoid generic constraints on the current selection.
///
/// The selection is never reset due to not existing. It can be set to anything.
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
2025-01-10 18:34:56 +08:00
/// </remarks>
2025-01-23 15:11:02 +08:00
public object? CurrentSelection
2025-01-10 18:34:56 +08:00
{
2025-01-23 15:11:02 +08:00
get = > currentSelection . Model ;
set = > setSelection ( value ) ;
}
2025-01-10 19:24:14 +08:00
2025-01-23 15:11:02 +08:00
/// <summary>
2025-01-23 21:44:39 +08:00
/// Activate the current selection, if a selection exists and matches keyboard selection.
/// If keyboard selection does not match selection, this will transfer the selection on first invocation.
2025-01-23 15:11:02 +08:00
/// </summary>
2025-01-23 21:44:39 +08:00
public void TryActivateSelection ( )
2025-01-23 15:11:02 +08:00
{
if ( currentSelection . CarouselItem ! = currentKeyboardSelection . CarouselItem )
{
CurrentSelection = currentKeyboardSelection . Model ;
return ;
2025-01-10 18:34:56 +08:00
}
2025-01-23 15:11:02 +08:00
if ( currentSelection . CarouselItem ! = null )
2025-01-24 17:40:48 +08:00
{
( GetMaterialisedDrawableForItem ( currentSelection . CarouselItem ) as ICarouselPanel ) ? . Activated ( ) ;
2025-01-23 15:11:02 +08:00
HandleItemActivated ( currentSelection . CarouselItem ) ;
2025-01-24 17:40:48 +08:00
}
2025-01-10 18:34:56 +08:00
}
2025-01-17 17:49:12 +08:00
#endregion
2025-01-10 19:55:53 +08:00
2025-01-17 17:49:12 +08:00
#region Properties and methods concerning implementations
2025-01-10 19:55:53 +08:00
2025-01-17 17:49:12 +08:00
/// <summary>
/// A collection of filters which should be run each time a <see cref="FilterAsync"/> is executed.
/// </summary>
/// <remarks>
/// Implementations should add all required filters as part of their initialisation.
///
/// Importantly, each filter is sequentially run in the order provided.
/// Each filter receives the output of the previous filter.
///
/// A filter may add, mutate or remove items.
/// </remarks>
2025-02-01 13:55:48 +08:00
public IEnumerable < ICarouselFilter > Filters { get ; init ; } = Enumerable . Empty < ICarouselFilter > ( ) ;
2025-01-10 19:55:53 +08:00
2025-01-17 17:49:12 +08:00
/// <summary>
/// All items which are to be considered for display in this carousel.
/// Mutating this list will automatically queue a <see cref="FilterAsync"/>.
/// </summary>
/// <remarks>
/// Note that an <see cref="ICarouselFilter"/> may add new items which are displayed but not tracked in this list.
/// </remarks>
protected readonly BindableList < T > Items = new BindableList < T > ( ) ;
2025-01-10 19:55:53 +08:00
/// <summary>
/// Queue an asynchronous filter operation.
/// </summary>
2025-01-14 19:23:53 +08:00
protected virtual Task FilterAsync ( ) = > filterTask = performFilter ( ) ;
2025-01-10 19:55:53 +08:00
/// <summary>
/// Create a drawable for the given carousel item so it can be displayed.
/// </summary>
/// <remarks>
/// For efficiency, it is recommended the drawables are retrieved from a <see cref="DrawablePool{T}"/>.
/// </remarks>
/// <param name="item">The item which should be represented by the returned drawable.</param>
/// <returns>The manifested drawable.</returns>
protected abstract Drawable GetDrawableForDisplay ( CarouselItem item ) ;
2025-01-10 19:30:41 +08:00
/// <summary>
2025-01-23 15:11:02 +08:00
/// Given a <see cref="CarouselItem"/>, find a drawable representation if it is currently displayed in the carousel.
/// </summary>
/// <remarks>
/// This will only return a drawable if it is "on-screen".
/// </remarks>
/// <param name="item">The item to find a related drawable representation.</param>
/// <returns>The drawable representation if it exists.</returns>
protected Drawable ? GetMaterialisedDrawableForItem ( CarouselItem item ) = >
scroll . Panels . SingleOrDefault ( p = > ( ( ICarouselPanel ) p ) . Item = = item ) ;
2025-01-31 19:58:32 +08:00
/// <summary>
/// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target.
/// </summary>
/// <param name="item">The candidate item.</param>
/// <returns>Whether the provided item is a valid group target. If <c>false</c>, more panels will be checked in the user's requested direction until a valid target is found.</returns>
protected virtual bool CheckValidForGroupSelection ( CarouselItem item ) = > true ;
2025-01-23 15:11:02 +08:00
/// <summary>
/// Called when an item is "selected".
2025-01-10 19:30:41 +08:00
/// </summary>
2025-01-28 21:53:17 +08:00
/// <returns>Whether the item should be selected.</returns>
protected virtual bool HandleItemSelected ( object? model ) = > true ;
2025-01-23 15:11:02 +08:00
/// <summary>
/// Called when an item is "deselected".
/// </summary>
protected virtual void HandleItemDeselected ( object? model )
{
}
/// <summary>
/// Called when an item is "activated".
/// </summary>
/// <remarks>
/// An activated item should for instance:
/// - Open or close a folder
/// - Start gameplay on a beatmap difficulty.
/// </remarks>
/// <param name="item">The carousel item which was activated.</param>
protected virtual void HandleItemActivated ( CarouselItem item )
{
}
2025-01-10 19:30:41 +08:00
2025-01-17 17:49:12 +08:00
#endregion
#region Initialisation
private readonly CarouselScrollContainer scroll ;
protected Carousel ( )
{
InternalChild = scroll = new CarouselScrollContainer
{
RelativeSizeAxes = Axes . Both ,
} ;
Items . BindCollectionChanged ( ( _ , _ ) = > FilterAsync ( ) ) ;
}
#endregion
2025-01-10 19:55:53 +08:00
#region Filtering and display preparation
2025-01-23 14:58:35 +08:00
private List < CarouselItem > ? carouselItems ;
2025-01-17 17:49:12 +08:00
2025-01-10 19:55:53 +08:00
private Task filterTask = Task . CompletedTask ;
private CancellationTokenSource cancellationSource = new CancellationTokenSource ( ) ;
private async Task performFilter ( )
{
Debug . Assert ( SynchronizationContext . Current ! = null ) ;
2025-01-14 19:23:53 +08:00
Stopwatch stopwatch = Stopwatch . StartNew ( ) ;
2025-01-10 19:55:53 +08:00
var cts = new CancellationTokenSource ( ) ;
lock ( this )
{
cancellationSource . Cancel ( ) ;
cancellationSource = cts ;
}
2025-01-14 19:23:53 +08:00
if ( DebounceDelay > 0 )
{
log ( $"Filter operation queued, waiting for {DebounceDelay} ms debounce" ) ;
await Task . Delay ( DebounceDelay , cts . Token ) . ConfigureAwait ( true ) ;
}
// Copy must be performed on update thread for now (see ConfigureAwait above).
// Could potentially be optimised in the future if it becomes an issue.
2025-01-23 15:11:02 +08:00
IEnumerable < CarouselItem > items = new List < CarouselItem > ( Items . Select ( m = > new CarouselItem ( m ) ) ) ;
2025-01-10 19:55:53 +08:00
await Task . Run ( async ( ) = >
{
try
{
foreach ( var filter in Filters )
{
log ( $"Performing {filter.GetType().ReadableName()}" ) ;
items = await filter . Run ( items , cts . Token ) . ConfigureAwait ( false ) ;
}
log ( "Updating Y positions" ) ;
2025-01-23 15:11:02 +08:00
updateYPositions ( items , visibleHalfHeight , SpacingBetweenPanels ) ;
2025-01-10 19:55:53 +08:00
}
catch ( OperationCanceledException )
{
log ( "Cancelled due to newer request arriving" ) ;
}
} , cts . Token ) . ConfigureAwait ( true ) ;
if ( cts . Token . IsCancellationRequested )
return ;
log ( "Items ready for display" ) ;
2025-01-23 14:58:35 +08:00
carouselItems = items . ToList ( ) ;
2025-01-10 19:55:53 +08:00
displayedRange = null ;
2025-01-23 15:11:02 +08:00
// Need to call this to ensure correct post-selection logic is handled on the new items list.
HandleItemSelected ( currentSelection . Model ) ;
refreshAfterSelection ( ) ;
2025-01-10 18:34:56 +08:00
2025-01-15 15:18:34 +08:00
void log ( string text ) = > Logger . Log ( $"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}" ) ;
2025-01-10 19:55:53 +08:00
}
2025-01-23 15:11:02 +08:00
private static void updateYPositions ( IEnumerable < CarouselItem > carouselItems , float offset , float spacing )
2025-01-10 19:55:53 +08:00
{
foreach ( var item in carouselItems )
2025-01-23 15:11:02 +08:00
updateItemYPosition ( item , ref offset , spacing ) ;
}
private static void updateItemYPosition ( CarouselItem item , ref float offset , float spacing )
{
item . CarouselYPosition = offset ;
if ( item . IsVisible )
offset + = item . DrawHeight + spacing ;
}
#endregion
#region Input handling
public bool OnPressed ( KeyBindingPressEvent < GlobalAction > e )
{
switch ( e . Action )
2025-01-10 19:55:53 +08:00
{
2025-01-23 15:11:02 +08:00
case GlobalAction . Select :
2025-01-23 21:44:39 +08:00
TryActivateSelection ( ) ;
2025-01-23 15:11:02 +08:00
return true ;
case GlobalAction . SelectNext :
2025-02-01 10:18:45 +08:00
traverseKeyboardSelection ( 1 ) ;
2025-01-23 15:11:02 +08:00
return true ;
2025-02-01 10:18:45 +08:00
case GlobalAction . SelectPrevious :
traverseKeyboardSelection ( - 1 ) ;
2025-01-23 15:11:02 +08:00
return true ;
2025-02-01 10:18:45 +08:00
case GlobalAction . SelectNextGroup :
traverseGroupSelection ( 1 ) ;
2025-01-23 15:11:02 +08:00
return true ;
case GlobalAction . SelectPreviousGroup :
2025-02-01 10:18:45 +08:00
traverseGroupSelection ( - 1 ) ;
2025-01-23 15:11:02 +08:00
return true ;
2025-01-10 19:55:53 +08:00
}
2025-01-23 15:11:02 +08:00
return false ;
}
public void OnReleased ( KeyBindingReleaseEvent < GlobalAction > e )
{
}
2025-02-01 10:18:45 +08:00
private void traverseKeyboardSelection ( int direction )
{
if ( carouselItems = = null | | carouselItems . Count = = 0 ) return ;
int originalIndex ;
if ( currentKeyboardSelection . Index ! = null )
originalIndex = currentKeyboardSelection . Index . Value ;
else if ( direction > 0 )
originalIndex = carouselItems . Count - 1 ;
else
originalIndex = 0 ;
int newIndex = originalIndex ;
// Iterate over every item back to the current selection, finding the first valid item.
// The fail condition is when we reach the selection after a cyclic loop over every item.
do
{
newIndex = ( newIndex + direction + carouselItems . Count ) % carouselItems . Count ;
var newItem = carouselItems [ newIndex ] ;
if ( newItem . IsVisible )
{
setKeyboardSelection ( newItem . Model ) ;
return ;
}
} while ( newIndex ! = originalIndex ) ;
}
2025-01-23 15:11:02 +08:00
/// <summary>
/// Select the next valid selection relative to a current selection.
/// This is generally for keyboard based traversal.
/// </summary>
/// <param name="direction">Positive for downwards, negative for upwards.</param>
/// <returns>Whether selection was possible.</returns>
2025-02-01 10:18:45 +08:00
private void traverseGroupSelection ( int direction )
2025-01-23 15:11:02 +08:00
{
2025-02-01 10:18:45 +08:00
if ( carouselItems = = null | | carouselItems . Count = = 0 ) return ;
2025-01-23 15:11:02 +08:00
// If the user has a different keyboard selection and requests
// group selection, first transfer the keyboard selection to actual selection.
2025-02-01 10:18:45 +08:00
if ( currentSelection . CarouselItem ! = currentKeyboardSelection . CarouselItem )
2025-01-23 15:11:02 +08:00
{
2025-01-23 21:44:39 +08:00
TryActivateSelection ( ) ;
2025-02-04 01:55:57 +08:00
// There's a chance this couldn't resolve, at which point continue with standard traversal.
if ( currentSelection . CarouselItem = = currentKeyboardSelection . CarouselItem )
return ;
2025-01-23 15:11:02 +08:00
}
2025-02-01 10:18:45 +08:00
int originalIndex ;
2025-02-04 01:55:57 +08:00
int newIndex ;
2025-01-23 15:11:02 +08:00
2025-02-04 01:55:57 +08:00
if ( currentSelection . Index = = null )
{
// If there's no current selection, start from either end of the full list.
newIndex = originalIndex = direction > 0 ? carouselItems . Count - 1 : 0 ;
}
2025-02-01 10:18:45 +08:00
else
2025-01-23 15:11:02 +08:00
{
2025-02-04 01:55:57 +08:00
newIndex = originalIndex = currentSelection . Index . Value ;
// As a second special case, if we're group selecting backwards and the current selection isn't a group,
// make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early.
if ( direction < 0 )
{
while ( ! CheckValidForGroupSelection ( carouselItems [ newIndex ] ) )
newIndex - - ;
}
2025-01-23 15:11:02 +08:00
}
// Iterate over every item back to the current selection, finding the first valid item.
// The fail condition is when we reach the selection after a cyclic loop over every item.
do
{
2025-02-01 10:18:45 +08:00
newIndex = ( newIndex + direction + carouselItems . Count ) % carouselItems . Count ;
var newItem = carouselItems [ newIndex ] ;
2025-01-23 15:11:02 +08:00
2025-02-01 10:18:45 +08:00
if ( CheckValidForGroupSelection ( newItem ) )
{
setSelection ( newItem . Model ) ;
return ;
}
} while ( newIndex ! = originalIndex ) ;
2025-01-23 15:11:02 +08:00
}
2025-01-10 19:55:53 +08:00
#endregion
2025-01-10 18:34:56 +08:00
#region Selection handling
2025-01-28 21:53:17 +08:00
private readonly Cached selectionValid = new Cached ( ) ;
2025-01-23 15:11:02 +08:00
private Selection currentKeyboardSelection = new Selection ( ) ;
private Selection currentSelection = new Selection ( ) ;
2025-01-10 18:34:56 +08:00
2025-01-23 15:11:02 +08:00
private void setSelection ( object? model )
2025-01-10 18:34:56 +08:00
{
2025-01-23 15:11:02 +08:00
if ( currentSelection . Model = = model )
return ;
2025-01-10 18:34:56 +08:00
2025-01-28 21:53:17 +08:00
if ( HandleItemSelected ( model ) )
{
if ( currentSelection . Model ! = null )
HandleItemDeselected ( currentSelection . Model ) ;
2025-01-23 15:11:02 +08:00
2025-01-28 21:53:17 +08:00
currentKeyboardSelection = new Selection ( model ) ;
currentSelection = currentKeyboardSelection ;
}
2025-01-31 19:58:32 +08:00
selectionValid . Invalidate ( ) ;
2025-01-23 15:11:02 +08:00
}
private void setKeyboardSelection ( object? model )
{
currentKeyboardSelection = new Selection ( model ) ;
2025-01-28 21:53:17 +08:00
selectionValid . Invalidate ( ) ;
2025-01-23 15:11:02 +08:00
}
/// <summary>
/// Call after a selection of items change to re-attach <see cref="CarouselItem"/>s to current <see cref="Selection"/>s.
/// </summary>
private void refreshAfterSelection ( )
{
float yPos = visibleHalfHeight ;
// Invalidate display range as panel positions and visible status may have changed.
// Position transfer won't happen unless we invalidate this.
displayedRange = null ;
// The case where no items are available for display yet.
if ( carouselItems = = null )
2025-01-10 19:24:14 +08:00
{
2025-01-23 15:11:02 +08:00
currentKeyboardSelection = new Selection ( ) ;
currentSelection = new Selection ( ) ;
return ;
}
2025-01-10 19:24:14 +08:00
2025-01-23 15:11:02 +08:00
float spacing = SpacingBetweenPanels ;
int count = carouselItems . Count ;
2025-01-10 19:24:14 +08:00
2025-01-23 15:11:02 +08:00
Selection prevKeyboard = currentKeyboardSelection ;
2025-01-10 19:24:14 +08:00
2025-01-23 15:11:02 +08:00
// We are performing two important operations here:
// - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions.
// - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use.
for ( int i = 0 ; i < count ; i + + )
{
var item = carouselItems [ i ] ;
updateItemYPosition ( item , ref yPos , spacing ) ;
2025-01-10 19:24:14 +08:00
2025-01-23 15:11:02 +08:00
if ( ReferenceEquals ( item . Model , currentKeyboardSelection . Model ) )
currentKeyboardSelection = new Selection ( item . Model , item , item . CarouselYPosition , i ) ;
if ( ReferenceEquals ( item . Model , currentSelection . Model ) )
currentSelection = new Selection ( item . Model , item , item . CarouselYPosition , i ) ;
2025-01-10 19:24:14 +08:00
}
2025-01-23 15:11:02 +08:00
// If a keyboard selection is currently made, we want to keep the view stable around the selection.
// That means that we should offset the immediate scroll position by any change in Y position for the selection.
if ( prevKeyboard . YPosition ! = null & & currentKeyboardSelection . YPosition ! = prevKeyboard . YPosition )
scroll . OffsetScrollPosition ( ( float ) ( currentKeyboardSelection . YPosition ! . Value - prevKeyboard . YPosition . Value ) ) ;
}
private void scrollToSelection ( )
{
if ( currentKeyboardSelection . CarouselItem ! = null )
scroll . ScrollTo ( currentKeyboardSelection . CarouselItem . CarouselYPosition - visibleHalfHeight ) ;
2025-01-10 18:34:56 +08:00
}
#endregion
2025-01-10 19:55:53 +08:00
#region Display handling
private DisplayRange ? displayedRange ;
2025-01-23 15:11:02 +08:00
private readonly CarouselItem carouselBoundsItem = new CarouselItem ( new object ( ) ) ;
2025-01-10 19:55:53 +08:00
/// <summary>
/// The position of the lower visible bound with respect to the current scroll position.
/// </summary>
private float visibleBottomBound = > ( float ) ( scroll . Current + DrawHeight + BleedBottom ) ;
/// <summary>
/// The position of the upper visible bound with respect to the current scroll position.
/// </summary>
private float visibleUpperBound = > ( float ) ( scroll . Current - BleedTop ) ;
2025-01-15 19:02:46 +08:00
/// <summary>
/// Half the height of the visible content.
/// </summary>
private float visibleHalfHeight = > ( DrawHeight + BleedBottom + BleedTop ) / 2 ;
2025-01-10 19:55:53 +08:00
protected override void Update ( )
{
base . Update ( ) ;
2025-01-23 14:58:35 +08:00
if ( carouselItems = = null )
2025-01-10 19:55:53 +08:00
return ;
2025-01-28 21:53:17 +08:00
if ( ! selectionValid . IsValid )
{
refreshAfterSelection ( ) ;
scrollToSelection ( ) ;
selectionValid . Validate ( ) ;
}
2025-01-10 19:55:53 +08:00
var range = getDisplayRange ( ) ;
if ( range ! = displayedRange )
{
Logger . Log ( $"Updating displayed range of carousel from {displayedRange} to {range}" ) ;
displayedRange = range ;
updateDisplayedRange ( range ) ;
}
2025-01-14 19:07:09 +08:00
foreach ( var panel in scroll . Panels )
{
2025-01-15 19:02:46 +08:00
var c = ( ICarouselPanel ) panel ;
2025-01-14 19:07:09 +08:00
2025-01-24 18:02:31 +08:00
// panel in the process of expiring, ignore it.
if ( c . Item = = null )
continue ;
2025-01-15 19:02:46 +08:00
if ( panel . Depth ! = c . DrawYPosition )
scroll . Panels . ChangeChildDepth ( panel , ( float ) c . DrawYPosition ) ;
if ( c . DrawYPosition ! = c . Item . CarouselYPosition )
c . DrawYPosition = Interpolation . DampContinuously ( c . DrawYPosition , c . Item . CarouselYPosition , 50 , Time . Elapsed ) ;
Vector2 posInScroll = scroll . ToLocalSpace ( panel . ScreenSpaceDrawQuad . Centre ) ;
float dist = Math . Abs ( 1f - posInScroll . Y / visibleHalfHeight ) ;
panel . X = offsetX ( dist , visibleHalfHeight ) ;
2025-01-23 15:11:02 +08:00
c . Selected . Value = c . Item = = currentSelection ? . CarouselItem ;
c . KeyboardSelected . Value = c . Item = = currentKeyboardSelection ? . CarouselItem ;
2025-01-14 19:07:09 +08:00
}
2025-01-10 19:55:53 +08:00
}
2025-01-15 19:02:46 +08:00
/// <summary>
/// Computes the x-offset of currently visible items. Makes the carousel appear round.
/// </summary>
/// <param name="dist">
/// Vertical distance from the center of the carousel container
/// ranging from -1 to 1.
/// </param>
/// <param name="halfHeight">Half the height of the carousel container.</param>
private static float offsetX ( float dist , float halfHeight )
{
// The radius of the circle the carousel moves on.
const float circle_radius = 3 ;
float discriminant = MathF . Max ( 0 , circle_radius * circle_radius - dist * dist ) ;
return ( circle_radius - MathF . Sqrt ( discriminant ) ) * halfHeight ;
}
2025-01-10 19:55:53 +08:00
private DisplayRange getDisplayRange ( )
{
2025-01-23 14:58:35 +08:00
Debug . Assert ( carouselItems ! = null ) ;
2025-01-10 19:55:53 +08:00
// Find index range of all items that should be on-screen
carouselBoundsItem . CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload ;
2025-01-23 14:58:35 +08:00
int firstIndex = carouselItems . BinarySearch ( carouselBoundsItem ) ;
2025-01-10 19:55:53 +08:00
if ( firstIndex < 0 ) firstIndex = ~ firstIndex ;
carouselBoundsItem . CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload ;
2025-01-23 14:58:35 +08:00
int lastIndex = carouselItems . BinarySearch ( carouselBoundsItem ) ;
2025-01-10 19:55:53 +08:00
if ( lastIndex < 0 ) lastIndex = ~ lastIndex ;
firstIndex = Math . Max ( 0 , firstIndex - 1 ) ;
lastIndex = Math . Max ( 0 , lastIndex - 1 ) ;
return new DisplayRange ( firstIndex , lastIndex ) ;
}
private void updateDisplayedRange ( DisplayRange range )
{
2025-01-23 14:58:35 +08:00
Debug . Assert ( carouselItems ! = null ) ;
2025-01-10 19:55:53 +08:00
List < CarouselItem > toDisplay = range . Last - range . First = = 0
? new List < CarouselItem > ( )
2025-01-23 14:58:35 +08:00
: carouselItems . GetRange ( range . First , range . Last - range . First + 1 ) ;
2025-01-10 19:55:53 +08:00
2025-01-23 15:11:02 +08:00
toDisplay . RemoveAll ( i = > ! i . IsVisible ) ;
2025-01-10 19:55:53 +08:00
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
foreach ( var panel in scroll . Panels )
{
var carouselPanel = ( ICarouselPanel ) panel ;
// The case where we're intending to display this panel, but it's already displayed.
// Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation.
var existing = toDisplay . FirstOrDefault ( i = > i . Model = = carouselPanel . Item ! . Model ) ;
if ( existing ! = null )
{
carouselPanel . Item = existing ;
toDisplay . Remove ( existing ) ;
continue ;
}
// If the new display range doesn't contain the panel, it's no longer required for display.
expirePanelImmediately ( panel ) ;
}
// Add any new items which need to be displayed and haven't yet.
foreach ( var item in toDisplay )
{
var drawable = GetDrawableForDisplay ( item ) ;
if ( drawable is not ICarouselPanel carouselPanel )
throw new InvalidOperationException ( $"Carousel panel drawables must implement {typeof(ICarouselPanel)}" ) ;
2025-01-24 18:02:31 +08:00
carouselPanel . DrawYPosition = item . CarouselYPosition ;
2025-01-10 19:55:53 +08:00
carouselPanel . Item = item ;
2025-01-24 18:02:31 +08:00
2025-01-10 19:55:53 +08:00
scroll . Add ( drawable ) ;
}
// Update the total height of all items (to make the scroll container scrollable through the full height even though
// most items are not displayed / loaded).
2025-01-23 14:58:35 +08:00
if ( carouselItems . Count > 0 )
2025-01-10 19:55:53 +08:00
{
2025-01-23 14:58:35 +08:00
var lastItem = carouselItems [ ^ 1 ] ;
2025-01-15 19:24:41 +08:00
scroll . SetLayoutHeight ( ( float ) ( lastItem . CarouselYPosition + lastItem . DrawHeight + visibleHalfHeight ) ) ;
2025-01-10 19:55:53 +08:00
}
else
scroll . SetLayoutHeight ( 0 ) ;
}
private static void expirePanelImmediately ( Drawable panel )
{
panel . FinishTransforms ( ) ;
panel . Expire ( ) ;
2025-01-24 18:02:31 +08:00
var carouselPanel = ( ICarouselPanel ) panel ;
carouselPanel . Item = null ;
carouselPanel . Selected . Value = false ;
carouselPanel . KeyboardSelected . Value = false ;
2025-01-10 19:55:53 +08:00
}
#endregion
#region Internal helper classes
2025-01-23 15:11:02 +08:00
/// <summary>
/// Bookkeeping for a current selection.
/// </summary>
/// <param name="Model">The selected model. If <c>null</c>, there's no selection.</param>
/// <param name="CarouselItem">A related carousel item representation for the model. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
/// <param name="YPosition">The Y position of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
/// <param name="Index">The index of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
private record Selection ( object? Model = null , CarouselItem ? CarouselItem = null , double? YPosition = null , int? Index = null ) ;
2025-01-10 19:55:53 +08:00
private record DisplayRange ( int First , int Last ) ;
/// <summary>
/// Implementation of scroll container which handles very large vertical lists by internally using <c>double</c> precision
/// for pre-display Y values.
/// </summary>
2025-01-16 19:25:16 +08:00
private partial class CarouselScrollContainer : UserTrackingScrollContainer , IKeyBindingHandler < GlobalAction >
2025-01-10 19:55:53 +08:00
{
public readonly Container Panels ;
public void SetLayoutHeight ( float height ) = > Panels . Height = height ;
2025-01-15 16:01:07 +08:00
public CarouselScrollContainer ( )
2025-01-10 19:55:53 +08:00
{
// Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations,
// so we must maintain one level of separation from ScrollContent.
base . Add ( Panels = new Container
{
Name = "Layout content" ,
RelativeSizeAxes = Axes . X ,
} ) ;
}
2025-01-15 16:01:07 +08:00
public override void OffsetScrollPosition ( double offset )
{
base . OffsetScrollPosition ( offset ) ;
foreach ( var panel in Panels )
{
var c = ( ICarouselPanel ) panel ;
Debug . Assert ( c . Item ! = null ) ;
c . DrawYPosition + = offset ;
}
}
2025-01-10 19:55:53 +08:00
public override void Clear ( bool disposeChildren )
{
Panels . Height = 0 ;
Panels . Clear ( disposeChildren ) ;
}
public override void Add ( Drawable drawable )
{
if ( drawable is not ICarouselPanel )
throw new InvalidOperationException ( $"Carousel panel drawables must implement {typeof(ICarouselPanel)}" ) ;
Panels . Add ( drawable ) ;
}
public override double GetChildPosInContent ( Drawable d , Vector2 offset )
{
if ( d is not ICarouselPanel panel )
return base . GetChildPosInContent ( d , offset ) ;
2025-01-14 19:07:09 +08:00
return panel . DrawYPosition + offset . X ;
2025-01-10 19:55:53 +08:00
}
protected override void ApplyCurrentToContent ( )
{
Debug . Assert ( ScrollDirection = = Direction . Vertical ) ;
double scrollableExtent = - Current + ScrollableExtent * ScrollContent . RelativeAnchorPosition . Y ;
foreach ( var d in Panels )
2025-01-14 19:07:09 +08:00
d . Y = ( float ) ( ( ( ICarouselPanel ) d ) . DrawYPosition + scrollableExtent ) ;
2025-01-10 19:55:53 +08:00
}
2025-01-16 19:25:16 +08:00
#region Absolute scrolling
private bool absoluteScrolling ;
protected override bool IsDragging = > base . IsDragging | | absoluteScrolling ;
public bool OnPressed ( KeyBindingPressEvent < GlobalAction > e )
{
switch ( e . Action )
{
case GlobalAction . AbsoluteScrollSongList :
2025-01-21 16:12:45 +08:00
beginAbsoluteScrolling ( e ) ;
2025-01-16 19:25:16 +08:00
return true ;
}
return false ;
}
public void OnReleased ( KeyBindingReleaseEvent < GlobalAction > e )
{
switch ( e . Action )
{
case GlobalAction . AbsoluteScrollSongList :
2025-01-21 16:12:45 +08:00
endAbsoluteScrolling ( ) ;
2025-01-16 19:25:16 +08:00
break ;
}
}
2025-01-21 16:12:45 +08:00
protected override bool OnMouseDown ( MouseDownEvent e )
{
if ( e . Button = = MouseButton . Right )
{
// To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over.
if ( GetContainingInputManager ( ) ! . HoveredDrawables . OfType < IHasContextMenu > ( ) . Any ( ) )
return false ;
beginAbsoluteScrolling ( e ) ;
}
return base . OnMouseDown ( e ) ;
}
protected override void OnMouseUp ( MouseUpEvent e )
{
if ( e . Button = = MouseButton . Right )
endAbsoluteScrolling ( ) ;
base . OnMouseUp ( e ) ;
}
2025-01-16 19:25:16 +08:00
protected override bool OnMouseMove ( MouseMoveEvent e )
{
if ( absoluteScrolling )
{
ScrollToAbsolutePosition ( e . CurrentState . Mouse . Position ) ;
return true ;
}
return base . OnMouseMove ( e ) ;
}
2025-01-21 16:12:45 +08:00
private void beginAbsoluteScrolling ( UIEvent e )
{
ScrollToAbsolutePosition ( e . CurrentState . Mouse . Position ) ;
absoluteScrolling = true ;
}
private void endAbsoluteScrolling ( ) = > absoluteScrolling = false ;
2025-01-16 19:25:16 +08:00
#endregion
2025-01-10 19:55:53 +08:00
}
#endregion
}
}