diff --git a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs index 295bf417c4..a5f10ed436 100644 --- a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Edit.Blueprints; @@ -14,6 +16,8 @@ namespace osu.Game.Rulesets.Mania.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs index 50b5f9a8fe..9f54152596 100644 --- a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs +++ b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Edit.Blueprints; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Mania.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs index 9c94fe0e3d..5f7c8b77b0 100644 --- a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + public override PlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 37019a7a05..f87bd53ec3 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -38,6 +39,13 @@ namespace osu.Game.Rulesets.Osu.Edit new SpinnerCompositionTool() }; + private readonly BindableBool distanceSnapToggle = new BindableBool(true) { Description = "Distance Snap" }; + + protected override IEnumerable Toggles => new[] + { + distanceSnapToggle + }; + [BackgroundDependencyLoader] private void load() { @@ -45,6 +53,7 @@ namespace osu.Game.Rulesets.Osu.Edit EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); EditorBeatmap.PlacementObject.ValueChanged += _ => updateDistanceSnapGrid(); + distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); } protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) @@ -87,6 +96,10 @@ namespace osu.Game.Rulesets.Osu.Edit { distanceSnapGridContainer.Clear(); distanceSnapGridCache.Invalidate(); + distanceSnapGrid = null; + + if (!distanceSnapToggle.Value) + return; switch (BlueprintContainer.CurrentTool) { diff --git a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs index a377deb35f..596224e5c6 100644 --- a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + public override PlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs index 0de0af8f8c..c5e90da3bd 100644 --- a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs +++ b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); + public override PlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs index bf77c76670..587a4efecb 100644 --- a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Taiko.Edit.Blueprints; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs index e877cf6240..3e97b4e322 100644 --- a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Taiko.Edit.Blueprints; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles); + public override PlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint(); } } diff --git a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs index a6191fcedc..918afde1dd 100644 --- a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs +++ b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Taiko.Edit.Blueprints; @@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Edit { } + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners); + public override PlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint(); } } diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs new file mode 100644 index 0000000000..a79e0d0338 --- /dev/null +++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs @@ -0,0 +1,221 @@ +// 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Collections; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Collections.IO +{ + [TestFixture] + public class ImportCollectionsTest + { + [Test] + public async Task TestImportEmptyDatabase() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host); + + await osu.CollectionManager.Import(new MemoryStream()); + + Assert.That(osu.CollectionManager.Collections.Count, Is.Zero); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportWithNoBeatmaps() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host); + + await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); + Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.Zero); + + Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); + Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.Zero); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportWithBeatmaps() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host, true); + + await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); + Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(1)); + + Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second")); + Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(12)); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public async Task TestImportMalformedDatabase() + { + bool exceptionThrown = false; + UnhandledExceptionEventHandler setException = (_, __) => exceptionThrown = true; + + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + AppDomain.CurrentDomain.UnhandledException += setException; + + var osu = loadOsu(host, true); + + using (var ms = new MemoryStream()) + { + using (var bw = new BinaryWriter(ms, Encoding.UTF8, true)) + { + for (int i = 0; i < 10000; i++) + bw.Write((byte)i); + } + + ms.Seek(0, SeekOrigin.Begin); + + await osu.CollectionManager.Import(ms); + } + + Assert.That(host.UpdateThread.Running, Is.True); + Assert.That(exceptionThrown, Is.False); + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0)); + } + finally + { + host.Exit(); + AppDomain.CurrentDomain.UnhandledException -= setException; + } + } + } + + [Test] + public async Task TestSaveAndReload() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host, true); + + await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db")); + + // Move first beatmap from second collection into the first. + osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]); + osu.CollectionManager.Collections[1].Beatmaps.RemoveAt(0); + + // Rename the second collecction. + osu.CollectionManager.Collections[1].Name.Value = "Another"; + } + finally + { + host.Exit(); + } + } + + using (HeadlessGameHost host = new HeadlessGameHost("TestSaveAndReload")) + { + try + { + var osu = loadOsu(host, true); + + Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First")); + Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(2)); + + Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another")); + Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(11)); + } + finally + { + host.Exit(); + } + } + } + + private TestOsuGameBase loadOsu(GameHost host, bool withBeatmap = false) + { + var osu = new TestOsuGameBase(withBeatmap); + +#pragma warning disable 4014 + Task.Run(() => host.Run(osu)); +#pragma warning restore 4014 + + waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + + return osu; + } + + private void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + + private class TestOsuGameBase : OsuGameBase + { + public CollectionManager CollectionManager { get; private set; } + + private readonly bool withBeatmap; + + public TestOsuGameBase(bool withBeatmap) + { + this.withBeatmap = withBeatmap; + } + + [BackgroundDependencyLoader] + private void load() + { + // Beatmap must be imported before the collection manager is loaded. + if (withBeatmap) + BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + + AddInternal(CollectionManager = new CollectionManager(Storage)); + } + } + } +} diff --git a/osu.Game.Tests/Resources/Collections/collections.db b/osu.Game.Tests/Resources/Collections/collections.db new file mode 100644 index 0000000000..83e1c0f10a Binary files /dev/null and b/osu.Game.Tests/Resources/Collections/collections.db differ diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs new file mode 100644 index 0000000000..54ab20af7f --- /dev/null +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; +using osu.Game.Rulesets; +using osu.Game.Tests.Resources; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Collections +{ + public class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene + { + protected override Container Content => content; + + private readonly Container content; + private readonly DialogOverlay dialogOverlay; + private readonly CollectionManager manager; + + private RulesetStore rulesets; + private BeatmapManager beatmapManager; + + private ManageCollectionsDialog dialog; + + public TestSceneManageCollectionsDialog() + { + base.Content.AddRange(new Drawable[] + { + manager = new CollectionManager(LocalStorage), + content = new Container { RelativeSizeAxes = Axes.Both }, + dialogOverlay = new DialogOverlay() + }); + } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); + + beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.Cache(manager); + dependencies.Cache(dialogOverlay); + return dependencies; + } + + [SetUp] + public void SetUp() => Schedule(() => + { + manager.Collections.Clear(); + Child = dialog = new ManageCollectionsDialog(); + }); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("show dialog", () => dialog.Show()); + } + + [Test] + public void TestHideDialog() + { + AddWaitStep("wait for animation", 3); + AddStep("hide dialog", () => dialog.Hide()); + } + + [Test] + public void TestLastItemIsPlaceholder() + { + AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + } + + [Test] + public void TestAddCollectionExternal() + { + AddStep("add collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "First collection" } })); + assertCollectionCount(1); + assertCollectionName(0, "First collection"); + + AddStep("add another collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "Second collection" } })); + assertCollectionCount(2); + assertCollectionName(1, "Second collection"); + } + + [Test] + public void TestFocusPlaceholderDoesNotCreateCollection() + { + AddStep("focus placeholder", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + + assertCollectionCount(0); + } + + [Test] + public void TestAddCollectionViaPlaceholder() + { + DrawableCollectionListItem placeholderItem = null; + + AddStep("focus placeholder", () => + { + InputManager.MoveMouseTo(placeholderItem = dialog.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + + // Done directly via the collection since InputManager methods cannot add text to textbox... + AddStep("change collection name", () => placeholderItem.Model.Name.Value = "a"); + assertCollectionCount(1); + AddAssert("collection now exists", () => manager.Collections.Contains(placeholderItem.Model)); + + AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model)); + } + + [Test] + public void TestRemoveCollectionExternal() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" } }, + })); + + AddStep("remove first collection", () => manager.Collections.RemoveAt(0)); + assertCollectionCount(1); + assertCollectionName(0, "2"); + } + + [Test] + public void TestRemoveCollectionViaButton() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + })); + + assertCollectionCount(2); + + AddStep("click first delete button", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dialog not displayed", () => dialogOverlay.CurrentDialog == null); + assertCollectionCount(1); + assertCollectionName(0, "2"); + + AddStep("click first delete button", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog); + AddStep("click confirmation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + assertCollectionCount(0); + } + + [Test] + public void TestCollectionNotRemovedWhenDialogCancelled() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } }, + })); + + assertCollectionCount(1); + + AddStep("click first delete button", () => + { + InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog); + AddStep("click cancellation", () => + { + InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().Last()); + InputManager.Click(MouseButton.Left); + }); + + assertCollectionCount(1); + } + + [Test] + public void TestCollectionRenamedExternal() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" } }, + })); + + AddStep("change first collection name", () => manager.Collections[0].Name.Value = "First"); + + assertCollectionName(0, "First"); + } + + [Test] + public void TestCollectionRenamedOnTextChange() + { + AddStep("add two collections", () => manager.Collections.AddRange(new[] + { + new BeatmapCollection { Name = { Value = "1" } }, + new BeatmapCollection { Name = { Value = "2" } }, + })); + + assertCollectionCount(2); + + AddStep("change first collection name", () => dialog.ChildrenOfType().First().Text = "First"); + AddAssert("collection has new name", () => manager.Collections[0].Name.Value == "First"); + } + + private void assertCollectionCount(int count) + => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsCreated.Value) == count); + + private void assertCollectionName(int index, string name) + => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name); + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs index 293a6e6869..c8a32d966f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs @@ -18,6 +18,8 @@ namespace osu.Game.Tests.Visual.Editing protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + protected new TestEditor Editor => (TestEditor)base.Editor; + public override void SetUpSteps() { base.SetUpSteps(); @@ -35,6 +37,7 @@ namespace osu.Game.Tests.Visual.Editing addUndoSteps(); AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); } [Test] @@ -47,6 +50,7 @@ namespace osu.Game.Tests.Visual.Editing addRedoSteps(); AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); } [Test] @@ -64,9 +68,11 @@ namespace osu.Game.Tests.Visual.Editing AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 })); AddAssert("hitobject added", () => addedObject == expectedObject); + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); addUndoSteps(); AddAssert("hitobject removed", () => removedObject == expectedObject); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); } [Test] @@ -94,6 +100,17 @@ namespace osu.Game.Tests.Visual.Editing addRedoSteps(); AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance) AddAssert("no hitobject removed", () => removedObject == null); + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); + } + + [Test] + public void TestAddObjectThenSaveHasNoUnsavedChanges() + { + AddStep("add hitobject", () => editorBeatmap.Add(new HitCircle { StartTime = 1000 })); + + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); + AddStep("save changes", () => Editor.Save()); + AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges); } [Test] @@ -120,6 +137,7 @@ namespace osu.Game.Tests.Visual.Editing addUndoSteps(); AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance) AddAssert("no hitobject removed", () => removedObject == null); + AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); // 2 steps performed, 1 undone } [Test] @@ -148,19 +166,24 @@ namespace osu.Game.Tests.Visual.Editing addRedoSteps(); AddAssert("hitobject removed", () => removedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance after undo) AddAssert("no hitobject added", () => addedObject == null); + AddAssert("no changes", () => !Editor.HasUnsavedChanges); // end result is empty beatmap, matching original state } - private void addUndoSteps() => AddStep("undo", () => ((TestEditor)Editor).Undo()); + private void addUndoSteps() => AddStep("undo", () => Editor.Undo()); - private void addRedoSteps() => AddStep("redo", () => ((TestEditor)Editor).Redo()); + private void addRedoSteps() => AddStep("redo", () => Editor.Redo()); protected override Editor CreateEditor() => new TestEditor(); - private class TestEditor : Editor + protected class TestEditor : Editor { public new void Undo() => base.Undo(); public new void Redo() => base.Redo(); + + public new void Save() => base.Save(); + + public new bool HasUnsavedChanges => base.HasUnsavedChanges; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index e4d7e025a8..0b52ae2b95 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; using osu.Game.Screens.Edit.Components.RadioButtons; namespace osu.Game.Tests.Visual.Editing @@ -22,7 +23,7 @@ namespace osu.Game.Tests.Visual.Editing { new RadioButton("Item 1", () => { }), new RadioButton("Item 2", () => { }), - new RadioButton("Item 3", () => { }), + new RadioButton("Item 3", () => { }, () => new SpriteIcon { Icon = FontAwesome.Regular.Angry }), new RadioButton("Item 4", () => { }), new RadioButton("Item 5", () => { }) } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs new file mode 100644 index 0000000000..23feb1466e --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -0,0 +1,237 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osu.Game.Screens.Select; +using osu.Game.Tests.Resources; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public class TestSceneFilterControl : OsuManualInputManagerTestScene + { + protected override Container Content => content; + private readonly Container content; + + private readonly CollectionManager collectionManager; + + private RulesetStore rulesets; + private BeatmapManager beatmapManager; + + private FilterControl control; + + public TestSceneFilterControl() + { + base.Content.AddRange(new Drawable[] + { + collectionManager = new CollectionManager(LocalStorage), + content = new Container { RelativeSizeAxes = Axes.Both } + }); + } + + [BackgroundDependencyLoader] + private void load(GameHost host) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default)); + + beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait(); + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.Cache(collectionManager); + return dependencies; + } + + [SetUp] + public void SetUp() => Schedule(() => + { + collectionManager.Collections.Clear(); + + Child = control = new FilterControl + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = FilterControl.HEIGHT, + }; + }); + + [Test] + public void TestEmptyCollectionFilterContainsAllBeatmaps() + { + assertCollectionDropdownContains("All beatmaps"); + assertCollectionHeaderDisplays("All beatmaps"); + } + + [Test] + public void TestCollectionAddedToDropdown() + { + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); + assertCollectionDropdownContains("1"); + assertCollectionDropdownContains("2"); + } + + [Test] + public void TestCollectionRemovedFromDropdown() + { + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } })); + AddStep("remove collection", () => collectionManager.Collections.RemoveAt(0)); + + assertCollectionDropdownContains("1", false); + assertCollectionDropdownContains("2"); + } + + [Test] + public void TestCollectionRenamed() + { + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("select collection", () => + { + var dropdown = control.ChildrenOfType().Single(); + dropdown.Current.Value = dropdown.ItemSource.ElementAt(1); + }); + + addExpandHeaderStep(); + + AddStep("change name", () => collectionManager.Collections[0].Name.Value = "First"); + + assertCollectionDropdownContains("First"); + assertCollectionHeaderDisplays("First"); + } + + [Test] + public void TestAllBeatmapFilterDoesNotHaveAddButton() + { + addExpandHeaderStep(); + AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0))); + AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent); + } + + [Test] + public void TestCollectionFilterHasAddButton() + { + addExpandHeaderStep(); + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1))); + AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent); + } + + [Test] + public void TestButtonDisabledAndEnabledWithBeatmapChanges() + { + addExpandHeaderStep(); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + + AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); + AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value); + + AddStep("set dummy beatmap", () => Beatmap.SetDefault()); + AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value); + } + + [Test] + public void TestButtonChangesWhenAddedAndRemovedFromCollection() + { + addExpandHeaderStep(); + + AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + + AddStep("add beatmap to collection", () => collectionManager.Collections[0].Beatmaps.Add(Beatmap.Value.BeatmapInfo)); + AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + + AddStep("remove beatmap from collection", () => collectionManager.Collections[0].Beatmaps.Clear()); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + } + + [Test] + public void TestButtonAddsAndRemovesBeatmap() + { + addExpandHeaderStep(); + + AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0])); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + + addClickAddOrRemoveButtonStep(1); + AddAssert("collection contains beatmap", () => collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); + AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare)); + + addClickAddOrRemoveButtonStep(1); + AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo)); + AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare)); + } + + [Test] + public void TestManageCollectionsFilterIsNotSelected() + { + addExpandHeaderStep(); + + AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } })); + AddStep("select collection", () => + { + InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + addExpandHeaderStep(); + + AddStep("click manage collections filter", () => + { + InputManager.MoveMouseTo(getCollectionDropdownItems().Last()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("collection filter still selected", () => control.CreateCriteria().Collection?.Name.Value == "1"); + } + + private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true) + => AddAssert($"collection dropdown header displays '{collectionName}'", + () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName)); + + private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) => + AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", + // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 + () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); + + private IconButton getAddOrRemoveButton(int index) + => getCollectionDropdownItems().ElementAt(index).ChildrenOfType().Single(); + + private void addExpandHeaderStep() => AddStep("expand header", () => + { + InputManager.MoveMouseTo(control.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () => + { + InputManager.MoveMouseTo(getAddOrRemoveButton(index)); + InputManager.Click(MouseButton.Left); + }); + + private IEnumerable.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems() + => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>(); + } +} diff --git a/osu.Game/Collections/BeatmapCollection.cs b/osu.Game/Collections/BeatmapCollection.cs new file mode 100644 index 0000000000..7e4b15ecf9 --- /dev/null +++ b/osu.Game/Collections/BeatmapCollection.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Game.Beatmaps; + +namespace osu.Game.Collections +{ + /// + /// A collection of beatmaps grouped by a name. + /// + public class BeatmapCollection + { + /// + /// Invoked whenever any change occurs on this . + /// + public event Action Changed; + + /// + /// The collection's name. + /// + public readonly Bindable Name = new Bindable(); + + /// + /// The beatmaps contained by the collection. + /// + public readonly BindableList Beatmaps = new BindableList(); + + /// + /// The date when this collection was last modified. + /// + public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow; + + public BeatmapCollection() + { + Beatmaps.CollectionChanged += (_, __) => onChange(); + Name.ValueChanged += _ => onChange(); + } + + private void onChange() + { + LastModifyDate = DateTimeOffset.Now; + Changed?.Invoke(); + } + } +} diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs new file mode 100644 index 0000000000..a50ab5b07a --- /dev/null +++ b/osu.Game/Collections/CollectionManager.cs @@ -0,0 +1,303 @@ +// 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.Collections.Generic; +using System.Collections.Specialized; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.IO.Legacy; +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Collections +{ + /// + /// Handles user-defined collections of beatmaps. + /// + /// + /// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the + /// database backing the game. Going forward writing should be done in a similar way to other model stores. + /// + public class CollectionManager : Component + { + /// + /// Database version in stable-compatible YYYYMMDD format. + /// + private const int database_version = 30000000; + + private const string database_name = "collection.db"; + + public readonly BindableList Collections = new BindableList(); + + public bool SupportsImportFromStable => RuntimeInfo.IsDesktop; + + [Resolved] + private GameHost host { get; set; } + + [Resolved] + private BeatmapManager beatmaps { get; set; } + + private readonly Storage storage; + + public CollectionManager(Storage storage) + { + this.storage = storage; + } + + [BackgroundDependencyLoader] + private void load() + { + Collections.CollectionChanged += collectionsChanged; + + if (storage.Exists(database_name)) + { + using (var stream = storage.GetStream(database_name)) + importCollections(readCollections(stream)); + } + } + + private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + foreach (var c in e.NewItems.Cast()) + c.Changed += backgroundSave; + break; + + case NotifyCollectionChangedAction.Remove: + foreach (var c in e.OldItems.Cast()) + c.Changed -= backgroundSave; + break; + + case NotifyCollectionChangedAction.Replace: + foreach (var c in e.OldItems.Cast()) + c.Changed -= backgroundSave; + + foreach (var c in e.NewItems.Cast()) + c.Changed += backgroundSave; + break; + } + + backgroundSave(); + } + + /// + /// Set an endpoint for notifications to be posted to. + /// + public Action PostNotification { protected get; set; } + + /// + /// Set a storage with access to an osu-stable install for import purposes. + /// + public Func GetStableStorage { private get; set; } + + /// + /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future. + /// + public Task ImportFromStableAsync() + { + var stable = GetStableStorage?.Invoke(); + + if (stable == null) + { + Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error); + return Task.CompletedTask; + } + + if (!stable.Exists(database_name)) + { + // This handles situations like when the user does not have a collections.db file + Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error); + return Task.CompletedTask; + } + + return Task.Run(async () => + { + using (var stream = stable.GetStream(database_name)) + await Import(stream); + }); + } + + public async Task Import(Stream stream) + { + var notification = new ProgressNotification + { + State = ProgressNotificationState.Active, + Text = "Collections import is initialising..." + }; + + PostNotification?.Invoke(notification); + + var collection = readCollections(stream, notification); + bool importCompleted = false; + + Schedule(() => + { + importCollections(collection); + importCompleted = true; + }); + + while (!IsDisposed && !importCompleted) + await Task.Delay(10); + + notification.CompletionText = $"Imported {collection.Count} collections"; + notification.State = ProgressNotificationState.Completed; + } + + private void importCollections(List newCollections) + { + foreach (var newCol in newCollections) + { + var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name); + if (existing == null) + Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } }); + + foreach (var newBeatmap in newCol.Beatmaps) + { + if (!existing.Beatmaps.Contains(newBeatmap)) + existing.Beatmaps.Add(newBeatmap); + } + } + } + + private List readCollections(Stream stream, ProgressNotification notification = null) + { + if (notification != null) + { + notification.Text = "Reading collections..."; + notification.Progress = 0; + } + + var result = new List(); + + try + { + using (var sr = new SerializationReader(stream)) + { + sr.ReadInt32(); // Version + + int collectionCount = sr.ReadInt32(); + result.Capacity = collectionCount; + + for (int i = 0; i < collectionCount; i++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } }; + int mapCount = sr.ReadInt32(); + + for (int j = 0; j < mapCount; j++) + { + if (notification?.CancellationToken.IsCancellationRequested == true) + return result; + + string checksum = sr.ReadString(); + + var beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == checksum); + if (beatmap != null) + collection.Beatmaps.Add(beatmap); + } + + if (notification != null) + { + notification.Text = $"Imported {i + 1} of {collectionCount} collections"; + notification.Progress = (float)(i + 1) / collectionCount; + } + + result.Add(collection); + } + } + } + catch (Exception e) + { + Logger.Error(e, "Failed to read collection database."); + } + + return result; + } + + public void DeleteAll() + { + Collections.Clear(); + PostNotification?.Invoke(new SimpleNotification { Text = "Deleted all collections!" }); + } + + private readonly object saveLock = new object(); + private int lastSave; + private int saveFailures; + + /// + /// Perform a save with debounce. + /// + private void backgroundSave() + { + var current = Interlocked.Increment(ref lastSave); + Task.Delay(100).ContinueWith(task => + { + if (current != lastSave) + return; + + if (!save()) + backgroundSave(); + }); + } + + private bool save() + { + lock (saveLock) + { + Interlocked.Increment(ref lastSave); + + try + { + // This is NOT thread-safe!! + + using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write))) + { + sw.Write(database_version); + sw.Write(Collections.Count); + + foreach (var c in Collections) + { + sw.Write(c.Name.Value); + sw.Write(c.Beatmaps.Count); + + foreach (var b in c.Beatmaps) + sw.Write(b.MD5Hash); + } + } + + if (saveFailures < 10) + saveFailures = 0; + return true; + } + catch (Exception e) + { + // Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing). + // Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred. + if (++saveFailures == 10) + Logger.Error(e, "Failed to save collection database!"); + } + + return false; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + save(); + } + } +} diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs new file mode 100644 index 0000000000..e5a2f6fb81 --- /dev/null +++ b/osu.Game/Collections/DeleteCollectionDialog.cs @@ -0,0 +1,34 @@ +// 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 Humanizer; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Collections +{ + public class DeleteCollectionDialog : PopupDialog + { + public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction) + { + HeaderText = "Confirm deletion of"; + BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.Beatmaps.Count)})"; + + Icon = FontAwesome.Regular.TrashAlt; + + Buttons = new PopupDialogButton[] + { + new PopupDialogOkButton + { + Text = @"Yes. Go for it.", + Action = deleteAction + }, + new PopupDialogCancelButton + { + Text = @"No! Abort mission!", + }, + }; + } + } +} diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs new file mode 100644 index 0000000000..3c664a11d9 --- /dev/null +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -0,0 +1,122 @@ +// 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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.Containers; +using osuTK; + +namespace osu.Game.Collections +{ + /// + /// Visualises a list of s. + /// + public class DrawableCollectionList : OsuRearrangeableListContainer + { + private Scroll scroll; + + protected override ScrollContainer CreateScrollContainer() => scroll = new Scroll(); + + protected override FillFlowContainer> CreateListFillFlowContainer() => new Flow + { + DragActive = { BindTarget = DragActive } + }; + + protected override OsuRearrangeableListItem CreateOsuDrawable(BeatmapCollection item) + { + if (item == scroll.PlaceholderItem.Model) + return scroll.ReplacePlaceholder(); + + return new DrawableCollectionListItem(item, true); + } + + /// + /// The scroll container for this . + /// Contains the main flow of and attaches a placeholder item to the end of the list. + /// + /// + /// Use to transfer the placeholder into the main list. + /// + private class Scroll : OsuScrollContainer + { + /// + /// The currently-displayed placeholder item. + /// + public DrawableCollectionListItem PlaceholderItem { get; private set; } + + protected override Container Content => content; + private readonly Container content; + + private readonly Container placeholderContainer; + + public Scroll() + { + ScrollbarVisible = false; + Padding = new MarginPadding(10); + + base.Content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + LayoutDuration = 200, + LayoutEasing = Easing.OutQuint, + Children = new Drawable[] + { + content = new Container { RelativeSizeAxes = Axes.X }, + placeholderContainer = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + }); + + ReplacePlaceholder(); + } + + protected override void Update() + { + base.Update(); + + // AutoSizeAxes cannot be used as the height should represent the post-layout-transform height at all times, so that the placeholder doesn't bounce around. + content.Height = ((Flow)Child).Children.Sum(c => c.DrawHeight + 5); + } + + /// + /// Replaces the current with a new one, and returns the previous. + /// + /// The current . + public DrawableCollectionListItem ReplacePlaceholder() + { + var previous = PlaceholderItem; + + placeholderContainer.Clear(false); + placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false)); + + return previous; + } + } + + /// + /// The flow of . Disables layout easing unless a drag is in progress. + /// + private class Flow : FillFlowContainer> + { + public readonly IBindable DragActive = new Bindable(); + + public Flow() + { + Spacing = new Vector2(0, 5); + LayoutEasing = Easing.OutQuint; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + DragActive.BindValueChanged(active => LayoutDuration = active.NewValue ? 200 : 0); + } + } + } +} diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs new file mode 100644 index 0000000000..988a3443c3 --- /dev/null +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -0,0 +1,237 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Collections +{ + /// + /// Visualises a inside a . + /// + public class DrawableCollectionListItem : OsuRearrangeableListItem + { + private const float item_height = 35; + private const float button_width = item_height * 0.75f; + + /// + /// Whether the currently exists inside the . + /// + public IBindable IsCreated => isCreated; + + private readonly Bindable isCreated = new Bindable(); + + /// + /// Creates a new . + /// + /// The . + /// Whether currently exists inside the . + public DrawableCollectionListItem(BeatmapCollection item, bool isCreated) + : base(item) + { + this.isCreated.Value = isCreated; + + ShowDragHandle.BindTo(this.isCreated); + } + + protected override Drawable CreateContent() => new ItemContent(Model) + { + IsCreated = { BindTarget = isCreated } + }; + + /// + /// The main content of the . + /// + private class ItemContent : CircularContainer + { + public readonly Bindable IsCreated = new Bindable(); + + private readonly IBindable collectionName; + private readonly BeatmapCollection collection; + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + private Container textBoxPaddingContainer; + private ItemTextBox textBox; + + public ItemContent(BeatmapCollection collection) + { + this.collection = collection; + + RelativeSizeAxes = Axes.X; + Height = item_height; + Masking = true; + + collectionName = collection.Name.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Children = new Drawable[] + { + new DeleteButton(collection) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + IsCreated = { BindTarget = IsCreated }, + IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v) + }, + textBoxPaddingContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Right = button_width }, + Children = new Drawable[] + { + textBox = new ItemTextBox + { + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + CornerRadius = item_height / 2, + Current = collection.Name, + PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection" + }, + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + collectionName.BindValueChanged(_ => createNewCollection(), true); + IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true); + } + + private void createNewCollection() + { + if (IsCreated.Value) + return; + + if (string.IsNullOrEmpty(collectionName.Value)) + return; + + // Add the new collection and disable our placeholder. If all text is removed, the placeholder should not show back again. + collectionManager?.Collections.Add(collection); + textBox.PlaceholderText = string.Empty; + + // When this item changes from placeholder to non-placeholder (via changing containers), its textbox will lose focus, so it needs to be re-focused. + Schedule(() => GetContainingInputManager().ChangeFocus(textBox)); + + IsCreated.Value = true; + } + } + + private class ItemTextBox : OsuTextBox + { + protected override float LeftRightPadding => item_height / 2; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + BackgroundUnfocused = colours.GreySeafoamDarker.Darken(0.5f); + BackgroundFocused = colours.GreySeafoam; + } + } + + public class DeleteButton : CompositeDrawable + { + public readonly IBindable IsCreated = new Bindable(); + + public Func IsTextBoxHovered; + + [Resolved(CanBeNull = true)] + private DialogOverlay dialogOverlay { get; set; } + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + private readonly BeatmapCollection collection; + + private Drawable fadeContainer; + private Drawable background; + + public DeleteButton(BeatmapCollection collection) + { + this.collection = collection; + RelativeSizeAxes = Axes.Y; + + Width = button_width + item_height / 2; // add corner radius to cover with fill + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + InternalChild = fadeContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Red + }, + new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + X = -button_width * 0.6f, + Size = new Vector2(10), + Icon = FontAwesome.Solid.Trash + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + IsCreated.BindValueChanged(created => Alpha = created.NewValue ? 1 : 0, true); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); + + protected override bool OnHover(HoverEvent e) + { + fadeContainer.FadeTo(1f, 100, Easing.Out); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + fadeContainer.FadeTo(0.1f, 100); + } + + protected override bool OnClick(ClickEvent e) + { + background.FlashColour(Color4.White, 150); + + if (collection.Beatmaps.Count == 0) + deleteCollection(); + else + dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection)); + + return true; + } + + private void deleteCollection() => collectionManager?.Collections.Remove(collection); + } + } +} diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs new file mode 100644 index 0000000000..680fec904f --- /dev/null +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -0,0 +1,134 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Collections +{ + public class ManageCollectionsDialog : OsuFocusedOverlayContainer + { + private const double enter_duration = 500; + private const double exit_duration = 200; + + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + public ManageCollectionsDialog() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + RelativeSizeAxes = Axes.Both; + Size = new Vector2(0.5f, 0.8f); + + Masking = true; + CornerRadius = 10; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Children = new Drawable[] + { + new Box + { + Colour = colours.GreySeafoamDark, + RelativeSizeAxes = Axes.Both, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Manage collections", + Font = OsuFont.GetFont(size: 30), + Padding = new MarginPadding { Vertical = 10 }, + }, + new IconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Icon = FontAwesome.Solid.Times, + Colour = colours.GreySeafoamDarker, + Scale = new Vector2(0.8f), + X = -10, + Action = () => State.Value = Visibility.Hidden + } + } + } + }, + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoamDarker + }, + new DrawableCollectionList + { + RelativeSizeAxes = Axes.Both, + Items = { BindTarget = collectionManager?.Collections ?? new BindableList() } + } + } + } + }, + } + } + } + }; + } + + protected override void PopIn() + { + base.PopIn(); + + this.FadeIn(enter_duration, Easing.OutQuint); + this.ScaleTo(0.9f).Then().ScaleTo(1f, enter_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + base.PopOut(); + + this.FadeOut(exit_duration, Easing.OutQuint); + this.ScaleTo(0.9f, exit_duration); + + // Ensure that textboxes commit + GetContainingInputManager()?.TriggerFocusContention(this); + } + } +} diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 751ccc8f15..41fd37a0d7 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -103,6 +103,8 @@ namespace osu.Game.Graphics.Containers { } + private bool playedPopInSound; + protected override void UpdateState(ValueChangedEvent state) { switch (state.NewValue) @@ -110,16 +112,24 @@ namespace osu.Game.Graphics.Containers case Visibility.Visible: if (OverlayActivationMode.Value == OverlayActivation.Disabled) { + // todo: visual/audible feedback that this operation could not complete. State.Value = Visibility.Hidden; return; } samplePopIn?.Play(); + playedPopInSound = true; + if (BlockScreenWideMouse && DimMainContent) game?.AddBlockingOverlay(this); break; case Visibility.Hidden: - samplePopOut?.Play(); + if (playedPopInSound) + { + samplePopOut?.Play(); + playedPopInSound = false; + } + if (BlockScreenWideMouse) game?.RemoveBlockingOverlay(this); break; } diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs index 47aed1c500..1048fd094c 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs @@ -12,13 +12,13 @@ namespace osu.Game.Graphics.Containers /// /// Whether any item is currently being dragged. Used to hide other items' drag handles. /// - private readonly BindableBool playlistDragActive = new BindableBool(); + protected readonly BindableBool DragActive = new BindableBool(); protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer(); protected sealed override RearrangeableListItem CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d => { - d.PlaylistDragActive.BindTo(playlistDragActive); + d.DragActive.BindTo(DragActive); }); protected abstract OsuRearrangeableListItem CreateOsuDrawable(TModel item); diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs index 29553954fe..9cdcb19a81 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs @@ -19,7 +19,7 @@ namespace osu.Game.Graphics.Containers /// /// Whether any item is currently being dragged. Used to hide other items' drag handles. /// - public readonly BindableBool PlaylistDragActive = new BindableBool(); + public readonly BindableBool DragActive = new BindableBool(); private Color4 handleColour = Color4.White; @@ -44,8 +44,9 @@ namespace osu.Game.Graphics.Containers /// /// Whether the drag handle should be shown. /// - protected virtual bool ShowDragHandle => true; + protected readonly Bindable ShowDragHandle = new Bindable(); + private Container handleContainer; private PlaylistItemHandle handle; protected OsuRearrangeableListItem(TModel item) @@ -58,8 +59,6 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load() { - Container handleContainer; - InternalChild = new GridContainer { RelativeSizeAxes = Axes.X, @@ -88,9 +87,12 @@ namespace osu.Game.Graphics.Containers ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } }; + } - if (!ShowDragHandle) - handleContainer.Alpha = 0; + protected override void LoadComplete() + { + base.LoadComplete(); + ShowDragHandle.BindValueChanged(show => handleContainer.Alpha = show.NewValue ? 1 : 0, true); } protected override bool OnDragStart(DragStartEvent e) @@ -98,13 +100,13 @@ namespace osu.Game.Graphics.Containers if (!base.OnDragStart(e)) return false; - PlaylistDragActive.Value = true; + DragActive.Value = true; return true; } protected override void OnDragEnd(DragEndEvent e) { - PlaylistDragActive.Value = false; + DragActive.Value = false; base.OnDragEnd(e); } @@ -112,7 +114,7 @@ namespace osu.Game.Graphics.Containers protected override bool OnHover(HoverEvent e) { - handle.UpdateHoverState(IsDragged || !PlaylistDragActive.Value); + handle.UpdateHoverState(IsDragged || !DragActive.Value); return base.OnHover(e); } diff --git a/osu.Game/Graphics/UserInterface/IconButton.cs b/osu.Game/Graphics/UserInterface/IconButton.cs index d7e5666545..858f517985 100644 --- a/osu.Game/Graphics/UserInterface/IconButton.cs +++ b/osu.Game/Graphics/UserInterface/IconButton.cs @@ -24,7 +24,7 @@ namespace osu.Game.Graphics.UserInterface set { iconColour = value; - icon.Colour = value; + icon.FadeColour(value); } } diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs index 4b629080e1..8c7b44f952 100644 --- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs +++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs @@ -26,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface }; ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL }; + + MaxHeight = 250; } [BackgroundDependencyLoader] diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index db0f835c67..084ba89f6e 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -56,13 +56,14 @@ namespace osu.Game.Online.Leaderboards scrollFlow?.FadeOut(fade_duration, Easing.OutQuint).Expire(); scrollFlow = null; - loading.Hide(); - showScoresDelegate?.Cancel(); showScoresCancellationSource?.Cancel(); if (scores == null || !scores.Any()) + { + loading.Hide(); return; + } // ensure placeholder is hidden when displaying scores PlaceholderState = PlaceholderState.Successful; @@ -84,6 +85,7 @@ namespace osu.Game.Online.Leaderboards } scrollContainer.ScrollTo(0f, false); + loading.Hide(); }, (showScoresCancellationSource = new CancellationTokenSource()).Token)); } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index fec6640f2f..4a699dc82e 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -31,6 +31,7 @@ using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -610,12 +611,19 @@ namespace osu.Game d.Origin = Anchor.TopRight; }), rightFloatingOverlayContent.Add, true); + loadComponentSingleFile(new CollectionManager(Storage) + { + PostNotification = n => notifications.Post(n), + GetStableStorage = GetStorageForStableInstall + }, Add, true); + loadComponentSingleFile(screenshotManager, Add); // dependency on notification overlay, dependent by settings overlay loadComponentSingleFile(CreateUpdateManager(), Add, true); // overlay elements + loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true); loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 832673703b..848ce381a9 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -3,9 +3,11 @@ using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics.UserInterface; using osu.Game.Scoring; using osu.Game.Skinning; @@ -19,14 +21,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance private TriangleButton importBeatmapsButton; private TriangleButton importScoresButton; private TriangleButton importSkinsButton; + private TriangleButton importCollectionsButton; private TriangleButton deleteBeatmapsButton; private TriangleButton deleteScoresButton; private TriangleButton deleteSkinsButton; private TriangleButton restoreButton; private TriangleButton undeleteButton; - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, DialogOverlay dialogOverlay) + [BackgroundDependencyLoader(permitNulls: true)] + private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay) { if (beatmaps.SupportsImportFromStable) { @@ -93,20 +96,46 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance }); } - AddRange(new Drawable[] + Add(deleteSkinsButton = new DangerousSettingsButton { - deleteSkinsButton = new DangerousSettingsButton + Text = "Delete ALL skins", + Action = () => { - Text = "Delete ALL skins", + dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => + { + deleteSkinsButton.Enabled.Value = false; + Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); + })); + } + }); + + if (collectionManager != null) + { + if (collectionManager.SupportsImportFromStable) + { + Add(importCollectionsButton = new SettingsButton + { + Text = "Import collections from stable", + Action = () => + { + importCollectionsButton.Enabled.Value = false; + collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true)); + } + }); + } + + Add(new DangerousSettingsButton + { + Text = "Delete ALL collections", Action = () => { - dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => - { - deleteSkinsButton.Enabled.Value = false; - Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); - })); + dialogOverlay?.Push(new DeleteAllBeatmapsDialog(collectionManager.DeleteAll)); } - }, + }); + } + + AddRange(new Drawable[] + { restoreButton = new SettingsButton { Text = "Restore all hidden difficulties", diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index f134db1ffe..b9cc054ed3 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -13,6 +14,7 @@ using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -92,9 +94,18 @@ namespace osu.Game.Rulesets.Edit Name = "Sidebar", RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Right = 10 }, + Spacing = new Vector2(10), Children = new Drawable[] { - new ToolboxGroup { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } } + new ToolboxGroup("toolbox") { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } }, + new ToolboxGroup("toggles") + { + ChildrenEnumerable = Toggles.Select(b => new SettingsCheckbox + { + Bindable = b, + LabelText = b?.Description ?? "unknown" + }) + } } }, new Container @@ -126,7 +137,7 @@ namespace osu.Game.Rulesets.Edit toolboxCollection.Items = CompositionTools .Prepend(new SelectTool()) - .Select(t => new RadioButton(t.Name, () => toolSelected(t))) + .Select(t => new RadioButton(t.Name, () => toolSelected(t), t.CreateIcon)) .ToList(); setSelectTool(); @@ -156,6 +167,12 @@ namespace osu.Game.Rulesets.Edit /// protected abstract IReadOnlyList CompositionTools { get; } + /// + /// A collection of toggles which will be displayed to the user. + /// The display name will be decided by . + /// + protected virtual IEnumerable Toggles => Enumerable.Empty(); + /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. /// diff --git a/osu.Game/Rulesets/Edit/ToolboxGroup.cs b/osu.Game/Rulesets/Edit/ToolboxGroup.cs index 7e17d88e17..22b2b05657 100644 --- a/osu.Game/Rulesets/Edit/ToolboxGroup.cs +++ b/osu.Game/Rulesets/Edit/ToolboxGroup.cs @@ -8,8 +8,8 @@ namespace osu.Game.Rulesets.Edit { public class ToolboxGroup : PlayerSettingsGroup { - public ToolboxGroup() - : base("toolbox") + public ToolboxGroup(string title) + : base(title) { RelativeSizeAxes = Axes.X; Width = 1; diff --git a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs index 0631031302..0a01ac4320 100644 --- a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; + namespace osu.Game.Rulesets.Edit.Tools { public abstract class HitObjectCompositionTool @@ -14,6 +16,8 @@ namespace osu.Game.Rulesets.Edit.Tools public abstract PlacementBlueprint CreatePlacementBlueprint(); + public virtual Drawable CreateIcon() => null; + public override string ToString() => Name; } } diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs index b96eeb0790..c050766b23 100644 --- a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs +++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs @@ -1,6 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; + namespace osu.Game.Rulesets.Edit.Tools { public class SelectTool : HitObjectCompositionTool @@ -10,6 +13,8 @@ namespace osu.Game.Rulesets.Edit.Tools { } + public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.MousePointer }; + public override PlacementBlueprint CreatePlacementBlueprint() => null; } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs index 7be91f4e8e..0cf7b83f3b 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs @@ -5,7 +5,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; @@ -29,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons private Color4 selectedBackgroundColour; private Color4 selectedBubbleColour; - private readonly Drawable bubble; + private Drawable icon; private readonly RadioButton button; public DrawableRadioButton(RadioButton button) @@ -40,19 +39,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Action = button.Select; RelativeSizeAxes = Axes.X; - - bubble = new CircularContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - Scale = new Vector2(0.5f), - X = 10, - Masking = true, - Blending = BlendingParameters.Additive, - Child = new Box { RelativeSizeAxes = Axes.Both } - }; } [BackgroundDependencyLoader] @@ -73,7 +59,14 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Colour = Color4.Black.Opacity(0.5f) }; - Add(bubble); + Add(icon = (button.CreateIcon?.Invoke() ?? new Circle()).With(b => + { + b.Blending = BlendingParameters.Additive; + b.Anchor = Anchor.CentreLeft; + b.Origin = Anchor.CentreLeft; + b.Size = new Vector2(20); + b.X = 10; + })); } protected override void LoadComplete() @@ -96,7 +89,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons return; BackgroundColour = button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - bubble.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; + icon.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; } protected override SpriteText CreateText() => new OsuSpriteText diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index b515d7c8bd..a7b0fb05e3 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Bindables; +using osu.Framework.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { @@ -19,11 +20,17 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons /// public object Item; + /// + /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. + /// + public readonly Func CreateIcon; + private readonly Action action; - public RadioButton(object item, Action action) + public RadioButton(object item, Action action, Func createIcon = null) { Item = item; + CreateIcon = createIcon; this.action = action; Selected = new BindableBool(); } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index ac1f61c4fd..23eb704920 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -2,39 +2,40 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osuTK.Graphics; -using osu.Framework.Screens; +using System.Collections.Generic; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Screens.Edit.Components.Timelines.Summary; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; -using osu.Framework.Input.Events; -using osu.Framework.Platform; -using osu.Framework.Timing; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Edit.Components; -using osu.Game.Screens.Edit.Components.Menus; -using osu.Game.Screens.Edit.Design; -using osuTK.Input; -using System.Collections.Generic; -using osu.Framework; using osu.Framework.Input; using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Screens; +using osu.Framework.Timing; using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.API; +using osu.Game.Overlays; using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Components; +using osu.Game.Screens.Edit.Components.Menus; +using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.Compose; +using osu.Game.Screens.Edit.Design; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Play; using osu.Game.Users; +using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Screens.Edit { @@ -51,9 +52,18 @@ namespace osu.Game.Screens.Edit public override bool AllowRateAdjustments => false; + protected bool HasUnsavedChanges => lastSavedHash != changeHandler.CurrentStateHash; + [Resolved] private BeatmapManager beatmapManager { get; set; } + [Resolved(canBeNull: true)] + private DialogOverlay dialogOverlay { get; set; } + + private bool exitConfirmed; + + private string lastSavedHash; + private Box bottomBackground; private Container screenContainer; @@ -118,13 +128,15 @@ namespace osu.Game.Screens.Edit changeHandler = new EditorChangeHandler(editorBeatmap); dependencies.CacheAs(changeHandler); + updateLastSavedHash(); + EditorMenuBar menuBar; OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; var fileMenuItems = new List { - new EditorMenuItem("Save", MenuItemType.Standard, saveBeatmap) + new EditorMenuItem("Save", MenuItemType.Standard, Save) }; if (RuntimeInfo.IsDesktop) @@ -237,6 +249,17 @@ namespace osu.Game.Screens.Edit bottomBackground.Colour = colours.Gray2; } + protected void Save() + { + // apply any set-level metadata changes. + beatmapManager.Update(playableBeatmap.BeatmapInfo.BeatmapSet); + + // save the loaded beatmap's data stream. + beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap, editorBeatmap.BeatmapSkin); + + updateLastSavedHash(); + } + protected override void Update() { base.Update(); @@ -256,7 +279,7 @@ namespace osu.Game.Screens.Edit return true; case PlatformActionType.Save: - saveBeatmap(); + Save(); return true; } @@ -346,12 +369,31 @@ namespace osu.Game.Screens.Edit public override bool OnExiting(IScreen next) { + if (!exitConfirmed && dialogOverlay != null && HasUnsavedChanges) + { + dialogOverlay?.Push(new PromptForSaveDialog(confirmExit, confirmExitWithSave)); + return true; + } + Background.FadeColour(Color4.White, 500); resetTrack(); return base.OnExiting(next); } + private void confirmExitWithSave() + { + exitConfirmed = true; + Save(); + this.Exit(); + } + + private void confirmExit() + { + exitConfirmed = true; + this.Exit(); + } + protected void Undo() => changeHandler.RestoreState(-1); protected void Redo() => changeHandler.RestoreState(1); @@ -415,21 +457,17 @@ namespace osu.Game.Screens.Edit clock.SeekForward(!clock.IsRunning, amount); } - private void saveBeatmap() - { - // apply any set-level metadata changes. - beatmapManager.Update(playableBeatmap.BeatmapInfo.BeatmapSet); - - // save the loaded beatmap's data stream. - beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap, editorBeatmap.BeatmapSkin); - } - private void exportBeatmap() { - saveBeatmap(); + Save(); beatmapManager.Export(Beatmap.Value.BeatmapSetInfo); } + private void updateLastSavedHash() + { + lastSavedHash = changeHandler.CurrentStateHash; + } + public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); public double GetBeatLengthAtTime(double referenceTime) => editorBeatmap.GetBeatLengthAtTime(referenceTime); diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs index 927c823c64..aa0f89912a 100644 --- a/osu.Game/Screens/Edit/EditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Text; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Game.Beatmaps.Formats; using osu.Game.Rulesets.Objects; @@ -24,6 +25,18 @@ namespace osu.Game.Screens.Edit private int currentState = -1; + /// + /// A SHA-2 hash representing the current visible editor state. + /// + public string CurrentStateHash + { + get + { + using (var stream = new MemoryStream(savedStates[currentState])) + return stream.ComputeSHA2Hash(); + } + } + private readonly EditorBeatmap editorBeatmap; private int bulkChangesStarted; private bool isRestoring; diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs new file mode 100644 index 0000000000..16504b47bd --- /dev/null +++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Screens.Edit +{ + public class PromptForSaveDialog : PopupDialog + { + public PromptForSaveDialog(Action exit, Action saveAndExit) + { + HeaderText = "Did you want to save your changes?"; + + Icon = FontAwesome.Regular.Save; + + Buttons = new PopupDialogButton[] + { + new PopupDialogCancelButton + { + Text = @"Save my masterpiece!", + Action = saveAndExit + }, + new PopupDialogOkButton + { + Text = @"Forget all changes", + Action = exit + }, + new PopupDialogCancelButton + { + Text = @"Oops, continue editing", + }, + }; + } + } +} diff --git a/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs index c0892235f2..b007e0349d 100644 --- a/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/Multi/DrawableRoomPlaylistItem.cs @@ -37,8 +37,6 @@ namespace osu.Game.Screens.Multi public readonly Bindable SelectedItem = new Bindable(); - protected override bool ShowDragHandle => allowEdit; - private Container maskingContainer; private Container difficultyIconContainer; private LinkFlowContainer beatmapText; @@ -63,12 +61,13 @@ namespace osu.Game.Screens.Multi // TODO: edit support should be moved out into a derived class this.allowEdit = allowEdit; - this.allowSelection = allowSelection; beatmap.BindTo(item.Beatmap); ruleset.BindTo(item.Ruleset); requiredMods.BindTo(item.RequiredMods); + + ShowDragHandle.Value = allowEdit; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index ed54c158db..3892e02a8f 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -60,6 +60,9 @@ namespace osu.Game.Screens.Select.Carousel match &= terms.Any(term => term.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0); } + if (match) + match &= criteria.Collection?.Beatmaps.Contains(Beatmap) ?? true; + Filtered.Value = !match; } diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs index c559b4f8f5..10745fe3c1 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmap.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -17,6 +18,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; @@ -46,6 +48,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private BeatmapDifficultyManager difficultyManager { get; set; } + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + private IBindable starDifficultyBindable; private CancellationTokenSource starDifficultyCancellationSource; @@ -213,16 +221,39 @@ namespace osu.Game.Screens.Select.Carousel if (editRequested != null) items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmap))); + if (beatmap.OnlineBeatmapID.HasValue && beatmapOverlay != null) + items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); + + if (collectionManager != null) + { + var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + } + if (hideRequested != null) items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmap))); - if (beatmap.OnlineBeatmapID.HasValue && beatmapOverlay != null) - items.Add(new OsuMenuItem("Details", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmap.OnlineBeatmapID.Value))); - return items.ToArray(); } } + private MenuItem createCollectionMenuItem(BeatmapCollection collection) + { + return new ToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s => + { + if (s) + collection.Beatmaps.Add(beatmap); + else + collection.Beatmaps.Remove(beatmap); + }) + { + State = { Value = collection.Beatmaps.Contains(beatmap) } + }; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 5acb6d1946..3c8ac69dd2 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -16,6 +16,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; +using osu.Game.Collections; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -34,6 +35,12 @@ namespace osu.Game.Screens.Select.Carousel [Resolved(CanBeNull = true)] private DialogOverlay dialogOverlay { get; set; } + [Resolved(CanBeNull = true)] + private CollectionManager collectionManager { get; set; } + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + private readonly BeatmapSetInfo beatmapSet; public DrawableCarouselBeatmapSet(CarouselBeatmapSet set) @@ -135,16 +142,61 @@ namespace osu.Game.Screens.Select.Carousel if (beatmapSet.OnlineBeatmapSetID != null && viewDetails != null) items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineBeatmapSetID.Value))); + if (collectionManager != null) + { + var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList(); + if (manageCollectionsDialog != null) + collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show)); + + items.Add(new OsuMenuItem("Collections") { Items = collectionItems }); + } + if (beatmapSet.Beatmaps.Any(b => b.Hidden)) items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); if (dialogOverlay != null) - items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); - + items.Add(new OsuMenuItem("Delete...", MenuItemType.Destructive, () => dialogOverlay.Push(new BeatmapDeleteDialog(beatmapSet)))); return items.ToArray(); } } + private MenuItem createCollectionMenuItem(BeatmapCollection collection) + { + TernaryState state; + + var countExisting = beatmapSet.Beatmaps.Count(b => collection.Beatmaps.Contains(b)); + + if (countExisting == beatmapSet.Beatmaps.Count) + state = TernaryState.True; + else if (countExisting > 0) + state = TernaryState.Indeterminate; + else + state = TernaryState.False; + + return new TernaryStateMenuItem(collection.Name.Value, MenuItemType.Standard, s => + { + foreach (var b in beatmapSet.Beatmaps) + { + switch (s) + { + case TernaryState.True: + if (collection.Beatmaps.Contains(b)) + continue; + + collection.Beatmaps.Add(b); + break; + + case TernaryState.False: + collection.Beatmaps.Remove(b); + break; + } + } + }) + { + State = { Value = state } + }; + } + private class PanelBackground : BufferedContainer { public PanelBackground(WorkingBeatmap working) diff --git a/osu.Game/Screens/Select/CollectionFilterDropdown.cs b/osu.Game/Screens/Select/CollectionFilterDropdown.cs new file mode 100644 index 0000000000..b08c3b167d --- /dev/null +++ b/osu.Game/Screens/Select/CollectionFilterDropdown.cs @@ -0,0 +1,273 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Collections; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Select +{ + /// + /// A dropdown to select the to filter beatmaps using. + /// + public class CollectionFilterDropdown : OsuDropdown + { + private readonly IBindableList collections = new BindableList(); + private readonly IBindableList beatmaps = new BindableList(); + private readonly BindableList filters = new BindableList(); + + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog manageCollectionsDialog { get; set; } + + public CollectionFilterDropdown() + { + ItemSource = filters; + } + + [BackgroundDependencyLoader(permitNulls: true)] + private void load([CanBeNull] CollectionManager collectionManager) + { + if (collectionManager != null) + collections.BindTo(collectionManager.Collections); + + collections.CollectionChanged += (_, __) => collectionsChanged(); + collectionsChanged(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(filterChanged, true); + } + + /// + /// Occurs when a collection has been added or removed. + /// + private void collectionsChanged() + { + var selectedItem = SelectedItem?.Value?.Collection; + + filters.Clear(); + filters.Add(new AllBeatmapsCollectionMenuItem()); + filters.AddRange(collections.Select(c => new CollectionMenuItem(c))); + filters.Add(new ManageCollectionsMenuItem()); + + Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection == selectedItem) ?? filters[0]; + } + + /// + /// Occurs when the selection has changed. + /// + private void filterChanged(ValueChangedEvent filter) + { + // Binding the beatmaps will trigger a collection change event, which results in an infinite-loop. This is rebound later, when it's safe to do so. + beatmaps.CollectionChanged -= filterBeatmapsChanged; + + if (filter.OldValue?.Collection != null) + beatmaps.UnbindFrom(filter.OldValue.Collection.Beatmaps); + + if (filter.NewValue?.Collection != null) + beatmaps.BindTo(filter.NewValue.Collection.Beatmaps); + + beatmaps.CollectionChanged += filterBeatmapsChanged; + + // Never select the manage collection filter - rollback to the previous filter. + // This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value. + if (filter.NewValue is ManageCollectionsMenuItem) + { + Current.Value = filter.OldValue; + manageCollectionsDialog?.Show(); + } + } + + /// + /// Occurs when the beatmaps contained by a have changed. + /// + private void filterBeatmapsChanged(object sender, NotifyCollectionChangedEventArgs e) + { + // The filtered beatmaps have changed, without the filter having changed itself. So a change in filter must be notified. + // Note that this does NOT propagate to bound bindables, so the FilterControl must bind directly to the value change event of this bindable. + Current.TriggerChange(); + } + + protected override string GenerateItemText(CollectionMenuItem item) => item.CollectionName.Value; + + protected override DropdownHeader CreateHeader() => new CollectionDropdownHeader + { + SelectedItem = { BindTarget = Current } + }; + + protected override DropdownMenu CreateMenu() => new CollectionDropdownMenu(); + + public class CollectionDropdownHeader : OsuDropdownHeader + { + public readonly Bindable SelectedItem = new Bindable(); + private readonly Bindable collectionName = new Bindable(); + + protected override string Label + { + get => base.Label; + set { } // See updateText(). + } + + public CollectionDropdownHeader() + { + Height = 25; + Icon.Size = new Vector2(16); + Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(_ => updateBindable(), true); + } + + private void updateBindable() + { + collectionName.UnbindAll(); + + if (SelectedItem.Value != null) + collectionName.BindTo(SelectedItem.Value.CollectionName); + + collectionName.BindValueChanged(_ => updateText(), true); + } + + // Dropdowns don't bind to value changes, so the real name is copied directly from the selected item here. + private void updateText() => base.Label = collectionName.Value; + } + + private class CollectionDropdownMenu : OsuDropdownMenu + { + public CollectionDropdownMenu() + { + MaxHeight = 200; + } + + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item); + } + + private class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem + { + [NotNull] + protected new CollectionMenuItem Item => ((DropdownMenuItem)base.Item).Value; + + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private IBindable beatmap { get; set; } + + [CanBeNull] + private readonly BindableList collectionBeatmaps; + + [NotNull] + private readonly Bindable collectionName; + + private IconButton addOrRemoveButton; + private Content content; + private bool beatmapInCollection; + + public CollectionDropdownMenuItem(MenuItem item) + : base(item) + { + collectionBeatmaps = Item.Collection?.Beatmaps.GetBoundCopy(); + collectionName = Item.CollectionName.GetBoundCopy(); + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(addOrRemoveButton = new IconButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -OsuScrollContainer.SCROLL_BAR_HEIGHT, + Scale = new Vector2(0.65f), + Action = addOrRemove, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (collectionBeatmaps != null) + { + collectionBeatmaps.CollectionChanged += (_, __) => collectionChanged(); + beatmap.BindValueChanged(_ => collectionChanged(), true); + } + + // Although the DrawableMenuItem binds to value changes of the item's text, the item is an internal implementation detail of Dropdown that has no knowledge + // of the underlying CollectionFilter value and its accompanying name, so the real name has to be copied here. Without this, the collection name wouldn't update when changed. + collectionName.BindValueChanged(name => content.Text = name.NewValue, true); + + updateButtonVisibility(); + } + + protected override bool OnHover(HoverEvent e) + { + updateButtonVisibility(); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateButtonVisibility(); + base.OnHoverLost(e); + } + + private void collectionChanged() + { + Debug.Assert(collectionBeatmaps != null); + + beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo); + + addOrRemoveButton.Enabled.Value = !beatmap.IsDefault; + addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare; + addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap"; + + updateButtonVisibility(); + } + + protected override void OnSelectChange() + { + base.OnSelectChange(); + updateButtonVisibility(); + } + + private void updateButtonVisibility() + { + if (collectionBeatmaps == null) + addOrRemoveButton.Alpha = 0; + else + addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0; + } + + private void addOrRemove() + { + Debug.Assert(collectionBeatmaps != null); + + if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo)) + collectionBeatmaps.Add(beatmap.Value.BeatmapInfo); + } + + protected override Drawable CreateContent() => content = (Content)base.CreateContent(); + } + } +} diff --git a/osu.Game/Screens/Select/CollectionMenuItem.cs b/osu.Game/Screens/Select/CollectionMenuItem.cs new file mode 100644 index 0000000000..995651de19 --- /dev/null +++ b/osu.Game/Screens/Select/CollectionMenuItem.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Bindables; +using osu.Game.Collections; + +namespace osu.Game.Screens.Select +{ + /// + /// A filter. + /// + public class CollectionMenuItem + { + /// + /// The collection to filter beatmaps from. + /// May be null to not filter by collection (include all beatmaps). + /// + [CanBeNull] + public readonly BeatmapCollection Collection; + + /// + /// The name of the collection. + /// + [NotNull] + public readonly Bindable CollectionName; + + /// + /// Creates a new . + /// + /// The collection to filter beatmaps from. + public CollectionMenuItem([CanBeNull] BeatmapCollection collection) + { + Collection = collection; + CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable("All beatmaps"); + } + } + + public class AllBeatmapsCollectionMenuItem : CollectionMenuItem + { + public AllBeatmapsCollectionMenuItem() + : base(null) + { + } + } + + public class ManageCollectionsMenuItem : CollectionMenuItem + { + public ManageCollectionsMenuItem() + : base(null) + { + CollectionName.Value = "Manage collections..."; + } + } +} diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs index e111ec4b15..9128160608 100644 --- a/osu.Game/Screens/Select/FilterControl.cs +++ b/osu.Game/Screens/Select/FilterControl.cs @@ -21,7 +21,8 @@ namespace osu.Game.Screens.Select { public class FilterControl : Container { - public const float HEIGHT = 100; + public const float HEIGHT = 2 * side_margin + 85; + private const float side_margin = 20; public Action FilterChanged; @@ -41,6 +42,7 @@ namespace osu.Game.Screens.Select Sort = sortMode.Value, AllowConvertedBeatmaps = showConverted.Value, Ruleset = ruleset.Value, + Collection = collectionDropdown?.Current.Value.Collection }; if (!minimumStars.IsDefault) @@ -54,6 +56,7 @@ namespace osu.Game.Screens.Select } private SeekLimitedSearchTextBox searchTextBox; + private CollectionFilterDropdown collectionDropdown; public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); @@ -90,65 +93,104 @@ namespace osu.Game.Screens.Select }, new Container { - Padding = new MarginPadding(20), + Padding = new MarginPadding(side_margin), RelativeSizeAxes = Axes.Both, Width = 0.5f, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Children = new Drawable[] + Child = new GridContainer { - searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X }, - new Box + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - Height = 1, - Colour = OsuColour.Gray(80), - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, + new Dimension(GridSizeMode.Absolute, 60), + new Dimension(GridSizeMode.Absolute, 5), + new Dimension(GridSizeMode.Absolute, 20), }, - new FillFlowContainer + Content = new[] { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Direction = FillDirection.Horizontal, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(OsuTabControl.HORIZONTAL_SPACING, 0), - Children = new Drawable[] + new Drawable[] { - new OsuTabControlCheckbox + new Container { - Text = "Show converted", - Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - sortTabs = new OsuTabControl + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X }, + new Box + { + RelativeSizeAxes = Axes.X, + Height = 1, + Colour = OsuColour.Gray(80), + Origin = Anchor.BottomLeft, + Anchor = Anchor.BottomLeft, + }, + new FillFlowContainer + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Direction = FillDirection.Horizontal, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(OsuTabControl.HORIZONTAL_SPACING, 0), + Children = new Drawable[] + { + new OsuTabControlCheckbox + { + Text = "Show converted", + Current = config.GetBindable(OsuSetting.ShowConvertedBeatmaps), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + sortTabs = new OsuTabControl + { + RelativeSizeAxes = Axes.X, + Width = 0.5f, + Height = 24, + AutoSort = true, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + AccentColour = colours.GreenLight, + Current = { BindTarget = sortMode } + }, + new OsuSpriteText + { + Text = "Sort by", + Font = OsuFont.GetFont(size: 14), + Margin = new MarginPadding(5), + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + }, + } + }, + } + } + }, + null, + new Drawable[] + { + new Container { - RelativeSizeAxes = Axes.X, - Width = 0.5f, - Height = 24, - AutoSort = true, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AccentColour = colours.GreenLight, - Current = { BindTarget = sortMode } - }, - new OsuSpriteText - { - Text = "Sort by", - Font = OsuFont.GetFont(size: 14), - Margin = new MarginPadding(5), - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - }, - } - }, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + collectionDropdown = new CollectionFilterDropdown + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0.4f, + } + } + } + }, + } } } }; - searchTextBox.Current.ValueChanged += _ => FilterChanged?.Invoke(CreateCriteria()); + collectionDropdown.Current.ValueChanged += _ => updateCriteria(); + searchTextBox.Current.ValueChanged += _ => updateCriteria(); updateCriteria(); } @@ -156,7 +198,6 @@ namespace osu.Game.Screens.Select public void Deactivate() { searchTextBox.ReadOnly = true; - searchTextBox.HoldFocus = false; if (searchTextBox.HasFocus) GetContainingInputManager().ChangeFocus(searchTextBox); diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 18be4fcac8..66f164bca8 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Rulesets; using osu.Game.Screens.Select.Filter; @@ -51,6 +53,12 @@ namespace osu.Game.Screens.Select } } + /// + /// The collection to filter beatmaps from. + /// + [CanBeNull] + public BeatmapCollection Collection; + public struct OptionalRange : IEquatable> where T : struct { diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 9fc20fd0f2..a375a17bcf 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -27,6 +27,7 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo.Ruleset = ruleset; BeatmapInfo.RulesetID = ruleset.ID ?? 0; BeatmapInfo.BeatmapSet.Metadata = BeatmapInfo.Metadata; + BeatmapInfo.BeatmapSet.Files = new List(); BeatmapInfo.BeatmapSet.Beatmaps = new List { BeatmapInfo }; BeatmapInfo.BeatmapSet.OnlineInfo = new BeatmapSetOnlineInfo {