Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Graphics/Carousel/Carousel.cs
4397 views
// Copyright (c) ppy Pty Ltd <[email protected]>. 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.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Development;
using osu.Framework.Extensions.PolygonExtensions;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
using osu.Framework.Logging;
using osu.Framework.Utils;
using osu.Game.Input.Bindings;
using osu.Game.Online.Multiplayer;
using osuTK;
using osuTK.Input;

namespace osu.Game.Graphics.Carousel
{
    /// <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>
    public abstract partial class Carousel<T> : CompositeDrawable, IKeyBindingHandler<GlobalAction>
        where T : notnull
    {
        #region Properties and methods for external usage

        /// <summary>
        /// Called after a filter operation or change in items results in the visible carousel items changing.
        /// </summary>
        public Action<IEnumerable<CarouselItem>>? NewItemsPresented { private get; init; }

        /// <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; }

        /// <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; }

        /// <summary>
        /// The number of pixels outside the carousel's vertical bounds to manifest drawables.
        /// This allows preloading content before it scrolls into view.
        /// </summary>
        public float DistanceOffscreenToPreload { get; set; }

        /// <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>
        public int DebounceDelay { get; set; }

        /// <summary>
        /// Whether an asynchronous filter / group operation is currently underway.
        /// </summary>
        public bool IsFiltering => !filterTask.IsCompleted;

        /// <summary>
        /// Whether absolute scrolling is currently triggered.
        /// </summary>
        public bool AbsoluteScrolling => Scroll.AbsoluteScrolling;

        /// <summary>
        /// The number of times filter operations have been triggered.
        /// </summary>
        public int FilterCount { get; private set; }

        /// <summary>
        /// The number of displayable items currently being tracked (before filtering).
        /// </summary>
        public int ItemsTracked => Items.Count;

        /// <summary>
        /// The items currently in rotation for display.
        /// </summary>
        public int DisplayableItems => carouselItems?.Count ?? 0;

        /// <summary>
        /// The number of items currently actualised into drawables.
        /// </summary>
        public int VisibleItems => Scroll.Panels.Count;

        /// <summary>
        /// The currently selected model. Generally of type T.
        /// </summary>
        /// <remarks>
        /// 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.
        /// </remarks>
        protected object? CurrentSelection
        {
            get => currentSelection.Model;
            set
            {
                if (!CheckModelEquality(currentSelection.Model, value))
                {
                    HandleItemSelected(value);

                    if (currentSelection.Model != null)
                        HandleItemDeselected(currentSelection.Model);

                    currentSelection = new Selection(value);
                    currentKeyboardSelection = currentSelection;
                    selectionValid.Invalidate();
                }

                // Check keyboard selection equality separately.
                //
                // If current selection set to an already-selected value, we want to ensure
                // that keyboard selection (which basically represents the "visual" tracking of selection)
                // is still reset back to the newly set value.
                //
                // The main case this handles is when a set header is clicked and we want to make sure one of its
                // "children" are re-selected.
                if (!CheckModelEquality(currentKeyboardSelection.Model, value))
                {
                    currentKeyboardSelection = currentSelection;
                    selectionValid.Invalidate();
                }
            }
        }

        /// <summary>
        /// Activate the specified item.
        /// </summary>
        /// <param name="item"></param>
        public void Activate(CarouselItem item)
        {
            // Regardless of how the item handles activation, update keyboard selection to the activated panel.
            // In other words, when a panel is clicked, keyboard selection should default to matching the clicked
            // item.
            setKeyboardSelection(item.Model);

            (GetMaterialisedDrawableForItem(item) as ICarouselPanel)?.Activated();
            HandleItemActivated(item);

            selectionValid.Invalidate();
        }

        /// <summary>
        /// Scroll carousel to the selected item if available.
        /// </summary>
        /// <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 ScrollToSelection(bool immediate = false)
        {
            // if an immediate scroll is already requested, don't override it with a slower scroll
            if (scrollToSelection == PendingScrollOperation.Immediate)
                return;

            scrollToSelection = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard;
        }

        /// <summary>
        /// Returns the vertical spacing between two given carousel items. Negative value can be used to create an overlapping effect.
        /// </summary>
        protected virtual float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom) => 0f;

        #endregion

        #region Properties and methods concerning implementations

        /// <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>
        public IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();

        /// <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>();

