1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-07 12:27:25 +08:00

Merge pull request #10079 from smoogipoo/collection-database

This commit is contained in:
Dean Herbert 2020-09-10 18:29:36 +09:00 committed by GitHub
commit aae8b29a71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2159 additions and 77 deletions

View File

@ -0,0 +1,221 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
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<bool> 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));
}
}
}
}

Binary file not shown.

View File

@ -0,0 +1,244 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.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<Drawable> 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<DrawableCollectionListItem>().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<DrawableCollectionListItem>().Last());
InputManager.Click(MouseButton.Left);
});
assertCollectionCount(0);
}
[Test]
public void TestAddCollectionViaPlaceholder()
{
DrawableCollectionListItem placeholderItem = null;
AddStep("focus placeholder", () =>
{
InputManager.MoveMouseTo(placeholderItem = dialog.ChildrenOfType<DrawableCollectionListItem>().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<DrawableCollectionListItem>().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<DrawableCollectionListItem.DeleteButton>().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<DrawableCollectionListItem.DeleteButton>().First(), new Vector2(5, 0));
InputManager.Click(MouseButton.Left);
});
AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog);
AddStep("click confirmation", () =>
{
InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType<PopupDialogButton>().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<DrawableCollectionListItem.DeleteButton>().First(), new Vector2(5, 0));
InputManager.Click(MouseButton.Left);
});
AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog);
AddStep("click cancellation", () =>
{
InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType<PopupDialogButton>().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<TextBox>().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<DrawableCollectionListItem>().Count(i => i.IsCreated.Value) == count);
private void assertCollectionName(int index, string name)
=> AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType<DrawableCollectionListItem>().ElementAt(index).ChildrenOfType<TextBox>().First().Text == name);
}
}

View File

@ -0,0 +1,237 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.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<Drawable> 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<CollectionFilterDropdown>().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<CollectionFilterDropdown.CollectionDropdownHeader>().Single().ChildrenOfType<SpriteText>().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<FillFlowContainer>().OfType<IHasText>().First().Text == collectionName)));
private IconButton getAddOrRemoveButton(int index)
=> getCollectionDropdownItems().ElementAt(index).ChildrenOfType<IconButton>().Single();
private void addExpandHeaderStep() => AddStep("expand header", () =>
{
InputManager.MoveMouseTo(control.ChildrenOfType<CollectionFilterDropdown.CollectionDropdownHeader>().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<Dropdown<CollectionMenuItem>.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems()
=> control.ChildrenOfType<CollectionFilterDropdown>().Single().ChildrenOfType<Dropdown<CollectionMenuItem>.DropdownMenu.DrawableDropdownMenuItem>();
}
}

View File

@ -0,0 +1,47 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
namespace osu.Game.Collections
{
/// <summary>
/// A collection of beatmaps grouped by a name.
/// </summary>
public class BeatmapCollection
{
/// <summary>
/// Invoked whenever any change occurs on this <see cref="BeatmapCollection"/>.
/// </summary>
public event Action Changed;
/// <summary>
/// The collection's name.
/// </summary>
public readonly Bindable<string> Name = new Bindable<string>();
/// <summary>
/// The beatmaps contained by the collection.
/// </summary>
public readonly BindableList<BeatmapInfo> Beatmaps = new BindableList<BeatmapInfo>();
/// <summary>
/// The date when this collection was last modified.
/// </summary>
public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow;
public BeatmapCollection()
{
Beatmaps.CollectionChanged += (_, __) => onChange();
Name.ValueChanged += _ => onChange();
}
private void onChange()
{
LastModifyDate = DateTimeOffset.Now;
Changed?.Invoke();
}
}
}

View File

@ -0,0 +1,303 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
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
{
/// <summary>
/// Handles user-defined collections of beatmaps.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class CollectionManager : Component
{
/// <summary>
/// Database version in stable-compatible YYYYMMDD format.
/// </summary>
private const int database_version = 30000000;
private const string database_name = "collection.db";
public readonly BindableList<BeatmapCollection> Collections = new BindableList<BeatmapCollection>();
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<BeatmapCollection>())
c.Changed += backgroundSave;
break;
case NotifyCollectionChangedAction.Remove:
foreach (var c in e.OldItems.Cast<BeatmapCollection>())
c.Changed -= backgroundSave;
break;
case NotifyCollectionChangedAction.Replace:
foreach (var c in e.OldItems.Cast<BeatmapCollection>())
c.Changed -= backgroundSave;
foreach (var c in e.NewItems.Cast<BeatmapCollection>())
c.Changed += backgroundSave;
break;
}
backgroundSave();
}
/// <summary>
/// Set an endpoint for notifications to be posted to.
/// </summary>
public Action<Notification> PostNotification { protected get; set; }
/// <summary>
/// Set a storage with access to an osu-stable install for import purposes.
/// </summary>
public Func<Storage> GetStableStorage { private get; set; }
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
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<BeatmapCollection> 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<BeatmapCollection> readCollections(Stream stream, ProgressNotification notification = null)
{
if (notification != null)
{
notification.Text = "Reading collections...";
notification.Progress = 0;
}
var result = new List<BeatmapCollection>();
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;
/// <summary>
/// Perform a save with debounce.
/// </summary>
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();
}
}
}

