diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index 86a82df5ab..a9f3e70e1d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; @@ -363,7 +364,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private static async Task> runGrouping(GroupMode group, List beatmapSets) { - var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }); + var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }, () => new List()); return await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None); } diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs index ba7759d8a5..8d88a81830 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs @@ -159,6 +159,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }); } + protected void WaitForFiltering() => AddUntilStep("wait for filtering", () => !SongSelect.IsFiltering); + protected void ImportBeatmapForRuleset(params int[] rulesetIds) { int beatmapsCount = 0; diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs index 1240394f7a..5c4969f9ad 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneCollectionDropdown.cs @@ -197,8 +197,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestManageCollectionsFilterIsNotSelected() { - bool received = false; - addExpandHeaderStep(); AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List { "abc" })))); @@ -212,12 +210,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 addExpandHeaderStep(); - AddStep("watch for filter requests", () => - { - received = false; - dropdown.ChildrenOfType().First().RequestFilter = () => received = true; - }); - AddStep("click manage collections filter", () => { int lastItemIndex = dropdown.ChildrenOfType().Single().Items.Count() - 1; @@ -226,8 +218,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }); AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1"); - - AddAssert("filter request not fired", () => !received); } private void writeAndRefresh(Action action) => Realm.Write(r => diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs new file mode 100644 index 0000000000..4081a40a7b --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs @@ -0,0 +1,115 @@ +// 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.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Extensions; +using osu.Game.Screens.Select.Filter; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneSongSelectGrouping : SongSelectTestScene + { + private BeatmapCarouselFilterGrouping grouping => Carousel.Filters.OfType().Single(); + + [Test] + public void TestCollectionGrouping() + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + BeatmapSetInfo[] beatmapSets = null!; + + AddStep("add collections", () => + { + beatmapSets = Beatmaps.GetAllUsableBeatmapSets().OrderBy(b => b.OnlineID).ToArray(); + + Realm.Write(r => + { + r.RemoveAll(); + r.Add(new BeatmapCollection("My Collection #1", beatmapSets[0].Beatmaps.Select(b => b.MD5Hash).ToList())); + r.Add(new BeatmapCollection("My Collection #2", beatmapSets[1].Beatmaps.Select(b => b.MD5Hash).ToList())); + r.Add(new BeatmapCollection("My Collection #3")); + }); + }); + + LoadSongSelect(); + GroupBy(GroupMode.Collections); + WaitForFiltering(); + + AddAssert("first collection present", () => + { + var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #1"); + return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[0]); + }); + + AddAssert("second collection present", () => + { + var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #2"); + return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[1]); + }); + + AddAssert("third collection not present", () => grouping.GroupItems.All(g => g.Key.Title != "My Collection #3")); + + AddAssert("no-collection group present", () => + { + var group = grouping.GroupItems.Single(g => g.Key.Title == "Not in collection"); + return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[2]); + }); + } + + [Test] + public void TestCollectionGroupingUpdatesOnChange() + { + ImportBeatmapForRuleset(0); + + BeatmapSetInfo beatmapSet = null!; + + AddStep("add collections", () => + { + beatmapSet = Beatmaps.GetAllUsableBeatmapSets().Single(); + + Realm.Write(r => + { + r.RemoveAll(); + r.Add(new BeatmapCollection("My Collection #4")); + }); + }); + + LoadSongSelect(); + GroupBy(GroupMode.Collections); + WaitForFiltering(); + + AddAssert("collection not present", () => grouping.GroupItems.All(g => g.Key.Title != "My Collection #4")); + + AddAssert("no-collection group present", () => + { + var group = grouping.GroupItems.Single(g => g.Key.Title == "Not in collection"); + return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSet); + }); + + AddStep("add beatmap to collection", () => + { + Realm.Write(r => + { + var collection = r.All().Single(); + collection.BeatmapMD5Hashes.AddRange(beatmapSet.Beatmaps.Select(b => b.MD5Hash)); + }); + }); + + WaitForFiltering(); + + AddAssert("collection present", () => + { + var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #4"); + return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSet); + }); + + AddAssert("no-collection group not present", () => grouping.GroupItems.All(g => g.Key.Title != "Not in collection")); + } + } +} diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index d43f90c292..2c4d36f7d0 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -10,6 +10,7 @@ using AutoMapper; using AutoMapper.Internal; using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Input.Bindings; using osu.Game.Models; using osu.Game.Rulesets; @@ -170,6 +171,7 @@ namespace osu.Game.Database }); c.CreateMap(); + c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); diff --git a/osu.Game/Screens/Select/Filter/GroupMode.cs b/osu.Game/Screens/Select/Filter/GroupMode.cs index fc98bd3cfd..6a48a21bf5 100644 --- a/osu.Game/Screens/Select/Filter/GroupMode.cs +++ b/osu.Game/Screens/Select/Filter/GroupMode.cs @@ -20,8 +20,8 @@ namespace osu.Game.Screens.Select.Filter [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.BPM))] BPM, - // [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Collections))] - // Collections, + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Collections))] + Collections, [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateAdded))] DateAdded, diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index eb360a1f60..b67641dc96 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -18,6 +18,7 @@ using osu.Framework.Graphics.Pooling; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; @@ -98,17 +99,18 @@ namespace osu.Game.Screens.SelectV2 { matching = new BeatmapCarouselFilterMatching(() => Criteria!), new BeatmapCarouselFilterSorting(() => Criteria!), - grouping = new BeatmapCarouselFilterGrouping(() => Criteria!), + grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, () => detachedCollections()) }; AddInternal(loading = new LoadingLayer()); } [BackgroundDependencyLoader] - private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken) + private void load(BeatmapStore beatmapStore, RealmAccess realm, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken) { setupPools(); detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); + detachedCollections = () => realm.Run(r => r.All().AsEnumerable().Detach()); loadSamples(audio); config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm); @@ -696,6 +698,8 @@ namespace osu.Game.Screens.SelectV2 private Sample? spinSample; private Sample? randomSelectSample; + private Func> detachedCollections = null!; + public bool NextRandom() { var carouselItems = GetCarouselItems(); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 5e9a187500..aa053bb727 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.Framework.Extensions; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; @@ -33,10 +34,12 @@ namespace osu.Game.Screens.SelectV2 private readonly Dictionary> groupMap = new Dictionary>(); private readonly Func getCriteria; + private readonly Func>? getCollections; - public BeatmapCarouselFilterGrouping(Func getCriteria) + public BeatmapCarouselFilterGrouping(Func getCriteria, Func>? getCollections) { this.getCriteria = getCriteria; + this.getCollections = getCollections; } public async Task> Run(IEnumerable items, CancellationToken cancellationToken) @@ -206,11 +209,11 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.Source: return getGroupsBy(b => defineGroupBySource(b.BeatmapSet!.Metadata.Source), items); + case GroupMode.Collections: + var collections = getCollections?.Invoke() ?? Enumerable.Empty(); + return getGroupsBy(b => defineGroupByCollection(b, collections), items); + // TODO: need implementation - // - // case GroupMode.Collections: - // goto case GroupMode.None; - // // case GroupMode.Favourites: // goto case GroupMode.None; // @@ -374,6 +377,17 @@ namespace osu.Game.Screens.SelectV2 return new GroupDefinition(0, source); } + private GroupDefinition defineGroupByCollection(BeatmapInfo beatmap, IEnumerable collections) + { + foreach (var collection in collections) + { + if (collection.BeatmapMD5Hashes.Contains(beatmap.MD5Hash)) + return new GroupDefinition(0, collection.Name); + } + + return new GroupDefinition(1, "Not in collection"); + } + private static T? aggregateMax(BeatmapInfo b, Func func) { var beatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); diff --git a/osu.Game/Screens/SelectV2/CollectionDropdown.cs b/osu.Game/Screens/SelectV2/CollectionDropdown.cs index 1582fcbf31..a333be5776 100644 --- a/osu.Game/Screens/SelectV2/CollectionDropdown.cs +++ b/osu.Game/Screens/SelectV2/CollectionDropdown.cs @@ -34,8 +34,6 @@ namespace osu.Game.Screens.SelectV2 /// protected virtual bool ShowManageCollectionsItem => true; - public Action? RequestFilter { private get; set; } - private readonly BindableList filters = new BindableList(); [Resolved] @@ -110,16 +108,12 @@ namespace osu.Game.Screens.SelectV2 Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0]; }); - // Trigger an external re-filter if the current item was in the change set. - RequestFilter?.Invoke(); break; } } } } - private Live? lastFiltered; - private void selectionChanged(ValueChangedEvent filter) { // May be null during .Clear(). @@ -132,17 +126,6 @@ namespace osu.Game.Screens.SelectV2 { Current.Value = filter.OldValue; manageCollectionsDialog?.Show(); - return; - } - - var newCollection = filter.NewValue.Collection; - - // This dropdown be weird. - // We only care about filtering if the actual collection has changed. - if (newCollection != lastFiltered) - { - RequestFilter?.Invoke(); - lastFiltered = newCollection; } } diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 7bc4e2105b..54702d2c84 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -14,6 +14,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Collections; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -50,6 +51,9 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private OsuConfigManager config { get; set; } = null!; + [Resolved] + private RealmAccess realm { get; set; } = null!; + public LocalisableString StatusText { get => searchTextBox.StatusText; @@ -60,6 +64,8 @@ namespace osu.Game.Screens.SelectV2 private FilterCriteria currentCriteria = null!; + private IDisposable? collectionsSubscription; + [BackgroundDependencyLoader] private void load() { @@ -218,10 +224,20 @@ namespace osu.Game.Screens.SelectV2 updateCriteria(); }); - + collectionsSubscription = realm.RegisterForNotifications(r => r.All(), (collections, changeSet) => + { + if (changeSet != null && groupDropdown.Current.Value == GroupMode.Collections) + updateCriteria(); + }); updateCriteria(); } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + collectionsSubscription?.Dispose(); + } + /// /// Creates a based on the current state of the controls. /// diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index edc94953da..b11254264a 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -509,8 +509,7 @@ namespace osu.Game.Screens.SelectV2 // While filtering, let's not ever attempt to change selection. // This will be resolved after the filter completes, see `newItemsPresented`. - bool carouselStateIsValid = filterDebounce?.State != ScheduledDelegate.RunState.Waiting && !carousel.IsFiltering; - if (!carouselStateIsValid) + if (IsFiltering) return false; // Refetch to be confident that the current selection is still valid. It may have been deleted or hidden. @@ -738,6 +737,11 @@ namespace osu.Game.Screens.SelectV2 /// public bool CarouselItemsPresented { get; private set; } + /// + /// Whether the carousel is or will be undergoing a filter operation. + /// + public bool IsFiltering => carousel.IsFiltering || filterDebounce?.State == ScheduledDelegate.RunState.Waiting; + private const double filter_delay = 250; private ScheduledDelegate? filterDebounce; @@ -752,7 +756,7 @@ namespace osu.Game.Screens.SelectV2 // Criteria change may have included a ruleset change which made the current selection invalid. bool isSelectionValid = checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo, criteria); - filterDebounce = Scheduler.AddDelayed(() => { carousel.Filter(criteria, !isSelectionValid); }, isFirstFilter || !isSelectionValid ? 0 : filter_delay); + filterDebounce = Scheduler.AddDelayed(() => carousel.Filter(criteria, !isSelectionValid), isFirstFilter || !isSelectionValid ? 0 : filter_delay); } private void newItemsPresented(IEnumerable carouselItems)