// 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. /// protected 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 . /// /// /// Note that an may add new items which are displayed but not tracked in this list. /// protected readonly BindableList Items = new BindableList(); /// /// The currently selected model. /// /// /// Setting this will ensure is set to true only on the matching . /// Of note, if no matching item exists all items will be deselected while waiting for potential new item which matches. /// public virtual object? CurrentSelection { get => currentSelection; set { if (currentSelectionCarouselItem != null) currentSelectionCarouselItem.Selected.Value = false; currentSelection = value; currentSelectionCarouselItem = null; currentSelectionYPosition = null; updateSelection(); } } 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); /// /// Create an internal carousel representation for the provided model object. /// /// The model. /// A representing the model. protected abstract CarouselItem CreateCarouselItemForModel(T model); #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.Select(CreateCarouselItemForModel)); 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; updateSelection(); 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 Selection handling private object? currentSelection; private CarouselItem? currentSelectionCarouselItem; private double? currentSelectionYPosition; private void updateSelection() { currentSelectionCarouselItem = null; if (displayedCarouselItems == null) return; foreach (var item in displayedCarouselItems) { bool isSelected = item.Model == currentSelection; if (isSelected) { currentSelectionCarouselItem = item; if (currentSelectionYPosition != item.CarouselYPosition) { if (currentSelectionYPosition != null) { float adjustment = (float)(item.CarouselYPosition - currentSelectionYPosition.Value); scroll.OffsetScrollPosition(adjustment); } currentSelectionYPosition = item.CarouselYPosition; } } item.Selected.Value = isSelected; } } #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 } }