View File

@ -0,0 +1,34 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
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!",
},
};
}
}
}

View File

@ -0,0 +1,122 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osuTK;
namespace osu.Game.Collections
{
/// <summary>
/// Visualises a list of <see cref="BeatmapCollection"/>s.
/// </summary>
public class DrawableCollectionList : OsuRearrangeableListContainer<BeatmapCollection>
{
private Scroll scroll;
protected override ScrollContainer<Drawable> CreateScrollContainer() => scroll = new Scroll();
protected override FillFlowContainer<RearrangeableListItem<BeatmapCollection>> CreateListFillFlowContainer() => new Flow
{
DragActive = { BindTarget = DragActive }
};
protected override OsuRearrangeableListItem<BeatmapCollection> CreateOsuDrawable(BeatmapCollection item)
{
if (item == scroll.PlaceholderItem.Model)
return scroll.ReplacePlaceholder();
return new DrawableCollectionListItem(item, true);
}
/// <summary>
/// The scroll container for this <see cref="DrawableCollectionList"/>.
/// Contains the main flow of <see cref="DrawableCollectionListItem"/> and attaches a placeholder item to the end of the list.
/// </summary>
/// <remarks>
/// Use <see cref="ReplacePlaceholder"/> to transfer the placeholder into the main list.
/// </remarks>
private class Scroll : OsuScrollContainer
{
/// <summary>
/// The currently-displayed placeholder item.
/// </summary>
public DrawableCollectionListItem PlaceholderItem { get; private set; }
protected override Container<Drawable> Content => content;
private readonly Container content;
private readonly Container<DrawableCollectionListItem> 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<DrawableCollectionListItem>
{
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);
}
/// <summary>
/// Replaces the current <see cref="PlaceholderItem"/> with a new one, and returns the previous.
/// </summary>
/// <returns>The current <see cref="PlaceholderItem"/>.</returns>
public DrawableCollectionListItem ReplacePlaceholder()
{
var previous = PlaceholderItem;
placeholderContainer.Clear(false);
placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false));
return previous;
}
}
/// <summary>
/// The flow of <see cref="DrawableCollectionListItem"/>. Disables layout easing unless a drag is in progress.
/// </summary>
private class Flow : FillFlowContainer<RearrangeableListItem<BeatmapCollection>>
{
public readonly IBindable<bool> DragActive = new Bindable<bool>();
public Flow()
{
Spacing = new Vector2(0, 5);
LayoutEasing = Easing.OutQuint;
}
protected override void LoadComplete()
{
base.LoadComplete();
DragActive.BindValueChanged(active => LayoutDuration = active.NewValue ? 200 : 0);
}
}
}
}

View File

@ -0,0 +1,237 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
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
{
/// <summary>
/// Visualises a <see cref="BeatmapCollection"/> inside a <see cref="DrawableCollectionList"/>.
/// </summary>
public class DrawableCollectionListItem : OsuRearrangeableListItem<BeatmapCollection>
{
private const float item_height = 35;
private const float button_width = item_height * 0.75f;
/// <summary>
/// Whether the <see cref="BeatmapCollection"/> currently exists inside the <see cref="CollectionManager"/>.
/// </summary>
public IBindable<bool> IsCreated => isCreated;
private readonly Bindable<bool> isCreated = new Bindable<bool>();
/// <summary>
/// Creates a new <see cref="DrawableCollectionListItem"/>.
/// </summary>
/// <param name="item">The <see cref="BeatmapCollection"/>.</param>
/// <param name="isCreated">Whether <paramref name="item"/> currently exists inside the <see cref="CollectionManager"/>.</param>
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 }
};
/// <summary>
/// The main content of the <see cref="DrawableCollectionListItem"/>.
/// </summary>
private class ItemContent : CircularContainer
{
public readonly Bindable<bool> IsCreated = new Bindable<bool>();
private readonly IBindable<string> 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<bool> IsCreated = new Bindable<bool>();
public Func<Vector2, bool> 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);
}
}
}