        /// <summary>
        /// Queue an asynchronous filter operation.
        /// </summary>
        /// <param name="clearExistingPanels">Whether all existing drawable panels should be reset post filter.</param>
        protected virtual Task<IEnumerable<CarouselItem>> FilterAsync(bool clearExistingPanels = false)
        {
            FilterCount++;

            if (clearExistingPanels)
                filterReusesPanels.Invalidate();

            filterAfterItemsChanged.Validate();

            filterTask = performFilter();
            filterTask.FireAndForget();
            return filterTask;
        }

        /// <summary>
        /// Called when <see cref="Items"/> changes in any way.
        /// </summary>
        /// <returns>Whether a re-filter is required.</returns>
        protected virtual bool HandleItemsChanged(NotifyCollectionChangedEventArgs args) => true;

        /// <summary>
        /// Fired after a filter operation completed.
        /// </summary>
        protected virtual void HandleFilterCompleted()
        {
        }

        /// <summary>
        /// Check whether two models are the same for display purposes.
        /// </summary>
        protected virtual bool CheckModelEquality(object? x, object? y) => ReferenceEquals(x, y);

        /// <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);

        /// <summary>
        /// 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);

        /// <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) => false;

        /// <summary>
        /// When a user is traversing the carousel via set 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 set 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 CheckValidForSetSelection(CarouselItem item) => true;

        /// <summary>
        /// Keyboard selection usually does not automatically activate an item. There may be exceptions to this rule.
        /// Returning <c>true</c> here will make keyboard traversal act like set traversal for the target item.
        /// </summary>
        protected virtual bool ShouldActivateOnKeyboardSelection(CarouselItem item) => false;

        /// <summary>
        /// Called after an item becomes the <see cref="CurrentSelection"/>.
        /// Should be used to handle any set expansion, item visibility changes, etc.
        /// </summary>
        protected virtual void HandleItemSelected(object? model) { }

        /// <summary>
        /// Called when the <see cref="CurrentSelection"/> changes to a new selection.
        /// Should be used to handle any set expansion, item visibility changes, etc.
        /// </summary>
        protected virtual void HandleItemDeselected(object? model) { }

        /// <summary>
        /// Called when an item is activated via user input (keyboard traversal or a mouse click).
        /// </summary>
        /// <remarks>
        /// An activated item should decide to perform an action, such as:
        /// - Change its expanded state (and show / hide children items).
        /// - Set the item to the <see cref="CurrentSelection"/>.
        /// - Start gameplay on a beatmap difficulty if already selected.
        /// </remarks>
        /// <param name="item">The carousel item which was activated.</param>
        protected virtual void HandleItemActivated(CarouselItem item) { }

        #endregion

        #region Initialisation

        protected readonly ScrollContainer Scroll;

        protected Carousel()
        {
            InternalChild = Scroll = new ScrollContainer
            {
                Masking = false,
                RelativeSizeAxes = Axes.Both,
            };

            Items.BindCollectionChanged((_, args) =>
            {
                if (HandleItemsChanged(args))
                    filterAfterItemsChanged.Invalidate();
            });
        }

        [BackgroundDependencyLoader]
        private void load(AudioManager audio)
        {
            loadSamples(audio);
        }

        #endregion

        #region Filtering and display preparation

        /// <summary>
        /// Retrieve a list of all <see cref="CarouselItem"/>s currently displayed.
        /// </summary>
        public IList<CarouselItem>? GetCarouselItems() => carouselItems;

        private List<CarouselItem>? carouselItems;

        private Task<IEnumerable<CarouselItem>> filterTask = Task.FromResult(Enumerable.Empty<CarouselItem>());
        private CancellationTokenSource cancellationSource = new CancellationTokenSource();

        /// <summary>
        /// For background re-filters, ensure we wait for the previous filter operation to complete before starting another.
        /// This avoids the carousel never updating its display in high churn scenarios.
        /// </summary>
        private readonly Cached filterAfterItemsChanged = new Cached();

