From c051ff84d293e5c2408e7ff59f55e176f0d1f1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 4 Mar 2025 13:04:23 +0100 Subject: [PATCH 1/8] Add UI for assigning custom tags to beatmaps Visual part for https://github.com/ppy/osu/issues/31913. Opening separately for appropriate visual UI adjustments. Also mostly ready to be hooked up to the results screen, pending merge of https://github.com/ppy/osu-web/pull/11951. --- .../Visual/Ranking/TestSceneUserTagControl.cs | 85 +++ osu.Game/Beatmaps/APIBeatmapTag.cs | 16 + osu.Game/Configuration/SessionStatics.cs | 13 +- .../API/Requests/AddBeatmapTagRequest.cs | 31 + .../Online/API/Requests/ListTagsRequest.cs | 12 + .../API/Requests/RemoveBeatmapTagRequest.cs | 29 + .../API/Requests/Responses/APIBeatmap.cs | 6 + .../Online/API/Requests/Responses/APITag.cs | 19 + .../Requests/Responses/APITagCollection.cs | 14 + osu.Game/Screens/Ranking/UserTagControl.cs | 537 ++++++++++++++++++ 10 files changed, 756 insertions(+), 6 deletions(-) create mode 100644 osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs create mode 100644 osu.Game/Beatmaps/APIBeatmapTag.cs create mode 100644 osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs create mode 100644 osu.Game/Online/API/Requests/ListTagsRequest.cs create mode 100644 osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs create mode 100644 osu.Game/Online/API/Requests/Responses/APITag.cs create mode 100644 osu.Game/Online/API/Requests/Responses/APITagCollection.cs create mode 100644 osu.Game/Screens/Ranking/UserTagControl.cs diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs new file mode 100644 index 0000000000..ebfd553815 --- /dev/null +++ b/osu.Game.Tests/Visual/Ranking/TestSceneUserTagControl.cs @@ -0,0 +1,85 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.Ranking; + +namespace osu.Game.Tests.Visual.Ranking +{ + public partial class TestSceneUserTagControl : OsuTestScene + { + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("set up working beatmap", () => + { + Beatmap.Value.BeatmapInfo.OnlineID = 42; + }); + AddStep("set up network requests", () => + { + dummyAPI.HandleRequest = request => + { + switch (request) + { + case ListTagsRequest listTagsRequest: + { + Scheduler.AddDelayed(() => listTagsRequest.TriggerSuccess(new APITagCollection + { + Tags = + [ + new APITag { Id = 1, Name = "tech", Description = "Tests uncommon skills.", }, + new APITag { Id = 2, Name = "alt", Description = "Colloquial term for maps which use rhythms that encourage the player to alternate notes. Typically distinct from burst or stream maps.", }, + new APITag { Id = 3, Name = "aim", Description = "Category for difficulty relating to cursor movement.", }, + new APITag { Id = 4, Name = "tap", Description = "Category for difficulty relating to tapping input.", }, + ] + }), 500); + return true; + } + + case GetBeatmapSetRequest getBeatmapSetRequest: + { + var beatmapSet = CreateAPIBeatmapSet(Beatmap.Value.BeatmapInfo); + beatmapSet.Beatmaps.Single().TopTags = + [ + new APIBeatmapTag { TagId = 3, VoteCount = 9 }, + ]; + Scheduler.AddDelayed(() => getBeatmapSetRequest.TriggerSuccess(beatmapSet), 500); + return true; + } + + case AddBeatmapTagRequest: + case RemoveBeatmapTagRequest: + { + Scheduler.AddDelayed(request.TriggerSuccess, 500); + return true; + } + } + + return false; + }; + }); + AddStep("create control", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new UserTagControl + { + Width = 500, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + }; + }); + } + } +} diff --git a/osu.Game/Beatmaps/APIBeatmapTag.cs b/osu.Game/Beatmaps/APIBeatmapTag.cs new file mode 100644 index 0000000000..5f4f9b851d --- /dev/null +++ b/osu.Game/Beatmaps/APIBeatmapTag.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public class APIBeatmapTag + { + [JsonProperty("tag_id")] + public long TagId { get; set; } + + [JsonProperty("count")] + public int VoteCount { get; set; } + } +} diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index d2069e4027..b816d1a88b 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -27,11 +25,12 @@ namespace osu.Game.Configuration SetDefault(Static.FeaturedArtistDisclaimerShownOnce, false); SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null); - SetDefault(Static.SeasonalBackgrounds, null); + SetDefault(Static.SeasonalBackgrounds, null); SetDefault(Static.TouchInputActive, RuntimeInfo.IsMobile); - SetDefault(Static.LastLocalUserScore, null); - SetDefault(Static.LastAppliedOffsetScore, null); - SetDefault(Static.UserOnlineActivity, null); + SetDefault(Static.LastLocalUserScore, null); + SetDefault(Static.LastAppliedOffsetScore, null); + SetDefault(Static.UserOnlineActivity, null); + SetDefault(Static.AllBeatmapTags, null); } /// @@ -99,5 +98,7 @@ namespace osu.Game.Configuration /// The activity for the current user to broadcast to other players. /// UserOnlineActivity, + + AllBeatmapTags, } } diff --git a/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs new file mode 100644 index 0000000000..4fa02dc569 --- /dev/null +++ b/osu.Game/Online/API/Requests/AddBeatmapTagRequest.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Globalization; +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class AddBeatmapTagRequest : APIRequest + { + public int BeatmapID { get; } + public long TagID { get; } + + public AddBeatmapTagRequest(int beatmapID, long tagID) + { + BeatmapID = beatmapID; + TagID = tagID; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + req.AddParameter(@"tag_id", TagID.ToString(CultureInfo.InvariantCulture), RequestParameterType.Query); + return req; + } + + protected override string Target => $@"beatmaps/{BeatmapID}/tags"; + } +} diff --git a/osu.Game/Online/API/Requests/ListTagsRequest.cs b/osu.Game/Online/API/Requests/ListTagsRequest.cs new file mode 100644 index 0000000000..ac4b1a3e2a --- /dev/null +++ b/osu.Game/Online/API/Requests/ListTagsRequest.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class ListTagsRequest : APIRequest + { + protected override string Target => "tags"; + } +} diff --git a/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs new file mode 100644 index 0000000000..8090dd2cb0 --- /dev/null +++ b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class RemoveBeatmapTagRequest : APIRequest + { + public int BeatmapID { get; } + public long TagID { get; } + + public RemoveBeatmapTagRequest(int beatmapID, long tagID) + { + BeatmapID = beatmapID; + TagID = tagID; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => $@"beatmaps/{BeatmapID}/tags/{TagID}"; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index e5ecfe2c99..f06d0ef274 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -95,6 +95,12 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"failtimes")] public APIFailTimes? FailTimes { get; set; } + [JsonProperty(@"top_tag_ids")] + public APIBeatmapTag[]? TopTags { get; set; } + + [JsonProperty(@"own_tag_ids")] + public long[]? OwnTagIds { get; set; } + [JsonProperty(@"max_combo")] public int? MaxCombo { get; set; } diff --git a/osu.Game/Online/API/Requests/Responses/APITag.cs b/osu.Game/Online/API/Requests/Responses/APITag.cs new file mode 100644 index 0000000000..4dd18663af --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITag.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APITag + { + [JsonProperty("id")] + public long Id { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + [JsonProperty("description")] + public string Description { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/APITagCollection.cs b/osu.Game/Online/API/Requests/Responses/APITagCollection.cs new file mode 100644 index 0000000000..a177699348 --- /dev/null +++ b/osu.Game/Online/API/Requests/Responses/APITagCollection.cs @@ -0,0 +1,14 @@ +// 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 Newtonsoft.Json; + +namespace osu.Game.Online.API.Requests.Responses +{ + public class APITagCollection + { + [JsonProperty("tags")] + public APITag[] Tags { get; set; } = Array.Empty(); + } +} diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs new file mode 100644 index 0000000000..6b7d22a7c2 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -0,0 +1,537 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Extensions; +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.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Screens.Ranking +{ + public partial class UserTagControl : CompositeDrawable + { + public override bool HandlePositionalInput => true; + + private readonly Cached layout = new Cached(); + + private FillFlowContainer tagFlow = null!; + private LoadingLayer loadingLayer = null!; + + private BindableList displayedTags { get; } = new BindableList(); + private BindableList extraTags { get; } = new BindableList(); + + private Bindable allTags = null!; + private readonly Bindable apiBeatmap = new Bindable(); + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load(SessionStatics sessionStatics) + { + AutoSizeAxes = Axes.Y; + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(8), + Children = new Drawable[] + { + tagFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Full, + LayoutDuration = 300, + LayoutEasing = Easing.OutQuint, + Spacing = new Vector2(4), + }, + new ExtraTagsButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + OnTagSelected = onExtraTagSelected, + ExtraTags = { BindTarget = extraTags }, + }, + }, + }, + loadingLayer = new LoadingLayer + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible } + }, + }; + + allTags = sessionStatics.GetBindable(Static.AllBeatmapTags); + + if (allTags.Value == null) + { + var listTagsRequest = new ListTagsRequest(); + listTagsRequest.Success += tags => allTags.Value = tags.Tags.ToArray(); + api.Queue(listTagsRequest); + } + + var getBeatmapSetRequest = new GetBeatmapSetRequest(beatmap.Value.BeatmapInfo.BeatmapSet!.OnlineID); + getBeatmapSetRequest.Success += set => apiBeatmap.Value = set.Beatmaps.SingleOrDefault(b => b.MatchesOnlineID(beatmap.Value.BeatmapInfo)); + api.Queue(getBeatmapSetRequest); + } + + private void onExtraTagSelected(UserTag tag) + { + loadingLayer.Show(); + extraTags.Remove(tag); + + var req = new AddBeatmapTagRequest(beatmap.Value.BeatmapInfo.OnlineID, tag.Id); + req.Success += () => + { + tag.Voted.Value = true; + tag.VoteCount.Value += 1; + displayedTags.Add(tag); + loadingLayer.Hide(); + }; + req.Failure += _ => extraTags.Add(tag); + api.Queue(req); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + allTags.BindValueChanged(_ => updateTags()); + apiBeatmap.BindValueChanged(_ => updateTags()); + updateTags(); + + displayedTags.BindCollectionChanged(displayTags, true); + } + + private void updateTags() + { + if (allTags.Value == null || apiBeatmap.Value?.TopTags == null) + return; + + var allTagsById = allTags.Value.ToDictionary(t => t.Id); + var ownTagIds = apiBeatmap.Value.OwnTagIds?.ToHashSet() ?? new HashSet(); + + foreach (var topTag in apiBeatmap.Value.TopTags) + { + if (allTagsById.Remove(topTag.TagId, out var tag)) + { + displayedTags.Add(new UserTag(tag) + { + VoteCount = { Value = topTag.VoteCount }, + Voted = { Value = ownTagIds.Contains(tag.Id) } + }); + } + } + + extraTags.AddRange(allTagsById.Select(t => new UserTag(t.Value))); + + loadingLayer.Hide(); + } + + private void displayTags(object? sender, NotifyCollectionChangedEventArgs e) + { + var oldItems = tagFlow.ToArray(); + + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + { + for (int i = 0; i < e.NewItems!.Count; i++) + { + var tag = (UserTag)e.NewItems[i]!; + var drawableTag = new DrawableUserTag(tag); + tagFlow.Insert(tagFlow.Count, drawableTag); + tag.VoteCount.BindValueChanged(sortTags, true); + layout.Invalidate(); + } + + break; + } + + case NotifyCollectionChangedAction.Remove: + { + for (int i = 0; i < e.OldItems!.Count; i++) + { + var tag = (UserTag)e.OldItems[i]!; + tag.VoteCount.ValueChanged -= sortTags; + tagFlow.Remove(oldItems[e.OldStartingIndex + i], true); + } + + break; + } + + case NotifyCollectionChangedAction.Reset: + { + tagFlow.Clear(); + break; + } + } + } + + private void sortTags(ValueChangedEvent _) => layout.Invalidate(); + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid && !IsHovered) + { + var sortedTags = new Dictionary( + displayedTags.OrderByDescending(t => t.VoteCount.Value) + .ThenByDescending(t => t.Voted.Value) + .Select((tag, index) => new KeyValuePair(tag, index))); + + foreach (var drawableTag in tagFlow) + tagFlow.SetLayoutPosition(drawableTag, sortedTags[drawableTag.UserTag]); + + layout.Validate(); + } + } + + private partial class DrawableUserTag : OsuAnimatedButton + { + public readonly UserTag UserTag; + + private readonly Bindable voteCount = new Bindable(); + private readonly BindableBool voted = new BindableBool(); + private readonly Bindable confirmed = new BindableBool(); + + private Box mainBackground = null!; + private Box voteBackground = null!; + private OsuSpriteText tagNameText = null!; + private OsuSpriteText voteCountText = null!; + private LoadingSpinner spinner = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private Bindable beatmap { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private APIRequest? requestInFlight; + + public DrawableUserTag(UserTag userTag) + { + UserTag = userTag; + voteCount.BindTo(userTag.VoteCount); + voted.BindTo(userTag.Voted); + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + CornerRadius = 8; + Masking = true; + EdgeEffect = new EdgeEffectParameters + { + Colour = colours.Lime1, + Radius = 5, + Type = EdgeEffectType.Glow, + }; + Content.AddRange(new Drawable[] + { + mainBackground = new Box + { + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Padding = new MarginPadding { Left = 6, Right = 3, Vertical = 3, }, + Spacing = new Vector2(5), + Children = new Drawable[] + { + tagNameText = new OsuSpriteText + { + Text = UserTag.Name, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new Container + { + AutoSizeAxes = Axes.Both, + CornerRadius = 5, + Masking = true, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + voteBackground = new Box + { + RelativeSizeAxes = Axes.Both, + }, + voteCountText = new OsuSpriteText + { + Margin = new MarginPadding { Horizontal = 6, Vertical = 3, }, + }, + spinner = new LoadingSpinner(withBox: true) + { + Alpha = 0, + Size = new Vector2(18), + } + } + } + } + } + }); + + TooltipText = UserTag.Description; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + const double transition_duration = 300; + + voteCount.BindValueChanged(_ => + { + voteCountText.Text = voteCount.Value.ToLocalisableString(); + confirmed.Value = voteCount.Value >= 10; + }, true); + voted.BindValueChanged(v => + { + if (v.NewValue) + { + voteBackground.FadeColour(colours.Lime3, transition_duration, Easing.OutQuint); + voteCountText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + } + else + { + voteBackground.FadeColour(colours.Gray2, transition_duration, Easing.OutQuint); + voteCountText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + } + }, true); + confirmed.BindValueChanged(c => + { + if (c.NewValue) + { + mainBackground.FadeColour(colours.Lime1, transition_duration, Easing.OutQuint); + tagNameText.FadeColour(Colour4.Black, transition_duration, Easing.OutQuint); + FadeEdgeEffectTo(0.5f, transition_duration, Easing.OutQuint); + } + else + { + mainBackground.FadeColour(colours.Gray4, transition_duration, Easing.OutQuint); + tagNameText.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + FadeEdgeEffectTo(0f, transition_duration, Easing.OutQuint); + } + }, true); + FinishTransforms(true); + + Action = () => + { + if (requestInFlight != null) + return; + + spinner.Show(); + + APIRequest request; + + switch (voted.Value) + { + case true: + var removeReq = new RemoveBeatmapTagRequest(beatmap.Value.BeatmapInfo.OnlineID, UserTag.Id); + removeReq.Success += () => + { + voteCount.Value -= 1; + voted.Value = false; + }; + request = removeReq; + break; + + case false: + var addReq = new AddBeatmapTagRequest(beatmap.Value.BeatmapInfo.OnlineID, UserTag.Id); + addReq.Success += () => + { + voteCount.Value += 1; + voted.Value = true; + }; + request = addReq; + break; + } + + request.Success += () => + { + spinner.Hide(); + requestInFlight = null; + }; + request.Failure += _ => + { + spinner.Hide(); + requestInFlight = null; + }; + api.Queue(requestInFlight = request); + }; + } + } + + private partial class ExtraTagsButton : GrayButton, IHasPopover + { + public BindableList ExtraTags { get; } = new BindableList(); + + public Action? OnTagSelected { get; set; } + + public ExtraTagsButton() + : base(FontAwesome.Solid.Plus) + { + Size = new Vector2(30); + + Action = this.ShowPopover; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ExtraTags.BindCollectionChanged((_, _) => Enabled.Value = ExtraTags.Count > 0, true); + } + + public Popover GetPopover() => new ExtraTagsPopover + { + ExtraTags = { BindTarget = ExtraTags }, + OnSelected = OnTagSelected, + }; + } + + private partial class ExtraTagsPopover : OsuPopover + { + public BindableList ExtraTags { get; } = new BindableList(); + + public Action? OnSelected { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + Child = new OsuScrollContainer + { + Width = 250, + Height = 200, + ScrollbarOverlapsContent = false, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Right = 5 }, + Spacing = new Vector2(10), + ChildrenEnumerable = ExtraTags.Select(tag => new DrawableExtraTag(tag) + { + Action = () => + { + OnSelected?.Invoke(tag); + this.HidePopover(); + } + }) + } + }; + } + } + + private partial class DrawableExtraTag : OsuAnimatedButton + { + private readonly UserTag tag; + + public DrawableExtraTag(UserTag tag) + { + this.tag = tag; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Anchor = Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Content.AddRange(new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeaFoamDark, + Depth = float.MaxValue, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Padding = new MarginPadding(5), + Children = new Drawable[] + { + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(weight: FontWeight.Bold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = tag.Name, + }, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = tag.Description, + } + } + } + }); + } + } + } + + public record UserTag + { + public long Id { get; } + public string Name { get; } + public string Description { get; set; } + public BindableInt VoteCount { get; } = new BindableInt(); + public BindableBool Voted { get; } = new BindableBool(); + + public UserTag(APITag tag) + { + Id = tag.Id; + Name = tag.Name; + Description = tag.Description; + } + } +} From 3661107e4ffc4478ac7fe50e5189877f71b44cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 5 Mar 2025 08:05:15 +0100 Subject: [PATCH 2/8] Update property name in line with web changes --- osu.Game/Online/API/Requests/Responses/APIBeatmap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index f06d0ef274..66e17739a8 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -98,7 +98,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"top_tag_ids")] public APIBeatmapTag[]? TopTags { get; set; } - [JsonProperty(@"own_tag_ids")] + [JsonProperty(@"current_user_tag_ids")] public long[]? OwnTagIds { get; set; } [JsonProperty(@"max_combo")] From 2272ca1ae5420f020cf158d5de9418b9cea249fe Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 8 Mar 2025 22:18:08 +0900 Subject: [PATCH 3/8] Fix namespace --- osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs index 8090dd2cb0..4ac00e28f4 100644 --- a/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs +++ b/osu.Game/Online/API/Requests/RemoveBeatmapTagRequest.cs @@ -4,7 +4,7 @@ using System.Net.Http; using osu.Framework.IO.Network; -namespace osu.Game.Online.API.Requests.Responses +namespace osu.Game.Online.API.Requests { public class RemoveBeatmapTagRequest : APIRequest { From 3d4dd8507723fcd3a048442834336486debb9732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 10 Mar 2025 10:44:22 +0100 Subject: [PATCH 4/8] Move back tag to extra if reached zero votes --- osu.Game/Screens/Ranking/UserTagControl.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs index 6b7d22a7c2..b11dc1588b 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -172,7 +172,7 @@ namespace osu.Game.Screens.Ranking var tag = (UserTag)e.NewItems[i]!; var drawableTag = new DrawableUserTag(tag); tagFlow.Insert(tagFlow.Count, drawableTag); - tag.VoteCount.BindValueChanged(sortTags, true); + tag.VoteCount.BindValueChanged(voteCountChanged, true); layout.Invalidate(); } @@ -184,7 +184,7 @@ namespace osu.Game.Screens.Ranking for (int i = 0; i < e.OldItems!.Count; i++) { var tag = (UserTag)e.OldItems[i]!; - tag.VoteCount.ValueChanged -= sortTags; + tag.VoteCount.ValueChanged -= voteCountChanged; tagFlow.Remove(oldItems[e.OldStartingIndex + i], true); } @@ -199,7 +199,18 @@ namespace osu.Game.Screens.Ranking } } - private void sortTags(ValueChangedEvent _) => layout.Invalidate(); + private void voteCountChanged(ValueChangedEvent _) + { + var tagsWithNoVotes = displayedTags.Where(t => t.VoteCount.Value == 0).ToArray(); + + foreach (var tag in tagsWithNoVotes) + { + displayedTags.Remove(tag); + extraTags.Add(tag); + } + + layout.Invalidate(); + } protected override void Update() { From 00127b363d532ed4f51ec03de13f06e0478d5920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 10 Mar 2025 10:52:24 +0100 Subject: [PATCH 5/8] Add search box to user tag control --- osu.Game/Screens/Ranking/UserTagControl.cs | 56 ++++++++++++++++------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs index b11dc1588b..57b05f078c 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -17,6 +17,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Extensions; @@ -447,6 +448,9 @@ namespace osu.Game.Screens.Ranking private partial class ExtraTagsPopover : OsuPopover { + private SearchTextBox searchBox = null!; + private SearchContainer searchContainer = null!; + public BindableList ExtraTags { get; } = new BindableList(); public Action? OnSelected { get; set; } @@ -457,28 +461,43 @@ namespace osu.Game.Screens.Ranking Child = new OsuScrollContainer { Width = 250, - Height = 200, + Height = 250, ScrollbarOverlapsContent = false, - Child = new FillFlowContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 5 }, - Spacing = new Vector2(10), - ChildrenEnumerable = ExtraTags.Select(tag => new DrawableExtraTag(tag) + searchBox = new SearchTextBox { - Action = () => + RelativeSizeAxes = Axes.X, + }, + searchContainer = new SearchContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Right = 5, Top = 50, }, + Spacing = new Vector2(10), + ChildrenEnumerable = ExtraTags.Select(tag => new DrawableExtraTag(tag) { - OnSelected?.Invoke(tag); - this.HidePopover(); - } - }) - } + Action = () => + { + OnSelected?.Invoke(tag); + this.HidePopover(); + } + }) + } + }, }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + searchBox.Current.BindValueChanged(_ => searchContainer.SearchTerm = searchBox.Current.Value, true); + } } - private partial class DrawableExtraTag : OsuAnimatedButton + private partial class DrawableExtraTag : OsuAnimatedButton, IFilterable { private readonly UserTag tag; @@ -527,6 +546,15 @@ namespace osu.Game.Screens.Ranking } }); } + + public IEnumerable FilterTerms => [tag.Name, tag.Description]; + + public bool MatchingFilter + { + set => Alpha = value ? 1 : 0; + } + + public bool FilteringActive { set { } } } } From 749df665d161fd27b253247980f7e441a528f6ef Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 11 Mar 2025 18:47:16 +0900 Subject: [PATCH 6/8] Focus search box immediately --- osu.Game/Screens/Ranking/UserTagControl.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs index 57b05f078c..a643bd6206 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -467,6 +467,7 @@ namespace osu.Game.Screens.Ranking { searchBox = new SearchTextBox { + HoldFocus = true, RelativeSizeAxes = Axes.X, }, searchContainer = new SearchContainer From 345f565b90b947eb6d353381a2cc5fc1d7a38a7e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 11 Mar 2025 18:47:28 +0900 Subject: [PATCH 7/8] Allow using `Enter` key to select a single match --- osu.Game/Screens/Ranking/UserTagControl.cs | 39 ++++++++++++++++------ 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs index a643bd6206..2e559ff534 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -17,6 +17,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -30,6 +31,7 @@ using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osuTK; +using osuTK.Input; namespace osu.Game.Screens.Ranking { @@ -479,32 +481,49 @@ namespace osu.Game.Screens.Ranking Spacing = new Vector2(10), ChildrenEnumerable = ExtraTags.Select(tag => new DrawableExtraTag(tag) { - Action = () => - { - OnSelected?.Invoke(tag); - this.HidePopover(); - } + Action = () => select(tag) }) } }, }; } + private void select(UserTag tag) + { + OnSelected?.Invoke(tag); + this.HidePopover(); + } + protected override void LoadComplete() { base.LoadComplete(); searchBox.Current.BindValueChanged(_ => searchContainer.SearchTerm = searchBox.Current.Value, true); } + + protected override bool OnKeyDown(KeyDownEvent e) + { + var visibleItems = searchContainer.OfType().Where(d => d.IsPresent).ToArray(); + + if (e.Key == Key.Enter) + { + if (visibleItems.Length == 1) + select(visibleItems.Single().Tag); + + return true; + } + + return base.OnKeyDown(e); + } } private partial class DrawableExtraTag : OsuAnimatedButton, IFilterable { - private readonly UserTag tag; + public readonly UserTag Tag; public DrawableExtraTag(UserTag tag) { - this.tag = tag; + Tag = tag; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -535,20 +554,20 @@ namespace osu.Game.Screens.Ranking { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Text = tag.Name, + Text = Tag.Name, }, new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 14)) { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Text = tag.Description, + Text = Tag.Description, } } } }); } - public IEnumerable FilterTerms => [tag.Name, tag.Description]; + public IEnumerable FilterTerms => [Tag.Name, Tag.Description]; public bool MatchingFilter { From bcdc49e248b826b6dce387242d84c4710762dd1c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 11 Mar 2025 18:54:17 +0900 Subject: [PATCH 8/8] Adjust naming and subclassing --- osu.Game/Screens/Ranking/UserTag.cs | 25 ++++ osu.Game/Screens/Ranking/UserTagControl.cs | 144 +++++++++------------ 2 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 osu.Game/Screens/Ranking/UserTag.cs diff --git a/osu.Game/Screens/Ranking/UserTag.cs b/osu.Game/Screens/Ranking/UserTag.cs new file mode 100644 index 0000000000..d44e531330 --- /dev/null +++ b/osu.Game/Screens/Ranking/UserTag.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Screens.Ranking +{ + public record UserTag + { + public long Id { get; } + public string Name { get; } + public string Description { get; } + + public BindableInt VoteCount { get; } = new BindableInt(); + public BindableBool Voted { get; } = new BindableBool(); + + public UserTag(APITag tag) + { + Id = tag.Id; + Name = tag.Name; + Description = tag.Description; + } + } +} diff --git a/osu.Game/Screens/Ranking/UserTagControl.cs b/osu.Game/Screens/Ranking/UserTagControl.cs index 2e559ff534..7600d0aaae 100644 --- a/osu.Game/Screens/Ranking/UserTagControl.cs +++ b/osu.Game/Screens/Ranking/UserTagControl.cs @@ -79,12 +79,12 @@ namespace osu.Game.Screens.Ranking LayoutEasing = Easing.OutQuint, Spacing = new Vector2(4), }, - new ExtraTagsButton + new AddTagsButton { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, OnTagSelected = onExtraTagSelected, - ExtraTags = { BindTarget = extraTags }, + AvailableTags = { BindTarget = extraTags }, }, }, }, @@ -420,13 +420,13 @@ namespace osu.Game.Screens.Ranking } } - private partial class ExtraTagsButton : GrayButton, IHasPopover + private partial class AddTagsButton : GrayButton, IHasPopover { - public BindableList ExtraTags { get; } = new BindableList(); + public BindableList AvailableTags { get; } = new BindableList(); public Action? OnTagSelected { get; set; } - public ExtraTagsButton() + public AddTagsButton() : base(FontAwesome.Solid.Plus) { Size = new Vector2(30); @@ -438,22 +438,22 @@ namespace osu.Game.Screens.Ranking { base.LoadComplete(); - ExtraTags.BindCollectionChanged((_, _) => Enabled.Value = ExtraTags.Count > 0, true); + AvailableTags.BindCollectionChanged((_, _) => Enabled.Value = AvailableTags.Count > 0, true); } - public Popover GetPopover() => new ExtraTagsPopover + public Popover GetPopover() => new AddTagsPopover { - ExtraTags = { BindTarget = ExtraTags }, + AvailableTags = { BindTarget = AvailableTags }, OnSelected = OnTagSelected, }; } - private partial class ExtraTagsPopover : OsuPopover + private partial class AddTagsPopover : OsuPopover { private SearchTextBox searchBox = null!; private SearchContainer searchContainer = null!; - public BindableList ExtraTags { get; } = new BindableList(); + public BindableList AvailableTags { get; } = new BindableList(); public Action? OnSelected { get; set; } @@ -479,7 +479,7 @@ namespace osu.Game.Screens.Ranking Direction = FillDirection.Vertical, Padding = new MarginPadding { Right = 5, Top = 50, }, Spacing = new Vector2(10), - ChildrenEnumerable = ExtraTags.Select(tag => new DrawableExtraTag(tag) + ChildrenEnumerable = AvailableTags.Select(tag => new DrawableAddableTag(tag) { Action = () => select(tag) }) @@ -488,12 +488,6 @@ namespace osu.Game.Screens.Ranking }; } - private void select(UserTag tag) - { - OnSelected?.Invoke(tag); - this.HidePopover(); - } - protected override void LoadComplete() { base.LoadComplete(); @@ -503,7 +497,7 @@ namespace osu.Game.Screens.Ranking protected override bool OnKeyDown(KeyDownEvent e) { - var visibleItems = searchContainer.OfType().Where(d => d.IsPresent).ToArray(); + var visibleItems = searchContainer.OfType().Where(d => d.IsPresent).ToArray(); if (e.Key == Key.Enter) { @@ -515,82 +509,68 @@ namespace osu.Game.Screens.Ranking return base.OnKeyDown(e); } - } - private partial class DrawableExtraTag : OsuAnimatedButton, IFilterable - { - public readonly UserTag Tag; - - public DrawableExtraTag(UserTag tag) + private void select(UserTag tag) { - Tag = tag; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Anchor = Origin = Anchor.Centre; + OnSelected?.Invoke(tag); + this.HidePopover(); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + private partial class DrawableAddableTag : OsuAnimatedButton, IFilterable { - Content.AddRange(new Drawable[] + public readonly UserTag Tag; + + public DrawableAddableTag(UserTag tag) { - new Box + Tag = tag; + + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Anchor = Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Content.AddRange(new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = colours.GreySeaFoamDark, - Depth = float.MaxValue, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2), - Padding = new MarginPadding(5), - Children = new Drawable[] + new Box { - new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(weight: FontWeight.Bold)) + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeaFoamDark, + Depth = float.MaxValue, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Padding = new MarginPadding(5), + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = Tag.Name, - }, - new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 14)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = Tag.Description, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(weight: FontWeight.Bold)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = Tag.Name, + }, + new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 14)) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Text = Tag.Description, + } } } - } - }); + }); + } + + public IEnumerable FilterTerms => [Tag.Name, Tag.Description]; + + public bool MatchingFilter { set => Alpha = value ? 1 : 0; } + public bool FilteringActive { set { } } } - - public IEnumerable FilterTerms => [Tag.Name, Tag.Description]; - - public bool MatchingFilter - { - set => Alpha = value ? 1 : 0; - } - - public bool FilteringActive { set { } } - } - } - - public record UserTag - { - public long Id { get; } - public string Name { get; } - public string Description { get; set; } - public BindableInt VoteCount { get; } = new BindableInt(); - public BindableBool Voted { get; } = new BindableBool(); - - public UserTag(APITag tag) - { - Id = tag.Id; - Name = tag.Name; - Description = tag.Description; } } }