View File

@ -0,0 +1,134 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using 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<BeatmapCollection>() }
}
}
}
},
}
}
}
};
}
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);
}
}
}

View File

@ -12,13 +12,13 @@ namespace osu.Game.Graphics.Containers
/// <summary> /// <summary>
/// Whether any item is currently being dragged. Used to hide other items' drag handles. /// Whether any item is currently being dragged. Used to hide other items' drag handles.
/// </summary> /// </summary>
private readonly BindableBool playlistDragActive = new BindableBool(); protected readonly BindableBool DragActive = new BindableBool();
protected override ScrollContainer<Drawable> CreateScrollContainer() => new OsuScrollContainer(); protected override ScrollContainer<Drawable> CreateScrollContainer() => new OsuScrollContainer();
protected sealed override RearrangeableListItem<TModel> CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d => protected sealed override RearrangeableListItem<TModel> CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d =>
{ {
d.PlaylistDragActive.BindTo(playlistDragActive); d.DragActive.BindTo(DragActive);
}); });
protected abstract OsuRearrangeableListItem<TModel> CreateOsuDrawable(TModel item); protected abstract OsuRearrangeableListItem<TModel> CreateOsuDrawable(TModel item);

View File

@ -19,7 +19,7 @@ namespace osu.Game.Graphics.Containers
/// <summary> /// <summary>
/// Whether any item is currently being dragged. Used to hide other items' drag handles. /// Whether any item is currently being dragged. Used to hide other items' drag handles.
/// </summary> /// </summary>
public readonly BindableBool PlaylistDragActive = new BindableBool(); public readonly BindableBool DragActive = new BindableBool();
private Color4 handleColour = Color4.White; private Color4 handleColour = Color4.White;
@ -44,8 +44,9 @@ namespace osu.Game.Graphics.Containers
/// <summary> /// <summary>
/// Whether the drag handle should be shown. /// Whether the drag handle should be shown.
/// </summary> /// </summary>
protected virtual bool ShowDragHandle => true; protected readonly Bindable<bool> ShowDragHandle = new Bindable<bool>();
private Container handleContainer;
private PlaylistItemHandle handle; private PlaylistItemHandle handle;
protected OsuRearrangeableListItem(TModel item) protected OsuRearrangeableListItem(TModel item)
@ -58,8 +59,6 @@ namespace osu.Game.Graphics.Containers
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
Container handleContainer;
InternalChild = new GridContainer InternalChild = new GridContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
@ -88,9 +87,12 @@ namespace osu.Game.Graphics.Containers
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }
}; };
}
if (!ShowDragHandle) protected override void LoadComplete()
handleContainer.Alpha = 0; {
base.LoadComplete();
ShowDragHandle.BindValueChanged(show => handleContainer.Alpha = show.NewValue ? 1 : 0, true);
} }
protected override bool OnDragStart(DragStartEvent e) protected override bool OnDragStart(DragStartEvent e)
@ -98,13 +100,13 @@ namespace osu.Game.Graphics.Containers
if (!base.OnDragStart(e)) if (!base.OnDragStart(e))
return false; return false;
PlaylistDragActive.Value = true; DragActive.Value = true;
return true; return true;
} }
protected override void OnDragEnd(DragEndEvent e) protected override void OnDragEnd(DragEndEvent e)
{ {
PlaylistDragActive.Value = false; DragActive.Value = false;
base.OnDragEnd(e); base.OnDragEnd(e);
} }
@ -112,7 +114,7 @@ namespace osu.Game.Graphics.Containers
protected override bool OnHover(HoverEvent e) protected override bool OnHover(HoverEvent e)
{ {
handle.UpdateHoverState(IsDragged || !PlaylistDragActive.Value); handle.UpdateHoverState(IsDragged || !DragActive.Value);
return base.OnHover(e); return base.OnHover(e);
} }

View File

@ -24,7 +24,7 @@ namespace osu.Game.Graphics.UserInterface
set set
{ {
iconColour = value; iconColour = value;
icon.Colour = value; icon.FadeColour(value);
} }
} }

View File

@ -26,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface
}; };
ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL }; ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL };
MaxHeight = 250;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]

View File

