diff --git a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs index 281be924a1..0a9719423c 100644 --- a/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs +++ b/osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs @@ -21,6 +21,7 @@ using osu.Game.Screens.SelectV2; using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK.Graphics; +using osuTK.Input; using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; namespace osu.Game.Tests.Visual.SongSelect @@ -53,7 +54,7 @@ namespace osu.Game.Tests.Visual.SongSelect } [SetUpSteps] - public void SetUpSteps() + public virtual void SetUpSteps() { RemoveAllBeatmaps(); @@ -129,12 +130,59 @@ namespace osu.Game.Tests.Visual.SongSelect }); } - protected void SortBy(FilterCriteria criteria) => AddStep($"sort by {criteria.Sort}", () => Carousel.Filter(criteria)); + protected void SortBy(FilterCriteria criteria) => AddStep($"sort {criteria.Sort} group {criteria.Group}", () => 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)); + protected void SelectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down)); + protected void SelectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up)); + protected void SelectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right)); + protected void SelectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left)); + + protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter)); + + protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null); + protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null); + + protected void WaitForGroupSelection(int group, int panel) + { + AddUntilStep($"selected is group{group} panel{panel}", () => + { + var groupingFilter = Carousel.Filters.OfType().Single(); + + GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(group); + CarouselItem item = groupingFilter.GroupItems[g].ElementAt(panel); + + return ReferenceEquals(Carousel.CurrentSelection, item.Model); + }); + } + + protected 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); + }); + } + + protected void ClickVisiblePanel(int index) + where T : Drawable + { + AddStep($"click panel at index {index}", () => + { + Carousel.ChildrenOfType() + .Where(p => ((ICarouselPanel)p).Item?.IsVisible == true) + .Reverse() + .ElementAt(index) + .TriggerClick(); + }); + } + /// /// Add requested beatmap sets count to list. /// diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs index 3a516ea762..8ffb51b995 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Basics.cs @@ -32,6 +32,13 @@ namespace osu.Game.Tests.Visual.SongSelect RemoveAllBeatmaps(); } + [Test] + public void TestOffScreenLoading() + { + AddStep("disable masking", () => Scroll.Masking = false); + AddStep("enable masking", () => Scroll.Masking = true); + } + [Test] public void TestAddRemoveOneByOne() { @@ -43,7 +50,7 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestSorting() { AddBeatmaps(10); - SortBy(new FilterCriteria { Sort = SortMode.Difficulty }); + SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty }); SortBy(new FilterCriteria { Sort = SortMode.Artist }); } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2GroupSelection.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2GroupSelection.cs new file mode 100644 index 0000000000..04ca0a9085 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2GroupSelection.cs @@ -0,0 +1,135 @@ +// 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.Select; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelect +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselV2GroupSelection : BeatmapCarouselV2TestScene + { + public override void SetUpSteps() + { + RemoveAllBeatmaps(); + + CreateCarousel(); + + SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty }); + } + + [Test] + public void TestOpenCloseGroupWithNoSelection() + { + AddBeatmaps(10, 5); + WaitForDrawablePanels(); + + AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + + ClickVisiblePanel(0); + AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + CheckNoSelection(); + + ClickVisiblePanel(0); + AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.Zero); + CheckNoSelection(); + } + + [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!)); + + AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); + AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True); + + ClickVisiblePanel(0); + AddUntilStep("carousel item not visible", getSelectedPanel, () => Is.Null); + + ClickVisiblePanel(0); + AddUntilStep("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True); + + BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.Selected.Value); + } + + [Test] + public void TestGroupSelectionOnHeader() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + SelectNextGroup(); + WaitForGroupSelection(0, 0); + + SelectPrevPanel(); + SelectPrevGroup(); + WaitForGroupSelection(2, 9); + } + + [Test] + public void TestKeyboardSelection() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + CheckNoSelection(); + + // open first group + Select(); + CheckNoSelection(); + AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType().Count(p => p.Alpha > 0), () => Is.GreaterThan(0)); + + SelectNextPanel(); + Select(); + WaitForGroupSelection(0, 0); + + SelectNextGroup(); + WaitForGroupSelection(0, 1); + + SelectNextGroup(); + WaitForGroupSelection(0, 2); + + SelectPrevGroup(); + WaitForGroupSelection(0, 1); + + SelectPrevGroup(); + WaitForGroupSelection(0, 0); + + SelectPrevGroup(); + WaitForGroupSelection(2, 9); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs index 3c42969d8c..b087c252e4 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2Selection.cs @@ -22,10 +22,10 @@ namespace osu.Game.Tests.Visual.SongSelect { AddBeatmaps(10); WaitForDrawablePanels(); - checkNoSelection(); + CheckNoSelection(); - select(); - checkNoSelection(); + Select(); + CheckNoSelection(); AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); checkSelectionIterating(false); @@ -39,8 +39,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); checkSelectionIterating(false); - select(); - checkHasSelection(); + Select(); + CheckHasSelection(); } /// @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual.SongSelect { AddBeatmaps(10); WaitForDrawablePanels(); - checkNoSelection(); + CheckNoSelection(); AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); checkSelectionIterating(true); @@ -73,13 +73,13 @@ namespace osu.Game.Tests.Visual.SongSelect AddBeatmaps(10); WaitForDrawablePanels(); - selectNextGroup(); + SelectNextGroup(); object? selection = null; AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model); - checkHasSelection(); + CheckHasSelection(); AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null); AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); @@ -89,13 +89,14 @@ namespace osu.Game.Tests.Visual.SongSelect AddBeatmaps(10); WaitForDrawablePanels(); - checkHasSelection(); + CheckHasSelection(); AddAssert("no drawable selection", getSelectedPanel, () => Is.Null); AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!)); + AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); - AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); + AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True); BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType().SingleOrDefault(p => p.Selected.Value); } @@ -108,10 +109,10 @@ namespace osu.Game.Tests.Visual.SongSelect AddBeatmaps(total_set_count); WaitForDrawablePanels(); - selectNextGroup(); - waitForSelection(0, 0); - selectPrevGroup(); - waitForSelection(total_set_count - 1, 0); + SelectNextGroup(); + WaitForSelection(0, 0); + SelectPrevGroup(); + WaitForSelection(total_set_count - 1, 0); } [Test] @@ -122,10 +123,25 @@ namespace osu.Game.Tests.Visual.SongSelect AddBeatmaps(total_set_count); WaitForDrawablePanels(); - selectPrevGroup(); - waitForSelection(total_set_count - 1, 0); - selectNextGroup(); - waitForSelection(0, 0); + SelectPrevGroup(); + WaitForSelection(total_set_count - 1, 0); + SelectNextGroup(); + WaitForSelection(0, 0); + } + + [Test] + public void TestGroupSelectionOnHeader() + { + AddBeatmaps(10, 3); + WaitForDrawablePanels(); + + SelectNextGroup(); + SelectNextGroup(); + WaitForSelection(1, 0); + + SelectPrevPanel(); + SelectPrevGroup(); + WaitForSelection(0, 0); } [Test] @@ -134,71 +150,50 @@ namespace osu.Game.Tests.Visual.SongSelect AddBeatmaps(10, 3); WaitForDrawablePanels(); - selectNextPanel(); - selectNextPanel(); - selectNextPanel(); - selectNextPanel(); - checkNoSelection(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + SelectNextPanel(); + CheckNoSelection(); - select(); - waitForSelection(3, 0); + Select(); + WaitForSelection(3, 0); - selectNextPanel(); - waitForSelection(3, 0); + SelectNextPanel(); + WaitForSelection(3, 0); - select(); - waitForSelection(3, 1); + Select(); + WaitForSelection(3, 1); - selectNextPanel(); - waitForSelection(3, 1); + SelectNextPanel(); + WaitForSelection(3, 1); - select(); - waitForSelection(3, 2); + Select(); + WaitForSelection(3, 2); - selectNextPanel(); - waitForSelection(3, 2); + SelectNextPanel(); + WaitForSelection(3, 2); - select(); - waitForSelection(4, 0); + Select(); + WaitForSelection(4, 0); } [Test] public void TestEmptyTraversal() { - selectNextPanel(); - checkNoSelection(); + SelectNextPanel(); + CheckNoSelection(); - selectNextGroup(); - checkNoSelection(); + SelectNextGroup(); + CheckNoSelection(); - selectPrevPanel(); - checkNoSelection(); + SelectPrevPanel(); + CheckNoSelection(); - selectPrevGroup(); - 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; diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index bb13c7449d..858888c517 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -92,34 +92,102 @@ namespace osu.Game.Screens.SelectV2 #region Selection handling - protected override void HandleItemSelected(object? model) + private GroupDefinition? lastSelectedGroup; + private BeatmapInfo? lastSelectedBeatmap; + + protected override bool HandleItemSelected(object? model) { base.HandleItemSelected(model); - // Selecting a set isn't valid – let's re-select the first difficulty. - if (model is BeatmapSetInfo setInfo) + switch (model) { - CurrentSelection = setInfo.Beatmaps.First(); - return; + case GroupDefinition group: + // Special case – collapsing an open group. + if (lastSelectedGroup == group) + { + setVisibilityOfGroupItems(lastSelectedGroup, false); + lastSelectedGroup = null; + return false; + } + + setVisibleGroup(group); + return false; + + case BeatmapSetInfo setInfo: + // Selecting a set isn't valid – let's re-select the first difficulty. + CurrentSelection = setInfo.Beatmaps.First(); + return false; + + case BeatmapInfo beatmapInfo: + + // If we have groups, we need to account for them. + if (Criteria.SplitOutDifficulties) + { + // Find the containing group. There should never be too many groups so iterating is efficient enough. + GroupDefinition? group = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key; + + if (group != null) + setVisibleGroup(group); + } + else + { + setVisibleSet(beatmapInfo); + } + + return true; } - if (model is BeatmapInfo beatmapInfo) - setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true); + return true; } - protected override void HandleItemDeselected(object? model) + protected override bool CheckValidForGroupSelection(CarouselItem item) { - base.HandleItemDeselected(model); + switch (item.Model) + { + case BeatmapSetInfo: + return true; - if (model is BeatmapInfo beatmapInfo) - setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, false); + case BeatmapInfo: + return Criteria.SplitOutDifficulties; + + case GroupDefinition: + return false; + + default: + throw new ArgumentException($"Unsupported model type {item.Model}"); + } + } + + private void setVisibleGroup(GroupDefinition group) + { + if (lastSelectedGroup != null) + setVisibilityOfGroupItems(lastSelectedGroup, false); + lastSelectedGroup = group; + setVisibilityOfGroupItems(group, true); + } + + private void setVisibilityOfGroupItems(GroupDefinition group, bool visible) + { + if (grouping.GroupItems.TryGetValue(group, out var items)) + { + foreach (var i in items) + i.IsVisible = visible; + } + } + + private void setVisibleSet(BeatmapInfo beatmapInfo) + { + if (lastSelectedBeatmap != null) + setVisibilityOfSetItems(lastSelectedBeatmap.BeatmapSet!, false); + lastSelectedBeatmap = beatmapInfo; + setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true); } private void setVisibilityOfSetItems(BeatmapSetInfo set, bool visible) { - if (grouping.SetItems.TryGetValue(set, out var group)) + if (grouping.SetItems.TryGetValue(set, out var items)) { - foreach (var i in group) + foreach (var i in items) i.IsVisible = visible; } } @@ -143,9 +211,11 @@ namespace osu.Game.Screens.SelectV2 private readonly DrawablePool beatmapPanelPool = new DrawablePool(100); private readonly DrawablePool setPanelPool = new DrawablePool(100); + private readonly DrawablePool groupPanelPool = new DrawablePool(100); private void setupPools() { + AddInternal(groupPanelPool); AddInternal(beatmapPanelPool); AddInternal(setPanelPool); } @@ -154,7 +224,12 @@ namespace osu.Game.Screens.SelectV2 { switch (item.Model) { + case GroupDefinition: + return groupPanelPool.Get(); + case BeatmapInfo: + // TODO: if beatmap is a group selection target, it needs to be a different drawable + // with more information attached. return beatmapPanelPool.Get(); case BeatmapSetInfo: @@ -166,4 +241,6 @@ namespace osu.Game.Screens.SelectV2 #endregion } + + public record GroupDefinition(string Title); } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 0658263a8c..ea737d8b7f 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Game.Beatmaps; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; namespace osu.Game.Screens.SelectV2 { @@ -18,7 +19,13 @@ namespace osu.Game.Screens.SelectV2 /// public IDictionary> SetItems => setItems; + /// + /// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection. + /// + public IDictionary> GroupItems => groupItems; + private readonly Dictionary> setItems = new Dictionary>(); + private readonly Dictionary> groupItems = new Dictionary>(); private readonly Func getCriteria; @@ -29,50 +36,97 @@ namespace osu.Game.Screens.SelectV2 public async Task> Run(IEnumerable items, CancellationToken cancellationToken) => await Task.Run(() => { + bool groupSetsTogether; + + setItems.Clear(); + groupItems.Clear(); + var criteria = getCriteria(); - - if (criteria.SplitOutDifficulties) - { - foreach (var item in items) - { - item.IsVisible = true; - item.IsGroupSelectionTarget = true; - } - - return items; - } - - CarouselItem? lastItem = null; - var newItems = new List(items.Count()); - foreach (var item in items) + // Add criteria groups. + switch (criteria.Group) + { + default: + groupSetsTogether = true; + newItems.AddRange(items); + break; + + case GroupMode.Difficulty: + groupSetsTogether = false; + int starGroup = int.MinValue; + + foreach (var item in items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var b = (BeatmapInfo)item.Model; + + if (b.StarRating > starGroup) + { + starGroup = (int)Math.Floor(b.StarRating); + newItems.Add(new CarouselItem(new GroupDefinition($"{starGroup} - {++starGroup} *")) { DrawHeight = GroupPanel.HEIGHT }); + } + + newItems.Add(item); + } + + break; + } + + // Add set headers wherever required. + CarouselItem? lastItem = null; + + if (groupSetsTogether) + { + for (int i = 0; i < newItems.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var item = newItems[i]; + + if (item.Model is BeatmapInfo beatmap) + { + bool newBeatmapSet = lastItem == null || (lastItem.Model is BeatmapInfo lastBeatmap && lastBeatmap.BeatmapSet!.ID != beatmap.BeatmapSet!.ID); + + if (newBeatmapSet) + { + newItems.Insert(i, new CarouselItem(beatmap.BeatmapSet!) { DrawHeight = BeatmapSetPanel.HEIGHT }); + i++; + } + + if (!setItems.TryGetValue(beatmap.BeatmapSet!, out var related)) + setItems[beatmap.BeatmapSet!] = related = new HashSet(); + + related.Add(item); + item.IsVisible = false; + } + + lastItem = item; + } + } + + // Link group items to their headers. + GroupDefinition? lastGroup = null; + + foreach (var item in newItems) { cancellationToken.ThrowIfCancellationRequested(); - if (item.Model is BeatmapInfo b) + if (item.Model is GroupDefinition group) { - // Add set header - if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID)) - { - newItems.Add(new CarouselItem(b.BeatmapSet!) - { - DrawHeight = BeatmapSetPanel.HEIGHT, - IsGroupSelectionTarget = true - }); - } - - if (!setItems.TryGetValue(b.BeatmapSet!, out var related)) - setItems[b.BeatmapSet!] = related = new HashSet(); - - related.Add(item); + lastGroup = group; + continue; } - newItems.Add(item); - lastItem = item; + if (lastGroup != null) + { + if (!groupItems.TryGetValue(lastGroup, out var groupRelated)) + groupItems[lastGroup] = groupRelated = new HashSet(); + groupRelated.Add(item); - item.IsGroupSelectionTarget = false; - item.IsVisible = false; + item.IsVisible = false; + } } return newItems; diff --git a/osu.Game/Screens/SelectV2/BeatmapSetPanel.cs b/osu.Game/Screens/SelectV2/BeatmapSetPanel.cs index 37e8b88f71..06e3ad3426 100644 --- a/osu.Game/Screens/SelectV2/BeatmapSetPanel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapSetPanel.cs @@ -67,7 +67,6 @@ namespace osu.Game.Screens.SelectV2 base.PrepareForUse(); Debug.Assert(Item != null); - Debug.Assert(Item.IsGroupSelectionTarget); var beatmapSetInfo = (BeatmapSetInfo)Item.Model; diff --git a/osu.Game/Screens/SelectV2/Carousel.cs b/osu.Game/Screens/SelectV2/Carousel.cs index f41154b878..a1bafac620 100644 --- a/osu.Game/Screens/SelectV2/Carousel.cs +++ b/osu.Game/Screens/SelectV2/Carousel.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -130,7 +131,7 @@ namespace osu.Game.Screens.SelectV2 /// /// A filter may add, mutate or remove items. /// - protected IEnumerable Filters { get; init; } = Enumerable.Empty(); + public IEnumerable Filters { get; init; } = Enumerable.Empty(); /// /// All items which are to be considered for display in this carousel. @@ -167,12 +168,18 @@ namespace osu.Game.Screens.SelectV2 protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) => scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item); + /// + /// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target. + /// + /// The candidate item. + /// Whether the provided item is a valid group target. If false, more panels will be checked in the user's requested direction until a valid target is found. + protected virtual bool CheckValidForGroupSelection(CarouselItem item) => true; + /// /// Called when an item is "selected". /// - protected virtual void HandleItemSelected(object? model) - { - } + /// Whether the item should be selected. + protected virtual bool HandleItemSelected(object? model) => true; /// /// Called when an item is "deselected". @@ -205,7 +212,6 @@ namespace osu.Game.Screens.SelectV2 InternalChild = scroll = new CarouselScrollContainer { RelativeSizeAxes = Axes.Both, - Masking = false, }; Items.BindCollectionChanged((_, _) => FilterAsync()); @@ -303,19 +309,19 @@ namespace osu.Game.Screens.SelectV2 return true; case GlobalAction.SelectNext: - selectNext(1, isGroupSelection: false); - return true; - - case GlobalAction.SelectNextGroup: - selectNext(1, isGroupSelection: true); + traverseKeyboardSelection(1); return true; case GlobalAction.SelectPrevious: - selectNext(-1, isGroupSelection: false); + traverseKeyboardSelection(-1); + return true; + + case GlobalAction.SelectNextGroup: + traverseGroupSelection(1); return true; case GlobalAction.SelectPreviousGroup: - selectNext(-1, isGroupSelection: true); + traverseGroupSelection(-1); return true; } @@ -326,91 +332,99 @@ namespace osu.Game.Screens.SelectV2 { } - /// - /// 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) + private void traverseKeyboardSelection(int direction) { - // Ensure sanity - Debug.Assert(direction != 0); - direction = direction > 0 ? 1 : -1; + if (carouselItems == null || carouselItems.Count == 0) return; - if (carouselItems == null || carouselItems.Count == 0) - return false; + int originalIndex; - // 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; - } + if (currentKeyboardSelection.Index != null) + originalIndex = currentKeyboardSelection.Index.Value; + else if (direction > 0) + originalIndex = carouselItems.Count - 1; + else + originalIndex = 0; - 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; + 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 { - selectionIndex += direction; - newItem = carouselItems[(selectionIndex + carouselItems.Count) % carouselItems.Count]; + newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count; + var newItem = carouselItems[newIndex]; - if (attemptSelection(newItem)) - return true; - } while (newItem != selectionItem); + if (newItem.IsVisible) + { + setKeyboardSelection(newItem.Model); + return; + } + } while (newIndex != originalIndex); + } - return false; + /// + /// Select the next valid selection relative to a current selection. + /// This is generally for keyboard based traversal. + /// + /// Positive for downwards, negative for upwards. + /// Whether selection was possible. + private void traverseGroupSelection(int direction) + { + if (carouselItems == null || carouselItems.Count == 0) return; - bool attemptSelection(CarouselItem item) + // If the user has a different keyboard selection and requests + // group selection, first transfer the keyboard selection to actual selection. + if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) { - if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget)) - return false; + TryActivateSelection(); - if (isGroupSelection) - setSelection(item.Model); - else - setKeyboardSelection(item.Model); - - return true; + // There's a chance this couldn't resolve, at which point continue with standard traversal. + if (currentSelection.CarouselItem == currentKeyboardSelection.CarouselItem) + return; } + + int originalIndex; + int newIndex; + + if (currentSelection.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 = currentSelection.Index.Value; + + // 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 (direction < 0) + { + while (!CheckValidForGroupSelection(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; + var newItem = carouselItems[newIndex]; + + if (CheckValidForGroupSelection(newItem)) + { + setSelection(newItem.Model); + return; + } + } while (newIndex != originalIndex); } #endregion #region Selection handling + private readonly Cached selectionValid = new Cached(); + private Selection currentKeyboardSelection = new Selection(); private Selection currentSelection = new Selection(); @@ -419,29 +433,22 @@ namespace osu.Game.Screens.SelectV2 if (currentSelection.Model == model) return; - var previousSelection = currentSelection; + if (HandleItemSelected(model)) + { + if (currentSelection.Model != null) + HandleItemDeselected(currentSelection.Model); - if (previousSelection.Model != null) - HandleItemDeselected(previousSelection.Model); + currentKeyboardSelection = new Selection(model); + currentSelection = currentKeyboardSelection; + } - 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(); + selectionValid.Invalidate(); } private void setKeyboardSelection(object? model) { currentKeyboardSelection = new Selection(model); - - refreshAfterSelection(); - scrollToSelection(); + selectionValid.Invalidate(); } /// @@ -526,6 +533,13 @@ namespace osu.Game.Screens.SelectV2 if (carouselItems == null) return; + if (!selectionValid.IsValid) + { + refreshAfterSelection(); + scrollToSelection(); + selectionValid.Validate(); + } + var range = getDisplayRange(); if (range != displayedRange) diff --git a/osu.Game/Screens/SelectV2/CarouselItem.cs b/osu.Game/Screens/SelectV2/CarouselItem.cs index 2cb96a3d7f..13d5c840cf 100644 --- a/osu.Game/Screens/SelectV2/CarouselItem.cs +++ b/osu.Game/Screens/SelectV2/CarouselItem.cs @@ -29,11 +29,6 @@ namespace osu.Game.Screens.SelectV2 /// public float DrawHeight { get; set; } = DEFAULT_HEIGHT; - /// - /// 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). /// diff --git a/osu.Game/Screens/SelectV2/GroupPanel.cs b/osu.Game/Screens/SelectV2/GroupPanel.cs new file mode 100644 index 0000000000..882d77cb8d --- /dev/null +++ b/osu.Game/Screens/SelectV2/GroupPanel.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class GroupPanel : PoolableDrawable, ICarouselPanel + { + public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 2; + + [Resolved] + private BeatmapCarousel carousel { get; set; } = null!; + + private Box activationFlash = null!; + private OsuSpriteText text = null!; + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(500, HEIGHT); + Masking = true; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue.Darken(5), + 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) + { + BorderThickness = 5; + BorderColour = Color4.Pink; + } + else + { + BorderThickness = 0; + } + }); + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + + Debug.Assert(Item != null); + + GroupDefinition group = (GroupDefinition)Item.Model; + + text.Text = group.Title; + + this.FadeInFromZero(500, Easing.OutQuint); + } + + protected override bool OnClick(ClickEvent e) + { + carousel.CurrentSelection = Item!.Model; + return true; + } + + #region ICarouselPanel + + 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() + { + // sets should never be activated. + throw new InvalidOperationException(); + } + + #endregion + } +}