From 5e9a7532d31d594a36013d19772e7ea4a95a0a46 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 10 Jan 2025 20:55:53 +0900 Subject: [PATCH] Add basic implementation of new beatmap carousel --- .../SongSelect/TestSceneBeatmapCarouselV2.cs | 189 +++++++++ .../Screens/SelectV2/BeatmapCarouselV2.cs | 205 ++++++++++ osu.Game/Screens/SelectV2/Carousel.cs | 371 ++++++++++++++++++ osu.Game/Screens/SelectV2/CarouselItem.cs | 41 ++ osu.Game/Screens/SelectV2/ICarouselFilter.cs | 23 ++ osu.Game/Screens/SelectV2/ICarouselPanel.cs | 23 ++ osu.Game/Tests/Beatmaps/TestBeatmapStore.cs | 2 +- 7 files changed, 853 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapCarouselV2.cs create mode 100644 osu.Game/Screens/SelectV2/Carousel.cs create mode 100644 osu.Game/Screens/SelectV2/CarouselItem.cs create mode 100644 osu.Game/Screens/SelectV2/ICarouselFilter.cs create mode 100644 osu.Game/Screens/SelectV2/ICarouselPanel.cs diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs new file mode 100644 index 0000000000..75223adc2b --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs @@ -0,0 +1,189 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2 : OsuManualInputManagerTestScene + { + private readonly BindableList beatmapSets = new BindableList(); + + [Cached(typeof(BeatmapStore))] + private BeatmapStore store; + + private OsuTextFlowContainer stats = null!; + private BeatmapCarouselV2 carousel = null!; + + private int beatmapCount; + + public TestSceneBeatmapCarouselV2() + { + store = new TestBeatmapStore + { + BeatmapSets = { BindTarget = beatmapSets } + }; + + beatmapSets.BindCollectionChanged((_, _) => + { + beatmapCount = beatmapSets.Sum(s => s.Beatmaps.Count); + }); + + Scheduler.AddDelayed(updateStats, 100, true); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create components", () => + { + beatmapSets.Clear(); + + Box topBox; + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.Relative, 1), + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.Absolute, 200), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 200), + }, + Content = new[] + { + new Drawable[] + { + topBox = new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Cyan, + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + }, + }, + new Drawable[] + { + carousel = new BeatmapCarouselV2 + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + RelativeSizeAxes = Axes.Y, + }, + }, + new[] + { + new Box + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Colour = Color4.Cyan, + RelativeSizeAxes = Axes.Both, + Alpha = 0.4f, + }, + topBox.CreateProxy(), + } + } + }, + stats = new OsuTextFlowContainer(cp => cp.Font = FrameworkFont.Regular.With()) + { + Padding = new MarginPadding(10), + TextAnchor = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + }; + }); + } + + [Test] + public void TestBasic() + { + AddStep("add 10 beatmaps", () => + { + for (int i = 0; i < 10; i++) + beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))); + }); + + AddStep("add 1 beatmap", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)))); + + AddStep("remove all beatmaps", () => beatmapSets.Clear()); + } + + [Test] + public void TestAddRemoveOneByOne() + { + AddRepeatStep("add beatmaps", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20); + + AddRepeatStep("remove beatmaps", () => beatmapSets.RemoveAt(RNG.Next(0, beatmapSets.Count)), 20); + } + + [Test] + [Explicit] + public void TestInsane() + { + const int count = 200000; + + List generated = new List(); + + AddStep($"populate {count} test beatmaps", () => + { + generated.Clear(); + Task.Run(() => + { + for (int j = 0; j < count; j++) + generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))); + }).ConfigureAwait(true); + }); + + AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3)); + AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2)); + AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count)); + + AddStep("add all beatmaps", () => beatmapSets.AddRange(generated)); + } + + private void updateStats() + { + if (carousel.IsNull()) + return; + + stats.Text = $""" + store + sets: {beatmapSets.Count} + beatmaps: {beatmapCount} + carousel: + sorting: {carousel.IsFiltering} + tracked: {carousel.ItemsTracked} + displayable: {carousel.DisplayableItems} + displayed: {carousel.VisibleItems} + """; + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselV2.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselV2.cs new file mode 100644 index 0000000000..a54c2aceff --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselV2.cs @@ -0,0 +1,205 @@ +// Copyright (c) ppy Pty Ltd . 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.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Select; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapCarouselV2 : Carousel + { + private IBindableList detachedBeatmaps = null!; + + private readonly DrawablePool carouselPanelPool = new DrawablePool(100); + + public BeatmapCarouselV2() + { + DebounceDelay = 100; + DistanceOffscreenToPreload = 100; + + Filters = new ICarouselFilter[] + { + new Sorter(), + new Grouper(), + }; + + AddInternal(carouselPanelPool); + } + + [BackgroundDependencyLoader] + private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken) + { + detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); + detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); + } + + protected override Drawable GetDrawableForDisplay(CarouselItem item) + { + var drawable = carouselPanelPool.Get(); + drawable.FlashColour(Color4.Red, 2000); + + return drawable; + } + + private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) + { + // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. + // right now we are managing this locally which is a bit of added overhead. + IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); + IEnumerable? beatmapSetInfos = changed.OldItems?.Cast(); + + switch (changed.Action) + { + case NotifyCollectionChangedAction.Add: + Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps).Select(b => new BeatmapCarouselItem(b))); + break; + + case NotifyCollectionChangedAction.Remove: + + foreach (var set in beatmapSetInfos!) + { + foreach (var beatmap in set.Beatmaps) + Items.RemoveAll(i => i.Model is BeatmapInfo bi && beatmap.Equals(bi)); + } + + break; + + case NotifyCollectionChangedAction.Move: + case NotifyCollectionChangedAction.Replace: + throw new NotImplementedException(); + + case NotifyCollectionChangedAction.Reset: + Items.Clear(); + break; + } + } + + public FilterCriteria Criteria { get; private set; } = new FilterCriteria(); + + public void Filter(FilterCriteria criteria) + { + Criteria = criteria; + QueueFilter(); + } + } + + public partial class BeatmapCarouselPanel : PoolableDrawable, ICarouselPanel + { + public CarouselItem? Item { get; set; } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + Size = new Vector2(500, Item.DrawHeight); + + InternalChildren = new Drawable[] + { + new Box + { + Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5), + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = Item.ToString() ?? string.Empty, + Padding = new MarginPadding(5), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + } + } + + public class BeatmapCarouselItem : CarouselItem + { + public readonly Guid ID; + + public override float DrawHeight => Model is BeatmapInfo ? 40 : 80; + + public BeatmapCarouselItem(object model) + : base(model) + { + ID = (Model as IHasGuidPrimaryKey)?.ID ?? Guid.NewGuid(); + } + + public override string? ToString() + { + switch (Model) + { + case BeatmapInfo bi: + return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)"; + + case BeatmapSetInfo si: + return $"{si.Metadata}"; + } + + return Model.ToString(); + } + } + + public class Grouper : ICarouselFilter + { + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + { + // TODO: perform grouping based on FilterCriteria + + CarouselItem? lastItem = null; + + var newItems = new List(); + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (item.Model is BeatmapInfo b1) + { + // Add set header + if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b1.BeatmapSet!.OnlineID)) + newItems.Add(new BeatmapCarouselItem(b1.BeatmapSet!)); + } + + newItems.Add(item); + lastItem = item; + } + + return newItems; + }, cancellationToken).ConfigureAwait(false); + } + + public class Sorter : ICarouselFilter + { + public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => + { + return items.OrderDescending(Comparer.Create((a, b) => + { + if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb) + return ab.OnlineID.CompareTo(bb.OnlineID); + + if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem) + return aItem.ID.CompareTo(bItem.ID); + + return 0; + })); + }, cancellationToken).ConfigureAwait(false); + } +} diff --git a/osu.Game/Screens/SelectV2/Carousel.cs b/osu.Game/Screens/SelectV2/Carousel.cs new file mode 100644 index 0000000000..2f3c47a0a3 --- /dev/null +++ b/osu.Game/Screens/SelectV2/Carousel.cs @@ -0,0 +1,371 @@ +// Copyright (c) ppy Pty Ltd . 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; +using osu.Framework.Extensions.TypeExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Logging; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// 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. + /// + public abstract partial class Carousel : CompositeDrawable + { + /// + /// A collection of filters which should be run each time a is executed. + /// + public IEnumerable Filters { get; init; } = Enumerable.Empty(); + + /// + /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedTop { get; set; } = 0; + + /// + /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it. + /// + public float BleedBottom { get; set; } = 0; + + /// + /// The number of pixels outside the carousel's vertical bounds to manifest drawables. + /// This allows preloading content before it scrolls into view. + /// + public float DistanceOffscreenToPreload { get; set; } = 0; + + /// + /// 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. + /// + public int DebounceDelay { get; set; } = 0; + + /// + /// Whether an asynchronous filter / group operation is currently underway. + /// + public bool IsFiltering => !filterTask.IsCompleted; + + /// + /// The number of displayable items currently being tracked (before filtering). + /// + public int ItemsTracked => Items.Count; + + /// + /// The number of carousel items currently in rotation for display. + /// + public int DisplayableItems => displayedCarouselItems?.Count ?? 0; + + /// + /// The number of items currently actualised into drawables. + /// + public int VisibleItems => scroll.Panels.Count; + + /// + /// All items which are to be considered for display in this carousel. + /// Mutating this list will automatically queue a . + /// + protected readonly BindableList Items = new BindableList(); + + private List? displayedCarouselItems; + + private readonly DoublePrecisionScroll scroll; + + protected Carousel() + { + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + }, + scroll = new DoublePrecisionScroll + { + RelativeSizeAxes = Axes.Both, + Masking = false, + } + }; + + Items.BindCollectionChanged((_, _) => QueueFilter()); + } + + /// + /// Queue an asynchronous filter operation. + /// + public void QueueFilter() => Scheduler.AddOnce(() => filterTask = performFilter()); + + /// + /// Create a drawable for the given carousel item so it can be displayed. + /// + /// + /// For efficiency, it is recommended the drawables are retrieved from a . + /// + /// The item which should be represented by the returned drawable. + /// The manifested drawable. + protected abstract Drawable GetDrawableForDisplay(CarouselItem item); + + #region Filtering and display preparation + + private Task filterTask = Task.CompletedTask; + private CancellationTokenSource cancellationSource = new CancellationTokenSource(); + + private async Task performFilter() + { + Debug.Assert(SynchronizationContext.Current != null); + + var cts = new CancellationTokenSource(); + + lock (this) + { + cancellationSource.Cancel(); + cancellationSource = cts; + } + + Stopwatch stopwatch = Stopwatch.StartNew(); + IEnumerable items = new List(Items); + + await Task.Run(async () => + { + try + { + if (DebounceDelay > 0) + { + log($"Filter operation queued, waiting for {DebounceDelay} ms debounce"); + await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(false); + } + + foreach (var filter in Filters) + { + log($"Performing {filter.GetType().ReadableName()}"); + items = await filter.Run(items, cts.Token).ConfigureAwait(false); + } + + log("Updating Y positions"); + await updateYPositions(items, cts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + log("Cancelled due to newer request arriving"); + } + }, cts.Token).ConfigureAwait(true); + + if (cts.Token.IsCancellationRequested) + return; + + log("Items ready for display"); + displayedCarouselItems = items.ToList(); + displayedRange = null; + + void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString().Substring(0, 5)}] {stopwatch.ElapsedMilliseconds} ms: {text}"); + } + + private async Task updateYPositions(IEnumerable carouselItems, CancellationToken cancellationToken) => await Task.Run(() => + { + const float spacing = 10; + float yPos = 0; + + foreach (var item in carouselItems) + { + item.CarouselYPosition = yPos; + yPos += item.DrawHeight + spacing; + } + }, cancellationToken).ConfigureAwait(false); + + #endregion + + #region Display handling + + private DisplayRange? displayedRange; + + private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem(); + + /// + /// The position of the lower visible bound with respect to the current scroll position. + /// + private float visibleBottomBound => (float)(scroll.Current + DrawHeight + BleedBottom); + + /// + /// The position of the upper visible bound with respect to the current scroll position. + /// + private float visibleUpperBound => (float)(scroll.Current - BleedTop); + + protected override void Update() + { + base.Update(); + + if (displayedCarouselItems == null) + return; + + var range = getDisplayRange(); + + if (range != displayedRange) + { + Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}"); + displayedRange = range; + + updateDisplayedRange(range); + } + } + + private DisplayRange getDisplayRange() + { + Debug.Assert(displayedCarouselItems != null); + + // Find index range of all items that should be on-screen + carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload; + int firstIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem); + if (firstIndex < 0) firstIndex = ~firstIndex; + + carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload; + int lastIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem); + 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) + { + Debug.Assert(displayedCarouselItems != null); + + List toDisplay = range.Last - range.First == 0 + ? new List() + : displayedCarouselItems.GetRange(range.First, range.Last - range.First + 1); + + // 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)}"); + + carouselPanel.Item = item; + 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). + if (displayedCarouselItems.Count > 0) + { + var lastItem = displayedCarouselItems[^1]; + scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight)); + } + else + scroll.SetLayoutHeight(0); + } + + private static void expirePanelImmediately(Drawable panel) + { + panel.FinishTransforms(); + panel.Expire(); + } + + #endregion + + #region Internal helper classes + + private record DisplayRange(int First, int Last); + + /// + /// Implementation of scroll container which handles very large vertical lists by internally using double precision + /// for pre-display Y values. + /// + private partial class DoublePrecisionScroll : OsuScrollContainer + { + public readonly Container Panels; + + public void SetLayoutHeight(float height) => Panels.Height = height; + + public DoublePrecisionScroll() + { + // 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, + }); + } + + 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); + + return panel.YPosition + offset.X; + } + + protected override void ApplyCurrentToContent() + { + Debug.Assert(ScrollDirection == Direction.Vertical); + + double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y; + + foreach (var d in Panels) + d.Y = (float)(((ICarouselPanel)d).YPosition + scrollableExtent); + } + } + + private class BoundsCarouselItem : CarouselItem + { + public override float DrawHeight => 0; + + public BoundsCarouselItem() + : base(new object()) + { + } + } + + #endregion + } +} diff --git a/osu.Game/Screens/SelectV2/CarouselItem.cs b/osu.Game/Screens/SelectV2/CarouselItem.cs new file mode 100644 index 0000000000..69abe86205 --- /dev/null +++ b/osu.Game/Screens/SelectV2/CarouselItem.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// Represents a single display item for display in a . + /// This is used to house information related to the attached model that helps with display and tracking. + /// + public abstract class CarouselItem : IComparable + { + /// + /// The model this item is representing. + /// + public readonly object Model; + + /// + /// The current Y position in the carousel. This is managed by and should not be set manually. + /// + public double CarouselYPosition { get; set; } + + /// + /// The height this item will take when displayed. + /// + public abstract float DrawHeight { get; } + + protected CarouselItem(object model) + { + Model = model; + } + + public int CompareTo(CarouselItem? other) + { + if (other == null) return 1; + + return CarouselYPosition.CompareTo(other.CarouselYPosition); + } + } +} diff --git a/osu.Game/Screens/SelectV2/ICarouselFilter.cs b/osu.Game/Screens/SelectV2/ICarouselFilter.cs new file mode 100644 index 0000000000..82aca18b85 --- /dev/null +++ b/osu.Game/Screens/SelectV2/ICarouselFilter.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// An interface representing a filter operation which can be run on a . + /// + public interface ICarouselFilter + { + /// + /// Execute the filter operation. + /// + /// The items to be filtered. + /// A cancellation token. + /// The post-filtered items. + Task> Run(IEnumerable items, CancellationToken cancellationToken); + } +} diff --git a/osu.Game/Screens/SelectV2/ICarouselPanel.cs b/osu.Game/Screens/SelectV2/ICarouselPanel.cs new file mode 100644 index 0000000000..2f03bd8e26 --- /dev/null +++ b/osu.Game/Screens/SelectV2/ICarouselPanel.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// An interface to be attached to any s which are used for display inside a . + /// + public interface ICarouselPanel + { + /// + /// The Y position which should be used for displaying this item within the carousel. + /// + double YPosition => Item!.CarouselYPosition; + + /// + /// The carousel item this drawable is representing. This is managed by and should not be set manually. + /// + CarouselItem? Item { get; set; } + } +} diff --git a/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs index 1734f1397f..eaef2af7c8 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs @@ -11,6 +11,6 @@ namespace osu.Game.Tests.Beatmaps internal partial class TestBeatmapStore : BeatmapStore { public readonly BindableList BeatmapSets = new BindableList(); - public override IBindableList GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets; + public override IBindableList GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy(); } }