diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index 54ee1659e1..d0fa5fc737 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -9,6 +9,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; @@ -16,6 +17,7 @@ using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Menu; using osu.Game.Screens.Select; +using osu.Game.Screens.Select.Filter; using osu.Game.Tests.Resources; using osuTK.Input; @@ -203,6 +205,33 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for music stopped", () => !Game.MusicController.IsPlaying); } + [TestCase(SortMode.Title)] + [TestCase(SortMode.Difficulty)] + public void TestSelectionRetainedOnExit(SortMode sortMode) + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep($"set sort mode to {sortMode}", () => Game.LocalConfig.SetValue(OsuSetting.SongSelectSortingMode, sortMode)); + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + + AddStep("exit editor", () => InputManager.Key(Key.Escape)); + AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); + + AddUntilStep("selection retained on song select", + () => Game.Beatmap.Value.BeatmapInfo.ID, + () => Is.EqualTo(beatmapSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0).ID)); + } + private EditorBeatmap getEditorBeatmap() => getEditor().ChildrenOfType().Single(); private Editor getEditor() => (Editor)Game.ScreenStack.CurrentScreen; diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 61f95dc628..040b341584 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -39,6 +39,7 @@ namespace osu.Game.Tests.Visual.SongSelect private BeatmapInfo currentSelection => carousel.SelectedBeatmapInfo; private const int set_count = 5; + private const int diff_count = 3; [BackgroundDependencyLoader] private void load(RulesetStore rulesets) @@ -111,7 +112,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestScrollPositionMaintainedOnAdd() { - loadBeatmaps(count: 1, randomDifficulties: false); + loadBeatmaps(setCount: 1); for (int i = 0; i < 10; i++) { @@ -124,7 +125,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestDeletion() { - loadBeatmaps(count: 5, randomDifficulties: true); + loadBeatmaps(setCount: 5, randomDifficulties: true); AddStep("remove first set", () => carousel.RemoveBeatmapSet(carousel.Items.Select(item => item.Item).OfType().First().BeatmapSet)); AddUntilStep("4 beatmap sets visible", () => this.ChildrenOfType().Count(set => set.Alpha > 0) == 4); @@ -133,7 +134,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestScrollPositionMaintainedOnDelete() { - loadBeatmaps(count: 50, randomDifficulties: false); + loadBeatmaps(setCount: 50); for (int i = 0; i < 10; i++) { @@ -150,7 +151,7 @@ namespace osu.Game.Tests.Visual.SongSelect [Test] public void TestManyPanels() { - loadBeatmaps(count: 5000, randomDifficulties: true); + loadBeatmaps(setCount: 5000, randomDifficulties: true); } [Test] @@ -501,6 +502,33 @@ namespace osu.Game.Tests.Visual.SongSelect waitForSelection(set_count); } + [Test] + public void TestAddRemoveDifficultySort() + { + const int local_set_count = 2; + const int local_diff_count = 2; + + loadBeatmaps(setCount: local_set_count, diffCount: local_diff_count); + + AddStep("Sort by difficulty", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }, false)); + + checkVisibleItemCount(false, local_set_count * local_diff_count); + + var firstAdded = TestResources.CreateTestBeatmapSetInfo(local_diff_count); + + AddStep("Add new set", () => carousel.UpdateBeatmapSet(firstAdded)); + + checkVisibleItemCount(false, (local_set_count + 1) * local_diff_count); + + AddStep("Remove set", () => carousel.RemoveBeatmapSet(firstAdded)); + + checkVisibleItemCount(false, (local_set_count) * local_diff_count); + + setSelected(local_set_count, 1); + + waitForSelection(local_set_count); + } + [Test] public void TestSelectionEnteringFromEmptyRuleset() { @@ -662,7 +690,7 @@ namespace osu.Game.Tests.Visual.SongSelect for (int i = 0; i < 3; i++) { - var set = TestResources.CreateTestBeatmapSetInfo(3); + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); // only need to set the first as they are a shared reference. var beatmap = set.Beatmaps.First(); @@ -709,7 +737,7 @@ namespace osu.Game.Tests.Visual.SongSelect for (int i = 0; i < 3; i++) { - var set = TestResources.CreateTestBeatmapSetInfo(3); + var set = TestResources.CreateTestBeatmapSetInfo(diff_count); // only need to set the first as they are a shared reference. var beatmap = set.Beatmaps.First(); @@ -758,32 +786,54 @@ namespace osu.Game.Tests.Visual.SongSelect } [Test] - public void TestSortingWithFiltered() + public void TestSortingWithDifficultyFiltered() { + const int local_diff_count = 3; + const int local_set_count = 2; + List sets = new List(); AddStep("Populuate beatmap sets", () => { sets.Clear(); - for (int i = 0; i < 3; i++) + for (int i = 0; i < local_set_count; i++) { - var set = TestResources.CreateTestBeatmapSetInfo(3); + var set = TestResources.CreateTestBeatmapSetInfo(local_diff_count); set.Beatmaps[0].StarRating = 3 - i; - set.Beatmaps[2].StarRating = 6 + i; + set.Beatmaps[1].StarRating = 6 + i; sets.Add(set); } }); loadBeatmaps(sets); + AddStep("Sort by difficulty", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }, false)); + + checkVisibleItemCount(false, local_set_count * local_diff_count); + checkVisibleItemCount(true, 1); + AddStep("Filter to normal", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Normal" }, false)); - AddAssert("Check first set at end", () => carousel.BeatmapSets.First().Equals(sets.Last())); - AddAssert("Check last set at start", () => carousel.BeatmapSets.Last().Equals(sets.First())); + checkVisibleItemCount(false, local_set_count); + checkVisibleItemCount(true, 1); + + AddUntilStep("Check all visible sets have one normal", () => + { + return carousel.Items.OfType() + .Where(p => p.IsPresent) + .Count(p => ((CarouselBeatmapSet)p.Item)!.Beatmaps.Single().BeatmapInfo.DifficultyName.StartsWith("Normal", StringComparison.Ordinal)) == local_set_count; + }); AddStep("Filter to insane", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty, SearchText = "Insane" }, false)); - AddAssert("Check first set at start", () => carousel.BeatmapSets.First().Equals(sets.First())); - AddAssert("Check last set at end", () => carousel.BeatmapSets.Last().Equals(sets.Last())); + checkVisibleItemCount(false, local_set_count); + checkVisibleItemCount(true, 1); + + AddUntilStep("Check all visible sets have one insane", () => + { + return carousel.Items.OfType() + .Where(p => p.IsPresent) + .Count(p => ((CarouselBeatmapSet)p.Item)!.Beatmaps.Single().BeatmapInfo.DifficultyName.StartsWith("Insane", StringComparison.Ordinal)) == local_set_count; + }); } [Test] @@ -838,7 +888,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("create hidden set", () => { - hidingSet = TestResources.CreateTestBeatmapSetInfo(3); + hidingSet = TestResources.CreateTestBeatmapSetInfo(diff_count); hidingSet.Beatmaps[1].Hidden = true; hiddenList.Clear(); @@ -885,7 +935,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("add mixed ruleset beatmapset", () => { - testMixed = TestResources.CreateTestBeatmapSetInfo(3); + testMixed = TestResources.CreateTestBeatmapSetInfo(diff_count); for (int i = 0; i <= 2; i++) { @@ -907,7 +957,7 @@ namespace osu.Game.Tests.Visual.SongSelect BeatmapSetInfo testSingle = null; AddStep("add single ruleset beatmapset", () => { - testSingle = TestResources.CreateTestBeatmapSetInfo(3); + testSingle = TestResources.CreateTestBeatmapSetInfo(diff_count); testSingle.Beatmaps.ForEach(b => { b.Ruleset = rulesets.AvailableRulesets.ElementAt(1); @@ -930,7 +980,7 @@ namespace osu.Game.Tests.Visual.SongSelect manySets.Clear(); for (int i = 1; i <= 50; i++) - manySets.Add(TestResources.CreateTestBeatmapSetInfo(3)); + manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count)); }); loadBeatmaps(manySets); @@ -955,6 +1005,43 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); } + [Test] + public void TestCarouselRemembersSelectionDifficultySort() + { + List manySets = new List(); + + AddStep("Populate beatmap sets", () => + { + manySets.Clear(); + + for (int i = 1; i <= 50; i++) + manySets.Add(TestResources.CreateTestBeatmapSetInfo(diff_count)); + }); + + loadBeatmaps(manySets); + + AddStep("Sort by difficulty", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty }, false)); + + advanceSelection(direction: 1, diff: false); + + for (int i = 0; i < 5; i++) + { + AddStep("Toggle non-matching filter", () => + { + carousel.Filter(new FilterCriteria { SearchText = Guid.NewGuid().ToString() }, false); + }); + + AddStep("Restore no filter", () => + { + carousel.Filter(new FilterCriteria(), false); + eagerSelectedIDs.Add(carousel.SelectedBeatmapSet!.ID); + }); + } + + // always returns to same selection as long as it's available. + AddAssert("Selection was remembered", () => eagerSelectedIDs.Count == 1); + } + [Test] public void TestFilteringByUserStarDifficulty() { @@ -1081,8 +1168,8 @@ namespace osu.Game.Tests.Visual.SongSelect } } - private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, int? count = null, - bool randomDifficulties = false) + private void loadBeatmaps(List beatmapSets = null, Func initialCriteria = null, Action carouselAdjust = null, + int? setCount = null, int? diffCount = null, bool randomDifficulties = false) { bool changed = false; @@ -1090,11 +1177,11 @@ namespace osu.Game.Tests.Visual.SongSelect { beatmapSets = new List(); - for (int i = 1; i <= (count ?? set_count); i++) + for (int i = 1; i <= (setCount ?? set_count); i++) { beatmapSets.Add(randomDifficulties ? TestResources.CreateTestBeatmapSetInfo() - : TestResources.CreateTestBeatmapSetInfo(3)); + : TestResources.CreateTestBeatmapSetInfo(diffCount ?? diff_count)); } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs index 11d55bc0bd..6d97be730b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneUpdateBeatmapSetButton.cs @@ -15,6 +15,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Carousel; +using osu.Game.Screens.Select.Filter; using osu.Game.Tests.Online; using osu.Game.Tests.Resources; using osuTK.Input; @@ -192,6 +193,57 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("release mouse button", () => InputManager.ReleaseButton(MouseButton.Left)); } + [Test] + public void TestSplitDisplay() + { + ArchiveDownloadRequest? downloadRequest = null; + + AddStep("set difficulty sort mode", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty })); + AddStep("update online hash", () => + { + testBeatmapSetInfo.Beatmaps.First().OnlineMD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + }); + + AddUntilStep("multiple \"sets\" visible", () => carousel.ChildrenOfType().Count(), () => Is.GreaterThan(1)); + AddUntilStep("update button visible", getUpdateButton, () => Is.Not.Null); + + AddStep("click button", () => getUpdateButton()?.TriggerClick()); + + AddUntilStep("wait for download started", () => + { + downloadRequest = beatmapDownloader.GetExistingDownload(testBeatmapSetInfo); + return downloadRequest != null; + }); + + AddUntilStep("wait for button disabled", () => getUpdateButton()?.Enabled.Value == false); + + AddUntilStep("progress download to completion", () => + { + if (downloadRequest is TestSceneOnlinePlayBeatmapAvailabilityTracker.TestDownloadRequest testRequest) + { + testRequest.SetProgress(testRequest.Progress + 0.1f); + + if (testRequest.Progress >= 1) + { + testRequest.TriggerSuccess(); + + // usually this would be done by the import process. + testBeatmapSetInfo.Beatmaps.First().MD5Hash = "different hash"; + testBeatmapSetInfo.Beatmaps.First().LastOnlineUpdate = DateTimeOffset.Now; + + // usually this would be done by a realm subscription. + carousel.UpdateBeatmapSet(testBeatmapSetInfo); + return true; + } + } + + return false; + }); + } + private BeatmapCarousel createCarousel() { return carousel = new BeatmapCarousel @@ -199,7 +251,7 @@ namespace osu.Game.Tests.Visual.SongSelect RelativeSizeAxes = Axes.Both, BeatmapSets = new List { - (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo()), + (testBeatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(5)), } }; } diff --git a/osu.Game.Tournament/SaveChangesOverlay.cs b/osu.Game.Tournament/SaveChangesOverlay.cs index 7838d4ba48..0b5194a51f 100644 --- a/osu.Game.Tournament/SaveChangesOverlay.cs +++ b/osu.Game.Tournament/SaveChangesOverlay.cs @@ -7,13 +7,16 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osuTK; namespace osu.Game.Tournament { - internal partial class SaveChangesOverlay : CompositeDrawable + internal partial class SaveChangesOverlay : CompositeDrawable, IKeyBindingHandler { [Resolved] private TournamentGame tournamentGame { get; set; } = null!; @@ -78,6 +81,21 @@ namespace osu.Game.Tournament scheduleNextCheck(); } + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == PlatformAction.Save && !e.Repeat) + { + saveChangesButton.TriggerClick(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + private void scheduleNextCheck() => Scheduler.AddDelayed(() => checkForChanges().FireAndForget(), 1000); private void saveChanges() diff --git a/osu.Game/Rulesets/Mods/ModExtensions.cs b/osu.Game/Rulesets/Mods/ModExtensions.cs index b22030414b..aa106f1203 100644 --- a/osu.Game/Rulesets/Mods/ModExtensions.cs +++ b/osu.Game/Rulesets/Mods/ModExtensions.cs @@ -21,8 +21,9 @@ namespace osu.Game.Rulesets.Mods { User = new APIUser { - Id = APIUser.SYSTEM_USER_ID, + Id = replayData.User.OnlineID, Username = replayData.User.Username, + IsBot = replayData.User.IsBot, } } }; diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index 1d0499c909..03231f9329 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -156,7 +156,7 @@ namespace osu.Game.Screens.Ranking if (Score != null) { // only show flair / animation when arriving after watching a play that isn't autoplay. - bool shouldFlair = player != null && Score.Mods.All(m => m.UserPlayable); + bool shouldFlair = player != null && !Score.User.IsBot; ScorePanelList.AddScore(Score, shouldFlair); } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 9af9a0ce72..9ea94b2a52 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -78,6 +78,8 @@ namespace osu.Game.Screens.Select private CarouselBeatmapSet? selectedBeatmapSet; + private List originalBeatmapSetsDetached = new List(); + /// /// Raised when the is changed. /// @@ -127,15 +129,37 @@ namespace osu.Game.Screens.Select private void loadBeatmapSets(IEnumerable beatmapSets) { + originalBeatmapSetsDetached = beatmapSets.Detach(); + + if (selectedBeatmapSet != null && !originalBeatmapSetsDetached.Contains(selectedBeatmapSet.BeatmapSet)) + selectedBeatmapSet = null; + + var selectedBeatmapBefore = selectedBeatmap?.BeatmapInfo; + CarouselRoot newRoot = new CarouselRoot(this); - newRoot.AddItems(beatmapSets.Select(s => createCarouselSet(s.Detach())).OfType()); + if (beatmapsSplitOut) + { + var carouselBeatmapSets = originalBeatmapSetsDetached.SelectMany(s => s.Beatmaps).Select(b => + { + return createCarouselSet(new BeatmapSetInfo(new[] { b }) + { + ID = b.BeatmapSet!.ID, + OnlineID = b.BeatmapSet!.OnlineID + }); + }).OfType(); + + newRoot.AddItems(carouselBeatmapSets); + } + else + { + var carouselBeatmapSets = originalBeatmapSetsDetached.Select(createCarouselSet).OfType(); + + newRoot.AddItems(carouselBeatmapSets); + } root = newRoot; - if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet)) - selectedBeatmapSet = null; - Scroll.Clear(false); itemsCache.Invalidate(); ScrollToSelected(); @@ -144,6 +168,15 @@ namespace osu.Game.Screens.Select if (loadedTestBeatmaps) signalBeatmapsLoaded(); + + // Restore selection + if (selectedBeatmapBefore != null && newRoot.BeatmapSetsByID.TryGetValue(selectedBeatmapBefore.BeatmapSet!.ID, out var newSelectionCandidates)) + { + CarouselBeatmap? found = newSelectionCandidates.SelectMany(s => s.Beatmaps).SingleOrDefault(b => b.BeatmapInfo.ID == selectedBeatmapBefore.ID); + + if (found != null) + found.State.Value = CarouselItemState.Selected; + } } private readonly List visibleItems = new List(); @@ -330,8 +363,8 @@ namespace osu.Game.Screens.Select // Only require to action here if the beatmap is missing. // This avoids processing these events unnecessarily when new beatmaps are imported, for example. - if (root.BeatmapSetsByID.TryGetValue(beatmapSet.ID, out var existingSet) - && existingSet.BeatmapSet.Beatmaps.All(b => b.ID != beatmapInfo.ID)) + if (root.BeatmapSetsByID.TryGetValue(beatmapSet.ID, out var existingSets) + && existingSets.SelectMany(s => s.Beatmaps).All(b => b.BeatmapInfo.ID != beatmapInfo.ID)) { UpdateBeatmapSet(beatmapSet.Detach()); } @@ -345,15 +378,20 @@ namespace osu.Game.Screens.Select private void removeBeatmapSet(Guid beatmapSetID) => Schedule(() => { - if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSet)) + if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSets)) return; - foreach (var beatmap in existingSet.Beatmaps) - randomSelectedBeatmaps.Remove(beatmap); + originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSetID); - previouslyVisitedRandomSets.Remove(existingSet); + foreach (var set in existingSets) + { + foreach (var beatmap in set.Beatmaps) + randomSelectedBeatmaps.Remove(beatmap); + previouslyVisitedRandomSets.Remove(set); + + root.RemoveItem(set); + } - root.RemoveItem(existingSet); itemsCache.Invalidate(); if (!Scroll.UserScrolling) @@ -366,26 +404,63 @@ namespace osu.Game.Screens.Select { Guid? previouslySelectedID = null; + originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSet.ID); + originalBeatmapSetsDetached.Add(beatmapSet.Detach()); + // If the selected beatmap is about to be removed, store its ID so it can be re-selected if required if (selectedBeatmapSet?.BeatmapSet.ID == beatmapSet.ID) previouslySelectedID = selectedBeatmap?.BeatmapInfo.ID; - var newSet = createCarouselSet(beatmapSet); - var removedSet = root.RemoveChild(beatmapSet.ID); + var removedSets = root.RemoveItemsByID(beatmapSet.ID); - // If we don't remove this here, it may remain in a hidden state until scrolled off screen. - // Doesn't really affect anything during actual user interaction, but makes testing annoying. - var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); - if (removedDrawable != null) - expirePanelImmediately(removedDrawable); - - if (newSet != null) + foreach (var removedSet in removedSets) { - root.AddItem(newSet); + // If we don't remove this here, it may remain in a hidden state until scrolled off screen. + // Doesn't really affect anything during actual user interaction, but makes testing annoying. + var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet); + if (removedDrawable != null) + expirePanelImmediately(removedDrawable); + } + + if (beatmapsSplitOut) + { + var newSets = new List(); + + foreach (var beatmap in beatmapSet.Beatmaps) + { + var newSet = createCarouselSet(new BeatmapSetInfo(new[] { beatmap }) + { + ID = beatmapSet.ID, + OnlineID = beatmapSet.OnlineID + }); + + if (newSet != null) + { + newSets.Add(newSet); + root.AddItem(newSet); + } + } // check if we can/need to maintain our current selection. if (previouslySelectedID != null) - select((CarouselItem?)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); + { + var toSelect = newSets.FirstOrDefault(s => s.Beatmaps.Any(b => b.BeatmapInfo.ID == previouslySelectedID)) + ?? newSets.FirstOrDefault(); + select(toSelect); + } + } + else + { + var newSet = createCarouselSet(beatmapSet); + + if (newSet != null) + { + root.AddItem(newSet); + + // check if we can/need to maintain our current selection. + if (previouslySelectedID != null) + select((CarouselItem?)newSet.Beatmaps.FirstOrDefault(b => b.BeatmapInfo.ID == previouslySelectedID) ?? newSet); + } } itemsCache.Invalidate(); @@ -632,6 +707,8 @@ namespace osu.Game.Screens.Select applyActiveCriteria(debounce); } + private bool beatmapsSplitOut; + private void applyActiveCriteria(bool debounce, bool alwaysResetScrollPosition = true) { PendingFilter?.Cancel(); @@ -652,6 +729,13 @@ namespace osu.Game.Screens.Select { PendingFilter = null; + if (activeCriteria.SplitOutDifficulties != beatmapsSplitOut) + { + beatmapsSplitOut = activeCriteria.SplitOutDifficulties; + loadBeatmapSets(originalBeatmapSetsDetached); + return; + } + root.Filter(activeCriteria); itemsCache.Invalidate(); @@ -1055,7 +1139,7 @@ namespace osu.Game.Screens.Select // May only be null during construction (State.Value set causes PerformSelection to be triggered). private readonly BeatmapCarousel? carousel; - public readonly Dictionary BeatmapSetsByID = new Dictionary(); + public readonly Dictionary> BeatmapSetsByID = new Dictionary>(); public CarouselRoot(BeatmapCarousel carousel) { @@ -1069,20 +1153,25 @@ namespace osu.Game.Screens.Select public override void AddItem(CarouselItem i) { CarouselBeatmapSet set = (CarouselBeatmapSet)i; - BeatmapSetsByID.Add(set.BeatmapSet.ID, set); + if (BeatmapSetsByID.TryGetValue(set.BeatmapSet.ID, out var sets)) + sets.Add(set); + else + BeatmapSetsByID.Add(set.BeatmapSet.ID, new List { set }); base.AddItem(i); } - public CarouselBeatmapSet? RemoveChild(Guid beatmapSetID) + public IEnumerable RemoveItemsByID(Guid beatmapSetID) { - if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSet)) + if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSets)) { - RemoveItem(carouselBeatmapSet); - return carouselBeatmapSet; + foreach (var set in carouselBeatmapSets) + RemoveItem(set); + + return carouselBeatmapSets; } - return null; + return Enumerable.Empty(); } public override void RemoveItem(CarouselItem i) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index ab4f85fc92..a2ae114126 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -19,6 +19,11 @@ namespace osu.Game.Screens.Select public GroupMode Group; public SortMode Sort; + /// + /// Whether the display of beatmap sets should be split apart per-difficulty for the current criteria. + /// + public bool SplitOutDifficulties => Sort == SortMode.Difficulty; + public BeatmapSetInfo? SelectedBeatmapSet; public OptionalRange StarDifficulty;