diff --git a/osu.Game/Collections/BatchAddToCollectionHandler.cs b/osu.Game/Collections/BatchAddToCollectionHandler.cs new file mode 100644 index 0000000000..63c7ac8a9e --- /dev/null +++ b/osu.Game/Collections/BatchAddToCollectionHandler.cs @@ -0,0 +1,138 @@ +// 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.Linq; +using System.Threading.Tasks; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.LAsEzExtensions.Localization; +using osu.Game.Overlays.Dialog; + +namespace osu.Game.Collections +{ + internal static partial class BatchAddToCollectionHandler + { + public static void RequestSaveToCollection( + Live collection, + Func>? filteredBeatmapsProvider, + Action showDialog) + { + if (filteredBeatmapsProvider == null) + return; + + var hashes = filteredBeatmapsProvider().Select(b => b.MD5Hash) + .Where(h => !string.IsNullOrEmpty(h)) + .Distinct() + .ToList(); + + if (hashes.Count == 0) + return; + + var existing = collection.PerformRead(c => c.BeatmapMD5Hashes.ToList()); + var intersection = existing.Intersect(hashes).ToList(); + int overlapCount = intersection.Count; + + if (overlapCount == hashes.Count) + { + showDialog(new RemoveFilteredResultsDialog( + onRemove: () => runHashRemoval(collection, intersection))); + return; + } + + if (overlapCount > 0) + { + var toAdd = hashes.Except(existing).ToList(); + var toRemove = intersection; + + showDialog(new PartialOverlapFilteredResultsDialog( + overlapCount, + onAddDifference: () => runHashAddition(collection, toAdd), + onRemoveIntersection: () => runHashRemoval(collection, toRemove))); + return; + } + + string collectionName = collection.PerformRead(c => c.Name); + + showDialog(new AddFilteredResultsDialog( + collectionName, + hashes.Count, + onAddAll: () => runHashAddition(collection, hashes))); + } + + private static void runHashAddition(Live collection, IReadOnlyList hashes) + { + if (hashes.Count == 0) + return; + + Task.Run(() => collection.PerformWrite(c => + { + foreach (string hash in hashes) + { + if (!c.BeatmapMD5Hashes.Contains(hash)) + c.BeatmapMD5Hashes.Add(hash); + } + })); + } + + private static void runHashRemoval(Live collection, IReadOnlyList hashes) + { + Task.Run(() => collection.PerformWrite(c => + { + foreach (string hash in hashes) + c.BeatmapMD5Hashes.Remove(hash); + })); + } + + private partial class AddFilteredResultsDialog : DangerousActionDialog + { + public AddFilteredResultsDialog(string collectionName, int beatmapCount, Action onAddAll) + { + Icon = FontAwesome.Solid.Check; + HeaderText = EzSongSelectStrings.SAVE_TO_COLLECTION; + BodyText = $"Add {beatmapCount:#,0} beatmaps to \"{collectionName}\"?"; + DangerousAction = onAddAll; + } + } + + private partial class RemoveFilteredResultsDialog : DangerousActionDialog + { + public RemoveFilteredResultsDialog(Action onRemove) + { + Icon = FontAwesome.Solid.Trash; + HeaderText = EzSongSelectStrings.REMOVE_FROM_COLLECTION; + BodyText = EzSongSelectStrings.REMOVE_FROM_COLLECTION_TOOLTIP; + DangerousAction = onRemove; + } + } + + private partial class PartialOverlapFilteredResultsDialog : DangerousActionDialog + { + public PartialOverlapFilteredResultsDialog(int overlapCount, Action onAddDifference, Action onRemoveIntersection) + { + Icon = FontAwesome.Solid.Question; + HeaderText = EzSongSelectStrings.PARTIALLY_OVERLAPPED; + BodyText = $"{overlapCount} {EzSongSelectStrings.SELECT_ACTION_FOR_OVERLAP}"; + Buttons = new PopupDialogButton[] + { + new PopupDialogDangerousButton + { + Text = EzSongSelectStrings.ADD_DIFFERENCE, + Action = onAddDifference, + }, + new PopupDialogDangerousButton + { + Text = EzSongSelectStrings.REMOVE_FROM_COLLECTION, + Action = onRemoveIntersection, + }, + new PopupDialogCancelButton + { + Text = "Cancel" + } + }; + } + } + } +} diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 5df746459c..7bc62751e4 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -92,10 +92,19 @@ namespace osu.Game.Collections IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v) } : Empty(), + collection.IsManaged + ? new BatchAddToCollectionButton(collection) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -button_width, + IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v), + } + : Empty(), new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 }, + Padding = new MarginPadding { Right = collection.IsManaged ? button_width * 2 : 0 }, Children = new Drawable[] { TextBox = new ItemTextBox(collection) @@ -201,7 +210,7 @@ namespace osu.Game.Collections this.collection = collection; RelativeSizeAxes = Axes.Y; - Width = button_width + item_height / 2; // add corner radius to cover with fill + Width = button_width + item_height / 4; // add corner radius to cover with fill } [BackgroundDependencyLoader] @@ -238,7 +247,14 @@ namespace osu.Game.Collections }; } - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + if (!base.ReceivePositionalInputAt(screenSpacePos) || IsTextBoxHovered(screenSpacePos)) + return false; + + float localX = ToLocalSpace(screenSpacePos).X; + return localX > item_height / 4; + } protected override bool OnHover(HoverEvent e) { @@ -261,6 +277,77 @@ namespace osu.Game.Collections private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); } + public partial class BatchAddToCollectionButton : OsuClickableContainer + { + public Func IsTextBoxHovered = null!; + + private readonly Live collection; + private Color4 darkenedColour; + private Color4 normalColour; + + private Drawable background = null!; + + public BatchAddToCollectionButton(Live collection) + { + this.collection = collection; + + RelativeSizeAxes = Axes.Y; + Width = button_width + item_height / 4; + TooltipText = "Add all visible beatmaps to collection"; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + normalColour = colours.Yellow; + darkenedColour = normalColour.Darken(0.9f); + + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = darkenedColour, + }, + new SpriteIcon + { + Anchor = Anchor.CentreRight, + Origin = Anchor.Centre, + X = -button_width * 0.6f, + Size = new Vector2(10), + Icon = FontAwesome.Solid.Plus, + } + } + }; + + Action = () => this.FindClosestParent()?.RequestBatchAddToCollection(collection); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos); + + protected override bool OnHover(HoverEvent e) + { + background.FadeColour(normalColour, 100, Easing.Out); + return false; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeColour(darkenedColour, 100, Easing.Out); + } + + protected override bool OnClick(ClickEvent e) + { + background.FlashColour(Color4.White, 150); + return base.OnClick(e); + } + } + public IEnumerable FilterTerms => Model.PerformRead(m => m.IsValid ? new[] { (LocalisableString)m.Name } : []); private bool matchingFilter = true; diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 9989a7aaab..c67cbf3042 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -3,30 +3,21 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; 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.Framework.Localisation; -using osu.Game.Database; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.LAsEzExtensions.Localization; using osu.Game.Localisation; using osu.Game.Overlays; -using osu.Game.Overlays.Dialog; using osu.Game.Resources.Localisation.Web; using osuTK; -using Realms; -using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings; namespace osu.Game.Collections { @@ -42,27 +33,8 @@ namespace osu.Game.Collections private BasicSearchTextBox searchTextBox = null!; private DrawableCollectionList list = null!; - private SaveToCollectionDropdown saveToCollectionDropdown = null!; - private RoundedButton saveFilteredResultsButton = null!; - private readonly BindableList saveToCollectionItems = new BindableList(); - - private IDisposable? saveCollectionsSubscription; - - private Func>? filteredBeatmapsProvider; - - public Func>? FilteredBeatmapsProvider - { - get => filteredBeatmapsProvider; - set - { - filteredBeatmapsProvider = value; - updateSaveButtonState(); - } - } - - [Resolved] - private RealmAccess realm { get; set; } = null!; + public Func>? FilteredBeatmapsProvider { get; set; } [Resolved] private MusicController? musicController { get; set; } @@ -154,13 +126,12 @@ namespace osu.Game.Collections { list = new DrawableCollectionList { - Padding = new MarginPadding { Top = 90, Bottom = 50 }, + Padding = new MarginPadding { Vertical = 50 }, RelativeSizeAxes = Axes.Both, }, searchTextBox = new BasicSearchTextBox { RelativeSizeAxes = Axes.X, - Y = 45, Height = 40, ReleaseFocusOnCommit = false, HoldFocus = true, @@ -177,45 +148,6 @@ namespace osu.Game.Collections new NewCollectionEntryItem() } }, - new GridContainer - { - RelativeSizeAxes = Axes.X, - Height = 40, - ColumnDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension(GridSizeMode.AutoSize), - }, - Content = new[] - { - new[] - { - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Text = EzSongSelectStrings.SAVE_TO_COLLECTION, - Font = OsuFont.GetFont(size: 16), - }, - Empty(), - saveToCollectionDropdown = new SaveToCollectionDropdown - { - RelativeSizeAxes = Axes.X, - }, - Empty(), - saveFilteredResultsButton = new RoundedButton - { - Width = 90, - Height = 40, - Text = "Save", - Action = saveFilteredResults, - }, - } - } - } } }, } @@ -235,18 +167,12 @@ namespace osu.Game.Collections { list.SearchTerm = searchTextBox.Current.Value; }); - - saveToCollectionDropdown.ItemSource = saveToCollectionItems; - saveToCollectionDropdown.Current.BindValueChanged(_ => updateSaveButtonState(), true); - - saveCollectionsSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), saveCollectionsChanged); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); duckOperation?.Dispose(); - saveCollectionsSubscription?.Dispose(); } protected override void PopIn() @@ -300,145 +226,12 @@ namespace osu.Game.Collections } } - private void saveCollectionsChanged(IRealmCollection collections, ChangeSet? changes) + internal void RequestBatchAddToCollection(Live collection) { - var selectedId = saveToCollectionDropdown.Current.Value?.Collection?.ID; - - saveToCollectionItems.Clear(); - saveToCollectionItems.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm)))); - - if (saveToCollectionItems.Count > 0) - { - var selectedItem = saveToCollectionItems.FirstOrDefault(i => i.Collection?.ID == selectedId) ?? saveToCollectionItems[0]; - saveToCollectionDropdown.Current.Value = selectedItem; - } - - updateSaveButtonState(); - } - - private void updateSaveButtonState() - { - var currentId = saveToCollectionDropdown.Current.Value?.Collection?.ID; - bool hasTarget = currentId != null && saveToCollectionItems.Any(item => item.Collection?.ID == currentId); - bool hasProvider = FilteredBeatmapsProvider != null; - saveFilteredResultsButton.Enabled.Value = hasTarget && hasProvider; - } - - private void saveFilteredResults() - { - var provider = FilteredBeatmapsProvider; - var collection = saveToCollectionDropdown.Current.Value?.Collection; - - if (provider == null || collection == null) - return; - - var hashes = provider().Select(b => b.MD5Hash) - .Where(h => !string.IsNullOrEmpty(h)) - .Distinct() - .ToList(); - - if (hashes.Count == 0) - return; - - // Read existing entries from the collection to determine overlap. - var existing = collection.PerformRead(c => c.BeatmapMD5Hashes.ToList()); - var intersection = existing.Intersect(hashes).ToList(); - - // Case 1: collection already contains all filtered results -> offer bulk remove. - if (intersection.Count == hashes.Count) - { - Schedule(() => dialogOverlay?.Push(new SimplePopupDialog( - FontAwesome.Solid.Trash, - EzSongSelectStrings.REMOVE_FROM_COLLECTION, - EzSongSelectStrings.REMOVE_FROM_COLLECTION_TOOLTIP, - new PopupDialogButton[] - { - new PopupDialogCancelButton { Text = CommonStrings.ButtonsCancel }, - new PopupDialogDangerousButton - { - Text = AccountsStrings.UserTotpButtonRemove, - Action = () => Task.Run(() => collection.PerformWrite(c => - { - foreach (string h in intersection) - c.BeatmapMD5Hashes.Remove(h); - })) - } - }))); - - return; - } - - // Case 2: partial overlap -> let user choose to add remaining or remove overlapping parts. - if (intersection.Count > 0) - { - var toAdd = hashes.Except(existing).ToList(); - var toRemove = intersection; - - Schedule(() => dialogOverlay?.Push(new SimplePopupDialog( - FontAwesome.Solid.Question, - EzSongSelectStrings.PARTIALLY_OVERLAPPED, - $"{intersection.Count}{EzSongSelectStrings.SELECT_ACTION_FOR_OVERLAP}", - new PopupDialogButton[] - { - new PopupDialogCancelButton { Text = CommonStrings.ButtonsCancel }, - new PopupDialogOkButton - { - Text = EzSongSelectStrings.ADD_DIFFERENCE, - Action = () => - { - if (toAdd.Count == 0) return; - - Task.Run(() => collection.PerformWrite(c => - { - foreach (string h in toAdd) - { - if (!c.BeatmapMD5Hashes.Contains(h)) - c.BeatmapMD5Hashes.Add(h); - } - })); - } - }, - new PopupDialogDangerousButton - { - Text = EzSongSelectStrings.REMOVE_INTERSECTION, - Action = () => Task.Run(() => collection.PerformWrite(c => - { - foreach (string h in toRemove) - c.BeatmapMD5Hashes.Remove(h); - })) - } - }))); - - return; - } - - Task.Run(() => collection.PerformWrite(c => - { - foreach (string hash in hashes) - { - if (!c.BeatmapMD5Hashes.Contains(hash)) - c.BeatmapMD5Hashes.Add(hash); - } - })); - } - - private partial class SaveToCollectionDropdown : OsuDropdown - { - protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName; - } - - /// - /// A minimal concrete used for inline confirmation prompts in this dialog. - /// - private partial class SimplePopupDialog : PopupDialog - { - public SimplePopupDialog(IconUsage icon, LocalisableString header, LocalisableString body, PopupDialogButton[] buttons) - { - Icon = icon; - HeaderText = header; - BodyText = body; - Buttons = buttons; - } + BatchAddToCollectionHandler.RequestSaveToCollection( + collection, + FilteredBeatmapsProvider, + dialog => Schedule(() => dialogOverlay?.Push(dialog))); } } } diff --git a/osu.Game/LAsEzExtensions/Localization/EzSongSelectStrings.cs b/osu.Game/LAsEzExtensions/Localization/EzSongSelectStrings.cs index ee194a56a6..136fe7445c 100644 --- a/osu.Game/LAsEzExtensions/Localization/EzSongSelectStrings.cs +++ b/osu.Game/LAsEzExtensions/Localization/EzSongSelectStrings.cs @@ -8,8 +8,8 @@ namespace osu.Game.LAsEzExtensions.Localization public static class EzSongSelectStrings { public static readonly EzLocalizationManager.EzLocalisableString SAVE_TO_COLLECTION = new EzLocalizationManager.EzLocalisableString( - "将当前过滤结果保存到", - "Save current filter result to"); + "将当前过滤结果保存到收藏夹", + "Add all visible beatmaps to collection"); public static readonly EzLocalizationManager.EzLocalisableString REMOVE_FROM_COLLECTION = new EzLocalizationManager.EzLocalisableString( "从收藏夹移除当前过滤结果", @@ -21,7 +21,7 @@ namespace osu.Game.LAsEzExtensions.Localization public static readonly EzLocalizationManager.EzLocalisableString PARTIALLY_OVERLAPPED = new EzLocalizationManager.EzLocalisableString( "筛选结果与收藏夹部分重", - "The filter result is partially overlapped with the collection"); + "The visible beatmaps is partially overlapped with the collection"); public static readonly EzLocalizationManager.EzLocalisableString SELECT_ACTION_FOR_OVERLAP = new EzLocalizationManager.EzLocalisableString( " 个谱面已存在。请选择要执行的操作:", diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 7ed3bc6d01..22083dfe5f 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -7,8 +7,11 @@ using System.Collections.Immutable; using System.Linq; 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; using osu.Framework.Input.Events; using osu.Framework.Localisation; @@ -16,6 +19,7 @@ using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -31,6 +35,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osuTK; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Screens.SelectV2 @@ -66,6 +71,9 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private ISongSelect? songSelect { get; set; } + [Resolved(CanBeNull = true)] + private ManageCollectionsDialog? manageCollectionsDialog { get; set; } + [Resolved] private IBindable ruleset { get; set; } = null!; @@ -316,7 +324,19 @@ namespace osu.Game.Screens.SelectV2 updateCriteria(); }); - searchTextBox.Current.BindValueChanged(_ => updateCriteria()); + searchTextBox.Current.BindValueChanged(_ => + { + updateCriteria(); + updateVisibleResultsActionAvailability(); + }); + + searchTextBox.VisibleResultsAction = () => + { + if (manageCollectionsDialog == null) + return; + + manageCollectionsDialog.Show(); + }; difficultyRangeSlider.LowerBound.BindValueChanged(_ => updateCriteria()); difficultyRangeSlider.UpperBound.BindValueChanged(_ => updateCriteria()); showConvertedBeatmapsButton.Active.BindValueChanged(_ => updateCriteria()); @@ -344,9 +364,16 @@ namespace osu.Game.Screens.SelectV2 csSelector.Current.BindValueChanged(_ => updateCriteria()); xxySrFilterButton.Active.BindValueChanged(_ => updateCriteria()); + updateVisibleResultsActionAvailability(); updateCriteria(); } + private void updateVisibleResultsActionAvailability() + { + bool available = !string.IsNullOrWhiteSpace(searchTextBox.Current.Value); + searchTextBox.UpdateVisibleResultsActionAvailability(available); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); @@ -460,6 +487,29 @@ namespace osu.Game.Screens.SelectV2 { public IBindable ScopedBeatmapSet { get; } = new Bindable(); + public Action? VisibleResultsAction { get; set; } + + private readonly BatchAddToCollectionButton batchAddToCollectionButton; + + public SongSelectSearchTextBox() + { + AddInternal(batchAddToCollectionButton = new BatchAddToCollectionButton + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = -50, + Alpha = 0, + TooltipText = "Add all visible beatmaps to collection", + Action = () => VisibleResultsAction?.Invoke(), + }); + } + + public void UpdateVisibleResultsActionAvailability(bool available) + { + batchAddToCollectionButton.Enabled.Value = available; + batchAddToCollectionButton.FadeTo(available ? 1 : 0, 200, Easing.OutQuint); + } + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox { ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet }, @@ -492,6 +542,64 @@ namespace osu.Game.Screens.SelectV2 return base.OnPressed(e); } } + + private partial class BatchAddToCollectionButton : OsuClickableContainer + { + private readonly Box background; + + private Color4 normalColour; + private Color4 hoverColour; + + public BatchAddToCollectionButton() + { + RelativeSizeAxes = Axes.Y; + Width = 50; + CornerRadius = 7; + Masking = true; + + Child = background = new Box + { + RelativeSizeAxes = Axes.Both + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + normalColour = colours.Yellow.Darken(0.45f); + hoverColour = colours.Yellow; + + background.Colour = normalColour; + + AddInternal(new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.Plus, + Size = new Vector2(10), + Shear = -OsuGame.SHEAR, + } + } + }); + } + + protected override bool OnHover(HoverEvent e) + { + background.FadeColour(hoverColour, 100, Easing.Out); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + background.FadeColour(normalColour, 100, Easing.Out); + base.OnHoverLost(e); + } + } } } }