@ -31,6 +31,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Threading; using osu.Framework.Threading;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -610,12 +611,19 @@ namespace osu.Game
d.Origin = Anchor.TopRight; d.Origin = Anchor.TopRight;
}), rightFloatingOverlayContent.Add, true); }), rightFloatingOverlayContent.Add, true);
loadComponentSingleFile(new CollectionManager(Storage)
{
PostNotification = n => notifications.Post(n),
GetStableStorage = GetStorageForStableInstall
}, Add, true);
loadComponentSingleFile(screenshotManager, Add); loadComponentSingleFile(screenshotManager, Add);
// dependency on notification overlay, dependent by settings overlay // dependency on notification overlay, dependent by settings overlay
loadComponentSingleFile(CreateUpdateManager(), Add, true); loadComponentSingleFile(CreateUpdateManager(), Add, true);
// overlay elements // overlay elements
loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true);
loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true);
loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true);
loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true); loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true);

View File

@ -3,9 +3,11 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -19,14 +21,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
private TriangleButton importBeatmapsButton; private TriangleButton importBeatmapsButton;
private TriangleButton importScoresButton; private TriangleButton importScoresButton;
private TriangleButton importSkinsButton; private TriangleButton importSkinsButton;
private TriangleButton importCollectionsButton;
private TriangleButton deleteBeatmapsButton; private TriangleButton deleteBeatmapsButton;
private TriangleButton deleteScoresButton; private TriangleButton deleteScoresButton;
private TriangleButton deleteSkinsButton; private TriangleButton deleteSkinsButton;
private TriangleButton restoreButton; private TriangleButton restoreButton;
private TriangleButton undeleteButton; private TriangleButton undeleteButton;
[BackgroundDependencyLoader] [BackgroundDependencyLoader(permitNulls: true)]
private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, DialogOverlay dialogOverlay) private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay)
{ {
if (beatmaps.SupportsImportFromStable) 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 = () => Action = () =>
{ {
dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() => dialogOverlay?.Push(new DeleteAllBeatmapsDialog(collectionManager.DeleteAll));
{
deleteSkinsButton.Enabled.Value = false;
Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
}));
} }
}, });
}
AddRange(new Drawable[]
{
restoreButton = new SettingsButton restoreButton = new SettingsButton
{ {
Text = "Restore all hidden difficulties", Text = "Restore all hidden difficulties",

View File

@ -37,8 +37,6 @@ namespace osu.Game.Screens.Multi
public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>(); public readonly Bindable<PlaylistItem> SelectedItem = new Bindable<PlaylistItem>();
protected override bool ShowDragHandle => allowEdit;
private Container maskingContainer; private Container maskingContainer;
private Container difficultyIconContainer; private Container difficultyIconContainer;
private LinkFlowContainer beatmapText; private LinkFlowContainer beatmapText;
@ -63,12 +61,13 @@ namespace osu.Game.Screens.Multi
// TODO: edit support should be moved out into a derived class // TODO: edit support should be moved out into a derived class
this.allowEdit = allowEdit; this.allowEdit = allowEdit;
this.allowSelection = allowSelection; this.allowSelection = allowSelection;
beatmap.BindTo(item.Beatmap); beatmap.BindTo(item.Beatmap);
ruleset.BindTo(item.Ruleset); ruleset.BindTo(item.Ruleset);
requiredMods.BindTo(item.RequiredMods); requiredMods.BindTo(item.RequiredMods);
ShowDragHandle.Value = allowEdit;
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]

View File

@ -60,6 +60,9 @@ namespace osu.Game.Screens.Select.Carousel
match &= terms.Any(term => term.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0); match &= terms.Any(term => term.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0);
} }
if (match)
match &= criteria.Collection?.Beatmaps.Contains(Beatmap) ?? true;
Filtered.Value = !match; Filtered.Value = !match;
} }

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -17,6 +18,7 @@ using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables;
using osu.Game.Collections;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
@ -46,6 +48,12 @@ namespace osu.Game.Screens.Select.Carousel
[Resolved] [Resolved]
private BeatmapDifficultyManager difficultyManager { get; set; } private BeatmapDifficultyManager difficultyManager { get; set; }
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog manageCollectionsDialog { get; set; }
private IBindable<StarDifficulty> starDifficultyBindable; private IBindable<StarDifficulty> starDifficultyBindable;
private CancellationTokenSource starDifficultyCancellationSource; private CancellationTokenSource starDifficultyCancellationSource;
@ -213,16 +221,39 @@ namespace osu.Game.Screens.Select.Carousel
if (editRequested != null) if (editRequested != null)
items.Add(new OsuMenuItem("Edit", MenuItemType.Standard, () => editRequested(beatmap))); 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) if (hideRequested != null)
items.Add(new OsuMenuItem("Hide", MenuItemType.Destructive, () => hideRequested(beatmap))); 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(); 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) protected override void Dispose(bool isDisposing)
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);

