diff --git a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs new file mode 100644 index 0000000000..3aa9f60181 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.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.Linq; +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; +using osu.Game.Graphics.Containers; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK.Graphics; +using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public abstract partial class BeatmapCarouselV2TestScene : OsuManualInputManagerTestScene + { + protected readonly BindableList BeatmapSets = new BindableList(); + + protected BeatmapCarousel Carousel = null!; + + protected OsuScrollContainer Scroll => Carousel.ChildrenOfType>().Single(); + + [Cached(typeof(BeatmapStore))] + private BeatmapStore store; + + private OsuTextFlowContainer stats = null!; + + private int beatmapCount; + + protected BeatmapCarouselV2TestScene() + { + store = new TestBeatmapStore + { + BeatmapSets = { BindTarget = BeatmapSets } + }; + + BeatmapSets.BindCollectionChanged((_, _) => beatmapCount = BeatmapSets.Sum(s => s.Beatmaps.Count)); + + Scheduler.AddDelayed(updateStats, 100, true); + } + + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + + CreateCarousel(); + + SortBy(new FilterCriteria { Sort = SortMode.Title }); + } + + protected void CreateCarousel() + { + AddStep("create components", () => + { + 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 BeatmapCarousel + { + 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 + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(10), + TextAnchor = Anchor.CentreLeft, + }, + }; + }); + } + + protected void SortBy(FilterCriteria criteria) => AddStep($"sort by {criteria.Sort}", () => Carousel.Filter(criteria)); + + protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType().Count(), () => Is.GreaterThan(0)); + protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False); + protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target)); + + /// + /// Add requested beatmap sets count to list. + /// + /// The count of beatmap sets to add. + /// If not null, the number of difficulties per set. If null, randomised difficulty count will be used. + protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null) => AddStep($"add {count} beatmaps", () => + { + for (int i = 0; i < count; i++) + BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4))); + }); + + protected void RemoveAllBeatmaps() => AddStep("clear all beatmaps", () => BeatmapSets.Clear()); + + protected void RemoveFirstBeatmap() => + AddStep("remove first beatmap", () => + { + if (BeatmapSets.Count == 0) return; + + BeatmapSets.Remove(BeatmapSets.First()); + }); + + private void updateStats() + { + if (Carousel.IsNull()) + return; + + stats.Clear(); + createHeader("beatmap store"); + stats.AddParagraph($""" + sets: {BeatmapSets.Count} + beatmaps: {beatmapCount} + """); + createHeader("carousel"); + stats.AddParagraph($""" + sorting: {Carousel.IsFiltering} + tracked: {Carousel.ItemsTracked} + displayable: {Carousel.DisplayableItems} + displayed: {Carousel.VisibleItems} + selected: {Carousel.CurrentSelection} + """); + + void createHeader(string text) + { + stats.AddParagraph(string.Empty); + stats.AddParagraph(text, cp => + { + cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold); + }); + } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs deleted file mode 100644 index 984352b2f5..0000000000 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs +++ /dev/null @@ -1,286 +0,0 @@ -// 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.Primitives; -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; -using osu.Game.Graphics.Containers; -using osu.Game.Screens.Select; -using osu.Game.Screens.Select.Filter; -using osu.Game.Screens.SelectV2; -using osu.Game.Tests.Beatmaps; -using osu.Game.Tests.Resources; -using osuTK.Graphics; -using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; - -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 BeatmapCarousel carousel = null!; - - private OsuScrollContainer scroll => carousel.ChildrenOfType>().Single(); - - 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 BeatmapCarousel - { - 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 - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(10), - TextAnchor = Anchor.CentreLeft, - }, - }; - }); - - AddStep("sort by title", () => - { - carousel.Filter(new FilterCriteria { Sort = SortMode.Title }); - }); - } - - [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 TestSorting() - { - AddStep("add 10 beatmaps", () => - { - for (int i = 0; i < 10; i++) - beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))); - }); - - AddStep("sort by difficulty", () => - { - carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }); - }); - - AddStep("sort by artist", () => - { - carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }); - }); - } - - [Test] - public void TestScrollPositionMaintainedOnAddSecondSelected() - { - Quad positionBefore = default; - - AddStep("add 10 beatmaps", () => - { - for (int i = 0; i < 10; i++) - beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))); - }); - - AddUntilStep("visual item added", () => carousel.ChildrenOfType().Count(), () => Is.GreaterThan(0)); - - AddStep("select middle beatmap", () => carousel.CurrentSelection = beatmapSets.ElementAt(beatmapSets.Count - 2)); - AddStep("scroll to selected item", () => scroll.ScrollTo(scroll.ChildrenOfType().Single(p => p.Item!.Selected.Value))); - - AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target)); - - AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad); - - AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last())); - AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False); - AddAssert("select screen position unchanged", () => carousel.ChildrenOfType().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad, - () => Is.EqualTo(positionBefore)); - } - - [Test] - public void TestScrollPositionMaintainedOnAddLastSelected() - { - Quad positionBefore = default; - - AddStep("add 10 beatmaps", () => - { - for (int i = 0; i < 10; i++) - beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))); - }); - - AddUntilStep("visual item added", () => carousel.ChildrenOfType().Count(), () => Is.GreaterThan(0)); - - AddStep("scroll to last item", () => scroll.ScrollToEnd(false)); - - AddStep("select last beatmap", () => carousel.CurrentSelection = beatmapSets.First()); - - AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target)); - - AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad); - - AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last())); - AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False); - AddAssert("select screen position unchanged", () => carousel.ChildrenOfType().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad, - () => Is.EqualTo(positionBefore)); - } - - [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.Clear(); - createHeader("beatmap store"); - stats.AddParagraph($""" - sets: {beatmapSets.Count} - beatmaps: {beatmapCount} - """); - createHeader("carousel"); - stats.AddParagraph($""" - sorting: {carousel.IsFiltering} - tracked: {carousel.ItemsTracked} - displayable: {carousel.DisplayableItems} - displayed: {carousel.VisibleItems} - selected: {carousel.CurrentSelection} - """); - - void createHeader(string text) - { - stats.AddParagraph(string.Empty); - stats.AddParagraph(text, cp => - { - cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold); - }); - } - } - } -} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs new file mode 100644 index 0000000000..748831bf7b --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs @@ -0,0 +1,119 @@ +// 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.Graphics.Primitives; +using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelect +{ + /// + /// Currently covers adding and removing of items and scrolling. + /// If we add more tests here, these two categories can likely be split out into separate scenes. + /// + [TestFixture] + public partial class TestSceneBeatmapCarouselV2Basics : BeatmapCarouselV2TestScene + { + [Test] + public void TestBasics() + { + AddBeatmaps(1); + AddBeatmaps(10); + RemoveFirstBeatmap(); + RemoveAllBeatmaps(); + } + + [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] + public void TestSorting() + { + AddBeatmaps(10); + SortBy(new FilterCriteria { Sort = SortMode.Difficulty }); + SortBy(new FilterCriteria { Sort = SortMode.Artist }); + } + + [Test] + public void TestScrollPositionMaintainedOnAddSecondSelected() + { + Quad positionBefore = default; + + AddBeatmaps(10); + WaitForDrawablePanels(); + + AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2)); + AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType().Single(p => p.Selected.Value))); + + WaitForScrolling(); + + AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad); + + RemoveFirstBeatmap(); + WaitForSorting(); + + AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType().Single(p => p.Selected.Value).ScreenSpaceDrawQuad, + () => Is.EqualTo(positionBefore)); + } + + [Test] + public void TestScrollPositionMaintainedOnAddLastSelected() + { + Quad positionBefore = default; + + AddBeatmaps(10); + WaitForDrawablePanels(); + + AddStep("scroll to last item", () => Scroll.ScrollToEnd(false)); + + AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last()); + + WaitForScrolling(); + + AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad); + + RemoveFirstBeatmap(); + WaitForSorting(); + AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType().Single(p => p.Selected.Value).ScreenSpaceDrawQuad, + () => Is.EqualTo(positionBefore)); + } + + [Test] + [Explicit] + public void TestPerformanceWithManyBeatmaps() + { + 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)); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs new file mode 100644 index 0000000000..305774b7d3 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Screens.SelectV2; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2Selection : BeatmapCarouselV2TestScene + { + /// + /// Keyboard selection via up and down arrows doesn't actually change the selection until + /// the select key is pressed. + /// + [Test] + public void TestKeyboardSelectionKeyRepeat() + { + AddBeatmaps(10); + WaitForDrawablePanels(); + checkNoSelection(); + + select(); + checkNoSelection(); + + AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); + checkSelectionIterating(false); + + AddStep("press up arrow", () => InputManager.PressKey(Key.Up)); + checkSelectionIterating(false); + + AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down)); + checkSelectionIterating(false); + + AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); + checkSelectionIterating(false); + + select(); + checkHasSelection(); + } + + /// + /// Keyboard selection via left and right arrows moves between groups, updating the selection + /// immediately. + /// + [Test] + public void TestGroupSelectionKeyRepeat() + { + AddBeatmaps(10); + WaitForDrawablePanels(); + checkNoSelection(); + + AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); + checkSelectionIterating(true); + + AddStep("press left arrow", () => InputManager.PressKey(Key.Left)); + checkSelectionIterating(true); + + AddStep("release right arrow", () => InputManager.ReleaseKey(Key.Right)); + checkSelectionIterating(true); + + AddStep("release left arrow", () => InputManager.ReleaseKey(Key.Left)); + checkSelectionIterating(false); + } + + [Test] + public void TestCarouselRemembersSelection() + { + AddBeatmaps(10); + WaitForDrawablePanels(); + + selectNextGroup(); + + object? selection = null; + + AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model); + + checkHasSelection(); + AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null); + AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + + RemoveAllBeatmaps(); + AddUntilStep("no drawable selection", getSelectedPanel, () => Is.Null); + + AddBeatmaps(10); + WaitForDrawablePanels(); + + checkHasSelection(); + AddAssert("no drawable selection", getSelectedPanel, () => Is.Null); + + AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!)); + + AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); + AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + + BeatmapCarouselPanel? getSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.Selected.Value); + } + + [Test] + public void TestTraversalBeyondStart() + { + const int total_set_count = 200; + + AddBeatmaps(total_set_count); + WaitForDrawablePanels(); + + selectNextGroup(); + waitForSelection(0, 0); + selectPrevGroup(); + waitForSelection(total_set_count - 1, 0); + } + + [Test] + public void TestTraversalBeyondEnd() + { + const int total_set_count = 200; + + AddBeatmaps(total_set_count); + WaitForDrawablePanels(); + + selectPrevGroup(); + waitForSelection(total_set_count - 1, 0); + selectNextGroup(); + waitForSelection(0, 0); + } + + [Test] + public void TestKeyboardSelection() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + selectNextPanel(); + selectNextPanel(); + selectNextPanel(); + selectNextPanel(); + checkNoSelection(); + + select(); + waitForSelection(3, 0); + + selectNextPanel(); + waitForSelection(3, 0); + + select(); + waitForSelection(3, 1); + + selectNextPanel(); + waitForSelection(3, 1); + + select(); + waitForSelection(3, 2); + + selectNextPanel(); + waitForSelection(3, 2); + + select(); + waitForSelection(4, 0); + } + + [Test] + public void TestEmptyTraversal() + { + selectNextPanel(); + checkNoSelection(); + + selectNextGroup(); + checkNoSelection(); + + selectPrevPanel(); + checkNoSelection(); + + selectPrevGroup(); + checkNoSelection(); + } + + private void waitForSelection(int set, int? diff = null) + { + AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () => + { + if (diff != null) + return ReferenceEquals(Carousel.CurrentSelection, BeatmapSets[set].Beatmaps[diff.Value]); + + return BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentSelection); + }); + } + + private void selectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down)); + private void selectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up)); + private void selectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right)); + private void selectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left)); + + private void select() => AddStep("select", () => InputManager.Key(Key.Enter)); + + private void checkNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null); + private void checkHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null); + + private void checkSelectionIterating(bool isIterating) + { + object? selection = null; + + for (int i = 0; i < 3; i++) + { + AddStep("store selection", () => selection = Carousel.CurrentSelection); + if (isIterating) + AddUntilStep("selection changed", () => Carousel.CurrentSelection != selection); + else + AddUntilStep("selection not changed", () => Carousel.CurrentSelection == selection); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index d9c049bbae..630f7b6583 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -22,10 +22,10 @@ namespace osu.Game.Screens.SelectV2 { private IBindableList detachedBeatmaps = null!; - private readonly DrawablePool carouselPanelPool = new DrawablePool(100); - private readonly LoadingLayer loading; + private readonly BeatmapCarouselFilterGrouping grouping; + public BeatmapCarousel() { DebounceDelay = 100; @@ -34,25 +34,27 @@ namespace osu.Game.Screens.SelectV2 Filters = new ICarouselFilter[] { new BeatmapCarouselFilterSorting(() => Criteria), - new BeatmapCarouselFilterGrouping(() => Criteria), + grouping = new BeatmapCarouselFilterGrouping(() => Criteria), }; - AddInternal(carouselPanelPool); - AddInternal(loading = new LoadingLayer(dimBackground: true)); } [BackgroundDependencyLoader] private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken) + { + setupPools(); + setupBeatmaps(beatmapStore, cancellationToken); + } + + #region Beatmap source hookup + + private void setupBeatmaps(BeatmapStore beatmapStore, CancellationToken? cancellationToken) { detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true); } - protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get(); - - protected override CarouselItem CreateCarouselItemForModel(BeatmapInfo model) => new BeatmapCarouselItem(model); - private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) { // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. @@ -86,6 +88,46 @@ namespace osu.Game.Screens.SelectV2 } } + #endregion + + #region Selection handling + + protected override void HandleItemSelected(object? model) + { + base.HandleItemSelected(model); + + // Selecting a set isn't valid – let's re-select the first difficulty. + if (model is BeatmapSetInfo setInfo) + { + CurrentSelection = setInfo.Beatmaps.First(); + return; + } + + if (model is BeatmapInfo beatmapInfo) + setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true); + } + + protected override void HandleItemDeselected(object? model) + { + base.HandleItemDeselected(model); + + if (model is BeatmapInfo beatmapInfo) + setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, false); + } + + private void setVisibilityOfSetItems(BeatmapSetInfo set, bool visible) + { + if (grouping.SetItems.TryGetValue(set, out var group)) + { + foreach (var i in group) + i.IsVisible = visible; + } + } + + #endregion + + #region Filtering + public FilterCriteria Criteria { get; private set; } = new FilterCriteria(); public void Filter(FilterCriteria criteria) @@ -94,5 +136,20 @@ namespace osu.Game.Screens.SelectV2 loading.Show(); FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide())); } + + #endregion + + #region Drawable pooling + + private readonly DrawablePool carouselPanelPool = new DrawablePool(100); + + private void setupPools() + { + AddInternal(carouselPanelPool); + } + + protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get(); + + #endregion } } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 6cdd15d301..4f0767048a 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -13,6 +13,13 @@ namespace osu.Game.Screens.SelectV2 { public class BeatmapCarouselFilterGrouping : ICarouselFilter { + /// + /// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection. + /// + public IDictionary> SetItems => setItems; + + private readonly Dictionary> setItems = new Dictionary>(); + private readonly Func getCriteria; public BeatmapCarouselFilterGrouping(Func getCriteria) @@ -27,7 +34,10 @@ namespace osu.Game.Screens.SelectV2 if (criteria.SplitOutDifficulties) { foreach (var item in items) - ((BeatmapCarouselItem)item).HasGroupHeader = false; + { + item.IsVisible = true; + item.IsGroupSelectionTarget = true; + } return items; } @@ -44,14 +54,25 @@ namespace osu.Game.Screens.SelectV2 { // Add set header if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID)) - newItems.Add(new BeatmapCarouselItem(b.BeatmapSet!) { IsGroupHeader = true }); + { + newItems.Add(new CarouselItem(b.BeatmapSet!) + { + DrawHeight = 80, + IsGroupSelectionTarget = true + }); + } + + if (!setItems.TryGetValue(b.BeatmapSet!, out var related)) + setItems[b.BeatmapSet!] = related = new HashSet(); + + related.Add(item); } newItems.Add(item); lastItem = item; - var beatmapCarouselItem = (BeatmapCarouselItem)item; - beatmapCarouselItem.HasGroupHeader = true; + item.IsGroupSelectionTarget = false; + item.IsVisible = false; } return newItems; diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs index df41aa3e86..0298616aa8 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs @@ -26,39 +26,34 @@ namespace osu.Game.Screens.SelectV2 { var criteria = getCriteria(); - return items.OrderDescending(Comparer.Create((a, b) => + return items.Order(Comparer.Create((a, b) => { - int comparison = 0; + int comparison; - if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb) + var ab = (BeatmapInfo)a.Model; + var bb = (BeatmapInfo)b.Model; + + switch (criteria.Sort) { - switch (criteria.Sort) - { - case SortMode.Artist: - comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist); - if (comparison == 0) - goto case SortMode.Title; - break; + case SortMode.Artist: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist); + if (comparison == 0) + goto case SortMode.Title; + break; - case SortMode.Difficulty: - comparison = ab.StarRating.CompareTo(bb.StarRating); - break; + case SortMode.Difficulty: + comparison = ab.StarRating.CompareTo(bb.StarRating); + break; - case SortMode.Title: - comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title); - break; + case SortMode.Title: + comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title); + break; - default: - throw new ArgumentOutOfRangeException(); - } + default: + throw new ArgumentOutOfRangeException(); } - if (comparison != 0) return comparison; - - if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem) - return aItem.ID.CompareTo(bItem.ID); - - return 0; + return comparison; })); }, cancellationToken).ConfigureAwait(false); } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs deleted file mode 100644 index dd7aae3db9..0000000000 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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 osu.Game.Beatmaps; -using osu.Game.Database; - -namespace osu.Game.Screens.SelectV2 -{ - public class BeatmapCarouselItem : CarouselItem - { - public readonly Guid ID; - - /// - /// Whether this item has a header providing extra information for it. - /// When displaying items which don't have header, we should make sure enough information is included inline. - /// - public bool HasGroupHeader { get; set; } - - /// - /// Whether this item is a group header. - /// Group headers are generally larger in display. Setting this will account for the size difference. - /// - public bool IsGroupHeader { get; set; } - - public override float DrawHeight => IsGroupHeader ? 80 : 40; - - 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(); - } - } -} diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs index 27023b50be..398ec7bf4c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs @@ -21,27 +21,41 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private BeatmapCarousel carousel { get; set; } = null!; - public CarouselItem? Item - { - get => item; - set - { - item = value; - - selected.UnbindBindings(); - - if (item != null) - selected.BindTo(item.Selected); - } - } - - private readonly BindableBool selected = new BindableBool(); - private CarouselItem? item; + private Box activationFlash = null!; + private Box background = null!; + private OsuSpriteText text = null!; [BackgroundDependencyLoader] private void load() { - selected.BindValueChanged(value => + InternalChildren = new Drawable[] + { + background = new Box + { + Alpha = 0.8f, + RelativeSizeAxes = Axes.Both, + }, + activationFlash = new Box + { + Colour = Color4.White, + Blending = BlendingParameters.Additive, + Alpha = 0, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Padding = new MarginPadding(5), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + } + }; + + Selected.BindValueChanged(value => + { + activationFlash.FadeTo(value.NewValue ? 0.2f : 0, 500, Easing.OutQuint); + }); + + KeyboardSelected.BindValueChanged(value => { if (value.NewValue) { @@ -59,6 +73,8 @@ namespace osu.Game.Screens.SelectV2 { base.FreeAfterUse(); Item = null; + Selected.Value = false; + KeyboardSelected.Value = false; } protected override void PrepareForUse() @@ -72,31 +88,44 @@ namespace osu.Game.Screens.SelectV2 Size = new Vector2(500, Item.DrawHeight); Masking = true; - 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, - } - }; + background.Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5); + text.Text = getTextFor(Item.Model); this.FadeInFromZero(500, Easing.OutQuint); } + private string getTextFor(object item) + { + switch (item) + { + case BeatmapInfo bi: + return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)"; + + case BeatmapSetInfo si: + return $"{si.Metadata}"; + } + + return "unknown"; + } + protected override bool OnClick(ClickEvent e) { - carousel.CurrentSelection = Item!.Model; + if (carousel.CurrentSelection == Item!.Model) + carousel.TryActivateSelection(); + else + carousel.CurrentSelection = Item!.Model; return true; } + public CarouselItem? Item { get; set; } + public BindableBool Selected { get; } = new BindableBool(); + public BindableBool KeyboardSelected { get; } = new BindableBool(); + public double DrawYPosition { get; set; } + + public void Activated() + { + activationFlash.FadeOutFromOne(500, Easing.OutQuint); + } } } diff --git a/osu.Game/Screens/SelectV2/Carousel.cs b/osu.Game/Screens/SelectV2/Carousel.cs index c042da167e..6ff27c6198 100644 --- a/osu.Game/Screens/SelectV2/Carousel.cs +++ b/osu.Game/Screens/SelectV2/Carousel.cs @@ -28,7 +28,8 @@ 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 + public abstract partial class Carousel : CompositeDrawable, IKeyBindingHandler + where T : notnull { #region Properties and methods for external usage @@ -80,25 +81,37 @@ namespace osu.Game.Screens.SelectV2 public int VisibleItems => scroll.Panels.Count; /// - /// The currently selected model. + /// The currently selected model. Generally of type T. /// /// - /// 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. + /// 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. /// - public virtual object? CurrentSelection + public object? CurrentSelection { - get => currentSelection; - set + get => currentSelection.Model; + set => setSelection(value); + } + + /// + /// Activate the current selection, if a selection exists and matches keyboard selection. + /// If keyboard selection does not match selection, this will transfer the selection on first invocation. + /// + public void TryActivateSelection() + { + if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) { - if (currentSelectionCarouselItem != null) - currentSelectionCarouselItem.Selected.Value = false; + CurrentSelection = currentKeyboardSelection.Model; + return; + } - currentSelection = value; - - currentSelectionCarouselItem = null; - currentSelectionYPosition = null; - updateSelection(); + if (currentSelection.CarouselItem != null) + { + (GetMaterialisedDrawableForItem(currentSelection.CarouselItem) as ICarouselPanel)?.Activated(); + HandleItemActivated(currentSelection.CarouselItem); } } @@ -144,11 +157,42 @@ namespace osu.Game.Screens.SelectV2 protected abstract Drawable GetDrawableForDisplay(CarouselItem item); /// - /// Create an internal carousel representation for the provided model object. + /// Given a , find a drawable representation if it is currently displayed in the carousel. /// - /// The model. - /// A representing the model. - protected abstract CarouselItem CreateCarouselItemForModel(T model); + /// + /// This will only return a drawable if it is "on-screen". + /// + /// The item to find a related drawable representation. + /// The drawable representation if it exists. + protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) => + scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item); + + /// + /// Called when an item is "selected". + /// + protected virtual void HandleItemSelected(object? model) + { + } + + /// + /// Called when an item is "deselected". + /// + protected virtual void HandleItemDeselected(object? model) + { + } + + /// + /// Called when an item is "activated". + /// + /// + /// An activated item should for instance: + /// - Open or close a folder + /// - Start gameplay on a beatmap difficulty. + /// + /// The carousel item which was activated. + protected virtual void HandleItemActivated(CarouselItem item) + { + } #endregion @@ -197,7 +241,7 @@ namespace osu.Game.Screens.SelectV2 // Copy must be performed on update thread for now (see ConfigureAwait above). // Could potentially be optimised in the future if it becomes an issue. - IEnumerable items = new List(Items.Select(CreateCarouselItemForModel)); + IEnumerable items = new List(Items.Select(m => new CarouselItem(m))); await Task.Run(async () => { @@ -210,7 +254,7 @@ namespace osu.Game.Screens.SelectV2 } log("Updating Y positions"); - await updateYPositions(items, cts.Token).ConfigureAwait(false); + updateYPositions(items, visibleHalfHeight, SpacingBetweenPanels); } catch (OperationCanceledException) { @@ -225,58 +269,231 @@ namespace osu.Game.Screens.SelectV2 carouselItems = items.ToList(); displayedRange = null; - updateSelection(); + // Need to call this to ensure correct post-selection logic is handled on the new items list. + HandleItemSelected(currentSelection.Model); + + refreshAfterSelection(); void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}"); } - private async Task updateYPositions(IEnumerable carouselItems, CancellationToken cancellationToken) => await Task.Run(() => + private static void updateYPositions(IEnumerable carouselItems, float offset, float spacing) { - float yPos = visibleHalfHeight; - foreach (var item in carouselItems) + updateItemYPosition(item, ref offset, spacing); + } + + private static void updateItemYPosition(CarouselItem item, ref float offset, float spacing) + { + item.CarouselYPosition = offset; + if (item.IsVisible) + offset += item.DrawHeight + spacing; + } + + #endregion + + #region Input handling + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) { - item.CarouselYPosition = yPos; - yPos += item.DrawHeight + SpacingBetweenPanels; + case GlobalAction.Select: + TryActivateSelection(); + return true; + + case GlobalAction.SelectNext: + selectNext(1, isGroupSelection: false); + return true; + + case GlobalAction.SelectNextGroup: + selectNext(1, isGroupSelection: true); + return true; + + case GlobalAction.SelectPrevious: + selectNext(-1, isGroupSelection: false); + return true; + + case GlobalAction.SelectPreviousGroup: + selectNext(-1, isGroupSelection: true); + return true; } - }, cancellationToken).ConfigureAwait(false); + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + /// + /// Select the next valid selection relative to a current selection. + /// This is generally for keyboard based traversal. + /// + /// Positive for downwards, negative for upwards. + /// Whether the selection should traverse groups. Group selection updates the actual selection immediately, while non-group selection will only prepare a future keyboard selection. + /// Whether selection was possible. + private bool selectNext(int direction, bool isGroupSelection) + { + // Ensure sanity + Debug.Assert(direction != 0); + direction = direction > 0 ? 1 : -1; + + if (carouselItems == null || carouselItems.Count == 0) + return false; + + // If the user has a different keyboard selection and requests + // group selection, first transfer the keyboard selection to actual selection. + if (isGroupSelection && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) + { + TryActivateSelection(); + return true; + } + + CarouselItem? selectionItem = currentKeyboardSelection.CarouselItem; + int selectionIndex = currentKeyboardSelection.Index ?? -1; + + // To keep things simple, let's first handle the cases where there's no selection yet. + if (selectionItem == null || selectionIndex < 0) + { + // Start by selecting the first item. + selectionItem = carouselItems.First(); + selectionIndex = 0; + + // In the forwards case, immediately attempt selection of this panel. + // If selection fails, continue with standard logic to find the next valid selection. + if (direction > 0 && attemptSelection(selectionItem)) + return true; + + // In the backwards direction we can just allow the selection logic to go ahead and loop around to the last valid. + } + + Debug.Assert(selectionItem != null); + + // As a second special case, if we're group selecting backwards and the current selection isn't a group, + // make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early. + if (isGroupSelection && direction < 0) + { + while (!carouselItems[selectionIndex].IsGroupSelectionTarget) + selectionIndex--; + } + + CarouselItem? newItem; + + // 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 + { + selectionIndex += direction; + newItem = carouselItems[(selectionIndex + carouselItems.Count) % carouselItems.Count]; + + if (attemptSelection(newItem)) + return true; + } while (newItem != selectionItem); + + return false; + + bool attemptSelection(CarouselItem item) + { + if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget)) + return false; + + if (isGroupSelection) + setSelection(item.Model); + else + setKeyboardSelection(item.Model); + + return true; + } + } #endregion #region Selection handling - private object? currentSelection; - private CarouselItem? currentSelectionCarouselItem; - private double? currentSelectionYPosition; + private Selection currentKeyboardSelection = new Selection(); + private Selection currentSelection = new Selection(); - private void updateSelection() + private void setSelection(object? model) { - currentSelectionCarouselItem = null; + if (currentSelection.Model == model) + return; - if (carouselItems == null) return; + var previousSelection = currentSelection; - foreach (var item in carouselItems) + if (previousSelection.Model != null) + HandleItemDeselected(previousSelection.Model); + + currentSelection = currentKeyboardSelection = new Selection(model); + HandleItemSelected(currentSelection.Model); + + // `HandleItemSelected` can alter `CurrentSelection`, which will recursively call `setSelection()` again. + // if that happens, the rest of this method should be a no-op. + if (currentSelection.Model != model) + return; + + refreshAfterSelection(); + scrollToSelection(); + } + + private void setKeyboardSelection(object? model) + { + currentKeyboardSelection = new Selection(model); + + refreshAfterSelection(); + scrollToSelection(); + } + + /// + /// Call after a selection of items change to re-attach s to current s. + /// + private void refreshAfterSelection() + { + float yPos = visibleHalfHeight; + + // Invalidate display range as panel positions and visible status may have changed. + // Position transfer won't happen unless we invalidate this. + displayedRange = null; + + // The case where no items are available for display yet. + if (carouselItems == null) { - 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; + currentKeyboardSelection = new Selection(); + currentSelection = new Selection(); + return; } + + float spacing = SpacingBetweenPanels; + int count = carouselItems.Count; + + Selection prevKeyboard = currentKeyboardSelection; + + // We are performing two important operations here: + // - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions. + // - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use. + for (int i = 0; i < count; i++) + { + var item = carouselItems[i]; + + updateItemYPosition(item, ref yPos, spacing); + + if (ReferenceEquals(item.Model, currentKeyboardSelection.Model)) + currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + + if (ReferenceEquals(item.Model, currentSelection.Model)) + currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + } + + // If a keyboard selection is currently made, we want to keep the view stable around the selection. + // That means that we should offset the immediate scroll position by any change in Y position for the selection. + if (prevKeyboard.YPosition != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition) + scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value)); + } + + private void scrollToSelection() + { + if (currentKeyboardSelection.CarouselItem != null) + scroll.ScrollTo(currentKeyboardSelection.CarouselItem.CarouselYPosition - visibleHalfHeight); } #endregion @@ -285,7 +502,7 @@ namespace osu.Game.Screens.SelectV2 private DisplayRange? displayedRange; - private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem(); + private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object()); /// /// The position of the lower visible bound with respect to the current scroll position. @@ -335,6 +552,9 @@ namespace osu.Game.Screens.SelectV2 float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight); panel.X = offsetX(dist, visibleHalfHeight); + + c.Selected.Value = c.Item == currentSelection?.CarouselItem; + c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem; } } @@ -381,6 +601,8 @@ namespace osu.Game.Screens.SelectV2 ? new List() : 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) { @@ -434,6 +656,15 @@ namespace osu.Game.Screens.SelectV2 #region Internal helper classes + /// + /// Bookkeeping for a current selection. + /// + /// The selected model. If null, there's no selection. + /// A related carousel item representation for the model. May be null if selection is not present as an item, or if has not been run yet. + /// The Y position of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. + /// The index of the selection as of the last run of . May be null if selection is not present as an item, or if has not been run yet. + private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null); + private record DisplayRange(int First, int Last); /// @@ -573,16 +804,6 @@ namespace osu.Game.Screens.SelectV2 #endregion } - 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 index 4636e8a32f..2cb96a3d7f 100644 --- a/osu.Game/Screens/SelectV2/CarouselItem.cs +++ b/osu.Game/Screens/SelectV2/CarouselItem.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Bindables; namespace osu.Game.Screens.SelectV2 { @@ -10,9 +9,9 @@ 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 + public sealed class CarouselItem : IComparable { - public readonly BindableBool Selected = new BindableBool(); + public const float DEFAULT_HEIGHT = 40; /// /// The model this item is representing. @@ -20,16 +19,27 @@ namespace osu.Game.Screens.SelectV2 public readonly object Model; /// - /// The current Y position in the carousel. This is managed by and should not be set manually. + /// 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. + /// The height this item will take when displayed. Defaults to . /// - public abstract float DrawHeight { get; } + public float DrawHeight { get; set; } = DEFAULT_HEIGHT; - protected CarouselItem(object model) + /// + /// Whether this item should be a valid target for user group selection hotkeys. + /// + public bool IsGroupSelectionTarget { get; set; } + + /// + /// Whether this item is visible or collapsed (hidden). + /// + public bool IsVisible { get; set; } = true; + + public CarouselItem(object model) { Model = model; } diff --git a/osu.Game/Screens/SelectV2/ICarouselPanel.cs b/osu.Game/Screens/SelectV2/ICarouselPanel.cs index 117feab621..a956bb22a3 100644 --- a/osu.Game/Screens/SelectV2/ICarouselPanel.cs +++ b/osu.Game/Screens/SelectV2/ICarouselPanel.cs @@ -1,22 +1,40 @@ // 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.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; namespace osu.Game.Screens.SelectV2 { /// /// An interface to be attached to any s which are used for display inside a . + /// Importantly, all properties in this interface are managed by and should not be written to elsewhere. /// public interface ICarouselPanel { /// - /// The Y position which should be used for displaying this item within the carousel. This is managed by and should not be set manually. + /// Whether this item has selection. Should be read from to update the visual state. + /// + BindableBool Selected { get; } + + /// + /// Whether this item has keyboard selection. Should be read from to update the visual state. + /// + BindableBool KeyboardSelected { get; } + + /// + /// Called when the panel is activated. Should be used to update the panel's visual state. + /// + void Activated(); + + /// + /// The Y position used internally for positioning in the carousel. /// double DrawYPosition { get; set; } /// - /// The carousel item this drawable is representing. This is managed by and should not be set manually. + /// The carousel item this drawable is representing. Will be set before is called. /// CarouselItem? Item { get; set; } }