2019-01-24 16:43:03 +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.
2018-04-13 17:19:50 +08:00
2022-06-17 15:37:17 +08:00
#nullable disable
2017-02-02 16:33:39 +08:00
using System ;
using System.Collections.Generic ;
2017-03-04 15:54:14 +08:00
using System.Diagnostics ;
2020-10-13 17:18:22 +08:00
using System.Linq ;
2017-03-17 18:12:15 +08:00
using osu.Framework.Allocation ;
2022-01-28 12:27:12 +08:00
using osu.Framework.Audio ;
using osu.Framework.Audio.Sample ;
2019-02-21 18:04:31 +08:00
using osu.Framework.Bindables ;
2017-12-12 16:48:38 +08:00
using osu.Framework.Caching ;
2022-06-07 15:31:58 +08:00
using osu.Framework.Extensions.EnumExtensions ;
2020-10-13 17:18:22 +08:00
using osu.Framework.Graphics ;
using osu.Framework.Graphics.Containers ;
2020-10-12 14:36:03 +08:00
using osu.Framework.Graphics.Pooling ;
2020-03-02 17:55:28 +08:00
using osu.Framework.Input.Bindings ;
2018-10-02 11:02:47 +08:00
using osu.Framework.Input.Events ;
2021-01-02 21:05:41 +08:00
using osu.Framework.Layout ;
2020-10-13 17:18:22 +08:00
using osu.Framework.Threading ;
using osu.Framework.Utils ;
2017-07-26 12:22:46 +08:00
using osu.Game.Beatmaps ;
2020-10-13 17:18:22 +08:00
using osu.Game.Configuration ;
2021-11-08 16:41:42 +08:00
using osu.Game.Database ;
2017-07-21 18:13:53 +08:00
using osu.Game.Graphics.Containers ;
2017-09-14 14:41:32 +08:00
using osu.Game.Graphics.Cursor ;
2020-03-02 17:55:28 +08:00
using osu.Game.Input.Bindings ;
2017-12-12 16:48:38 +08:00
using osu.Game.Screens.Select.Carousel ;
2020-10-13 17:18:22 +08:00
using osuTK ;
using osuTK.Input ;
2021-11-08 16:41:42 +08:00
using Realms ;
2018-04-13 17:19:50 +08:00
2016-11-21 03:34:16 +08:00
namespace osu.Game.Screens.Select
{
2020-03-02 17:55:28 +08:00
public class BeatmapCarousel : CompositeDrawable , IKeyBindingHandler < GlobalAction >
2016-11-21 03:34:16 +08:00
{
2020-04-21 03:42:43 +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>
2020-04-21 03:43:07 +08:00
public float BleedTop { get ; set ; }
2020-04-21 03:42:43 +08:00
/// <summary>
/// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
/// </summary>
2020-04-21 03:43:07 +08:00
public float BleedBottom { get ; set ; }
2019-07-26 12:07:28 +08:00
2017-12-13 18:56:16 +08:00
/// <summary>
2017-12-14 11:59:35 +08:00
/// Triggered when the <see cref="BeatmapSets"/> loaded change and are completely loaded.
2017-12-13 18:56:16 +08:00
/// </summary>
2017-12-14 11:59:35 +08:00
public Action BeatmapSetsChanged ;
2018-04-13 17:19:50 +08:00
2017-12-13 18:56:16 +08:00
/// <summary>
/// The currently selected beatmap.
/// </summary>
2021-10-02 11:44:22 +08:00
public BeatmapInfo SelectedBeatmapInfo = > selectedBeatmap ? . BeatmapInfo ;
2018-04-13 17:19:50 +08:00
2019-02-21 17:56:34 +08:00
private CarouselBeatmap selectedBeatmap = > selectedBeatmapSet ? . Beatmaps . FirstOrDefault ( s = > s . State . Value = = CarouselItemState . Selected ) ;
2018-04-13 17:19:50 +08:00
2017-12-14 11:59:35 +08:00
/// <summary>
/// The currently selected beatmap set.
/// </summary>
public BeatmapSetInfo SelectedBeatmapSet = > selectedBeatmapSet ? . BeatmapSet ;
2018-04-13 17:19:50 +08:00
2020-04-11 15:58:13 +08:00
/// <summary>
/// A function to optionally decide on a recommended difficulty from a beatmap set.
/// </summary>
public Func < IEnumerable < BeatmapInfo > , BeatmapInfo > GetRecommendedBeatmap ;
2017-12-17 04:53:13 +08:00
private CarouselBeatmapSet selectedBeatmapSet ;
2018-04-13 17:19:50 +08:00
2017-12-13 18:56:16 +08:00
/// <summary>
2021-10-02 11:44:22 +08:00
/// Raised when the <see cref="SelectedBeatmapInfo"/> is changed.
2017-12-13 18:56:16 +08:00
/// </summary>
public Action < BeatmapInfo > SelectionChanged ;
2018-04-13 17:19:50 +08:00
2018-09-26 13:01:15 +08:00
public override bool HandleNonPositionalInput = > AllowSelection ;
public override bool HandlePositionalInput = > AllowSelection ;
2018-04-13 17:19:50 +08:00
2020-01-27 13:55:47 +08:00
public override bool PropagatePositionalInputSubTree = > AllowSelection ;
public override bool PropagateNonPositionalInputSubTree = > AllowSelection ;
2020-10-13 18:10:35 +08:00
private ( int first , int last ) displayedRange ;
/// <summary>
/// Extend the range to retain already loaded pooled drawables.
/// </summary>
private const float distance_offscreen_before_unload = 1024 ;
/// <summary>
/// Extend the range to update positions / retrieve pooled drawables outside of visible range.
/// </summary>
private const float distance_offscreen_to_preload = 512 ; // todo: adjust this appropriately once we can make set panel contents load while off-screen.
2017-12-27 11:30:30 +08:00
/// <summary>
2019-03-21 19:51:06 +08:00
/// Whether carousel items have completed asynchronously loaded.
2017-12-27 11:30:30 +08:00
/// </summary>
2019-03-21 19:51:06 +08:00
public bool BeatmapSetsLoaded { get ; private set ; }
2018-04-13 17:19:50 +08:00
2020-11-26 17:28:52 +08:00
protected readonly CarouselScrollContainer Scroll ;
2019-08-15 13:00:12 +08:00
2022-06-07 15:32:15 +08:00
private readonly NoResultsPlaceholder noResultsPlaceholder ;
2022-07-21 15:06:06 +08:00
private IEnumerable < CarouselBeatmapSet > beatmapSets = > root . Items . OfType < CarouselBeatmapSet > ( ) ;
2018-04-13 17:19:50 +08:00
2020-01-29 15:51:14 +08:00
// todo: only used for testing, maybe remove.
2022-01-10 13:52:59 +08:00
private bool loadedTestBeatmaps ;
2017-12-14 11:59:35 +08:00
public IEnumerable < BeatmapSetInfo > BeatmapSets
2017-03-17 18:12:15 +08:00
{
2018-08-29 00:42:25 +08:00
get = > beatmapSets . Select ( g = > g . BeatmapSet ) ;
2022-01-10 13:52:59 +08:00
set
{
loadedTestBeatmaps = true ;
2022-02-03 17:40:10 +08:00
Schedule ( ( ) = > loadBeatmapSets ( value ) ) ;
2022-01-10 13:52:59 +08:00
}
2018-08-29 00:42:25 +08:00
}
2019-06-30 21:23:48 +08:00
private void loadBeatmapSets ( IEnumerable < BeatmapSetInfo > beatmapSets )
2018-08-29 00:42:25 +08:00
{
CarouselRoot newRoot = new CarouselRoot ( this ) ;
2022-07-21 15:16:39 +08:00
newRoot . AddItems ( beatmapSets . Select ( s = > createCarouselSet ( s . Detach ( ) ) ) . Where ( g = > g ! = null ) ) ;
2018-04-13 17:19:50 +08:00
2019-06-30 21:23:48 +08:00
root = newRoot ;
2022-01-20 20:58:16 +08:00
2022-01-17 12:40:27 +08:00
if ( selectedBeatmapSet ! = null & & ! beatmapSets . Contains ( selectedBeatmapSet . BeatmapSet ) )
2019-09-25 01:42:12 +08:00
selectedBeatmapSet = null ;
2020-11-26 17:28:52 +08:00
Scroll . Clear ( false ) ;
2019-06-30 21:23:48 +08:00
itemsCache . Invalidate ( ) ;
2020-11-27 12:54:36 +08:00
ScrollToSelected ( ) ;
2019-06-30 21:23:48 +08:00
2022-01-13 14:08:51 +08:00
applyActiveCriteria ( false ) ;
2020-07-12 21:21:16 +08:00
2022-01-20 15:39:42 +08:00
if ( loadedTestBeatmaps )
signalBeatmapsLoaded ( ) ;
2017-03-17 18:12:15 +08:00
}
2018-04-13 17:19:50 +08:00
2020-10-12 13:23:18 +08:00
private readonly List < CarouselItem > visibleItems = new List < CarouselItem > ( ) ;
2019-08-09 18:12:29 +08:00
private readonly Cached itemsCache = new Cached ( ) ;
2020-11-27 12:54:36 +08:00
private PendingScrollOperation pendingScrollOperation = PendingScrollOperation . None ;
2018-04-13 17:19:50 +08:00
2018-04-13 18:50:37 +08:00
public Bindable < bool > RightClickScrollingEnabled = new Bindable < bool > ( ) ;
2017-12-14 11:59:35 +08:00
public Bindable < RandomSelectAlgorithm > RandomAlgorithm = new Bindable < RandomSelectAlgorithm > ( ) ;
private readonly List < CarouselBeatmapSet > previouslyVisitedRandomSets = new List < CarouselBeatmapSet > ( ) ;
2017-12-14 15:59:08 +08:00
private readonly Stack < CarouselBeatmap > randomSelectedBeatmaps = new Stack < CarouselBeatmap > ( ) ;
2018-04-13 17:19:50 +08:00
2018-04-02 14:16:10 +08:00
private CarouselRoot root ;
2020-04-11 15:41:11 +08:00
2021-12-17 18:01:19 +08:00
private IDisposable subscriptionSets ;
2022-01-12 00:03:59 +08:00
private IDisposable subscriptionDeletedSets ;
2021-12-17 18:01:19 +08:00
private IDisposable subscriptionBeatmaps ;
2022-01-12 00:03:59 +08:00
private IDisposable subscriptionHiddenBeatmaps ;
2021-12-17 18:01:19 +08:00
2020-10-12 14:36:03 +08:00
private readonly DrawablePool < DrawableCarouselBeatmapSet > setPool = new DrawablePool < DrawableCarouselBeatmapSet > ( 100 ) ;
2022-01-28 12:27:12 +08:00
private Sample spinSample ;
2022-02-04 14:42:52 +08:00
private Sample randomSelectSample ;
2022-01-28 12:27:12 +08:00
private int visibleSetsCount ;
2017-03-17 18:12:54 +08:00
public BeatmapCarousel ( )
2017-01-18 08:18:15 +08:00
{
2018-04-02 14:16:10 +08:00
root = new CarouselRoot ( this ) ;
2019-08-15 13:00:12 +08:00
InternalChild = new OsuContextMenuContainer
2017-01-18 08:18:15 +08:00
{
2019-08-15 13:00:12 +08:00
RelativeSizeAxes = Axes . Both ,
2020-11-26 17:28:52 +08:00
Children = new Drawable [ ]
2017-09-14 14:41:32 +08:00
{
2020-11-26 17:28:52 +08:00
setPool ,
Scroll = new CarouselScrollContainer
2019-08-15 13:00:12 +08:00
{
2020-11-26 17:28:52 +08:00
RelativeSizeAxes = Axes . Both ,
2022-06-07 15:32:15 +08:00
} ,
noResultsPlaceholder = new NoResultsPlaceholder ( )
2017-09-14 14:41:32 +08:00
}
2017-12-14 12:49:43 +08:00
} ;
2017-01-18 08:18:15 +08:00
}
2018-04-13 17:19:50 +08:00
2021-12-17 18:01:19 +08:00
[BackgroundDependencyLoader]
2022-01-28 12:27:12 +08:00
private void load ( OsuConfigManager config , AudioManager audio )
2017-12-18 11:30:39 +08:00
{
2022-01-28 12:27:12 +08:00
spinSample = audio . Samples . Get ( "SongSelect/random-spin" ) ;
2022-02-04 14:42:52 +08:00
randomSelectSample = audio . Samples . Get ( @"SongSelect/select-random" ) ;
2022-01-28 12:27:12 +08:00
2017-12-18 11:30:39 +08:00
config . BindWith ( OsuSetting . RandomSelectAlgorithm , RandomAlgorithm ) ;
2018-04-18 18:26:54 +08:00
config . BindWith ( OsuSetting . SongSelectRightMouseScroll , RightClickScrollingEnabled ) ;
2018-04-13 18:50:37 +08:00
2020-11-26 17:28:52 +08:00
RightClickScrollingEnabled . ValueChanged + = enabled = > Scroll . RightMouseScrollbar = enabled . NewValue ;
2018-04-13 18:50:37 +08:00
RightClickScrollingEnabled . TriggerChange ( ) ;
2022-01-19 16:47:46 +08:00
2022-01-19 23:14:00 +08:00
if ( ! loadedTestBeatmaps )
2022-01-19 16:47:46 +08:00
{
2022-01-25 12:04:05 +08:00
realm . Run ( r = > loadBeatmapSets ( getBeatmapSets ( r ) ) ) ;
2022-01-19 16:47:46 +08:00
}
2021-11-08 16:41:42 +08:00
}
[Resolved]
2022-01-24 18:59:58 +08:00
private RealmAccess realm { get ; set ; }
2019-06-30 21:23:48 +08:00
2021-11-08 16:41:42 +08:00
protected override void LoadComplete ( )
{
base . LoadComplete ( ) ;
2020-01-29 15:51:14 +08:00
2022-01-24 18:59:58 +08:00
subscriptionSets = realm . RegisterForNotifications ( getBeatmapSets , beatmapSetsChanged ) ;
2022-01-25 12:09:47 +08:00
subscriptionBeatmaps = realm . RegisterForNotifications ( r = > r . All < BeatmapInfo > ( ) . Where ( b = > ! b . Hidden ) , beatmapsChanged ) ;
2022-01-12 00:03:59 +08:00
// Can't use main subscriptions because we can't lookup deleted indices.
// https://github.com/realm/realm-dotnet/discussions/2634#discussioncomment-1605595.
2022-01-25 12:09:47 +08:00
subscriptionDeletedSets = realm . RegisterForNotifications ( r = > r . All < BeatmapSetInfo > ( ) . Where ( s = > s . DeletePending & & ! s . Protected ) , deletedBeatmapSetsChanged ) ;
subscriptionHiddenBeatmaps = realm . RegisterForNotifications ( r = > r . All < BeatmapInfo > ( ) . Where ( b = > b . Hidden ) , beatmapsChanged ) ;
2022-01-12 00:03:59 +08:00
}
private void deletedBeatmapSetsChanged ( IRealmCollection < BeatmapSetInfo > sender , ChangeSet changes , Exception error )
{
// If loading test beatmaps, avoid overwriting with realm subscription callbacks.
if ( loadedTestBeatmaps )
return ;
if ( changes = = null )
return ;
foreach ( int i in changes . InsertedIndices )
2022-01-20 20:58:16 +08:00
removeBeatmapSet ( sender [ i ] . ID ) ;
2017-12-18 11:30:39 +08:00
}
2018-04-13 17:19:50 +08:00
2021-11-08 16:41:42 +08:00
private void beatmapSetsChanged ( IRealmCollection < BeatmapSetInfo > sender , ChangeSet changes , Exception error )
{
2022-01-10 13:52:59 +08:00
// If loading test beatmaps, avoid overwriting with realm subscription callbacks.
if ( loadedTestBeatmaps )
return ;
2021-11-08 16:41:42 +08:00
if ( changes = = null )
{
2022-01-19 16:47:46 +08:00
// During initial population, we must manually account for the fact that our original query was done on an async thread.
// Since then, there may have been imports or deletions.
// Here we manually catch up on any changes.
var realmSets = new HashSet < Guid > ( ) ;
2022-01-20 21:21:00 +08:00
for ( int i = 0 ; i < sender . Count ; i + + )
realmSets . Add ( sender [ i ] . ID ) ;
2022-01-19 16:47:46 +08:00
2022-01-20 20:58:16 +08:00
foreach ( var id in realmSets )
2022-01-19 16:47:46 +08:00
{
2022-01-20 20:58:16 +08:00
if ( ! root . BeatmapSetsByID . ContainsKey ( id ) )
2022-01-24 18:59:58 +08:00
UpdateBeatmapSet ( realm . Realm . Find < BeatmapSetInfo > ( id ) . Detach ( ) ) ;
2022-01-19 16:47:46 +08:00
}
2022-01-20 20:58:16 +08:00
foreach ( var id in root . BeatmapSetsByID . Keys )
2022-01-19 16:47:46 +08:00
{
2022-01-20 20:58:16 +08:00
if ( ! realmSets . Contains ( id ) )
removeBeatmapSet ( id ) ;
2022-01-19 16:47:46 +08:00
}
2022-01-20 15:39:42 +08:00
signalBeatmapsLoaded ( ) ;
2021-11-08 16:41:42 +08:00
return ;
}
foreach ( int i in changes . NewModifiedIndices )
2022-01-20 20:14:10 +08:00
UpdateBeatmapSet ( sender [ i ] . Detach ( ) ) ;
2021-11-08 16:41:42 +08:00
2022-01-12 00:03:59 +08:00
foreach ( int i in changes . InsertedIndices )
2022-01-20 20:14:10 +08:00
UpdateBeatmapSet ( sender [ i ] . Detach ( ) ) ;
2022-07-27 12:30:38 +08:00
if ( changes . DeletedIndices . Length > 0 )
{
// To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions.
// When an update occurs, the previous beatmap set is either soft or hard deleted.
// Check if the current selection was potentially deleted by re-querying its validity.
bool selectedSetMarkedDeleted = realm . Run ( r = > r . Find < BeatmapSetInfo > ( SelectedBeatmapSet . ID ) ) ? . DeletePending ! = false ;
2022-08-16 15:21:35 +08:00
int [ ] modifiedAndInserted = changes . NewModifiedIndices . Concat ( changes . InsertedIndices ) . ToArray ( ) ;
if ( selectedSetMarkedDeleted & & modifiedAndInserted . Any ( ) )
2022-07-27 12:30:38 +08:00
{
// If it is no longer valid, make the bold assumption that an updated version will be available in the modified indices.
// This relies on the full update operation being in a single transaction, so please don't change that.
2022-08-16 15:21:35 +08:00
foreach ( int i in modifiedAndInserted )
2022-07-27 12:30:38 +08:00
{
var beatmapSetInfo = sender [ i ] ;
foreach ( var beatmapInfo in beatmapSetInfo . Beatmaps )
{
2022-07-27 13:18:53 +08:00
if ( ! ( ( IBeatmapMetadataInfo ) beatmapInfo . Metadata ) . Equals ( SelectedBeatmapInfo . Metadata ) )
continue ;
2022-07-27 12:30:38 +08:00
2022-07-27 13:18:53 +08:00
// Best effort matching. We can't use ID because in the update flow a new version will get its own GUID.
if ( beatmapInfo . DifficultyName = = SelectedBeatmapInfo . DifficultyName )
2022-07-27 12:30:38 +08:00
{
SelectBeatmap ( beatmapInfo ) ;
2022-07-27 13:18:53 +08:00
return ;
2022-07-27 12:30:38 +08:00
}
}
}
2022-07-27 13:18:53 +08:00
// If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed.
// Let's attempt to follow set-level selection anyway.
SelectBeatmap ( sender [ changes . NewModifiedIndices . First ( ) ] . Beatmaps . First ( ) ) ;
2022-07-27 12:30:38 +08:00
}
}
2021-11-08 16:41:42 +08:00
}
private void beatmapsChanged ( IRealmCollection < BeatmapInfo > sender , ChangeSet changes , Exception error )
{
// we only care about actual changes in hidden status.
if ( changes = = null )
return ;
2022-01-12 00:03:59 +08:00
foreach ( int i in changes . InsertedIndices )
2022-01-20 20:58:16 +08:00
{
var beatmapInfo = sender [ i ] ;
var beatmapSet = beatmapInfo . BeatmapSet ;
Debug . Assert ( beatmapSet ! = null ) ;
// Only require to action here if the beatmap is missing.
// This avoids processing these events unnecessarily when new beatmaps are imported, for example.
if ( root . BeatmapSetsByID . TryGetValue ( beatmapSet . ID , out var existingSet )
& & existingSet . BeatmapSet . Beatmaps . All ( b = > b . ID ! = beatmapInfo . ID ) )
{
UpdateBeatmapSet ( beatmapSet . Detach ( ) ) ;
}
}
2021-11-08 16:41:42 +08:00
}
2020-03-12 14:26:22 +08:00
2022-01-23 18:42:26 +08:00
private IQueryable < BeatmapSetInfo > getBeatmapSets ( Realm realm ) = > realm . All < BeatmapSetInfo > ( ) . Where ( s = > ! s . DeletePending & & ! s . Protected ) ;
2022-01-19 16:47:46 +08:00
2022-01-20 20:58:16 +08:00
public void RemoveBeatmapSet ( BeatmapSetInfo beatmapSet ) = >
removeBeatmapSet ( beatmapSet . ID ) ;
2018-04-13 17:19:50 +08:00
2022-01-20 20:58:16 +08:00
private void removeBeatmapSet ( Guid beatmapSetID ) = > Schedule ( ( ) = >
{
if ( ! root . BeatmapSetsByID . TryGetValue ( beatmapSetID , out var existingSet ) )
2019-07-25 11:18:18 +08:00
return ;
2018-04-13 17:19:50 +08:00
2022-07-21 15:06:06 +08:00
root . RemoveItem ( existingSet ) ;
2019-07-25 11:18:18 +08:00
itemsCache . Invalidate ( ) ;
2022-01-27 15:17:38 +08:00
if ( ! Scroll . UserScrolling )
ScrollToSelected ( true ) ;
2019-07-25 11:18:18 +08:00
} ) ;
2018-04-13 17:19:50 +08:00
2019-06-26 10:40:33 +08:00
public void UpdateBeatmapSet ( BeatmapSetInfo beatmapSet ) = > Schedule ( ( ) = >
2017-07-28 13:22:14 +08:00
{
2021-11-24 11:16:08 +08:00
Guid ? previouslySelectedID = null ;
2019-06-26 10:40:33 +08:00
// If the selected beatmap is about to be removed, store its ID so it can be re-selected if required
2022-01-20 20:58:16 +08:00
if ( selectedBeatmapSet ? . BeatmapSet . ID = = beatmapSet . ID )
2021-10-02 11:44:22 +08:00
previouslySelectedID = selectedBeatmap ? . BeatmapInfo . ID ;
2018-04-13 17:19:50 +08:00
2019-06-26 10:40:33 +08:00
var newSet = createCarouselSet ( beatmapSet ) ;
2022-07-21 15:24:46 +08:00
var removedSet = root . RemoveChild ( beatmapSet . ID ) ;
2018-04-13 17:19:50 +08:00
2022-07-21 15:24:46 +08:00
// If we don't remove this here, it may remain in a hidden state until scrolled off screen.
// Doesn't really affect anything during actual user interaction, but makes testing annoying.
var removedDrawable = Scroll . FirstOrDefault ( c = > c . Item = = removedSet ) ;
if ( removedDrawable ! = null )
expirePanelImmediately ( removedDrawable ) ;
2018-04-13 17:19:50 +08:00
2022-01-20 20:58:16 +08:00
if ( newSet ! = null )
2019-06-26 10:40:33 +08:00
{
2022-07-21 15:06:06 +08:00
root . AddItem ( newSet ) ;
2018-04-13 17:19:50 +08:00
2022-01-20 20:58:16 +08:00
// check if we can/need to maintain our current selection.
if ( previouslySelectedID ! = null )
select ( ( CarouselItem ) newSet . Beatmaps . FirstOrDefault ( b = > b . BeatmapInfo . ID = = previouslySelectedID ) ? ? newSet ) ;
}
2018-04-13 17:19:50 +08:00
2019-06-26 10:40:33 +08:00
itemsCache . Invalidate ( ) ;
2018-04-13 17:19:50 +08:00
2022-01-27 15:16:49 +08:00
if ( ! Scroll . UserScrolling )
ScrollToSelected ( true ) ;
2018-04-13 17:19:50 +08:00
2022-01-27 15:16:49 +08:00
BeatmapSetsChanged ? . Invoke ( ) ;
2019-06-26 10:40:33 +08:00
} ) ;
2018-04-13 17:19:50 +08:00
2018-03-09 18:21:00 +08:00
/// <summary>
/// Selects a given beatmap on the carousel.
/// </summary>
2021-10-02 23:55:29 +08:00
/// <param name="beatmapInfo">The beatmap to select.</param>
2018-03-09 22:09:28 +08:00
/// <param name="bypassFilters">Whether to select the beatmap even if it is filtered (i.e., not visible on carousel).</param>
2018-03-09 18:52:59 +08:00
/// <returns>True if a selection was made, False if it wasn't.</returns>
2021-10-02 23:55:29 +08:00
public bool SelectBeatmap ( BeatmapInfo beatmapInfo , bool bypassFilters = true )
2017-03-17 18:12:15 +08:00
{
2020-04-16 17:10:35 +08:00
// ensure that any pending events from BeatmapManager have been run before attempting a selection.
Scheduler . Update ( ) ;
2021-10-02 23:55:29 +08:00
if ( beatmapInfo ? . Hidden ! = false )
2018-03-09 18:21:00 +08:00
return false ;
2018-04-13 17:19:50 +08:00
2018-03-09 18:52:59 +08:00
foreach ( CarouselBeatmapSet set in beatmapSets )
{
2019-02-21 17:56:34 +08:00
if ( ! bypassFilters & & set . Filtered . Value )
2018-03-09 18:52:59 +08:00
continue ;
2018-04-13 17:19:50 +08:00
2021-10-02 23:55:29 +08:00
var item = set . Beatmaps . FirstOrDefault ( p = > p . BeatmapInfo . Equals ( beatmapInfo ) ) ;
2018-04-13 17:19:50 +08:00
2018-03-09 18:52:59 +08:00
if ( item = = null )
// The beatmap that needs to be selected doesn't exist in this set
continue ;
2018-04-13 17:19:50 +08:00
2019-02-21 17:56:34 +08:00
if ( ! bypassFilters & & item . Filtered . Value )
2020-03-12 14:52:03 +08:00
return false ;
2018-04-13 17:19:50 +08:00
2020-03-12 14:52:03 +08:00
select ( item ) ;
2020-02-10 15:31:52 +08:00
2020-03-12 14:52:03 +08:00
// if we got here and the set is filtered, it means we were bypassing filters.
// in this case, reapplying the filter is necessary to ensure the panel is in the correct place
// (since it is forcefully being included in the carousel).
if ( set . Filtered . Value )
{
Debug . Assert ( bypassFilters ) ;
2020-02-10 15:31:52 +08:00
2020-03-12 14:52:03 +08:00
applyActiveCriteria ( false ) ;
2018-03-09 18:52:59 +08:00
}
2020-03-12 14:52:03 +08:00
return true ;
2018-03-09 18:52:59 +08:00
}
2018-04-13 17:19:50 +08:00
2018-03-09 18:52:59 +08:00
return false ;
2017-03-17 18:12:15 +08:00
}
2018-04-13 17:19:50 +08:00
2017-12-11 19:22:10 +08:00
/// <summary>
/// Increment selection in the carousel in a chosen direction.
/// </summary>
/// <param name="direction">The direction to increment. Negative is backwards.</param>
/// <param name="skipDifficulties">Whether to skip individual difficulties and only increment over full groups.</param>
2017-03-17 18:12:15 +08:00
public void SelectNext ( int direction = 1 , bool skipDifficulties = true )
{
2020-03-29 02:21:21 +08:00
if ( beatmapSets . All ( s = > s . Filtered . Value ) )
2017-12-23 19:53:11 +08:00
return ;
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
if ( skipDifficulties )
selectNextSet ( direction , true ) ;
else
selectNextDifficulty ( direction ) ;
}
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
private void selectNextSet ( int direction , bool skipDifficulties )
{
2020-03-29 23:07:48 +08:00
var unfilteredSets = beatmapSets . Where ( s = > ! s . Filtered . Value ) . ToList ( ) ;
2018-04-13 17:19:50 +08:00
2020-03-29 23:07:48 +08:00
var nextSet = unfilteredSets [ ( unfilteredSets . IndexOf ( selectedBeatmapSet ) + direction + unfilteredSets . Count ) % unfilteredSets . Count ] ;
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
if ( skipDifficulties )
2020-03-28 19:23:31 +08:00
select ( nextSet ) ;
2020-03-28 18:54:48 +08:00
else
2020-03-28 19:23:31 +08:00
select ( direction > 0 ? nextSet . Beatmaps . First ( b = > ! b . Filtered . Value ) : nextSet . Beatmaps . Last ( b = > ! b . Filtered . Value ) ) ;
2020-03-28 18:54:48 +08:00
}
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
private void selectNextDifficulty ( int direction )
{
2020-06-26 20:03:34 +08:00
if ( selectedBeatmap = = null )
return ;
2022-07-21 15:06:06 +08:00
var unfilteredDifficulties = selectedBeatmapSet . Items . Where ( s = > ! s . Filtered . Value ) . ToList ( ) ;
2018-04-13 17:19:50 +08:00
2020-03-29 23:07:48 +08:00
int index = unfilteredDifficulties . IndexOf ( selectedBeatmap ) ;
2018-04-13 17:19:50 +08:00
2020-03-29 23:07:48 +08:00
if ( index + direction < 0 | | index + direction > = unfilteredDifficulties . Count )
2020-03-28 18:54:48 +08:00
selectNextSet ( direction , false ) ;
else
2020-03-29 23:07:48 +08:00
select ( unfilteredDifficulties [ index + direction ] ) ;
2017-03-17 18:12:15 +08:00
}
2018-04-13 17:19:50 +08:00
2017-12-31 11:55:53 +08:00
/// <summary>
/// Select the next beatmap in the random sequence.
/// </summary>
/// <returns>True if a selection could be made, else False.</returns>
public bool SelectNextRandom ( )
2017-03-17 18:12:15 +08:00
{
2020-07-12 21:21:16 +08:00
if ( ! AllowSelection )
return false ;
2019-02-21 17:56:34 +08:00
var visibleSets = beatmapSets . Where ( s = > ! s . Filtered . Value ) . ToList ( ) ;
2022-01-28 12:27:12 +08:00
visibleSetsCount = visibleSets . Count ;
2017-12-31 09:10:54 +08:00
if ( ! visibleSets . Any ( ) )
2017-12-31 11:55:53 +08:00
return false ;
2018-04-13 17:19:50 +08:00
2017-12-12 16:48:38 +08:00
if ( selectedBeatmap ! = null )
2017-12-14 11:59:35 +08:00
{
2017-12-12 16:48:38 +08:00
randomSelectedBeatmaps . Push ( selectedBeatmap ) ;
2018-04-13 17:19:50 +08:00
2017-12-14 11:59:35 +08:00
// when performing a random, we want to add the current set to the previously visited list
// else the user may be "randomised" to the existing selection.
if ( previouslyVisitedRandomSets . LastOrDefault ( ) ! = selectedBeatmapSet )
previouslyVisitedRandomSets . Add ( selectedBeatmapSet ) ;
}
2018-04-13 17:19:50 +08:00
2017-12-14 11:59:35 +08:00
CarouselBeatmapSet set ;
2018-04-13 17:19:50 +08:00
2019-02-21 17:56:34 +08:00
if ( RandomAlgorithm . Value = = RandomSelectAlgorithm . RandomPermutation )
2017-06-01 00:41:15 +08:00
{
2017-12-31 09:10:54 +08:00
var notYetVisitedSets = visibleSets . Except ( previouslyVisitedRandomSets ) . ToList ( ) ;
2019-04-01 11:16:05 +08:00
2017-12-14 11:59:35 +08:00
if ( ! notYetVisitedSets . Any ( ) )
2017-06-01 02:31:05 +08:00
{
2017-12-31 20:47:27 +08:00
previouslyVisitedRandomSets . RemoveAll ( s = > visibleSets . Contains ( s ) ) ;
2017-12-31 09:10:54 +08:00
notYetVisitedSets = visibleSets ;
2017-06-01 00:41:15 +08:00
}
2018-04-13 17:19:50 +08:00
2017-12-14 15:59:08 +08:00
set = notYetVisitedSets . ElementAt ( RNG . Next ( notYetVisitedSets . Count ) ) ;
2017-12-14 11:59:35 +08:00
previouslyVisitedRandomSets . Add ( set ) ;
2017-06-01 00:41:15 +08:00
}
else
2017-12-31 09:10:54 +08:00
set = visibleSets . ElementAt ( RNG . Next ( visibleSets . Count ) ) ;
2018-04-13 17:19:50 +08:00
2022-01-28 12:27:12 +08:00
if ( selectedBeatmapSet ! = null )
playSpinSample ( distanceBetween ( set , selectedBeatmapSet ) ) ;
2020-03-20 12:01:24 +08:00
select ( set ) ;
2017-12-31 11:55:53 +08:00
return true ;
2017-03-17 18:12:15 +08:00
}
2018-04-13 17:19:50 +08:00
2017-06-05 17:24:28 +08:00
public void SelectPreviousRandom ( )
2017-06-02 01:54:42 +08:00
{
2017-06-05 17:24:28 +08:00
while ( randomSelectedBeatmaps . Any ( ) )
2017-06-02 01:54:42 +08:00
{
2017-12-12 16:48:38 +08:00
var beatmap = randomSelectedBeatmaps . Pop ( ) ;
2018-04-13 17:19:50 +08:00
2019-02-21 17:56:34 +08:00
if ( ! beatmap . Filtered . Value )
2017-06-02 01:54:42 +08:00
{
2019-02-21 17:56:34 +08:00
if ( RandomAlgorithm . Value = = RandomSelectAlgorithm . RandomPermutation )
2017-12-14 11:59:35 +08:00
previouslyVisitedRandomSets . Remove ( selectedBeatmapSet ) ;
2022-01-28 12:27:12 +08:00
if ( selectedBeatmapSet ! = null )
playSpinSample ( distanceBetween ( beatmap , selectedBeatmapSet ) ) ;
2017-12-12 16:48:38 +08:00
select ( beatmap ) ;
2017-06-02 01:54:42 +08:00
break ;
}
}
}
2018-04-13 17:19:50 +08:00
2022-01-28 12:27:12 +08:00
private double distanceBetween ( CarouselItem item1 , CarouselItem item2 ) = > Math . Ceiling ( Math . Abs ( item1 . CarouselYPosition - item2 . CarouselYPosition ) / DrawableCarouselItem . MAX_HEIGHT ) ;
private void playSpinSample ( double distance )
{
var chan = spinSample . GetChannel ( ) ;
chan . Frequency . Value = 1f + Math . Min ( 1f , distance / visibleSetsCount ) ;
chan . Play ( ) ;
2022-02-04 14:42:52 +08:00
randomSelectSample ? . Play ( ) ;
2022-01-28 12:27:12 +08:00
}
2017-12-18 11:30:39 +08:00
private void select ( CarouselItem item )
{
2019-03-21 20:02:45 +08:00
if ( ! AllowSelection )
return ;
2017-12-18 11:30:39 +08:00
if ( item = = null ) return ;
2019-02-28 12:31:40 +08:00
2017-12-18 11:30:39 +08:00
item . State . Value = CarouselItemState . Selected ;
}
2018-04-13 17:19:50 +08:00
2017-12-16 15:27:39 +08:00
private FilterCriteria activeCriteria = new FilterCriteria ( ) ;
2018-04-13 17:19:50 +08:00
2018-07-18 09:12:14 +08:00
protected ScheduledDelegate PendingFilter ;
2018-04-13 17:19:50 +08:00
2017-07-20 10:50:31 +08:00
public bool AllowSelection = true ;
2018-04-13 17:19:50 +08:00
2019-07-26 12:07:28 +08:00
/// <summary>
2019-07-26 14:22:29 +08:00
/// Half the height of the visible content.
2019-07-26 14:13:10 +08:00
/// <remarks>
2019-11-17 20:55:40 +08:00
/// This is different from the height of <see cref="ScrollContainer{T}"/>.displayableContent, since
2019-07-26 14:13:10 +08:00
/// the beatmap carousel bleeds into the <see cref="FilterControl"/> and the <see cref="Footer"/>
/// </remarks>
2019-07-26 12:07:28 +08:00
/// </summary>
2020-04-19 23:29:06 +08:00
private float visibleHalfHeight = > ( DrawHeight + BleedBottom + BleedTop ) / 2 ;
2019-07-26 12:07:28 +08:00
2019-07-26 14:13:10 +08:00
/// <summary>
/// The position of the lower visible bound with respect to the current scroll position.
/// </summary>
2020-11-26 17:28:52 +08:00
private float visibleBottomBound = > Scroll . Current + DrawHeight + BleedBottom ;
2019-07-26 14:13:10 +08:00
/// <summary>
/// The position of the upper visible bound with respect to the current scroll position.
/// </summary>
2020-11-26 17:28:52 +08:00
private float visibleUpperBound = > Scroll . Current - BleedTop ;
2019-07-26 14:13:10 +08:00
2017-12-16 15:27:39 +08:00
public void FlushPendingFilterOperations ( )
2017-07-21 16:20:52 +08:00
{
2018-07-18 09:12:14 +08:00
if ( PendingFilter ? . Completed = = false )
2018-03-23 00:06:05 +08:00
{
2020-03-10 18:59:49 +08:00
applyActiveCriteria ( false ) ;
2018-03-23 00:06:05 +08:00
Update ( ) ;
}
2017-07-21 16:20:52 +08:00
}
2018-04-13 17:19:50 +08:00
2017-12-16 15:27:39 +08:00
public void Filter ( FilterCriteria newCriteria , bool debounce = true )
2017-03-17 18:12:15 +08:00
{
2017-03-29 18:47:53 +08:00
if ( newCriteria ! = null )
2017-12-16 15:27:39 +08:00
activeCriteria = newCriteria ;
2018-04-13 17:19:50 +08:00
2020-03-10 18:59:49 +08:00
applyActiveCriteria ( debounce ) ;
2017-12-16 15:27:39 +08:00
}
2018-04-13 17:19:50 +08:00
2020-03-13 10:51:26 +08:00
private void applyActiveCriteria ( bool debounce , bool alwaysResetScrollPosition = true )
2017-12-16 15:27:39 +08:00
{
2020-07-12 21:21:16 +08:00
PendingFilter ? . Cancel ( ) ;
PendingFilter = null ;
if ( debounce )
PendingFilter = Scheduler . AddDelayed ( perform , 250 ) ;
else
{
// if initial load is not yet finished, this will be run inline in loadBeatmapSets to ensure correct order of operation.
if ( ! BeatmapSetsLoaded )
PendingFilter = Schedule ( perform ) ;
else
perform ( ) ;
}
2018-04-13 17:19:50 +08:00
2017-12-16 22:55:55 +08:00
void perform ( )
2016-11-25 17:14:56 +08:00
{
2018-07-18 09:12:14 +08:00
PendingFilter = null ;
2018-04-13 17:19:50 +08:00
2017-12-16 15:27:39 +08:00
root . Filter ( activeCriteria ) ;
2017-12-18 01:23:03 +08:00
itemsCache . Invalidate ( ) ;
2020-03-13 10:51:26 +08:00
2020-11-26 17:28:52 +08:00
if ( alwaysResetScrollPosition | | ! Scroll . UserScrolling )
2020-11-27 12:54:36 +08:00
ScrollToSelected ( true ) ;
2017-12-16 22:55:55 +08:00
}
2016-11-21 03:34:16 +08:00
}
2018-04-13 17:19:50 +08:00
2022-01-20 15:39:42 +08:00
private void signalBeatmapsLoaded ( )
{
2022-01-21 18:39:49 +08:00
if ( ! BeatmapSetsLoaded )
{
BeatmapSetsChanged ? . Invoke ( ) ;
BeatmapSetsLoaded = true ;
}
2022-01-20 15:39:42 +08:00
itemsCache . Invalidate ( ) ;
}
2017-12-17 01:33:01 +08:00
private float? scrollTarget ;
2018-04-13 17:19:50 +08:00
2020-03-13 10:51:26 +08:00
/// <summary>
2021-10-02 11:44:22 +08:00
/// Scroll to the current <see cref="SelectedBeatmapInfo"/>.
2020-03-13 10:51:26 +08:00
/// </summary>
2020-11-27 12:54:36 +08:00
/// <param name="immediate">
/// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels.
/// This should be true for operations like filtering - where panels are changing visibility state - to avoid large jumps in animation.
/// </param>
public void ScrollToSelected ( bool immediate = false ) = >
pendingScrollOperation = immediate ? PendingScrollOperation . Immediate : PendingScrollOperation . Standard ;
2018-04-13 17:19:50 +08:00
2022-05-04 08:52:10 +08:00
#region Button selection logic
2020-03-02 17:55:28 +08:00
2021-09-16 17:26:12 +08:00
public bool OnPressed ( KeyBindingPressEvent < GlobalAction > e )
2020-03-02 17:55:28 +08:00
{
2021-09-16 17:26:12 +08:00
switch ( e . Action )
2020-03-02 17:55:28 +08:00
{
case GlobalAction . SelectNext :
2022-05-04 21:46:23 +08:00
case GlobalAction . SelectNextGroup :
SelectNext ( 1 , e . Action = = GlobalAction . SelectNextGroup ) ;
2020-03-02 17:55:28 +08:00
return true ;
case GlobalAction . SelectPrevious :
2022-05-04 21:46:23 +08:00
case GlobalAction . SelectPreviousGroup :
SelectNext ( - 1 , e . Action = = GlobalAction . SelectPreviousGroup ) ;
2020-03-02 17:55:28 +08:00
return true ;
2017-12-18 11:30:39 +08:00
}
2018-04-13 17:19:50 +08:00
2020-03-02 17:55:28 +08:00
return false ;
}
2018-04-13 17:19:50 +08:00
2021-09-16 17:26:12 +08:00
public void OnReleased ( KeyBindingReleaseEvent < GlobalAction > e )
2020-03-02 17:55:28 +08:00
{
2020-06-25 18:47:23 +08:00
}
#endregion
2021-01-02 21:05:41 +08:00
protected override bool OnInvalidate ( Invalidation invalidation , InvalidationSource source )
{
// handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed).
2022-06-07 15:31:58 +08:00
if ( invalidation . HasFlagFast ( Invalidation . DrawSize ) )
2021-01-02 21:05:41 +08:00
itemsCache . Invalidate ( ) ;
return base . OnInvalidate ( invalidation , source ) ;
}
2017-12-18 11:30:39 +08:00
protected override void Update ( )
{
base . Update ( ) ;
2018-04-13 17:19:50 +08:00
2020-10-12 13:46:51 +08:00
bool revalidateItems = ! itemsCache . IsValid ;
2020-10-13 12:44:32 +08:00
// First we iterate over all non-filtered carousel items and populate their
// vertical position data.
2020-10-12 13:46:51 +08:00
if ( revalidateItems )
2022-06-07 15:32:15 +08:00
{
2020-10-12 18:55:17 +08:00
updateYPositions ( ) ;
2018-04-13 17:19:50 +08:00
2022-06-07 15:32:15 +08:00
if ( visibleItems . Count = = 0 )
{
noResultsPlaceholder . Filter = activeCriteria ;
noResultsPlaceholder . Show ( ) ;
}
else
noResultsPlaceholder . Hide ( ) ;
}
2020-11-26 17:42:51 +08:00
// if there is a pending scroll action we apply it without animation and transfer the difference in position to the panels.
2020-11-27 12:54:36 +08:00
// this is intentionally applied before updating the visible range below, to avoid animating new items (sourced from pool) from locations off-screen, as it looks bad.
if ( pendingScrollOperation ! = PendingScrollOperation . None )
2020-11-26 17:33:37 +08:00
updateScrollPosition ( ) ;
2020-10-13 12:44:32 +08:00
// This data is consumed to find the currently displayable range.
// This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn.
var newDisplayRange = getDisplayRange ( ) ;
2020-10-12 18:12:00 +08:00
2020-10-13 12:44:32 +08:00
// If the filtered items or visible range has changed, pooling requirements need to be checked.
// This involves fetching new items from the pool, returning no-longer required items.
if ( revalidateItems | | newDisplayRange ! = displayedRange )
2020-10-12 13:46:51 +08:00
{
2020-10-13 12:44:32 +08:00
displayedRange = newDisplayRange ;
2018-04-13 17:19:50 +08:00
2020-10-19 18:55:20 +08:00
if ( visibleItems . Count > 0 )
2017-12-18 11:30:39 +08:00
{
2020-10-19 18:55:20 +08:00
var toDisplay = visibleItems . GetRange ( displayedRange . first , displayedRange . last - displayedRange . first + 1 ) ;
2020-10-12 14:36:03 +08:00
2020-11-26 17:28:52 +08:00
foreach ( var panel in Scroll . Children )
2020-10-13 13:23:29 +08:00
{
2020-10-19 18:55:20 +08:00
if ( toDisplay . Remove ( panel . Item ) )
{
// panel already displayed.
continue ;
}
// panel loaded as drawable but not required by visible range.
// remove but only if too far off-screen
if ( panel . Y + panel . DrawHeight < visibleUpperBound - distance_offscreen_before_unload | | panel . Y > visibleBottomBound + distance_offscreen_before_unload )
2022-07-21 15:24:46 +08:00
expirePanelImmediately ( panel ) ;
2020-10-13 13:23:29 +08:00
}
2018-04-13 17:19:50 +08:00
2020-10-19 18:55:20 +08:00
// Add those items within the previously found index range that should be displayed.
foreach ( var item in toDisplay )
{
var panel = setPool . Get ( p = > p . Item = item ) ;
2020-10-13 12:51:27 +08:00
2020-10-19 18:55:20 +08:00
panel . Depth = item . CarouselYPosition ;
panel . Y = item . CarouselYPosition ;
2020-10-13 12:51:27 +08:00
2020-11-26 17:28:52 +08:00
Scroll . Add ( panel ) ;
2020-10-19 18:55:20 +08:00
}
2020-10-13 12:44:32 +08:00
}
}
2020-10-12 13:46:51 +08:00
2020-10-13 12:44:32 +08:00
// Update externally controlled state of currently visible items (e.g. x-offset and opacity).
// This is a per-frame update on all drawable panels.
2020-11-26 17:28:52 +08:00
foreach ( DrawableCarouselItem item in Scroll . Children )
2020-10-13 12:44:32 +08:00
{
2020-10-13 13:37:44 +08:00
updateItem ( item ) ;
if ( item is DrawableCarouselBeatmapSet set )
{
2020-10-13 18:15:56 +08:00
foreach ( var diff in set . DrawableBeatmaps )
2020-10-13 13:37:44 +08:00
updateItem ( diff , item ) ;
}
2020-10-13 12:44:32 +08:00
}
2017-12-18 11:30:39 +08:00
}
2018-04-13 17:19:50 +08:00
2022-07-21 15:24:46 +08:00
private static void expirePanelImmediately ( DrawableCarouselItem panel )
{
// may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected).
panel . ClearTransforms ( ) ;
panel . Expire ( ) ;
}
2020-10-13 17:33:31 +08:00
private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem ( ) ;
2020-10-13 12:21:21 +08:00
private ( int firstIndex , int lastIndex ) getDisplayRange ( )
{
// Find index range of all items that should be on-screen
2020-10-13 17:33:31 +08:00
carouselBoundsItem . CarouselYPosition = visibleUpperBound - distance_offscreen_to_preload ;
int firstIndex = visibleItems . BinarySearch ( carouselBoundsItem ) ;
2020-10-13 12:21:21 +08:00
if ( firstIndex < 0 ) firstIndex = ~ firstIndex ;
2020-10-13 17:33:31 +08:00
carouselBoundsItem . CarouselYPosition = visibleBottomBound + distance_offscreen_to_preload ;
int lastIndex = visibleItems . BinarySearch ( carouselBoundsItem ) ;
2020-10-13 12:21:21 +08:00
if ( lastIndex < 0 ) lastIndex = ~ lastIndex ;
// as we can't be 100% sure on the size of individual carousel drawables,
// always play it safe and extend bounds by one.
firstIndex = Math . Max ( 0 , firstIndex - 1 ) ;
2020-10-19 18:10:01 +08:00
lastIndex = Math . Clamp ( lastIndex + 1 , firstIndex , Math . Max ( 0 , visibleItems . Count - 1 ) ) ;
2020-10-13 17:33:31 +08:00
2020-10-13 12:21:21 +08:00
return ( firstIndex , lastIndex ) ;
}
2017-12-14 15:59:08 +08:00
private CarouselBeatmapSet createCarouselSet ( BeatmapSetInfo beatmapSet )
2017-03-17 18:12:15 +08:00
{
2022-01-20 16:50:17 +08:00
// This can be moved to the realm query if required using:
2022-01-20 17:36:20 +08:00
// .Filter("DeletePending == false && Protected == false && ANY Beatmaps.Hidden == false")
2022-01-20 16:50:17 +08:00
//
// As long as we are detaching though, it makes more sense to do it here as adding to the realm query has an overhead
// as seen at https://github.com/realm/realm-dotnet/discussions/2773#discussioncomment-2004275.
2017-10-20 09:50:00 +08:00
if ( beatmapSet . Beatmaps . All ( b = > b . Hidden ) )
return null ;
2018-04-13 17:19:50 +08:00
2020-04-11 15:58:13 +08:00
var set = new CarouselBeatmapSet ( beatmapSet )
2020-04-09 23:47:28 +08:00
{
2020-04-11 15:58:13 +08:00
GetRecommendedBeatmap = beatmaps = > GetRecommendedBeatmap ? . Invoke ( beatmaps )
} ;
2018-04-13 17:19:50 +08:00
2017-12-12 16:48:38 +08:00
foreach ( var c in set . Beatmaps )
2017-03-17 18:12:15 +08:00
{
2019-02-22 16:51:39 +08:00
c . State . ValueChanged + = state = >
2017-12-12 16:48:38 +08:00
{
2019-02-22 16:51:39 +08:00
if ( state . NewValue = = CarouselItemState . Selected )
2017-12-12 16:48:38 +08:00
{
2017-12-17 04:53:13 +08:00
selectedBeatmapSet = set ;
2021-10-02 11:44:22 +08:00
SelectionChanged ? . Invoke ( c . BeatmapInfo ) ;
2018-04-13 17:19:50 +08:00
2017-12-18 01:23:03 +08:00
itemsCache . Invalidate ( ) ;
2020-03-13 10:51:26 +08:00
ScrollToSelected ( ) ;
2017-12-12 16:48:38 +08:00
}
} ;
}
2018-04-13 17:19:50 +08:00
2017-12-12 16:48:38 +08:00
return set ;
2017-03-17 18:12:15 +08:00
}
2018-04-13 17:19:50 +08:00
2020-10-12 17:11:41 +08:00
private const float panel_padding = 5 ;
2016-11-24 00:42:21 +08:00
/// <summary>
2017-12-12 16:48:38 +08:00
/// Computes the target Y positions for every item in the carousel.
2016-11-24 00:42:21 +08:00
/// </summary>
2017-12-12 16:48:38 +08:00
/// <returns>The Y position of the currently selected item.</returns>
2020-10-12 18:55:17 +08:00
private void updateYPositions ( )
2016-11-21 03:34:16 +08:00
{
2020-10-12 13:23:18 +08:00
visibleItems . Clear ( ) ;
2018-04-13 17:19:50 +08:00
2019-07-26 14:22:29 +08:00
float currentY = visibleHalfHeight ;
2018-04-13 17:19:50 +08:00
2017-12-17 01:33:01 +08:00
scrollTarget = null ;
2018-04-13 17:19:50 +08:00
2022-07-21 15:06:06 +08:00
foreach ( CarouselItem item in root . Items )
2017-12-12 16:48:38 +08:00
{
2020-10-12 13:23:18 +08:00
if ( item . Filtered . Value )
continue ;
switch ( item )
2016-11-21 03:34:16 +08:00
{
2020-10-12 13:23:18 +08:00
case CarouselBeatmapSet set :
2017-12-17 01:33:01 +08:00
{
2020-10-12 13:23:18 +08:00
visibleItems . Add ( set ) ;
2020-10-13 12:21:21 +08:00
set . CarouselYPosition = currentY ;
2020-10-12 13:23:18 +08:00
2020-10-12 17:32:29 +08:00
if ( item . State . Value = = CarouselItemState . Selected )
{
// scroll position at currentY makes the set panel appear at the very top of the carousel's screen space
// move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas)
// then reapply the top semi-transparent area (because carousel's screen space starts below it)
scrollTarget = currentY + DrawableCarouselBeatmapSet . HEIGHT - visibleHalfHeight + BleedTop ;
foreach ( var b in set . Beatmaps )
{
2020-10-12 19:02:45 +08:00
if ( ! b . Visible )
continue ;
2020-10-12 17:32:29 +08:00
if ( b . State . Value = = CarouselItemState . Selected )
{
scrollTarget + = b . TotalHeight / 2 ;
break ;
}
2020-10-13 17:13:36 +08:00
scrollTarget + = b . TotalHeight ;
2020-10-12 17:32:29 +08:00
}
}
2020-10-12 17:11:41 +08:00
currentY + = set . TotalHeight + panel_padding ;
2020-10-12 13:23:18 +08:00
break ;
2017-12-17 01:33:01 +08:00
}
2020-10-12 13:23:18 +08:00
}
2016-11-21 03:34:16 +08:00
}
2018-04-13 17:19:50 +08:00
2019-07-26 14:22:29 +08:00
currentY + = visibleHalfHeight ;
2020-11-26 17:28:52 +08:00
Scroll . ScrollContent . Height = currentY ;
2018-04-13 17:19:50 +08:00
2021-01-03 11:53:25 +08:00
itemsCache . Validate ( ) ;
// update and let external consumers know about selection loss.
if ( BeatmapSetsLoaded )
2017-12-18 01:23:03 +08:00
{
2021-01-03 11:53:25 +08:00
bool selectionLost = selectedBeatmapSet ! = null & & selectedBeatmapSet . State . Value ! = CarouselItemState . Selected ;
2018-04-13 17:19:50 +08:00
2021-01-03 21:44:30 +08:00
if ( selectionLost )
2021-01-03 11:53:25 +08:00
{
selectedBeatmapSet = null ;
SelectionChanged ? . Invoke ( null ) ;
}
}
2016-11-21 14:59:46 +08:00
}
2018-04-13 17:19:50 +08:00
2019-11-22 09:51:49 +08:00
private bool firstScroll = true ;
2017-12-18 11:30:39 +08:00
private void updateScrollPosition ( )
2017-12-17 04:53:13 +08:00
{
2019-11-20 18:38:39 +08:00
if ( scrollTarget ! = null )
{
2019-11-22 09:51:49 +08:00
if ( firstScroll )
{
// reduce movement when first displaying the carousel.
2020-11-26 17:28:52 +08:00
Scroll . ScrollTo ( scrollTarget . Value - 200 , false ) ;
2019-11-22 09:51:49 +08:00
firstScroll = false ;
}
2020-11-27 12:54:36 +08:00
switch ( pendingScrollOperation )
{
case PendingScrollOperation . Standard :
Scroll . ScrollTo ( scrollTarget . Value ) ;
break ;
case PendingScrollOperation . Immediate :
2021-01-03 11:53:25 +08:00
2020-11-27 12:54:36 +08:00
// in order to simplify animation logic, rather than using the animated version of ScrollTo,
// we take the difference in scroll height and apply to all visible panels.
// this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer
// to enter clamp-special-case mode where it animates completely differently to normal.
float scrollChange = scrollTarget . Value - Scroll . Current ;
Scroll . ScrollTo ( scrollTarget . Value , false ) ;
foreach ( var i in Scroll . Children )
i . Y + = scrollChange ;
break ;
}
2020-11-26 17:42:51 +08:00
2020-11-27 12:54:36 +08:00
pendingScrollOperation = PendingScrollOperation . None ;
2019-11-20 18:38:39 +08:00
}
2017-12-17 04:53:13 +08:00
}
2018-04-13 17:19:50 +08:00
2017-03-17 18:12:15 +08:00
/// <summary>
2017-12-12 16:48:38 +08:00
/// Computes the x-offset of currently visible items. Makes the carousel appear round.
2017-03-17 18:12:15 +08:00
/// </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 )
2016-12-15 21:57:14 +08:00
{
2017-03-17 18:12:15 +08:00
// The radius of the circle the carousel moves on.
const float circle_radius = 3 ;
2019-11-25 07:45:42 +08:00
float discriminant = MathF . Max ( 0 , circle_radius * circle_radius - dist * dist ) ;
float x = ( circle_radius - MathF . Sqrt ( discriminant ) ) * halfHeight ;
2018-04-13 17:19:50 +08:00
2017-03-17 18:12:15 +08:00
return 125 + x ;
2017-02-24 13:37:54 +08:00
}
2018-04-13 17:19:50 +08:00
2017-03-17 18:12:15 +08:00
/// <summary>
2017-12-12 16:48:38 +08:00
/// Update a item's x position and multiplicative alpha based on its y position and
2017-03-17 18:12:15 +08:00
/// the current scroll position.
/// </summary>
2020-10-13 13:37:44 +08:00
/// <param name="item">The item to be updated.</param>
/// <param name="parent">For nested items, the parent of the item to be updated.</param>
private void updateItem ( DrawableCarouselItem item , DrawableCarouselItem parent = null )
2017-02-24 13:37:54 +08:00
{
2020-11-26 17:28:52 +08:00
Vector2 posInScroll = Scroll . ScrollContent . ToLocalSpace ( item . Header . ScreenSpaceDrawQuad . Centre ) ;
2020-10-13 13:37:44 +08:00
float itemDrawY = posInScroll . Y - visibleUpperBound ;
2019-07-26 14:22:29 +08:00
float dist = Math . Abs ( 1f - itemDrawY / visibleHalfHeight ) ;
2018-04-13 17:19:50 +08:00
2020-10-13 16:33:35 +08:00
// adjusting the item's overall X position can cause it to become masked away when
// child items (difficulties) are still visible.
item . Header . X = offsetX ( dist , visibleHalfHeight ) - ( parent ? . X ? ? 0 ) ;
2018-04-13 17:19:50 +08:00
2022-02-05 15:12:58 +08:00
// We are applying a multiplicative alpha (which is internally done by nesting an
// additional container and setting that container's alpha) such that we can
// layer alpha transformations on top.
item . SetMultiplicativeAlpha ( Math . Clamp ( 1.75f - 1.5f * dist , 0 , 1 ) ) ;
2016-12-15 21:57:14 +08:00
}
2018-04-13 17:19:50 +08:00
2020-11-27 12:54:36 +08:00
private enum PendingScrollOperation
{
None ,
Standard ,
Immediate ,
}
2020-10-13 12:21:21 +08:00
/// <summary>
/// A carousel item strictly used for binary search purposes.
/// </summary>
private class CarouselBoundsItem : CarouselItem
{
public override DrawableCarouselItem CreateDrawableRepresentation ( ) = >
throw new NotImplementedException ( ) ;
}
2018-04-02 14:23:58 +08:00
private class CarouselRoot : CarouselGroupEagerSelect
{
private readonly BeatmapCarousel carousel ;
2018-04-13 17:19:50 +08:00
2022-01-20 20:58:16 +08:00
public readonly Dictionary < Guid , CarouselBeatmapSet > BeatmapSetsByID = new Dictionary < Guid , CarouselBeatmapSet > ( ) ;
2018-04-02 14:23:58 +08:00
public CarouselRoot ( BeatmapCarousel carousel )
{
2020-03-21 23:32:53 +08:00
// root should always remain selected. if not, PerformSelection will not be called.
2020-03-20 14:01:26 +08:00
State . Value = CarouselItemState . Selected ;
2022-06-24 20:25:23 +08:00
State . ValueChanged + = _ = > State . Value = CarouselItemState . Selected ;
2020-03-20 14:01:26 +08:00
2018-04-02 14:23:58 +08:00
this . carousel = carousel ;
}
2018-04-13 17:19:50 +08:00
2022-07-21 15:06:06 +08:00
public override void AddItem ( CarouselItem i )
2022-01-20 20:58:16 +08:00
{
CarouselBeatmapSet set = ( CarouselBeatmapSet ) i ;
2022-01-21 14:29:21 +08:00
BeatmapSetsByID . Add ( set . BeatmapSet . ID , set ) ;
2022-01-20 20:58:16 +08:00
2022-07-21 15:06:06 +08:00
base . AddItem ( i ) ;
2022-01-20 20:58:16 +08:00
}
2022-07-21 15:24:46 +08:00
public CarouselBeatmapSet RemoveChild ( Guid beatmapSetID )
2022-01-20 20:58:16 +08:00
{
if ( BeatmapSetsByID . TryGetValue ( beatmapSetID , out var carouselBeatmapSet ) )
2022-07-21 15:24:46 +08:00
{
2022-07-21 15:06:06 +08:00
RemoveItem ( carouselBeatmapSet ) ;
2022-07-21 15:24:46 +08:00
return carouselBeatmapSet ;
}
return null ;
2022-01-20 20:58:16 +08:00
}
2022-07-21 15:06:06 +08:00
public override void RemoveItem ( CarouselItem i )
2022-01-20 20:58:16 +08:00
{
CarouselBeatmapSet set = ( CarouselBeatmapSet ) i ;
BeatmapSetsByID . Remove ( set . BeatmapSet . ID ) ;
2022-07-21 15:06:06 +08:00
base . RemoveItem ( i ) ;
2022-01-20 20:58:16 +08:00
}
2018-04-02 14:23:58 +08:00
protected override void PerformSelection ( )
{
2020-03-20 12:01:24 +08:00
if ( LastSelected = = null | | LastSelected . Filtered . Value )
2020-03-20 14:01:26 +08:00
carousel ? . SelectNextRandom ( ) ;
2018-04-02 14:23:58 +08:00
else
base . PerformSelection ( ) ;
}
}
2019-08-15 17:27:45 +08:00
2020-12-22 23:36:44 +08:00
protected class CarouselScrollContainer : UserTrackingScrollContainer < DrawableCarouselItem >
2019-08-15 17:27:45 +08:00
{
private bool rightMouseScrollBlocked ;
2020-11-26 17:28:52 +08:00
public CarouselScrollContainer ( )
{
// size is determined by the carousel itself, due to not all content necessarily being loaded.
ScrollContent . AutoSizeAxes = Axes . None ;
2020-12-03 12:26:28 +08:00
// the scroll container may get pushed off-screen by global screen changes, but we still want panels to display outside of the bounds.
Masking = false ;
2020-11-26 17:28:52 +08:00
}
2019-08-15 17:27:45 +08:00
protected override bool OnMouseDown ( MouseDownEvent e )
{
if ( e . Button = = MouseButton . Right )
{
// we need to block right click absolute scrolling when hovering a carousel item so context menus can display.
// this can be reconsidered when we have an alternative to right click scrolling.
if ( GetContainingInputManager ( ) . HoveredDrawables . OfType < DrawableCarouselItem > ( ) . Any ( ) )
{
rightMouseScrollBlocked = true ;
return false ;
}
}
2019-08-15 18:25:33 +08:00
rightMouseScrollBlocked = false ;
2019-08-15 17:27:45 +08:00
return base . OnMouseDown ( e ) ;
}
protected override bool OnDragStart ( DragStartEvent e )
{
if ( rightMouseScrollBlocked )
return false ;
return base . OnDragStart ( e ) ;
}
}
2021-11-05 17:05:31 +08:00
protected override void Dispose ( bool isDisposing )
{
base . Dispose ( isDisposing ) ;
2021-12-17 18:01:19 +08:00
subscriptionSets ? . Dispose ( ) ;
2022-01-12 00:03:59 +08:00
subscriptionDeletedSets ? . Dispose ( ) ;
2021-12-17 18:01:19 +08:00
subscriptionBeatmaps ? . Dispose ( ) ;
2022-01-12 00:03:59 +08:00
subscriptionHiddenBeatmaps ? . Dispose ( ) ;
2021-11-05 17:05:31 +08:00
}
2016-11-21 03:34:16 +08:00
}
}