View File

@ -16,6 +16,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables;
using osu.Game.Collections;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
@ -34,6 +35,12 @@ namespace osu.Game.Screens.Select.Carousel
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private DialogOverlay dialogOverlay { get; set; } 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; private readonly BeatmapSetInfo beatmapSet;
public DrawableCarouselBeatmapSet(CarouselBeatmapSet set) public DrawableCarouselBeatmapSet(CarouselBeatmapSet set)
@ -135,16 +142,61 @@ namespace osu.Game.Screens.Select.Carousel
if (beatmapSet.OnlineBeatmapSetID != null && viewDetails != null) if (beatmapSet.OnlineBeatmapSetID != null && viewDetails != null)
items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineBeatmapSetID.Value))); 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)) if (beatmapSet.Beatmaps.Any(b => b.Hidden))
items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet))); items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet)));
if (dialogOverlay != null) 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(); 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 private class PanelBackground : BufferedContainer
{ {
public PanelBackground(WorkingBeatmap working) public PanelBackground(WorkingBeatmap working)

View File

@ -0,0 +1,273 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.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
{
/// <summary>
/// A dropdown to select the <see cref="CollectionMenuItem"/> to filter beatmaps using.
/// </summary>
public class CollectionFilterDropdown : OsuDropdown<CollectionMenuItem>
{
private readonly IBindableList<BeatmapCollection> collections = new BindableList<BeatmapCollection>();
private readonly IBindableList<BeatmapInfo> beatmaps = new BindableList<BeatmapInfo>();
private readonly BindableList<CollectionMenuItem> filters = new BindableList<CollectionMenuItem>();
[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);
}
/// <summary>
/// Occurs when a collection has been added or removed.
/// </summary>
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];
}
/// <summary>
/// Occurs when the <see cref="CollectionMenuItem"/> selection has changed.
/// </summary>
private void filterChanged(ValueChangedEvent<CollectionMenuItem> 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();
}
}
/// <summary>
/// Occurs when the beatmaps contained by a <see cref="BeatmapCollection"/> have changed.
/// </summary>
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<CollectionMenuItem> SelectedItem = new Bindable<CollectionMenuItem>();
private readonly Bindable<string> collectionName = new Bindable<string>();
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<CollectionMenuItem>)base.Item).Value;
[Resolved]
private OsuColour colours { get; set; }
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; }
[CanBeNull]
private readonly BindableList<BeatmapInfo> collectionBeatmaps;
[NotNull]
private readonly Bindable<string> 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();
}
}
}

View File

@ -0,0 +1,55 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Game.Collections;
namespace osu.Game.Screens.Select
{
/// <summary>
/// A <see cref="BeatmapCollection"/> filter.
/// </summary>
public class CollectionMenuItem
{
/// <summary>
/// The collection to filter beatmaps from.
/// May be null to not filter by collection (include all beatmaps).
/// </summary>
[CanBeNull]
public readonly BeatmapCollection Collection;
/// <summary>
/// The name of the collection.
/// </summary>
[NotNull]
public readonly Bindable<string> CollectionName;
/// <summary>
/// Creates a new <see cref="CollectionMenuItem"/>.
/// </summary>
/// <param name="collection">The collection to filter beatmaps from.</param>
public CollectionMenuItem([CanBeNull] BeatmapCollection collection)
{
Collection = collection;
CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable<string>("All beatmaps");
}
}
public class AllBeatmapsCollectionMenuItem : CollectionMenuItem
{
public AllBeatmapsCollectionMenuItem()
: base(null)
{
}
}
public class ManageCollectionsMenuItem : CollectionMenuItem
{
public ManageCollectionsMenuItem()
: base(null)
{
CollectionName.Value = "Manage collections...";
}
}
}

View File