        private async Task<IEnumerable<CarouselItem>> performFilter()
        {
            Stopwatch stopwatch = Stopwatch.StartNew();
            var cts = new CancellationTokenSource();

            var previousCancellationSource = Interlocked.Exchange(ref cancellationSource, cts);
            await previousCancellationSource.CancelAsync().ConfigureAwait(true);

            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.
            Debug.Assert(ThreadSafety.IsUpdateThread);
            List<CarouselItem> items = new List<CarouselItem>(Items.Select(m => new CarouselItem(m)));

            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");
                    updateYPositions(items, visibleHalfHeight);
                }
                catch (OperationCanceledException)
                {
                    log("Cancelled due to newer request arriving");
                }
            }, cts.Token).ConfigureAwait(false);

            if (cts.Token.IsCancellationRequested)
                return Enumerable.Empty<CarouselItem>();

            Schedule(() =>
            {
                log("Items ready for display");
                carouselItems = items;
                displayedRange = null;

                if (!filterReusesPanels.IsValid)
                {
                    foreach (var panel in Scroll.Panels)
                        expirePanel(panel);

                    filterReusesPanels.Validate();
                }

                HandleFilterCompleted();

                refreshAfterSelection();
                if (!Scroll.UserScrolling)
                    ScrollToSelection(immediate: true);

                NewItemsPresented?.Invoke(carouselItems);
            });

            return items;

            void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
        }

        private void updateYPositions(IEnumerable<CarouselItem> carouselItems, float offset)
        {
            CarouselItem? previousVisible = null;

            foreach (var item in carouselItems)
                updateItemYPosition(item, ref previousVisible, ref offset);
        }

        private void updateItemYPosition(CarouselItem item, ref CarouselItem? previousVisible, ref float offset)
        {
            float spacing = previousVisible == null || !item.IsVisible ? 0 : GetSpacingBetweenPanels(previousVisible, item);

            offset += spacing;
            item.CarouselYPosition = offset;

            // ensure there are no input gaps where clicking will fall through the carousel.
            // notably, only do this where there's positive spacing between panels (negative spacing means they overlap already and there is no gap to fill).
            if (spacing > 0)
            {
                item.CarouselInputLenienceAbove = spacing / 2;
                if (previousVisible != null)
                    previousVisible.CarouselInputLenienceBelow = item.CarouselInputLenienceAbove;
            }

            if (item.IsVisible)
            {
                offset += item.DrawHeight;
                previousVisible = item;
            }
        }

        #endregion

        #region Input handling

        protected override bool OnKeyDown(KeyDownEvent e)
        {
            switch (e.Key)
            {
                // this is a special hard-coded case; we can't rely on OnPressed as GlobalActionContainer is
                // matching with exact modifier consideration (so Ctrl+Enter would be ignored).
                case Key.Enter:
                case Key.KeypadEnter:
                    return activateSelection();
            }

            return base.OnKeyDown(e);
        }

        public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
        {
            switch (e.Action)
            {
                case GlobalAction.Select:
                    return activateSelection();

                // the selection traversal handlers below are scheduled to avoid an issue
                // wherein if the update frame rate is low, keeping one of the actions below pressed leads to selection moving back to the start / end.
                // the reason why that happens is that the code managing `current(Keyboard)?Selection` can lose track of the index of the selected item
                // if the selection is changed more than once during an update frame,
                // which can happen if repeat inputs are enqueued for processing at a rate faster than the update refresh rate.
                // `refreshAfterSelection()` is the method responsible for updating the index of the selected item here which runs once per frame.
                case GlobalAction.SelectPrevious:
                    Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, -1));
                    return true;

                case GlobalAction.SelectNext:
                    Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Keyboard, 1));
                    return true;

                case GlobalAction.ActivatePreviousSet:
                    Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, -1));
                    return true;

                case GlobalAction.ActivateNextSet:
                    Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Set, 1));
                    return true;

                case GlobalAction.ExpandPreviousGroup:
                    Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Group, -1));
                    return true;

                case GlobalAction.ExpandNextGroup:
                    Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Group, 1));
                    return true;

                case GlobalAction.ToggleCurrentGroup:
                    if (carouselItems == null || carouselItems.Count == 0)
                        return true;

                    if (currentKeyboardSelection.CarouselItem == null || currentKeyboardSelection.Index == null)
                        return true;

                    if (CheckValidForGroupSelection(currentKeyboardSelection.CarouselItem))
                    {
                        // If keyboard selection is a group, toggle group and then change keyboard selection to actual selection.
                        Activate(currentKeyboardSelection.CarouselItem);
                    }
                    else
                    {
                        // If current keyboard selection is not a group, toggle the closest group and move keyboard selection to that group.
                        for (int i = currentKeyboardSelection.Index.Value; i >= 0; i--)
                        {
                            var newItem = carouselItems[i];

                            if (CheckValidForGroupSelection(newItem))
                            {
                                Activate(newItem);
                                return true;
                            }
                        }
                    }

                    return true;
            }

            return false;

            void traverseFromKey(TraversalOperation traversal)
            {
                switch (traversal.Type)
                {
                    case TraversalType.Keyboard:
                        traverseKeyboardSelection(traversal.Direction);
                        break;

                    case TraversalType.Set:
                        traverseSetSelection(traversal.Direction);
                        break;

                    case TraversalType.Group:
                        traverseGroupSelection(traversal.Direction);
                        break;

                    default:
                        throw new ArgumentOutOfRangeException();
                }
            }
        }

        private enum TraversalType
        {
            Keyboard,
            Set,
            Group
        }

        private record TraversalOperation(TraversalType Type, int Direction);

        public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
        {
        }

        private bool activateSelection()
        {
            if (currentKeyboardSelection.CarouselItem != null)
            {
                Activate(currentKeyboardSelection.CarouselItem);
                return true;
            }

            return false;
        }

        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)
                {
                    if (!CheckModelEquality(currentSelection.Model, newItem.Model) && ShouldActivateOnKeyboardSelection(newItem))
                        Activate(newItem);
                    else
                    {
                        playTraversalSound();
                        setKeyboardSelection(newItem.Model);
                    }

                    return;
                }
            } while (newIndex != originalIndex);
        }

        /// <summary>
        /// Select the next valid group 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>
        private void traverseGroupSelection(int direction) => traverseSelection(direction, CheckValidForGroupSelection);

        /// <summary>
        /// Select the next valid set 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>
        private void traverseSetSelection(int direction)
        {
            // If the user has a different keyboard selection and requests
            // set selection, first transfer the keyboard selection to actual selection.
            //
            // It is assumed that selecting a set will immediately change selection to one of its children.
            if (currentKeyboardSelection.CarouselItem != null && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
            {
                Activate(currentKeyboardSelection.CarouselItem);
                return;
            }

            traverseSelection(direction, CheckValidForSetSelection);
        }

        private void traverseSelection(int direction, Func<CarouselItem, bool> predicate)
        {
            if (carouselItems == null || carouselItems.Count == 0) return;

            int originalIndex;
            int newIndex;

            if (currentKeyboardSelection.Index == null)
            {
                // If there's no current selection, start from either end of the full list.
                newIndex = originalIndex = direction > 0 ? carouselItems.Count - 1 : 0;
            }
            else
            {
                newIndex = originalIndex = currentKeyboardSelection.Index.Value;

                // As a second special case, if we're set selecting backwards and the current selection isn't a set,
                // make sure to go back to the set header this item belongs to, so that the block below doesn't find it and stop too early.
                if (direction < 0)
                {
                    while (newIndex > 0 && !predicate(carouselItems[newIndex]))
                        newIndex--;
                }
            }

            // 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;

                if (newIndex == originalIndex)
                    break;

                var newItem = carouselItems[newIndex];

                if (!newItem.IsExpanded && predicate(newItem))
                {
                    Activate(newItem);
                    return;
                }
            } while (true);
        }

        #endregion

        #region Scrolling

        /// <summary>
        /// Scrolling to selection relies on <see cref="currentKeyboardSelection"/> being fully populated.
        /// This flag ensures it runs after <see cref="refreshAfterSelection"/> validates this.
        /// </summary>
        private PendingScrollOperation scrollToSelection = PendingScrollOperation.None;

        private enum PendingScrollOperation
        {
            None,
            Standard,
            Immediate,
        }

        #endregion

        #region Audio

        private Sample? sampleKeyboardTraversal;

        private double audioFeedbackLastPlaybackTime;

        private void loadSamples(AudioManager audio)
        {
            sampleKeyboardTraversal = audio.Samples.Get(@"SongSelect/select-difficulty");
        }

        private void playTraversalSound()
        {
            if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME)
            {
                sampleKeyboardTraversal?.Play();
                audioFeedbackLastPlaybackTime = Time.Current;
            }
        }

        #endregion

        #region Selection handling

        /// <summary>
        /// The currently selected <see cref="CarouselItem"/>, if any is selected.
        /// </summary>
        protected CarouselItem? CurrentSelectionItem => currentSelection.CarouselItem;

        /// <summary>
        /// The index in <see cref="GetCarouselItems"/> of the current selection, if available.
        /// </summary>
        protected int? CurrentSelectionIndex => currentSelection.Index;

        /// <summary>
        /// Becomes invalid when the current selection has changed and needs to be updated visually.
        /// </summary>
        private readonly Cached selectionValid = new Cached();

        private Selection currentKeyboardSelection = new Selection();
        private Selection currentSelection = new Selection();

        private void setKeyboardSelection(object? model)
        {
            currentKeyboardSelection = new Selection(model);
            selectionValid.Invalidate();
        }

        /// <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;

            Selection prevKeyboard = currentKeyboardSelection;

            // Importantly, we also reset the `Selection` to the most basic state.
            // Removing the index and carousel item here is important to ensure we are aware of if a selection has been filtered away.
            // If it hasn't been filtered, the full details will be re-populated just below in the loop.
            currentKeyboardSelection = new Selection(currentKeyboardSelection.Model);
            currentSelection = new Selection(currentSelection.Model);

            if (carouselItems == null)
                return;

            CarouselItem? lastVisible = null;
            int count = carouselItems.Count;

            // 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.
            FindCarouselItemsForSelection(ref currentKeyboardSelection, ref currentSelection, carouselItems);

            for (int i = 0; i < count; i++)
            {
                var item = carouselItems[i];
                updateItemYPosition(item, ref lastVisible, ref yPos);
            }

            if (currentKeyboardSelection.CarouselItem is CarouselItem currentKeyboardSelectionItem)
                currentKeyboardSelection = currentKeyboardSelection with { YPosition = currentKeyboardSelectionItem.CarouselYPosition + currentKeyboardSelectionItem.DrawHeight / 2 };

            if (currentSelection.CarouselItem is CarouselItem currentSelectionItem)
                currentSelection = currentSelection with { YPosition = currentSelectionItem.CarouselYPosition + currentSelectionItem.DrawHeight / 2 };

            // 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).
            Scroll.SetLayoutHeight(yPos + visibleHalfHeight);

            // 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 != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition)
                Scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value));
        }

        protected virtual void FindCarouselItemsForSelection(ref Selection keyboardSelection, ref Selection selection, IList<CarouselItem> items)
        {
            for (int i = 0; i < items.Count; i++)
            {
                var item = items[i];

                bool isKeyboardSelection = CheckModelEquality(item.Model, keyboardSelection.Model!);
                bool isSelection = CheckModelEquality(item.Model, selection.Model!);

                // while we don't know the Y position of the item yet, as it's about to be updated,
                // consumers (specifically `BeatmapCarousel.GetSpacingBetweenPanels()`) benefit from `CurrentSelectionItem` already pointing
                // at the correct item to avoid redundant local equality checks.
                // the Y positions will be filled in after they're computed.
                if (isKeyboardSelection)
                    keyboardSelection = new Selection(keyboardSelection.Model, item, null, i);

                if (isSelection)
                    selection = new Selection(selection.Model, item, null, i);
            }
        }

        #endregion

        #region Display handling

        private DisplayRange? displayedRange;

        private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object());

        /// <summary>
        /// The position of the lower visible bound with respect to the current scroll position.
        /// </summary>
        private float visibleBottomBound;

        /// <summary>
        /// The position of the upper visible bound with respect to the current scroll position.
        /// </summary>
        private float visibleUpperBound;

        /// <summary>
        /// Half the height of the visible content.
        /// </summary>
        private float visibleHalfHeight;

        /// <summary>
        /// Whether existing panels can be re-used in the next filter.
        /// </summary>
        private readonly Cached filterReusesPanels = new Cached();

        protected override void Update()
        {
            base.Update();

            if (carouselItems == null)
                return;

            visibleBottomBound = (float)(Scroll.Current + DrawHeight + BleedBottom);
            visibleUpperBound = (float)(Scroll.Current - BleedTop);
            visibleHalfHeight = (DrawHeight + BleedBottom + BleedTop) / 2;

            if (!selectionValid.IsValid)
            {
                refreshAfterSelection();

                // Always scroll to selection in this case (regardless of `UserScrolling` state), centering the selection.
                ScrollToSelection();

                selectionValid.Validate();
            }

            var range = getDisplayRange();

            if (range != displayedRange)
            {
                displayedRange = range;
                updateDisplayedRange(range);
            }

            double selectedYPos = currentSelection.CarouselItem?.CarouselYPosition ?? 0;

            foreach (var panel in Scroll.Panels)
            {
                var c = (ICarouselPanel)panel;

                // panel in the process of expiring, ignore it.
                if (c.Item == null)
                    continue;

                float normalisedDepth = (float)(Math.Abs(selectedYPos - c.Item.CarouselYPosition) / DrawHeight);
                Scroll.Panels.ChangeChildDepth(panel, c.Item.DepthLayer + normalisedDepth);

                if (c.DrawYPosition != c.Item.CarouselYPosition)
                    c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed);

                panel.X = GetPanelXOffset(panel);

                c.Selected.Value = currentSelection?.CarouselItem != null && CheckModelEquality(c.Item, currentSelection.CarouselItem);
                c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem;
                c.Expanded.Value = c.Item.IsExpanded;
            }

            if (!filterAfterItemsChanged.IsValid && !IsFiltering)
                FilterAsync();
        }

        protected override void UpdateAfterChildren()
        {
            base.UpdateAfterChildren();

            if (scrollToSelection != PendingScrollOperation.None)
            {
                if (GetScrollTarget() is double scrollTarget)
                    Scroll.ScrollTo(scrollTarget - visibleHalfHeight + BleedTop, animated: scrollToSelection == PendingScrollOperation.Standard);

                scrollToSelection = PendingScrollOperation.None;
            }
        }

        /// <summary>
        /// Returns the Y position to scroll to in order to show the most relevant carousel item(s).
        /// </summary>
        protected virtual double? GetScrollTarget() => currentKeyboardSelection.YPosition;

        protected virtual float GetPanelXOffset(Drawable panel)
        {
            Vector2 posInScroll = Scroll.ToLocalSpace(panel.ScreenSpaceDrawQuad.Centre);
            float dist = Math.Abs(1f - (posInScroll.Y + BleedTop) / visibleHalfHeight);

            return offsetX(dist, visibleHalfHeight);
        }

        /// <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;
        }

        private DisplayRange getDisplayRange()
        {
            Debug.Assert(carouselItems != null);

            if (carouselItems.Count == 0)
                return DisplayRange.EMPTY;

            // Find index range of all items that should be on-screen
            carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload;
            int firstIndex = carouselItems.BinarySearch(carouselBoundsItem);
            if (firstIndex < 0) firstIndex = ~firstIndex;

            carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload;
            int lastIndex = carouselItems.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(carouselItems != null);

            List<CarouselItem> toDisplay = range == DisplayRange.EMPTY
                ? new List<CarouselItem>()
                : carouselItems.GetRange(range.First, range.Last - range.First + 1);

            toDisplay.RemoveAll(i => !i.IsVisible);

            // 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;

                if (carouselPanel.Item == null)
                {
                    // Item is null when a panel is already fading away from existence; should be ignored for tracking purposes.
                    continue;
                }

                var existing = toDisplay.FirstOrDefault(i => CheckModelEquality(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.
                expirePanel(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;
                carouselPanel.DrawYPosition = item.CarouselYPosition;

                Scroll.Add(drawable);
            }

            if (toDisplay.Any())
            {
                // To make transitions of items appearing in the flow look good, do a pass and make sure newly added items spawn from
                // just beneath the *current interpolated position* of the previous panel.
                var orderedPanels = Scroll.Panels
                                          .Where(p => Scroll.ScreenSpaceDrawQuad.Intersects(p.ScreenSpaceDrawQuad))
                                          .OfType<ICarouselPanel>()
                                          .Where(p => p.Item != null)
                                          .OrderBy(p => p.Item!.CarouselYPosition)
                                          .ToList();

                for (int i = 0; i < orderedPanels.Count; i++)
                {
                    var panel = orderedPanels[i];

                    if (toDisplay.Contains(panel.Item!))
                    {
                        // Don't apply to the last because animating the tail of the list looks bad.
                        // It's usually off-screen anyway.
                        if (i > 0 && i < orderedPanels.Count - 1)
                            panel.DrawYPosition = orderedPanels[i - 1].DrawYPosition;
                    }
                }
            }
        }

        private void expirePanel(Drawable panel)
        {
            var carouselPanel = (ICarouselPanel)panel;

            // expired panels should have a depth behind all other panels to make the transition not look weird.
            Scroll.Panels.ChangeChildDepth(panel, panel.Depth + 1024);

            panel.FadeOut(150, Easing.OutQuint);
            panel.MoveToX(panel.X + 100, 200, Easing.Out);

            panel.Expire();

            carouselPanel.Item = null;
            carouselPanel.Selected.Value = false;
            carouselPanel.KeyboardSelected.Value = false;
            carouselPanel.Expanded.Value = false;
        }

        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).
            if (invalidation.HasFlag(Invalidation.DrawSize))
                selectionValid.Invalidate();

            return base.OnInvalidate(invalidation, source);
        }

        #endregion

        #region Internal helper classes

        /// <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>
        protected record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null);

        private record DisplayRange(int First, int Last)
        {
            public static readonly DisplayRange EMPTY = new DisplayRange(-1, -1);
        }

        #endregion
    }
}