mirror of
https://github.com/ppy/osu.git
synced 2026-05-17 08:02:38 +08:00
Merge pull request #34459 from peppy/collection-group-sane
Add support for grouping beatmaps by collections
This commit is contained in:
@@ -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<List<CarouselItem>> runGrouping(GroupMode group, List<BeatmapSetInfo> beatmapSets)
|
||||
{
|
||||
var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group });
|
||||
var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }, () => new List<BeatmapCollection>());
|
||||
return await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> { "abc" }))));
|
||||
@@ -212,12 +210,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
|
||||
|
||||
addExpandHeaderStep();
|
||||
|
||||
AddStep("watch for filter requests", () =>
|
||||
{
|
||||
received = false;
|
||||
dropdown.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
|
||||
});
|
||||
|
||||
AddStep("click manage collections filter", () =>
|
||||
{
|
||||
int lastItemIndex = dropdown.ChildrenOfType<CollectionDropdown>().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<Realm> action) => Realm.Write(r =>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<BeatmapCarouselFilterGrouping>().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<BeatmapCollection>();
|
||||
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<BeatmapSetInfo>().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<BeatmapSetInfo>().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<BeatmapSetInfo>().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<BeatmapCollection>();
|
||||
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<BeatmapSetInfo>().Single().Equals(beatmapSet);
|
||||
});
|
||||
|
||||
AddStep("add beatmap to collection", () =>
|
||||
{
|
||||
Realm.Write(r =>
|
||||
{
|
||||
var collection = r.All<BeatmapCollection>().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<BeatmapSetInfo>().Single().Equals(beatmapSet);
|
||||
});
|
||||
|
||||
AddAssert("no-collection group not present", () => grouping.GroupItems.All(g => g.Key.Title != "Not in collection"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<RealmKeyBinding, RealmKeyBinding>();
|
||||
c.CreateMap<BeatmapCollection, BeatmapCollection>();
|
||||
c.CreateMap<BeatmapMetadata, BeatmapMetadata>();
|
||||
c.CreateMap<BeatmapUserSettings, BeatmapUserSettings>();
|
||||
c.CreateMap<BeatmapDifficulty, BeatmapDifficulty>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BeatmapCollection>().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<List<BeatmapCollection>> detachedCollections = null!;
|
||||
|
||||
public bool NextRandom()
|
||||
{
|
||||
var carouselItems = GetCarouselItems();
|
||||
|
||||
@@ -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<GroupDefinition, HashSet<CarouselItem>> groupMap = new Dictionary<GroupDefinition, HashSet<CarouselItem>>();
|
||||
|
||||
private readonly Func<FilterCriteria> getCriteria;
|
||||
private readonly Func<List<BeatmapCollection>>? getCollections;
|
||||
|
||||
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
|
||||
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria, Func<List<BeatmapCollection>>? getCollections)
|
||||
{
|
||||
this.getCriteria = getCriteria;
|
||||
this.getCollections = getCollections;
|
||||
}
|
||||
|
||||
public async Task<List<CarouselItem>> Run(IEnumerable<CarouselItem> 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<BeatmapCollection>();
|
||||
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<BeatmapCollection> 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<T>(BeatmapInfo b, Func<BeatmapInfo, T> func)
|
||||
{
|
||||
var beatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden);
|
||||
|
||||
@@ -34,8 +34,6 @@ namespace osu.Game.Screens.SelectV2
|
||||
/// </summary>
|
||||
protected virtual bool ShowManageCollectionsItem => true;
|
||||
|
||||
public Action? RequestFilter { private get; set; }
|
||||
|
||||
private readonly BindableList<CollectionFilterMenuItem> filters = new BindableList<CollectionFilterMenuItem>();
|
||||
|
||||
[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<BeatmapCollection>? lastFiltered;
|
||||
|
||||
private void selectionChanged(ValueChangedEvent<CollectionFilterMenuItem> 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BeatmapCollection>(), (collections, changeSet) =>
|
||||
{
|
||||
if (changeSet != null && groupDropdown.Current.Value == GroupMode.Collections)
|
||||
updateCriteria();
|
||||
});
|
||||
updateCriteria();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool isDisposing)
|
||||
{
|
||||
base.Dispose(isDisposing);
|
||||
collectionsSubscription?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FilterCriteria"/> based on the current state of the controls.
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public bool CarouselItemsPresented { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the carousel is or will be undergoing a filter operation.
|
||||
/// </summary>
|
||||
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<CarouselItem> carouselItems)
|
||||
|
||||
Reference in New Issue
Block a user