@ -21,7 +21,8 @@ namespace osu.Game.Screens.Select
{ {
public class FilterControl : Container 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<FilterCriteria> FilterChanged; public Action<FilterCriteria> FilterChanged;
@ -41,6 +42,7 @@ namespace osu.Game.Screens.Select
Sort = sortMode.Value, Sort = sortMode.Value,
AllowConvertedBeatmaps = showConverted.Value, AllowConvertedBeatmaps = showConverted.Value,
Ruleset = ruleset.Value, Ruleset = ruleset.Value,
Collection = collectionDropdown?.Current.Value.Collection
}; };
if (!minimumStars.IsDefault) if (!minimumStars.IsDefault)
@ -54,6 +56,7 @@ namespace osu.Game.Screens.Select
} }
private SeekLimitedSearchTextBox searchTextBox; private SeekLimitedSearchTextBox searchTextBox;
private CollectionFilterDropdown collectionDropdown;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos); base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos);
@ -90,65 +93,104 @@ namespace osu.Game.Screens.Select
}, },
new Container new Container
{ {
Padding = new MarginPadding(20), Padding = new MarginPadding(side_margin),
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Width = 0.5f, Width = 0.5f,
Anchor = Anchor.TopRight, Anchor = Anchor.TopRight,
Origin = Anchor.TopRight, Origin = Anchor.TopRight,
Children = new Drawable[] Child = new GridContainer
{ {
searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X }, RelativeSizeAxes = Axes.Both,
new Box RowDimensions = new[]
{ {
RelativeSizeAxes = Axes.X, new Dimension(GridSizeMode.Absolute, 60),
Height = 1, new Dimension(GridSizeMode.Absolute, 5),
Colour = OsuColour.Gray(80), new Dimension(GridSizeMode.Absolute, 20),
Origin = Anchor.BottomLeft,
Anchor = Anchor.BottomLeft,
}, },
new FillFlowContainer Content = new[]
{ {
Anchor = Anchor.BottomRight, new Drawable[]
Origin = Anchor.BottomRight,
Direction = FillDirection.Horizontal,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Spacing = new Vector2(OsuTabControl<SortMode>.HORIZONTAL_SPACING, 0),
Children = new Drawable[]
{ {
new OsuTabControlCheckbox new Container
{ {
Text = "Show converted", RelativeSizeAxes = Axes.Both,
Current = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps), Children = new Drawable[]
Anchor = Anchor.BottomRight, {
Origin = Anchor.BottomRight, searchTextBox = new SeekLimitedSearchTextBox { RelativeSizeAxes = Axes.X },
}, new Box
sortTabs = new OsuTabControl<SortMode> {
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<SortMode>.HORIZONTAL_SPACING, 0),
Children = new Drawable[]
{
new OsuTabControlCheckbox
{
Text = "Show converted",
Current = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps),
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
sortTabs = new OsuTabControl<SortMode>
{
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, RelativeSizeAxes = Axes.Both,
Width = 0.5f, Children = new Drawable[]
Height = 24, {
AutoSort = true, collectionDropdown = new CollectionFilterDropdown
Anchor = Anchor.BottomRight, {
Origin = Anchor.BottomRight, Anchor = Anchor.TopRight,
AccentColour = colours.GreenLight, Origin = Anchor.TopRight,
Current = { BindTarget = sortMode } RelativeSizeAxes = Axes.X,
}, Width = 0.4f,
new OsuSpriteText }
{ }
Text = "Sort by", }
Font = OsuFont.GetFont(size: 14), },
Margin = new MarginPadding(5), }
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
},
}
},
} }
} }
}; };
searchTextBox.Current.ValueChanged += _ => FilterChanged?.Invoke(CreateCriteria()); collectionDropdown.Current.ValueChanged += _ => updateCriteria();
searchTextBox.Current.ValueChanged += _ => updateCriteria();
updateCriteria(); updateCriteria();
} }
@ -156,7 +198,6 @@ namespace osu.Game.Screens.Select
public void Deactivate() public void Deactivate()
{ {
searchTextBox.ReadOnly = true; searchTextBox.ReadOnly = true;
searchTextBox.HoldFocus = false; searchTextBox.HoldFocus = false;
if (searchTextBox.HasFocus) if (searchTextBox.HasFocus)
GetContainingInputManager().ChangeFocus(searchTextBox); GetContainingInputManager().ChangeFocus(searchTextBox);

View File

@ -4,7 +4,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Screens.Select.Filter; using osu.Game.Screens.Select.Filter;
@ -51,6 +53,12 @@ namespace osu.Game.Screens.Select
} }
} }
/// <summary>
/// The collection to filter beatmaps from.
/// </summary>
[CanBeNull]
public BeatmapCollection Collection;
public struct OptionalRange<T> : IEquatable<OptionalRange<T>> public struct OptionalRange<T> : IEquatable<OptionalRange<T>>
where T : struct where T : struct
{ {