From b4b26e3a1de490ff527e15b2a2cf7f528dc591a6 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 16 Oct 2025 17:40:19 +0900 Subject: [PATCH 001/133] Remove song select dependency from FooterButtonOptions --- .../Visual/SongSelectV2/TestSceneScreenFooter.cs | 2 +- osu.Game/Screens/OsuScreen.cs | 2 -- osu.Game/Screens/SelectV2/FooterButtonOptions.cs | 8 ++++++-- osu.Game/Screens/SelectV2/SongSelect.cs | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs index e247b92f52..bb0fb16dcf 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { new FooterButtonMods(modOverlay) { Current = SelectedMods }, new FooterButtonRandom(), - new FooterButtonOptions(), + new FooterButtonOptions(null), }); }); diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index 1307be6494..ce04db0189 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -117,8 +117,6 @@ namespace osu.Game.Screens internal void CreateLeasedDependencies(IReadOnlyDependencyContainer dependencies) => createDependencies(dependencies); - internal void LoadComponentsAgainstScreenDependencies(IEnumerable components) => LoadComponents(components); - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { if (screenDependencies == null) diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs b/osu.Game/Screens/SelectV2/FooterButtonOptions.cs index 3371785dd2..3e08e69bf8 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonOptions.cs @@ -23,8 +23,12 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IBindable beatmap { get; set; } = null!; - [Resolved] - private ISongSelect? songSelect { get; set; } + private readonly ISongSelect? songSelect; + + public FooterButtonOptions(ISongSelect? songSelect) + { + this.songSelect = songSelect; + } [BackgroundDependencyLoader] private void load(OsuColour colour) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index e8843876d3..f047184d99 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -352,7 +352,7 @@ namespace osu.Game.Screens.SelectV2 errorSample?.Play(); } }, - new FooterButtonOptions + new FooterButtonOptions(this) { Hotkey = GlobalAction.ToggleBeatmapOptions, } From d85c2ee623869d352f8400fecdbc727804027192 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 16 Oct 2025 17:56:15 +0900 Subject: [PATCH 002/133] Isolate footer behaviour to ScreenStackFooter, support subscreens --- osu.Game/OsuGame.cs | 69 +----- osu.Game/Screens/Footer/ScreenStackFooter.cs | 216 +++++++++++++++++++ 2 files changed, 222 insertions(+), 63 deletions(-) create mode 100644 osu.Game/Screens/Footer/ScreenStackFooter.cs diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4dd42b7fd2..4ea9fae183 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -189,19 +189,14 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); - /// - /// Whether the back button is currently displayed. - /// - private readonly IBindable backButtonVisibility = new BindableBool(); - IBindable ILocalUserPlayInfo.PlayingState => UserPlayingState; protected readonly Bindable UserPlayingState = new Bindable(); protected OsuScreenStack ScreenStack; - protected BackButton BackButton; - protected ScreenFooter ScreenFooter; + protected BackButton BackButton => screenStackFooter.BackButton; + protected ScreenFooter ScreenFooter => screenStackFooter.Footer; protected SettingsOverlay Settings; @@ -233,6 +228,8 @@ namespace osu.Game private RealmDetachedBeatmapStore detachedBeatmapStore; + private ScreenStackFooter screenStackFooter; + private readonly string[] args; private readonly List focusedOverlays = new List(); @@ -1132,12 +1129,6 @@ namespace osu.Game { backReceptor = new ScreenFooter.BackReceptor(), ScreenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, - BackButton = new BackButton(backReceptor) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Action = handleBackButton, - }, logoContainer = new Container { RelativeSizeAxes = Axes.Both }, // TODO: what is this? why is this? // TODO: this is being screen scaled even though it's probably AN OVERLAY. @@ -1150,7 +1141,7 @@ namespace osu.Game { Depth = -1, RelativeSizeAxes = Axes.Both, - Child = ScreenFooter = new ScreenFooter(backReceptor) + Child = screenStackFooter = new ScreenStackFooter(ScreenStack, backReceptor) { // TODO: this is really really weird and should not exist. RequestLogoInFront = inFront => ScreenContainer.ChangeChildDepth(logoContainer, inFront ? float.MinValue : 0), @@ -1324,14 +1315,6 @@ namespace osu.Game if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; - backButtonVisibility.ValueChanged += visible => - { - if (visible.NewValue) - BackButton.Show(); - else - BackButton.Hide(); - }; - // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. handleStartupImport(); } @@ -1723,13 +1706,12 @@ namespace osu.Game if (current != null) { - backButtonVisibility.UnbindFrom(current.BackButtonVisibility); OverlayActivationMode.UnbindFrom(current.OverlayActivationMode); configUserActivity.UnbindFrom(current.Activity); } // Bind to new screen. - if (newScreen != null) + if (newScreen is OsuScreen newOsuScreen) { OverlayActivationMode.BindTo(newScreen.OverlayActivationMode); configUserActivity.BindTo(newScreen.Activity); @@ -1742,45 +1724,6 @@ namespace osu.Game else Toolbar.Show(); - var newOsuScreen = (OsuScreen)newScreen; - - if (newScreen.ShowFooter) - { - // the legacy back button should never display while the new footer is in use, as it - // contains its own local back button. - ((BindableBool)backButtonVisibility).Value = false; - - BackButton.Hide(); - ScreenFooter.Show(); - - if (newOsuScreen.IsLoaded) - updateFooterButtons(); - else - { - // ensure the current buttons are immediately disabled on screen change (so they can't be pressed). - ScreenFooter.SetButtons(Array.Empty()); - - newOsuScreen.OnLoadComplete += _ => updateFooterButtons(); - } - - void updateFooterButtons() - { - var buttons = newScreen.CreateFooterButtons(); - - newOsuScreen.LoadComponentsAgainstScreenDependencies(buttons); - - ScreenFooter.SetButtons(buttons); - ScreenFooter.Show(); - } - } - else - { - backButtonVisibility.BindTo(newScreen.BackButtonVisibility); - - ScreenFooter.SetButtons(Array.Empty()); - ScreenFooter.Hide(); - } - skinEditor.SetTarget(newOsuScreen); } } diff --git a/osu.Game/Screens/Footer/ScreenStackFooter.cs b/osu.Game/Screens/Footer/ScreenStackFooter.cs new file mode 100644 index 0000000000..3fb470cab0 --- /dev/null +++ b/osu.Game/Screens/Footer/ScreenStackFooter.cs @@ -0,0 +1,216 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Screens; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Footer +{ + public partial class ScreenStackFooter : CompositeDrawable + { + /// + /// Called when logo tracking begins, intended to bring the osu! logo to the frontmost visually. + /// + public Action? RequestLogoInFront { private get; init; } + + /// + /// The back button was pressed. + /// + public Action? BackButtonPressed { private get; init; } + + /// + /// The (legacy) back button. + /// + public readonly BackButton BackButton; + + /// + /// The footer. + /// + public readonly ScreenFooter Footer; + + /// + /// Whether the legacy back button is currently displayed. + /// + private readonly IBindable backButtonVisibility = new BindableBool(); + + private readonly ScreenStackTracker screenTracker; + + public ScreenStackFooter(ScreenStack screenStack, ScreenFooter.BackReceptor? backReceptor = null) + { + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + BackButton = new BackButton(backReceptor) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Action = () => BackButtonPressed?.Invoke(), + }, + Footer = new ScreenFooter(backReceptor) + { + RequestLogoInFront = v => RequestLogoInFront?.Invoke(v), + BackButtonPressed = () => BackButtonPressed?.Invoke() + } + }; + + screenTracker = new ScreenStackTracker(screenStack); + screenTracker.ScreenChanged += onScreenChanged; + + backButtonVisibility.ValueChanged += onBackButtonVisibilityChanged; + } + + private void onScreenChanged(IScreen lastScreen, IScreen newScreen) + { + unbindScreen(lastScreen); + bindScreen(newScreen); + } + + private void onBackButtonVisibilityChanged(ValueChangedEvent visible) + { + if (visible.NewValue) + BackButton.Show(); + else + BackButton.Hide(); + } + + private void unbindScreen(IScreen screen) + { + if (screen is not OsuScreen osuScreen) + return; + + backButtonVisibility.UnbindFrom(osuScreen.BackButtonVisibility); + } + + private void bindScreen(IScreen screen) + { + if (screen is not OsuScreen osuScreen) + { + ((BindableBool)backButtonVisibility).Value = true; + + Footer.SetButtons([]); + Footer.Hide(); + return; + } + + if (osuScreen.ShowFooter) + { + // the legacy back button should never display while the new footer is in use, as it + // contains its own local back button. + ((BindableBool)backButtonVisibility).Value = false; + + Footer.Show(); + + if (osuScreen.IsLoaded) + updateFooterButtons(); + else + { + // ensure the current buttons are immediately disabled on screen change (so they can't be pressed). + Footer.SetButtons([]); + + osuScreen.OnLoadComplete += _ => updateFooterButtons(); + } + + void updateFooterButtons() + { + Footer.SetButtons(osuScreen.CreateFooterButtons()); + Footer.Show(); + } + } + else + { + backButtonVisibility.BindTo(osuScreen.BackButtonVisibility); + + Footer.SetButtons([]); + Footer.Hide(); + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + screenTracker.Dispose(); + } + + /// + /// Recursively represents a single screen stack and any nested subscreen stack. + /// + private class ScreenStackTracker : IDisposable + { + /// + /// Invoked when the leading screen changes. + /// + /// + /// This differs from and + /// because lastScreen and newScreen may be subscreens of the current screen stack. + ///
+ /// As such, no assumptions may be made as to the relation of screens to this entry's . + ///
+ public event ScreenChangedDelegate? ScreenChanged; + + /// + /// The screen stack tracked by this entry. + /// + private readonly ScreenStack stack; + + /// + /// An entry corresponding to the subscreen stack of the current screen, if any. + /// + private ScreenStackTracker? subScreenTracker; + + /// + /// The screen which should be bound to the screen footer - the most nested subscreen. + /// + private IScreen leadingScreen => subScreenTracker?.leadingScreen ?? stack.CurrentScreen; + + public ScreenStackTracker(ScreenStack stack) + { + this.stack = stack; + + stack.ScreenPushed += onParentScreenChanged; + stack.ScreenExited += onParentScreenChanged; + } + + private void onParentScreenChanged(IScreen lastScreen, IScreen newScreen) + { + // The screen which we will be UNBINDING from the screen footer later on. + IScreen lastLeadingScreen = subScreenTracker?.leadingScreen ?? lastScreen; + + // Subscreens are attached to a parent screen, so when the parent changes the subscreen must also. + subScreenTracker?.Dispose(); + subScreenTracker = null; + + // Check if we've switched to a screen that has a subscreen. + if (newScreen is IHasSubScreenStack newStack) + { + subScreenTracker = new ScreenStackTracker(newStack.SubScreenStack); + subScreenTracker.ScreenChanged += onSubScreenScreenChanged; + } + + ScreenChanged?.Invoke(lastLeadingScreen, leadingScreen); + } + + private void onSubScreenScreenChanged(IScreen lastScreen, IScreen newScreen) + { + ScreenChanged?.Invoke(lastScreen, newScreen); + } + + public void Dispose() + { + stack.ScreenPushed -= onParentScreenChanged; + stack.ScreenExited -= onParentScreenChanged; + + if (subScreenTracker != null) + { + subScreenTracker.ScreenChanged -= onSubScreenScreenChanged; + subScreenTracker.Dispose(); + } + } + } + } +} From c80fe7ab82171159d3ebd9ddb7804fdbe75ba795 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 16 Oct 2025 18:07:57 +0900 Subject: [PATCH 003/133] Use new implementation in ScreenTestScene --- .../SongSelectV2/TestSceneSongSelect.cs | 4 +- osu.Game/Tests/Visual/ScreenTestScene.cs | 59 ++++--------------- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs index 8419684b27..e4f05b2e49 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -213,7 +213,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("mods selected", () => SelectedMods.Value, () => Has.Count.EqualTo(1)); AddStep("right click mod button", () => { - InputManager.MoveMouseTo(Footer.ChildrenOfType().Single()); + InputManager.MoveMouseTo(ScreenFooter.ChildrenOfType().Single()); InputManager.Click(MouseButton.Right); }); AddAssert("not mods selected", () => SelectedMods.Value, () => Has.Count.EqualTo(0)); @@ -620,7 +620,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("previous random invoked", () => previousRandomCalled && !nextRandomCalled); } - private FooterButtonRandom randomButton => Footer.ChildrenOfType().Single(); + private FooterButtonRandom randomButton => ScreenFooter.ChildrenOfType().Single(); [Test] public void TestFooterOptions() diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index 42199faa4d..7d28ee1d1d 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -9,7 +9,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Logging; -using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Overlays; @@ -34,12 +33,16 @@ namespace osu.Game.Tests.Visual protected DialogOverlay DialogOverlay { get; private set; } [Cached] - protected ScreenFooter Footer { get; private set; } + protected ScreenFooter ScreenFooter { get; private set; } protected ScreenTestScene() { + ScreenStackFooter screenStackFooter; + ScreenFooter.BackReceptor backReceptor; + base.Content.AddRange(new Drawable[] { + backReceptor = new ScreenFooter.BackReceptor(), Stack = new OsuScreenStack { Name = nameof(ScreenTestScene), @@ -51,7 +54,10 @@ namespace osu.Game.Tests.Visual Children = new Drawable[] { content = new Container { RelativeSizeAxes = Axes.Both }, - Footer = new ScreenFooter(), + screenStackFooter = new ScreenStackFooter(Stack, backReceptor) + { + BackButtonPressed = () => Stack.Exit() + } } }, overlayContent = new Container @@ -61,16 +67,10 @@ namespace osu.Game.Tests.Visual }, }); - Stack.ScreenPushed += (oldScreen, newScreen) => - { - updateFooter(oldScreen, newScreen); - Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); - }; - Stack.ScreenExited += (oldScreen, newScreen) => - { - updateFooter(oldScreen, newScreen); - Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); - }; + ScreenFooter = screenStackFooter.Footer; + + Stack.ScreenPushed += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed → {newScreen}"); + Stack.ScreenExited += (_, newScreen) => Logger.Log($"{nameof(ScreenTestScene)} screen changed ← {newScreen}"); } protected void LoadScreen(OsuScreen screen) => Stack.Push(screen); @@ -96,39 +96,6 @@ namespace osu.Game.Tests.Visual }); } - private void updateFooter(IScreen? _, IScreen? newScreen) - { - if (newScreen is OsuScreen osuScreen && osuScreen.ShowFooter) - { - Footer.Show(); - - if (osuScreen.IsLoaded) - updateFooterButtons(); - else - { - // ensure the current buttons are immediately disabled on screen change (so they can't be pressed). - Footer.SetButtons(Array.Empty()); - - osuScreen.OnLoadComplete += _ => updateFooterButtons(); - } - - void updateFooterButtons() - { - var buttons = osuScreen.CreateFooterButtons(); - - osuScreen.LoadComponentsAgainstScreenDependencies(buttons); - - Footer.SetButtons(buttons); - Footer.Show(); - } - } - else - { - Footer.Hide(); - Footer.SetButtons(Array.Empty()); - } - } - #region IOverlayManager IBindable IOverlayManager.OverlayActivationMode { get; } = new Bindable(OverlayActivation.All); From bfa45a23ceb48bbcd58acde27475c961414fb4f6 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 11 Sep 2025 11:49:11 +0900 Subject: [PATCH 004/133] Add initial screen + test structure --- .../TestScenePlaylistsSongSelectV2.cs | 89 +++++++++++++++++++ .../Playlists/PlaylistsSongSelectV2.cs | 14 +++ .../Visual/OnlinePlay/OnlinePlayTestScene.cs | 2 +- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs create mode 100644 osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs new file mode 100644 index 0000000000..0bbd2f5d92 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs @@ -0,0 +1,89 @@ +// 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.Linq; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Scoring; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public class TestScenePlaylistsSongSelectV2 : OnlinePlayTestScene + { + private BeatmapManager beatmaps = null!; + private RealmRulesetStore rulesets = null!; + private OsuConfigManager config = null!; + private ScoreManager scoreManager = null!; + private RealmDetachedBeatmapStore beatmapStore = null!; + + private PlaylistsSongSelectV2 songSelect = null!; + + private BeatmapCarousel Carousel => songSelect.ChildrenOfType().Single(); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. + // At a point we have isolated interactive test runs enough, this can likely be removed. + dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); + dependencies.Cache(Realm); + dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, Dependencies.Get(), Resources, Dependencies.Get(), Beatmap.Default)); + dependencies.Cache(config = new OsuConfigManager(LocalStorage)); + dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, API, config)); + + dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); + + return dependencies; + } + + [BackgroundDependencyLoader] + private void load() + { + Add(beatmapStore); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + ImportBeatmapForRuleset(0); + + AddStep("load screen", () => LoadScreen(songSelect = new PlaylistsSongSelectV2())); + AddUntilStep("wait for load", () => Stack.CurrentScreen == songSelect && songSelect.IsLoaded); + AddUntilStep("wait for filtering", () => !Carousel.IsFiltering); + } + + protected void ImportBeatmapForRuleset(params int[] rulesetIds) => ImportBeatmapForRuleset(_ => { }, 3, rulesetIds); + + protected void ImportBeatmapForRuleset(Action applyToBeatmap, int difficultyCount, params int[] rulesetIds) + { + int beatmapsCount = 0; + + AddStep($"import test map for ruleset {rulesetIds}", () => + { + beatmapsCount = songSelect.IsNull() ? 0 : Carousel.Filters.OfType().Single().SetItems.Count; + + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(difficultyCount, rulesets.AvailableRulesets.Where(r => rulesetIds.Contains(r.OnlineID)).ToArray()); + applyToBeatmap(beatmapSet); + beatmaps.Import(beatmapSet); + }); + + // This is specifically for cases where the add is happening post song select load. + // For cases where song select is null, the assertions are provided by the load checks. + AddUntilStep("wait for imported to arrive in carousel", () => songSelect.IsNull() || Carousel.Filters.OfType().Single().SetItems.Count > beatmapsCount); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs new file mode 100644 index 0000000000..375e22cf00 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.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 osu.Game.Screens.SelectV2; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public class PlaylistsSongSelectV2 : SongSelect + { + protected override void OnStart() + { + } + } +} diff --git a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs index 75932bbfef..3aa126c250 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/OnlinePlayTestScene.cs @@ -46,7 +46,7 @@ namespace osu.Game.Tests.Visual.OnlinePlay }); } - protected sealed override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DelegatedDependencyContainer(base.CreateChildDependencies(parent)); public override void SetUpSteps() From ff6f797a3e0eef6e950b048386e72ca93598f0ae Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 11 Sep 2025 18:10:41 +0900 Subject: [PATCH 005/133] Add initial freemods button --- .../TestSceneFooterButtonFreeModsV2.cs | 44 +++++ .../OnlinePlay/FooterButtonFreeModsV2.cs | 171 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs create mode 100644 osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs new file mode 100644 index 0000000000..7eb82d5fd1 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs @@ -0,0 +1,44 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneFooterButtonFreeModsV2 : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + public TestSceneFooterButtonFreeModsV2() + { + ModSelectOverlay modSelectOverlay; + Add(modSelectOverlay = new TestModSelectOverlay()); + + FooterButtonFreeModsV2 button; + Add(button = new FooterButtonFreeModsV2(modSelectOverlay) + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + X = -100, + }); + + button.FreeMods.Value = new OsuRuleset().CreateAllMods().ToArray(); + } + + private partial class TestModSelectOverlay : UserModSelectOverlay + { + public TestModSelectOverlay() + : base(OverlayColourScheme.Aquamarine) + { + IsValidMod = _ => true; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs new file mode 100644 index 0000000000..37755cd326 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs @@ -0,0 +1,171 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay +{ + public partial class FooterButtonFreeModsV2 : ScreenFooterButton + { + private const float bar_height = 30f; + + public readonly Bindable> FreeMods = new Bindable>(); + public readonly IBindable Freestyle = new Bindable(); + + public new Action Action + { + set => throw new NotSupportedException("The click action is handled by the button itself."); + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + private ModDisplay modDisplay = null!; + private Container modContainer = null!; + private ModCountText overflowModCountDisplay = null!; + + public FooterButtonFreeModsV2(ModSelectOverlay overlay) + : base(overlay) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Text = "Freemods"; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colours.Lime1; + + AddRange(new[] + { + new Container + { + Y = -5f, + Depth = float.MaxValue, + Origin = Anchor.BottomLeft, + Shear = OsuGame.SHEAR, + CornerRadius = CORNER_RADIUS, + Size = new Vector2(BUTTON_WIDTH, bar_height), + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + modContainer = new Container + { + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + modDisplay = new ModDisplay(showExtendedInformation: true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Scale = new Vector2(0.5f), + Current = { BindTarget = FreeMods }, + ExpansionMode = ExpansionMode.AlwaysContracted, + }, + overflowModCountDisplay = new ModCountText { Mods = { BindTarget = FreeMods }, }, + } + }, + } + }, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Freestyle.BindValueChanged(f => Enabled.Value = !f.NewValue, true); + } + + protected override void Update() + { + base.Update(); + + if (FreeMods.Value.Count == 0) + return; + + if (modDisplay.DrawWidth * modDisplay.Scale.X > modContainer.DrawWidth) + overflowModCountDisplay.Show(); + else + overflowModCountDisplay.Hide(); + } + + private partial class ModCountText : CompositeDrawable + { + public readonly Bindable> Mods = new Bindable>(); + + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + Alpha = 0.8f, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Shear = -OsuGame.SHEAR, + } + }; + + Mods.BindValueChanged(v => text.Text = ModSelectOverlayStrings.Mods(v.NewValue.Count).ToUpper(), true); + } + } + } +} From f1539167eb4fed9f0e1ec0433fe2b5400825af15 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 11 Sep 2025 18:24:27 +0900 Subject: [PATCH 006/133] Add initial freestyle button --- .../TestSceneFooterButtonFreestyleV2.cs | 26 ++++ .../OnlinePlay/FooterButtonFreestyleV2.cs | 121 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs create mode 100644 osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs new file mode 100644 index 0000000000..32c2c33db1 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreestyleV2.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneFooterButtonFreestyleV2 : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + public TestSceneFooterButtonFreestyleV2() + { + Add(new FooterButtonFreestyleV2 + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + X = -100, + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs new file mode 100644 index 0000000000..5381c64cc4 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Screens.Footer; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay +{ + public class FooterButtonFreestyleV2 : ScreenFooterButton + { + private const float bar_height = 30f; + + public readonly Bindable Freestyle = new Bindable(); + + public new Action Action + { + set => throw new NotSupportedException("The click action is handled by the button itself."); + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public FooterButtonFreestyleV2() + { + // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. + base.Action = () => Freestyle.Value = !Freestyle.Value; + } + + [BackgroundDependencyLoader] + private void load() + { + Text = "Freestyle"; + Icon = FontAwesome.Solid.ExchangeAlt; + AccentColour = colours.Lime1; + + AddRange(new[] + { + new Container + { + Y = -5f, + Depth = float.MaxValue, + Origin = Anchor.BottomLeft, + Shear = OsuGame.SHEAR, + CornerRadius = CORNER_RADIUS, + Size = new Vector2(BUTTON_WIDTH, bar_height), + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), + }, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + new StatusText { Freestyle = { BindTarget = Freestyle } } + } + }, + }); + } + + private partial class StatusText : CompositeDrawable + { + public readonly Bindable Freestyle = new Bindable(); + + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + protected override void LoadComplete() + { + base.LoadComplete(); + + RelativeSizeAxes = Axes.Both; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + Alpha = 0.8f, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Shear = -OsuGame.SHEAR, + } + }; + + Freestyle.BindValueChanged(v => + { + text.Text = v.NewValue ? "ON" : "OFF"; + }, true); + } + } + } +} From 87834d1693a4d52a5b60a728ea5dd7620866c523 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 11 Sep 2025 18:44:31 +0900 Subject: [PATCH 007/133] Add overall mods/freemods/freestyle hookups --- .../TestScenePlaylistsSongSelectV2.cs | 3 +- .../Playlists/PlaylistsSongSelectV2.cs | 138 ++++++++++++++++++ osu.Game/Screens/SelectV2/SongSelect.cs | 17 ++- 3 files changed, 149 insertions(+), 9 deletions(-) diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs index 0bbd2f5d92..923970e921 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Playlists; @@ -61,7 +62,7 @@ namespace osu.Game.Tests.Visual.Playlists ImportBeatmapForRuleset(0); - AddStep("load screen", () => LoadScreen(songSelect = new PlaylistsSongSelectV2())); + AddStep("load screen", () => LoadScreen(songSelect = new PlaylistsSongSelectV2(new Room()))); AddUntilStep("wait for load", () => Stack.CurrentScreen == songSelect && songSelect.IsLoaded); AddUntilStep("wait for filtering", () => !Carousel.IsFiltering); } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index 375e22cf00..d63c105afb 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -1,14 +1,152 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Localisation; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.Footer; using osu.Game.Screens.SelectV2; +using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Playlists { public class PlaylistsSongSelectV2 : SongSelect { + private readonly Bindable> freeMods = new Bindable>([]); + private readonly Bindable freestyle = new Bindable(true); + + private readonly Room room; + private ModSelectOverlay modSelect = null!; + private FreeModSelectOverlay freeModSelect = null!; + + public PlaylistsSongSelectV2(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() + { + AddInternal(freeModSelect = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = freeMods }, + IsValidMod = isValidAllowedMod, + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Mods.BindValueChanged(onGlobalModsChanged); + Ruleset.BindValueChanged(onRulesetChanged); + freestyle.BindValueChanged(onFreestyleChanged); + + updateValidMods(); + } + + private void onGlobalModsChanged(ValueChangedEvent> mods) + { + updateValidMods(); + } + + private void onRulesetChanged(ValueChangedEvent ruleset) + { + // Todo: We can probably attempt to preserve across rulesets like the global mods do. + freeMods.Value = []; + } + + private void onFreestyleChanged(ValueChangedEvent enabled) + { + updateValidMods(); + + if (enabled.NewValue) + { + freeModSelect.Hide(); + + // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: + // - We probably don't want to store a gigantic list of acronyms to the database. + // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. + // Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass. + freeMods.Value = []; + } + else + { + // When disabling freestyle, enable freemods by default. + freeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); + } + } + + /// + /// Removes invalid mods from and , + /// and updates mod selection overlays to display the new mods valid for selection. + /// + private void updateValidMods() + { + Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray(); + if (!validMods.SequenceEqual(Mods.Value)) + Mods.Value = validMods; + + Mod[] validFreeMods = freeMods.Value.Where(isValidAllowedMod).ToArray(); + if (!validFreeMods.SequenceEqual(freeMods.Value)) + freeMods.Value = validFreeMods; + + modSelect.IsValidMod = isValidRequiredMod; + freeModSelect.IsValidMod = isValidAllowedMod; + } + protected override void OnStart() { } + + public override IReadOnlyList CreateFooterButtons() + { + var buttons = base.CreateFooterButtons().ToList(); + + buttons.Single(i => i is FooterButtonMods).TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; + + buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, + [ + new FooterButtonFreeModsV2(freeModSelect) + { + FreeMods = { BindTarget = freeMods }, + Freestyle = { BindTarget = freestyle } + }, + new FooterButtonFreestyleV2 + { + Freestyle = { BindTarget = freestyle } + } + ]); + + return buttons; + } + + protected override ModSelectOverlay CreateModSelectOverlay() => modSelect = new UserModSelectOverlay(OverlayColourScheme.Plum) + { + IsValidMod = isValidRequiredMod + }; + + /// + /// Checks whether a given is valid to be selected as a required mod. + /// + /// The to check. + private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, freestyle.Value); + + /// + /// Checks whether a given is valid to be selected as an allowed mod. + /// + /// The to check. + private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, freestyle.Value) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); } } diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index f047184d99..456e6b2a09 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -94,13 +94,7 @@ namespace osu.Game.Screens.SelectV2 /// protected bool ControlGlobalMusic { get; init; } = true; - // Colour scheme for mod overlay is left as default (green) to match mods button. - // Not sure about this, but we'll iterate based on feedback. - private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay - { - ShowPresets = true, - }; - + private ModSelectOverlay modSelectOverlay = null!; private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; // Blue is the most neutral choice, so I'm using that for now. @@ -297,7 +291,7 @@ namespace osu.Game.Screens.SelectV2 RelativeSizeAxes = Axes.Both, }, modSpeedHotkeyHandler = new ModSpeedHotkeyHandler(), - modSelectOverlay, + modSelectOverlay = CreateModSelectOverlay(), }); configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); @@ -312,6 +306,13 @@ namespace osu.Game.Screens.SelectV2 showConvertedBeatmaps = config.GetBindable(OsuSetting.ShowConvertedBeatmaps); } + // Colour scheme for mod overlay is left as default (green) to match mods button. + // Not sure about this, but we'll iterate based on feedback. + protected virtual ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay + { + ShowPresets = true, + }; + private void requestRecommendedSelection(IEnumerable groupedBeatmaps) { var recommendedBeatmap = difficultyRecommender?.GetRecommendedBeatmap(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap; From 259bc536999076421f0bbbbb9ad28e422e117731 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 12 Sep 2025 13:46:04 +0900 Subject: [PATCH 008/133] Add initial playlist button --- .../TestSceneFooterButtonPlaylistV2.cs | 37 ++++++ .../OnlinePlay/FooterButtonPlaylistV2.cs | 124 ++++++++++++++++++ .../Playlists/PlaylistsSongSelectV2.cs | 18 +++ 3 files changed, 179 insertions(+) create mode 100644 osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs create mode 100644 osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs new file mode 100644 index 0000000000..dc84102aef --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestSceneFooterButtonPlaylistV2 : OnlinePlayTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + public TestSceneFooterButtonPlaylistV2() + { + Room room = new Room(); + + Add(new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FooterButtonPlaylistV2(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.CentreLeft, + X = -100, + CreateNewItem = () => room.Playlist = room.Playlist.Append(new PlaylistItem(CreateAPIBeatmap())).ToArray() + } + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs new file mode 100644 index 0000000000..0cf515c544 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs @@ -0,0 +1,124 @@ +// 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.ComponentModel; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.Rooms; +using osu.Game.Screens.Footer; +using osu.Game.Screens.OnlinePlay.Playlists; +using osuTK; +using Container = osu.Framework.Graphics.Containers.Container; + +namespace osu.Game.Screens.OnlinePlay +{ + public partial class FooterButtonPlaylistV2 : ScreenFooterButton, IHasPopover + { + public required Action? CreateNewItem { get; init; } + + private readonly Room room; + + public FooterButtonPlaylistV2(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colour) + { + Text = "Playlist"; + Icon = FontAwesome.Solid.List; + AccentColour = colour.Purple1; + + Action = this.ShowPopover; + } + + public Popover GetPopover() => new PlaylistPopover(room) + { + CreateNewItem = CreateNewItem + }; + + private partial class PlaylistPopover : OsuPopover + { + public required Action? CreateNewItem { get; init; } + + private readonly Room room; + private PlaylistsRoomSettingsPlaylist playlist = null!; + + public PlaylistPopover(Room room) + { + this.room = room; + } + + [BackgroundDependencyLoader] + private void load() + { + Content.Padding = new MarginPadding(5); + + Add(new GridContainer + { + Size = new Vector2(300, 300), + Padding = new MarginPadding { Vertical = 10 }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Bottom = 10 }, + Child = playlist = new PlaylistsRoomSettingsPlaylist + { + RelativeSizeAxes = Axes.Both + } + } + }, + new Drawable[] + { + new RoundedButton + { + Text = "Add new playlist entry", + RelativeSizeAxes = Axes.Both, + Size = Vector2.One, + Action = () => CreateNewItem?.Invoke() + } + }, + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 50), + } + }); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + playlist.Items.BindCollectionChanged((_, __) => room.Playlist = playlist.Items.ToArray()); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomPlaylist(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateRoomPlaylist(); + } + + private void updateRoomPlaylist() + => playlist.Items.ReplaceRange(0, playlist.Items.Count, room.Playlist); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index d63c105afb..8e51ddb1a0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -5,7 +5,9 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Screens; using osu.Game.Localisation; +using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Mods; @@ -104,6 +106,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override void OnStart() { + room.Playlist = [createNewItem()]; + this.Exit(); } public override IReadOnlyList CreateFooterButtons() @@ -112,6 +116,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists buttons.Single(i => i is FooterButtonMods).TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; + buttons.Insert(0, new FooterButtonPlaylistV2(room) + { + CreateNewItem = () => room.Playlist = room.Playlist.Append(createNewItem()).ToArray() + }); + buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, [ new FooterButtonFreeModsV2(freeModSelect) @@ -133,6 +142,15 @@ namespace osu.Game.Screens.OnlinePlay.Playlists IsValidMod = isValidRequiredMod }; + private PlaylistItem createNewItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) + { + ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, + RulesetID = Ruleset.Value.OnlineID, + RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), + AllowedMods = freeMods.Value.Select(m => new APIMod(m)).ToArray(), + Freestyle = freestyle.Value + }; + /// /// Checks whether a given is valid to be selected as a required mod. /// From 3c1d45b8969335bee379bd935b5f1ca53c704045 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 19 Sep 2025 16:27:56 +0900 Subject: [PATCH 009/133] Integrate v2 playlists song select with game --- osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs | 4 ++++ .../Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs index 0cf515c544..fdf6c09e69 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osu.Game.Screens.Footer; using osu.Game.Screens.OnlinePlay.Playlists; using osuTK; @@ -54,6 +55,9 @@ namespace osu.Game.Screens.OnlinePlay private readonly Room room; private PlaylistsRoomSettingsPlaylist playlist = null!; + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); + public PlaylistPopover(Room room) { this.room = room; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index fdda6f6c85..9d210e4678 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -404,7 +404,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists EditPlaylist = () => { if (this.IsCurrentScreen()) - this.Push(new PlaylistsSongSelect(room)); + this.Push(new PlaylistsSongSelectV2(room)); } } } From b663fe17aba8aa58fa59e92b6b7d92b570d17a91 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 19 Sep 2025 16:47:48 +0900 Subject: [PATCH 010/133] Fix tests --- ...t.cs => TestScenePlaylistsSongSelectV2.cs} | 44 ++++----- .../Navigation/TestSceneScreenNavigation.cs | 4 +- .../TestScenePlaylistsSongSelectV2.cs | 90 ------------------- .../Playlists/PlaylistsSongSelectV2.cs | 25 +++--- 4 files changed, 35 insertions(+), 128 deletions(-) rename osu.Game.Tests/Visual/Multiplayer/{TestScenePlaylistsSongSelect.cs => TestScenePlaylistsSongSelectV2.cs} (78%) delete mode 100644 osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs similarity index 78% rename from osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs rename to osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs index 7135ff930d..ec853669af 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs @@ -9,9 +9,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; -using osu.Framework.Screens; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; @@ -22,7 +20,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay; -using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; @@ -30,7 +27,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { - public partial class TestScenePlaylistsSongSelect : OnlinePlayTestScene + public partial class TestScenePlaylistsSongSelectV2 : OnlinePlayTestScene { private RulesetStore rulesets = null!; private BeatmapManager manager = null!; @@ -69,47 +66,47 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect(room))); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); + AddUntilStep("wait for song select", () => songSelect.IsLoaded && !songSelect.IsFiltering); } [Test] public void TestItemAddedIfEmptyOnStart() { - AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddStep("finalise selection", () => InputManager.Key(Key.Enter)); AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] public void TestItemAddedWhenCreateNewItemClicked() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] public void TestItemNotAddedIfExistingOnStart() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("finalise selection", () => songSelect.FinaliseSelection()); + AddStep("create new item", () => songSelect.AddNewItem()); + AddStep("finalise selection", () => InputManager.Key(Key.Enter)); AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } [Test] public void TestAddSameItemMultipleTimes() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); + AddStep("create new item", () => songSelect.AddNewItem()); AddAssert("playlist has 2 items", () => room.Playlist.Count == 2); } [Test] public void TestAddItemAfterRearrangement() { - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); + AddStep("create new item", () => songSelect.AddNewItem()); AddStep("rearrange", () => room.Playlist = room.Playlist.Skip(1).Append(room.Playlist[0]).ToArray()); - AddStep("create new item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create new item", () => songSelect.AddNewItem()); AddAssert("new item has id 2", () => room.Playlist.Last().ID == 2); } @@ -120,9 +117,9 @@ namespace osu.Game.Tests.Visual.Multiplayer public void TestNewItemHasNewModInstances() { AddStep("set dt mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create item", () => songSelect.AddNewItem()); AddStep("change mod rate", () => ((OsuModDoubleTime)SelectedMods.Value[0]).SpeedChange.Value = 2); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create item", () => songSelect.AddNewItem()); AddAssert("item 1 has rate 1.5", () => { @@ -153,7 +150,7 @@ namespace osu.Game.Tests.Visual.Multiplayer mod = (OsuModDoubleTime)SelectedMods.Value[0]; }); - AddStep("create item", () => songSelect.BeatmapDetails.CreateNewItem!()); + AddStep("create item", () => songSelect.AddNewItem()); AddStep("change stored mod rate", () => mod.SpeedChange.Value = 2); AddAssert("item has rate 1.5", () => @@ -166,26 +163,23 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFreeModSelectionDisable() { - FooterButtonFreeMods freeMods = null!; - AddAssert("freestyle enabled", () => songSelect.Freestyle.Value, () => Is.True); AddStep("click icon in free mods button", () => { - freeMods = this.ChildrenOfType().Single(); - InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddAssert("mod select not visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); AddStep("toggle freestyle off", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddAssert("freestyle disabled", () => songSelect.Freestyle.Value, () => Is.False); AddStep("click icon in free mods button", () => { - InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); AddAssert("mod select visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); @@ -199,10 +193,8 @@ namespace osu.Game.Tests.Visual.Multiplayer rulesets.Dispose(); } - private partial class TestPlaylistsSongSelect : PlaylistsSongSelect + private partial class TestPlaylistsSongSelect : PlaylistsSongSelectV2 { - public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; - public new IBindable Freestyle => base.Freestyle; public TestPlaylistsSongSelect(Room room) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 8a0c9f561c..04e1da5b9b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -92,7 +92,7 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("edit playlist", () => InputManager.Key(Key.Enter)); - AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + AddUntilStep("wait for song select", () => playlistScreen.CurrentSubScreen is PlaylistsSongSelectV2 songSelect && songSelect.IsLoaded && !songSelect.IsFiltering); AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); @@ -106,7 +106,7 @@ namespace osu.Game.Tests.Visual.Navigation InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for song select", () => (playlistScreen.CurrentSubScreen as PlaylistsSongSelect)?.BeatmapSetsLoaded == true); + AddUntilStep("wait for song select", () => playlistScreen.CurrentSubScreen is PlaylistsSongSelectV2 songSelect && songSelect.IsLoaded && !songSelect.IsFiltering); AddStep("press home button", () => { diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs deleted file mode 100644 index 923970e921..0000000000 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsSongSelectV2.cs +++ /dev/null @@ -1,90 +0,0 @@ -// 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.Linq; -using osu.Framework.Allocation; -using osu.Framework.Audio; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Framework.Platform; -using osu.Framework.Testing; -using osu.Game.Beatmaps; -using osu.Game.Configuration; -using osu.Game.Database; -using osu.Game.Online.Rooms; -using osu.Game.Rulesets; -using osu.Game.Scoring; -using osu.Game.Screens.OnlinePlay.Playlists; -using osu.Game.Screens.SelectV2; -using osu.Game.Tests.Resources; -using osu.Game.Tests.Visual.OnlinePlay; - -namespace osu.Game.Tests.Visual.Playlists -{ - public class TestScenePlaylistsSongSelectV2 : OnlinePlayTestScene - { - private BeatmapManager beatmaps = null!; - private RealmRulesetStore rulesets = null!; - private OsuConfigManager config = null!; - private ScoreManager scoreManager = null!; - private RealmDetachedBeatmapStore beatmapStore = null!; - - private PlaylistsSongSelectV2 songSelect = null!; - - private BeatmapCarousel Carousel => songSelect.ChildrenOfType().Single(); - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - { - var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - - // These DI caches are required to ensure for interactive runs this test scene doesn't nuke all user beatmaps in the local install. - // At a point we have isolated interactive test runs enough, this can likely be removed. - dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); - dependencies.Cache(Realm); - dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, Dependencies.Get(), Resources, Dependencies.Get(), Beatmap.Default)); - dependencies.Cache(config = new OsuConfigManager(LocalStorage)); - dependencies.Cache(scoreManager = new ScoreManager(rulesets, () => beatmaps, LocalStorage, Realm, API, config)); - - dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); - - return dependencies; - } - - [BackgroundDependencyLoader] - private void load() - { - Add(beatmapStore); - } - - public override void SetUpSteps() - { - base.SetUpSteps(); - - ImportBeatmapForRuleset(0); - - AddStep("load screen", () => LoadScreen(songSelect = new PlaylistsSongSelectV2(new Room()))); - AddUntilStep("wait for load", () => Stack.CurrentScreen == songSelect && songSelect.IsLoaded); - AddUntilStep("wait for filtering", () => !Carousel.IsFiltering); - } - - protected void ImportBeatmapForRuleset(params int[] rulesetIds) => ImportBeatmapForRuleset(_ => { }, 3, rulesetIds); - - protected void ImportBeatmapForRuleset(Action applyToBeatmap, int difficultyCount, params int[] rulesetIds) - { - int beatmapsCount = 0; - - AddStep($"import test map for ruleset {rulesetIds}", () => - { - beatmapsCount = songSelect.IsNull() ? 0 : Carousel.Filters.OfType().Single().SetItems.Count; - - var beatmapSet = TestResources.CreateTestBeatmapSetInfo(difficultyCount, rulesets.AvailableRulesets.Where(r => rulesetIds.Contains(r.OnlineID)).ToArray()); - applyToBeatmap(beatmapSet); - beatmaps.Import(beatmapSet); - }); - - // This is specifically for cases where the add is happening post song select load. - // For cases where song select is null, the assertions are provided by the load checks. - AddUntilStep("wait for imported to arrive in carousel", () => songSelect.IsNull() || Carousel.Filters.OfType().Single().SetItems.Count > beatmapsCount); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index 8e51ddb1a0..b4a644342c 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -21,8 +21,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public class PlaylistsSongSelectV2 : SongSelect { + protected readonly Bindable Freestyle = new Bindable(true); private readonly Bindable> freeMods = new Bindable>([]); - private readonly Bindable freestyle = new Bindable(true); private readonly Room room; private ModSelectOverlay modSelect = null!; @@ -49,11 +49,16 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); - freestyle.BindValueChanged(onFreestyleChanged); + Freestyle.BindValueChanged(onFreestyleChanged); updateValidMods(); } + public void AddNewItem() + { + room.Playlist = room.Playlist.Append(createItem()).ToArray(); + } + private void onGlobalModsChanged(ValueChangedEvent> mods) { updateValidMods(); @@ -106,7 +111,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override void OnStart() { - room.Playlist = [createNewItem()]; + room.Playlist = [createItem()]; this.Exit(); } @@ -118,7 +123,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists buttons.Insert(0, new FooterButtonPlaylistV2(room) { - CreateNewItem = () => room.Playlist = room.Playlist.Append(createNewItem()).ToArray() + CreateNewItem = AddNewItem }); buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, @@ -126,11 +131,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new FooterButtonFreeModsV2(freeModSelect) { FreeMods = { BindTarget = freeMods }, - Freestyle = { BindTarget = freestyle } + Freestyle = { BindTarget = Freestyle } }, new FooterButtonFreestyleV2 { - Freestyle = { BindTarget = freestyle } + Freestyle = { BindTarget = Freestyle } } ]); @@ -142,26 +147,26 @@ namespace osu.Game.Screens.OnlinePlay.Playlists IsValidMod = isValidRequiredMod }; - private PlaylistItem createNewItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) + private PlaylistItem createItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) { ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, RulesetID = Ruleset.Value.OnlineID, RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), AllowedMods = freeMods.Value.Select(m => new APIMod(m)).ToArray(), - Freestyle = freestyle.Value + Freestyle = Freestyle.Value }; /// /// Checks whether a given is valid to be selected as a required mod. /// /// The to check. - private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, freestyle.Value); + private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); /// /// Checks whether a given is valid to be selected as an allowed mod. /// /// The to check. - private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, freestyle.Value) + private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) // Mod must not be contained in the required mods. && Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must be compatible with all the required mods. From f38162c550036ea78ed5a974d7139c2c41687272 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 25 Sep 2025 11:10:35 +0900 Subject: [PATCH 011/133] Fix padding as subscreen --- .../Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs | 4 ++++ osu.Game/Screens/SelectV2/SongSelect.cs | 3 +++ 2 files changed, 7 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index b4a644342c..ea7eba23b3 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Localisation; using osu.Game.Online.API; @@ -31,6 +32,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public PlaylistsSongSelectV2(Room room) { this.room = room; + + Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; + LeftPadding = new MarginPadding { Top = CORNER_RADIUS_HIDE_OFFSET + Header.HEIGHT }; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 456e6b2a09..8505bf0165 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -94,6 +94,8 @@ namespace osu.Game.Screens.SelectV2 /// protected bool ControlGlobalMusic { get; init; } = true; + protected MarginPadding LeftPadding { get; init; } + private ModSelectOverlay modSelectOverlay = null!; private ModSpeedHotkeyHandler modSpeedHotkeyHandler = null!; @@ -222,6 +224,7 @@ namespace osu.Game.Screens.SelectV2 RelativeSizeAxes = Axes.Both, Spacing = new Vector2(0f, 4f), Direction = FillDirection.Vertical, + Padding = LeftPadding, Children = new Drawable[] { new ShearAligningWrapper(titleWedge = new BeatmapTitleWedge()), From 798f6bdcc79929c8548b8a587e808d1f18dac16f Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 25 Sep 2025 11:43:34 +0900 Subject: [PATCH 012/133] Fix padding not considered in sizing --- osu.Game/Screens/SelectV2/SongSelect.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 8505bf0165..aaa029f684 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -396,7 +396,7 @@ namespace osu.Game.Screens.SelectV2 { base.Update(); - detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4; + detailsArea.Height = wedgesContainer.ChildSize.Y - titleWedge.LayoutSize.Y - 4; float widescreenBonusWidth = Math.Max(0, DrawWidth / DrawHeight - 2f); From 0777bdbfc54c0166a5c496063f72366cb418411e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 25 Sep 2025 11:11:24 +0900 Subject: [PATCH 013/133] Add screen title --- .../Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index ea7eba23b3..b0c2bda0dc 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -20,8 +21,12 @@ using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsSongSelectV2 : SongSelect + public class PlaylistsSongSelectV2 : SongSelect, IOnlinePlaySubScreen { + public string ShortTitle => "song selection"; + + public override string Title => ShortTitle.Humanize(); + protected readonly Bindable Freestyle = new Bindable(true); private readonly Bindable> freeMods = new Bindable>([]); From 67849ca4180924e31c26f2b511ecceb09d3f64c9 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 25 Sep 2025 12:13:40 +0900 Subject: [PATCH 014/133] Adjust button styling --- .../TestSceneFooterButtonFreeModsV2.cs | 23 ++++++- .../OnlinePlay/FooterButtonFreeModsV2.cs | 24 +++++-- .../OnlinePlay/FooterButtonFreestyleV2.cs | 62 ++++++++----------- 3 files changed, 67 insertions(+), 42 deletions(-) diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs index 7eb82d5fd1..222b9a80f8 100644 --- a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonFreeModsV2.cs @@ -2,6 +2,7 @@ // 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.Game.Overlays; @@ -16,20 +17,36 @@ namespace osu.Game.Tests.Visual.Playlists [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + private readonly FooterButtonFreeModsV2 button; + public TestSceneFooterButtonFreeModsV2() { ModSelectOverlay modSelectOverlay; Add(modSelectOverlay = new TestModSelectOverlay()); - - FooterButtonFreeModsV2 button; Add(button = new FooterButtonFreeModsV2(modSelectOverlay) { Anchor = Anchor.Centre, Origin = Anchor.CentreLeft, X = -100, }); + } - button.FreeMods.Value = new OsuRuleset().CreateAllMods().ToArray(); + [Test] + public void TestAllMods() + { + AddStep("all mods", () => button.FreeMods.Value = new OsuRuleset().CreateAllMods().ToArray()); + } + + [Test] + public void TestNoMods() + { + AddStep("no mods", () => button.FreeMods.Value = []); + } + + [Test] + public void TestFreestyle() + { + AddToggleStep("toggle freestyle", v => button.Freestyle.Value = v); } private partial class TestModSelectOverlay : UserModSelectOverlay diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs index 37755cd326..d9cb0acb4f 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs @@ -27,8 +27,8 @@ namespace osu.Game.Screens.OnlinePlay { private const float bar_height = 30f; - public readonly Bindable> FreeMods = new Bindable>(); - public readonly IBindable Freestyle = new Bindable(); + public readonly Bindable> FreeMods = new Bindable>([]); + public readonly Bindable Freestyle = new Bindable(); public new Action Action { @@ -104,7 +104,11 @@ namespace osu.Game.Screens.OnlinePlay Current = { BindTarget = FreeMods }, ExpansionMode = ExpansionMode.AlwaysContracted, }, - overflowModCountDisplay = new ModCountText { Mods = { BindTarget = FreeMods }, }, + overflowModCountDisplay = new ModCountText + { + Mods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = Freestyle } + }, } }, } @@ -135,6 +139,7 @@ namespace osu.Game.Screens.OnlinePlay private partial class ModCountText : CompositeDrawable { public readonly Bindable> Mods = new Bindable>(); + public readonly Bindable Freestyle = new Bindable(); private OsuSpriteText text = null!; @@ -164,7 +169,18 @@ namespace osu.Game.Screens.OnlinePlay } }; - Mods.BindValueChanged(v => text.Text = ModSelectOverlayStrings.Mods(v.NewValue.Count).ToUpper(), true); + Mods.BindValueChanged(_ => updateText()); + Freestyle.BindValueChanged(_ => updateText()); + + updateText(); + } + + private void updateText() + { + if (Freestyle.Value) + text.Text = "ALL MODS"; + else + text.Text = ModSelectOverlayStrings.Mods(Mods.Value.Count).ToUpper(); } } } diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs index 5381c64cc4..6d39ee92e7 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Screens.Footer; using osuTK; +using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay { @@ -34,6 +35,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + private Drawable statusBackground = null!; + private OsuSpriteText statusText = null!; + public FooterButtonFreestyleV2() { // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. @@ -66,56 +70,44 @@ namespace osu.Game.Screens.OnlinePlay Colour = Colour4.Black.Opacity(0.25f), Offset = new Vector2(0, 2), }, - Children = new Drawable[] + Children = new[] { - new Box + statusBackground = new Box { Colour = colourProvider.Background3, RelativeSizeAxes = Axes.Both, }, - new StatusText { Freestyle = { BindTarget = Freestyle } } + statusText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), + Shear = -OsuGame.SHEAR, + }, } }, }); } - private partial class StatusText : CompositeDrawable + protected override void LoadComplete() { - public readonly Bindable Freestyle = new Bindable(); + base.LoadComplete(); - private OsuSpriteText text = null!; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - protected override void LoadComplete() + Freestyle.BindValueChanged(v => { - base.LoadComplete(); - - RelativeSizeAxes = Axes.Both; - - InternalChildren = new Drawable[] + if (v.NewValue) { - new Box - { - Colour = colourProvider.Background3, - Alpha = 0.8f, - RelativeSizeAxes = Axes.Both, - }, - text = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), - Shear = -OsuGame.SHEAR, - } - }; - - Freestyle.BindValueChanged(v => + statusBackground.Colour = colours.Yellow; + statusText.Text = "ON"; + statusText.Colour = Color4.Black; + } + else { - text.Text = v.NewValue ? "ON" : "OFF"; - }, true); - } + statusBackground.Colour = colourProvider.Background3; + statusText.Text = "OFF"; + statusText.Colour = Color4.White; + } + }, true); } } } From 1cf9c04756974c464895c210837b53e558c3b23e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 25 Sep 2025 12:34:34 +0900 Subject: [PATCH 015/133] Fix playlist with >1 item getting cleared --- .../Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index b0c2bda0dc..af4476b693 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -120,7 +120,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected override void OnStart() { - room.Playlist = [createItem()]; + if (room.Playlist.Count <= 1) + room.Playlist = [createItem()]; + this.Exit(); } From 9333ef361bf5017e2f45fc7f7188866998f56484 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 25 Sep 2025 12:36:31 +0900 Subject: [PATCH 016/133] Remove old implementation --- .../Playlists/PlaylistsSongSelect.cs | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs deleted file mode 100644 index 84446ed0cf..0000000000 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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.Screens; -using osu.Game.Online.API; -using osu.Game.Online.Rooms; -using osu.Game.Screens.OnlinePlay.Components; -using osu.Game.Screens.Select; - -namespace osu.Game.Screens.OnlinePlay.Playlists -{ - public partial class PlaylistsSongSelect : OnlinePlaySongSelect - { - private readonly Room room; - - public PlaylistsSongSelect(Room room) - : base(room) - { - this.room = room; - } - - protected override BeatmapDetailArea CreateBeatmapDetailArea() => new MatchBeatmapDetailArea(room) - { - CreateNewItem = () => room.Playlist = room.Playlist.Append(createNewItem()).ToArray() - }; - - protected override bool SelectItem(PlaylistItem item) - { - if (room.Playlist.Count <= 1) - room.Playlist = [createNewItem()]; - - this.Exit(); - return true; - } - - private PlaylistItem createNewItem() => new PlaylistItem(Beatmap.Value.BeatmapInfo) - { - ID = room.Playlist.Count == 0 ? 0 : room.Playlist.Max(p => p.ID) + 1, - RulesetID = Ruleset.Value.OnlineID, - RequiredMods = Mods.Value.Select(m => new APIMod(m)).ToArray(), - AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray(), - Freestyle = Freestyle.Value - }; - } -} From c8762762f95b42aed9c707343a56ce6e2ad5f99c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 17 Oct 2025 09:19:27 +0900 Subject: [PATCH 017/133] Fix CI inspections --- osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs | 2 +- osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs index 6d39ee92e7..03d590ac6e 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs @@ -18,7 +18,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay { - public class FooterButtonFreestyleV2 : ScreenFooterButton + public partial class FooterButtonFreestyleV2 : ScreenFooterButton { private const float bar_height = 30f; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index af4476b693..cee4e549a7 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -21,7 +21,7 @@ using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Playlists { - public class PlaylistsSongSelectV2 : SongSelect, IOnlinePlaySubScreen + public partial class PlaylistsSongSelectV2 : SongSelect, IOnlinePlaySubScreen { public string ShortTitle => "song selection"; From 96dd95940fd9da26a8205245c9f7401a7ec93a01 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 18 Nov 2025 16:08:44 +0900 Subject: [PATCH 018/133] Add simple "Add to playlist" button in the footer --- .../TestScenePlaylistsSongSelectV2.cs | 6 ++ .../TestSceneAddToPlaylistFooterButton.cs | 40 +++++++++++ .../Playlists/AddToPlaylistFooterButton.cs | 68 +++++++++++++++++++ .../Playlists/PlaylistsSongSelectV2.cs | 63 +++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs create mode 100644 osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.cs diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs index ec853669af..1d78896ce7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs @@ -69,6 +69,12 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for song select", () => songSelect.IsLoaded && !songSelect.IsFiltering); } + [Test] + public void TestShowScreen() + { + AddStep("show screen", () => { }); + } + [Test] public void TestItemAddedIfEmptyOnStart() { diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs b/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs new file mode 100644 index 0000000000..a693738ad6 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Playlists; + +namespace osu.Game.Tests.Visual.Playlists +{ + public class TestSceneAddToPlaylistFooterButton : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private AddToPlaylistFooterButton button = null!; + + [SetUp] + public void Setup() => Schedule(() => + { + Child = button = new AddToPlaylistFooterButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => { } + }; + }); + + [Test] + public void TestAppearDisappear() + { + AddStep("appear", () => button.Appear()); + AddWaitStep("wait for animation", 3); + AddStep("disappear", () => button.Disappear()); + AddWaitStep("wait for animation", 3); + AddStep("appear", () => button.Appear()); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.cs b/osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.cs new file mode 100644 index 0000000000..1815bd9dcb --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/AddToPlaylistFooterButton.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Transforms; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class AddToPlaylistFooterButton : ShearedButton + { + public AddToPlaylistFooterButton() + : base(width: 220) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + DarkerColour = colours.Blue3; + LighterColour = colours.Blue1; + + ButtonContent.Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + X = -10, + Font = OsuFont.TorusAlternate.With(size: 17), + Text = "Add to playlist", + UseFullGlyphHeight = false, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + X = 35, + Font = OsuFont.TorusAlternate.With(size: 20), + Shadow = false, + Text = "+", + UseFullGlyphHeight = false, + }, + }; + } + + public void Appear() + { + FinishTransforms(); + + this.MoveToY(150f) + .FadeOut() + .MoveToY(0f, 240, Easing.OutCubic) + .FadeIn(240, Easing.OutCubic); + } + + public TransformSequence Disappear() + { + FinishTransforms(); + + return this.FadeOut(240, Easing.InOutCubic) + .MoveToY(150f, 240, Easing.InOutCubic); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index cee4e549a7..a301f19ddd 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -16,6 +16,7 @@ using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Footer; +using osu.Game.Screens.Menu; using osu.Game.Screens.SelectV2; using osu.Game.Utils; @@ -30,6 +31,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected readonly Bindable Freestyle = new Bindable(true); private readonly Bindable> freeMods = new Bindable>([]); + private readonly AddToPlaylistFooterButton addToPlaylistFooterButton; + private readonly Room room; private ModSelectOverlay modSelect = null!; private FreeModSelectOverlay freeModSelect = null!; @@ -40,6 +43,19 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; LeftPadding = new MarginPadding { Top = CORNER_RADIUS_HIDE_OFFSET + Header.HEIGHT }; + + addToPlaylistFooterButton = new AddToPlaylistFooterButton + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding + { + Bottom = OsuGame.SCREEN_EDGE_MARGIN, + Right = OsuGame.SCREEN_EDGE_MARGIN * 2 + }, + Alpha = 0, + Action = AddNewItem + }; } [BackgroundDependencyLoader] @@ -61,6 +77,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Freestyle.BindValueChanged(onFreestyleChanged); updateValidMods(); + + Footer?.Add(addToPlaylistFooterButton); } public void AddNewItem() @@ -126,6 +144,51 @@ namespace osu.Game.Screens.OnlinePlay.Playlists this.Exit(); } + public override void OnEntering(ScreenTransitionEvent e) + { + base.OnEntering(e); + + addToPlaylistFooterButton.Appear(); + } + + public override void OnResuming(ScreenTransitionEvent e) + { + base.OnResuming(e); + + addToPlaylistFooterButton.Appear(); + } + + public override void OnSuspending(ScreenTransitionEvent e) + { + base.OnSuspending(e); + + addToPlaylistFooterButton.Disappear(); + } + + public override bool OnExiting(ScreenExitEvent e) + { + if (base.OnExiting(e)) + return true; + + addToPlaylistFooterButton.Disappear().Expire(); + return false; + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + // Intentionally not calling base so the logo isn't shown. + } + + protected override void LogoExiting(OsuLogo logo) + { + // Intentionally not calling base so the logo isn't shown. + } + + protected override void LogoSuspending(OsuLogo logo) + { + // Intentionally not calling base so the logo isn't shown. + } + public override IReadOnlyList CreateFooterButtons() { var buttons = base.CreateFooterButtons().ToList(); From 549cc08bfe4cbde8180a05650fceb7974ce6bb35 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 18 Nov 2025 16:12:24 +0900 Subject: [PATCH 019/133] Remove add button from playlist popup --- .../TestSceneFooterButtonPlaylistV2.cs | 2 - .../OnlinePlay/FooterButtonPlaylistV2.cs | 51 ++----------------- .../Playlists/PlaylistsSongSelectV2.cs | 5 +- 3 files changed, 6 insertions(+), 52 deletions(-) diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs index dc84102aef..730696c363 100644 --- a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs +++ b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs @@ -1,7 +1,6 @@ // 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.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; @@ -29,7 +28,6 @@ namespace osu.Game.Tests.Visual.Playlists Anchor = Anchor.Centre, Origin = Anchor.CentreLeft, X = -100, - CreateNewItem = () => room.Playlist = room.Playlist.Append(new PlaylistItem(CreateAPIBeatmap())).ToArray() } }); } diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs index fdf6c09e69..f5f5024d4f 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs @@ -1,13 +1,11 @@ // 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.ComponentModel; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; @@ -18,14 +16,11 @@ using osu.Game.Overlays; using osu.Game.Screens.Footer; using osu.Game.Screens.OnlinePlay.Playlists; using osuTK; -using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay { public partial class FooterButtonPlaylistV2 : ScreenFooterButton, IHasPopover { - public required Action? CreateNewItem { get; init; } - private readonly Room room; public FooterButtonPlaylistV2(Room room) @@ -43,15 +38,10 @@ namespace osu.Game.Screens.OnlinePlay Action = this.ShowPopover; } - public Popover GetPopover() => new PlaylistPopover(room) - { - CreateNewItem = CreateNewItem - }; + public Popover GetPopover() => new PlaylistPopover(room); private partial class PlaylistPopover : OsuPopover { - public required Action? CreateNewItem { get; init; } - private readonly Room room; private PlaylistsRoomSettingsPlaylist playlist = null!; @@ -66,43 +56,12 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { - Content.Padding = new MarginPadding(5); + Content.Padding = new MarginPadding(10); - Add(new GridContainer + Child = playlist = new PlaylistsRoomSettingsPlaylist { - Size = new Vector2(300, 300), - Padding = new MarginPadding { Vertical = 10 }, - Content = new[] - { - new Drawable[] - { - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Bottom = 10 }, - Child = playlist = new PlaylistsRoomSettingsPlaylist - { - RelativeSizeAxes = Axes.Both - } - } - }, - new Drawable[] - { - new RoundedButton - { - Text = "Add new playlist entry", - RelativeSizeAxes = Axes.Both, - Size = Vector2.One, - Action = () => CreateNewItem?.Invoke() - } - }, - }, - RowDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.Absolute, 50), - } - }); + Size = new Vector2(300) + }; } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index a301f19ddd..ea5fc1536c 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -195,10 +195,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists buttons.Single(i => i is FooterButtonMods).TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; - buttons.Insert(0, new FooterButtonPlaylistV2(room) - { - CreateNewItem = AddNewItem - }); + buttons.Insert(0, new FooterButtonPlaylistV2(room)); buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, [ From bb017ade649926e5846ba50cf1d22df528b79d8c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 18 Nov 2025 17:26:34 +0900 Subject: [PATCH 020/133] Add simple tray --- .../Visual/Playlists/TestScenePlaylistTray.cs | 38 ++++++ .../PlaylistsSongSelectV2.PlaylistTray.cs | 128 ++++++++++++++++++ .../Playlists/PlaylistsSongSelectV2.cs | 21 ++- 3 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs create mode 100644 osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs new file mode 100644 index 0000000000..5efb859157 --- /dev/null +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Visual.OnlinePlay; + +namespace osu.Game.Tests.Visual.Playlists +{ + public partial class TestScenePlaylistTray : OnlinePlayTestScene + { + private Room room = null!; + private PlaylistsSongSelectV2.PlaylistTray tray = null!; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add tray", () => Child = tray = new PlaylistsSongSelectV2.PlaylistTray(room = new Room()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + } + + [Test] + public void TestAddItem() + { + AddStep("add playlist item", () => + { + room.Playlist = room.Playlist.Append(new PlaylistItem(CreateAPIBeatmap())).ToArray(); + }); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs new file mode 100644 index 0000000000..f2c210b33b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Rooms; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + public partial class PlaylistsSongSelectV2 + { + public partial class PlaylistTray : CompositeDrawable + { + private readonly Room room; + + private OsuScrollContainer scroll = null!; + private FillFlowContainer flow = null!; + + public PlaylistTray(Room room) + { + this.room = room; + + Size = new Vector2(400, 75); + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = true; + CornerRadius = 20; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = 0.9f + }, + new GridContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = DrawableRoomPlaylistItem.HEIGHT, + Padding = new MarginPadding { Horizontal = 20 }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = "Playlist", + Font = OsuFont.Default.With(size: 20) + }, + scroll = new OsuScrollContainer(Direction.Horizontal) + { + RelativeSizeAxes = Axes.Both, + Margin = new MarginPadding { Left = 10 }, + ScrollbarVisible = false, + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Direction = FillDirection.Horizontal + } + } + }, + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + room.PropertyChanged += onRoomPropertyChanged; + updateRoomPlaylist(); + } + + private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(Room.Playlist)) + updateRoomPlaylist(); + } + + private void updateRoomPlaylist() + { + if (room.Playlist.Count > 0) + { + flow.Add(new DrawableRoomPlaylistItem(room.Playlist[^1], loadImmediately: true) + { + RelativeSizeAxes = Axes.None, + Width = 250, + AllowReordering = false, + }); + } + + scroll.ScrollToStart(animated: false); + + this.FadeIn(200); + ScheduleAfterChildren(() => scroll.ScrollToEnd()); + this.Delay(1000).FadeOut(200); + } + + // Disallow the user from interacting with the scrolling elements. + public override bool PropagatePositionalInputSubTree => false; + public override bool PropagateNonPositionalInputSubTree => false; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index ea5fc1536c..2ceecf6985 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -61,10 +61,23 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load() { - AddInternal(freeModSelect = new FreeModSelectOverlay + AddRangeInternal(new Drawable[] { - SelectedMods = { BindTarget = freeMods }, - IsValidMod = isValidAllowedMod, + freeModSelect = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = freeMods }, + IsValidMod = isValidAllowedMod, + }, + new PlaylistTray(room) + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding + { + Bottom = ScreenFooterButton.HEIGHT, + Right = OsuGame.SCREEN_EDGE_MARGIN + } + } }); } @@ -195,8 +208,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists buttons.Single(i => i is FooterButtonMods).TooltipText = MultiplayerMatchStrings.RequiredModsButtonTooltip; - buttons.Insert(0, new FooterButtonPlaylistV2(room)); - buttons.InsertRange(buttons.FindIndex(b => b is FooterButtonMods) + 1, [ new FooterButtonFreeModsV2(freeModSelect) From 4b0017dfea7ac7560b3f8ab068f10c2d14fcac18 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 8 Dec 2025 16:33:53 +0900 Subject: [PATCH 021/133] Require hold-to-exit during multiplayer load --- .../Multiplayer/MultiplayerPlayerLoader.cs | 26 +++++++++++++++++++ .../Screens/Play/HUD/HoldForMenuButton.cs | 8 ++++-- osu.Game/Screens/Play/PlayerLoader.cs | 4 +-- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index dd9cb56862..4e8a2e08ce 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs @@ -4,10 +4,13 @@ using System; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -15,6 +18,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { public bool GameplayPassed => player?.GameplayState.HasPassed == true; + public override bool AllowUserExit => false; + [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; @@ -28,6 +33,27 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { } + [BackgroundDependencyLoader] + private void load() + { + PlayerSettings.Add(new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new HoldForMenuButton(true) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Margin = new MarginPadding(10), + Action = () => + { + if (this.IsCurrentScreen()) + this.Exit(); + } + } + }); + } + protected override bool ReadyForGameplay => ( // The user is ready to enter gameplay. diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 96e937fda7..c271adc057 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -44,8 +44,12 @@ namespace osu.Game.Screens.Play.HUD private Bindable alwaysShow; - public HoldForMenuButton() + private readonly bool isDangerousAction; + + public HoldForMenuButton(bool isDangerousAction = false) { + this.isDangerousAction = isDangerousAction; + Direction = FillDirection.Horizontal; Spacing = new Vector2(20, 0); Margin = new MarginPadding(10); @@ -64,7 +68,7 @@ namespace osu.Game.Screens.Play.HUD Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, - button = new HoldButton(player?.Configuration.AllowRestart == false) + button = new HoldButton(isDangerousAction || player?.Configuration.AllowRestart == false) { HoverGained = () => text.FadeIn(500, Easing.OutQuint), HoverLost = () => text.FadeOut(500, Easing.OutQuint), diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 57159afd22..3209ee21fe 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -72,7 +72,7 @@ namespace osu.Game.Screens.Play /// /// A fill flow containing the player settings groups, exposed for the ability to hide it from inheritors of the player loader. /// - protected FillFlowContainer PlayerSettings { get; private set; } = null!; + protected FillFlowContainer PlayerSettings { get; private set; } = null!; protected VisualSettings VisualSettings { get; private set; } = null!; @@ -233,7 +233,7 @@ namespace osu.Game.Screens.Play Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, Padding = new MarginPadding { Vertical = padding }, Masking = false, - Child = PlayerSettings = new FillFlowContainer + Child = PlayerSettings = new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, From 5f9639fc3f9f8c2aa0a8ee96cb50379a0639367b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 17 Dec 2025 19:32:07 +0900 Subject: [PATCH 022/133] Fix inspections --- .../Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs | 2 +- osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs | 3 +-- .../OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs b/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs index a693738ad6..b504671c99 100644 --- a/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs +++ b/osu.Game.Tests/Visual/Playlists/TestSceneAddToPlaylistFooterButton.cs @@ -9,7 +9,7 @@ using osu.Game.Screens.OnlinePlay.Playlists; namespace osu.Game.Tests.Visual.Playlists { - public class TestSceneAddToPlaylistFooterButton : OsuTestScene + public partial class TestSceneAddToPlaylistFooterButton : OsuTestScene { [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs index 5efb859157..e64d74ecba 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistTray.cs @@ -13,13 +13,12 @@ namespace osu.Game.Tests.Visual.Playlists public partial class TestScenePlaylistTray : OnlinePlayTestScene { private Room room = null!; - private PlaylistsSongSelectV2.PlaylistTray tray = null!; public override void SetUpSteps() { base.SetUpSteps(); - AddStep("add tray", () => Child = tray = new PlaylistsSongSelectV2.PlaylistTray(room = new Room()) + AddStep("add tray", () => Child = new PlaylistsSongSelectV2.PlaylistTray(room = new Room()) { Anchor = Anchor.Centre, Origin = Anchor.Centre diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs index f2c210b33b..b237e707f8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.ComponentModel; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; From b71a26fcecf3fa70d7b2743c47a75bcedd91b199 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 17 Dec 2025 19:51:42 +0900 Subject: [PATCH 023/133] Adjust tray to feel better --- .../Playlists/PlaylistsRoomSettingsOverlay.cs | 2 +- .../PlaylistsSongSelectV2.PlaylistTray.cs | 149 ++++++++++++------ .../Playlists/PlaylistsSongSelectV2.cs | 8 +- 3 files changed, 102 insertions(+), 57 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 9c0363f40e..378410d77d 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -241,7 +241,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { RelativeSizeAxes = Axes.X, Height = 40, - Text = "Edit playlist", + Text = "+ Add more beatmaps", Action = () => EditPlaylist?.Invoke() } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs index b237e707f8..1b44106e36 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs @@ -3,15 +3,19 @@ using System.ComponentModel; using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.Rooms; +using osu.Game.Overlays; using osuTK; -using osuTK.Graphics; +using Container = osu.Framework.Graphics.Containers.Container; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -23,64 +27,101 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private OsuScrollContainer scroll = null!; private FillFlowContainer flow = null!; + private OsuSpriteText text = null!; public PlaylistTray(Room room) { this.room = room; - - Size = new Vector2(400, 75); } [BackgroundDependencyLoader] - private void load() + private void load(OverlayColourProvider colourProvider) { + Size = new Vector2(500, 75); + Masking = true; CornerRadius = 20; - - InternalChildren = new Drawable[] + EdgeEffect = new EdgeEffectParameters { - new Box + Type = EdgeEffectType.Shadow, + Colour = colourProvider.Background6.Opacity(0.2f), + Offset = new Vector2(2), + Radius = 8, + }; + + InternalChild = new BufferedContainer(pixelSnapping: true) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = Color4.Black, - Alpha = 0.9f - }, - new GridContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.X, - Height = DrawableRoomPlaylistItem.HEIGHT, - Padding = new MarginPadding { Horizontal = 20 }, - ColumnDimensions = new[] + new Box { - new Dimension(GridSizeMode.AutoSize), - new Dimension() + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, }, - Content = new[] + new GridContainer { - new Drawable[] + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.X, + Height = DrawableRoomPlaylistItem.HEIGHT, + Padding = new MarginPadding { Horizontal = 10 }, + ColumnDimensions = new[] { - new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Text = "Playlist", - Font = OsuFont.Default.With(size: 20) - }, - scroll = new OsuScrollContainer(Direction.Horizontal) - { - RelativeSizeAxes = Axes.Both, - Margin = new MarginPadding { Left = 10 }, - ScrollbarVisible = false, - Child = flow = new FillFlowContainer - { - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Direction = FillDirection.Horizontal - } - } + new Dimension(GridSizeMode.AutoSize), + new Dimension() }, + Content = new[] + { + new Drawable[] + { + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + text = new OsuSpriteText + { + Font = OsuFont.Style.Heading2, + }, + new OsuSpriteText + { + Y = 20, + Font = OsuFont.Style.Caption2, + Text = "Manage items on previous screen" + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + scroll = new OsuScrollContainer(Direction.Horizontal) + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Spacing = new Vector2(5), + Direction = FillDirection.Horizontal + } + }, + new Box + { + Colour = ColourInfo.GradientHorizontal(colourProvider.Background3, colourProvider.Background3.Opacity(0)), + RelativeSizeAxes = Axes.Y, + X = -1, + Width = 60, + }, + } + }, + }, + } } } }; @@ -92,6 +133,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists room.PropertyChanged += onRoomPropertyChanged; updateRoomPlaylist(); + + this.FadeOut(); } private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -104,19 +147,27 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { if (room.Playlist.Count > 0) { - flow.Add(new DrawableRoomPlaylistItem(room.Playlist[^1], loadImmediately: true) + var newItem = new DrawableRoomPlaylistItem(room.Playlist[^1], loadImmediately: true) { RelativeSizeAxes = Axes.None, Width = 250, AllowReordering = false, - }); + }; + + if (flow.Count > 1) + flow[0].Expire(); + + flow.Add(newItem); + + scroll.ScrollToStart(animated: false); + ScheduleAfterChildren(() => scroll.ScrollToEnd()); + + Scheduler.AddDelayed(() => text.Text = $"{room.Playlist.Count} item(s)", 100); } - scroll.ScrollToStart(animated: false); - - this.FadeIn(200); - ScheduleAfterChildren(() => scroll.ScrollToEnd()); - this.Delay(1000).FadeOut(200); + this.FadeIn(200) + .Delay(2000) + .FadeOut(200); } // Disallow the user from interacting with the scrolling elements. diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index 2ceecf6985..b1adcadbc0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -149,13 +149,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists freeModSelect.IsValidMod = isValidAllowedMod; } - protected override void OnStart() - { - if (room.Playlist.Count <= 1) - room.Playlist = [createItem()]; - - this.Exit(); - } + protected override void OnStart() => AddNewItem(); public override void OnEntering(ScreenTransitionEvent e) { From 206578bf10708672f3607df41a150d37c605b355 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 25 Dec 2025 21:39:17 +0900 Subject: [PATCH 024/133] Always flash button when adding a new item to playlist --- .../Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index b1adcadbc0..586898f414 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -149,7 +149,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists freeModSelect.IsValidMod = isValidAllowedMod; } - protected override void OnStart() => AddNewItem(); + protected override void OnStart() + { + addToPlaylistFooterButton.TriggerClick(); + } public override void OnEntering(ScreenTransitionEvent e) { From 60d9c358b8ec6a4be778078956c907a4d47c0360 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 15:43:43 +0900 Subject: [PATCH 025/133] Move top user tags enumeration to helper --- .../API/Requests/Responses/APIBeatmap.cs | 19 +++++++++++++++++++ osu.Game/Overlays/BeatmapSet/Info.cs | 17 +---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index cbd8833fe8..6fd8416270 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Extensions; @@ -115,6 +117,23 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"owners")] public BeatmapOwner[] BeatmapOwners { get; set; } = Array.Empty(); + public APITag[] GetTopUserTags() + { + if (TopTags == null || TopTags.Length == 0 || BeatmapSet?.RelatedTags == null) + return []; + + var tagsById = BeatmapSet.RelatedTags.ToDictionary(t => t.Id); + + return TopTags + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => t.relatedTag!) + .ToArray(); + } + #region Implementation of IBeatmapInfo public IBeatmapMetadataInfo Metadata => (BeatmapSet as IBeatmapSetInfo)?.Metadata ?? new BeatmapMetadata(); diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 96e6622507..f7e3fa93c5 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -130,21 +129,7 @@ namespace osu.Game.Overlays.BeatmapSet private void updateUserTags() { - if (Beatmap.Value?.TopTags == null || Beatmap.Value.TopTags.Length == 0 || BeatmapSet.Value?.RelatedTags == null) - { - userTags.Metadata = null; - return; - } - - var tagsById = BeatmapSet.Value.RelatedTags.ToDictionary(t => t.Id); - userTags.Metadata = Beatmap.Value.TopTags - .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) - .Where(t => t.relatedTag != null) - // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria - .OrderByDescending(t => t.topTag.VoteCount) - .ThenBy(t => t.relatedTag!.Name) - .Select(t => t.relatedTag!.Name) - .ToArray(); + userTags.Metadata = Beatmap.Value.GetTopUserTags().Select(t => t.Name).ToArray(); } [BackgroundDependencyLoader] From 69fee16eeef9beb61072b49b25908f3a82ca3cb2 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 15:44:03 +0900 Subject: [PATCH 026/133] Add top tag and difficulty attributes --- .../TestSceneBeatmapSelectPanel.cs | 34 ++++- ...tchmakingSelectPanel.CardContentBeatmap.cs | 139 +++++++++++++++++- 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs index 4d2cf0bffe..4b2e470f36 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs @@ -5,6 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Beatmaps; using osu.Game.Graphics.Cursor; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -45,10 +46,41 @@ namespace osu.Game.Tests.Visual.Matchmaking AddStep("add panel", () => { + var beatmap = CreateAPIBeatmap(); + + beatmap.TopTags = + [ + new APIBeatmapTag { TagId = 4, VoteCount = 1 }, + new APIBeatmapTag { TagId = 2, VoteCount = 1 }, + new APIBeatmapTag { TagId = 23, VoteCount = 5 }, + ]; + + beatmap.BeatmapSet!.RelatedTags = + [ + new APITag + { + Id = 2, + Name = "song representation/simple", + Description = "Accessible and straightforward map design." + }, + new APITag + { + Id = 4, + Name = "style/clean", + Description = "Visually uncluttered and organised patterns, often involving few overlaps and equal visual spacing between objects." + }, + new APITag + { + Id = 23, + Name = "aim/aim control", + Description = "Patterns with velocity or direction changes which strongly go against a player's natural movement pattern." + } + ]; + Child = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = panel = new MatchmakingSelectPanelBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), CreateAPIBeatmap(), [])) + Child = panel = new MatchmakingSelectPanelBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), beatmap, [])) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs index b27ab2850b..ee7b2740a6 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -10,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; @@ -26,6 +28,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; using osuTK; @@ -44,6 +47,9 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect [Resolved] private BeatmapSetOverlay? beatmapSetOverlay { get; set; } + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + private readonly IBindable downloadState = new Bindable(); private readonly IBindableNumber downloadProgress = new BindableDouble(); private readonly Bindable favouriteState = new Bindable(); @@ -56,6 +62,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect private FillFlowContainer idleBottomContent = null!; private BeatmapCardDownloadProgressBar downloadProgressBar = null!; private AvatarOverlay selectionOverlay = null!; + private OsuTextFlowContainer beatmapAttributesText = null!; public CardContentBeatmap(APIBeatmap beatmap, Mod[] mods) { @@ -181,6 +188,28 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect }), } }, + new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + Children = new Drawable[] + { + new TopTagPill(beatmap) + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + beatmapAttributesText = new OsuTextFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + } + } + }, new Container { Name = @"Bottom content", @@ -255,13 +284,18 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect }, } }, - new ModFlowDisplay + new Container { AutoSizeAxes = Axes.Both, - Scale = new Vector2(0.5f), - Margin = new MarginPadding { Left = 5 }, - Current = { Value = mods } - }, + Alpha = mods.Length > 0 ? 1 : 0, + Child = new ModFlowDisplay + { + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Margin = new MarginPadding { Left = 5 }, + Current = { Value = mods }, + } + } }, } }, @@ -322,6 +356,62 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect Margin = new MarginPadding { Left = 4 } }; } + + bool firstAttribute = true; + + foreach (var attribute in getBeatmapAttributes()) + { + if (!firstAttribute) + { + beatmapAttributesText.AddText(@" / ", s => + { + font(s, false); + s.Spacing = new Vector2(-2, 0); + }); + } + + beatmapAttributesText.AddText(attribute.heading, s => font(s, false)); + beatmapAttributesText.AddText(@" ", s => font(s, false)); + beatmapAttributesText.AddText(attribute.content, s => font(s, true)); + + firstAttribute = false; + + static void font(SpriteText s, bool bold) + => s.Font = OsuFont.Style.Caption2.With(weight: bold ? FontWeight.Bold : FontWeight.Regular); + } + } + + private (string heading, string content)[] getBeatmapAttributes() + { + BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(beatmap.Difficulty); + foreach (var mod in mods.OfType()) + mod.ApplyToDifficulty(adjustedDifficulty); + + switch (beatmap.Ruleset.OnlineID) + { + default: + return new (string heading, string content)[] + { + ("CS", $"{adjustedDifficulty.CircleSize:0.#}"), + ("AR", $"{adjustedDifficulty.ApproachRate:0.#}"), + ("OD", $"{adjustedDifficulty.OverallDifficulty:0.#}"), + }; + + case 1: + case 3: + return new (string heading, string content)[] + { + ("OD", $"{adjustedDifficulty.OverallDifficulty:0.#}"), + ("HP", $"{adjustedDifficulty.DrainRate:0.#}") + }; + + case 2: + return new (string heading, string content)[] + { + ("CS", $"{adjustedDifficulty.CircleSize:0.#}"), + ("AR", $"{adjustedDifficulty.ApproachRate:0.#}"), + }; + } } protected override void LoadComplete() @@ -376,6 +466,45 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect return items.ToArray(); } } + + private partial class TopTagPill : CompositeDrawable, IHasTooltip + { + private readonly APIBeatmap beatmap; + + public TopTagPill(APIBeatmap beatmap) + { + this.beatmap = beatmap; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + InternalChild = new CircularContainer + { + AutoSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background1 + }, + new OsuSpriteText + { + Padding = new MarginPadding { Vertical = 2, Horizontal = 8 }, + Text = beatmap.GetTopUserTags().FirstOrDefault()?.Name ?? string.Empty, + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption2 + } + } + }; + } + + public LocalisableString TooltipText => string.Join('\n', beatmap.GetTopUserTags().Select(t => t.Name)); + } } } } From 10ebb5286f8303edf0fcdd661d8b802873a97936 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 17:17:38 +0900 Subject: [PATCH 027/133] Adjust metrics --- .../TestSceneBeatmapSelectPanel.cs | 11 + ...tchmakingSelectPanel.CardContentBeatmap.cs | 395 ++++++++---------- ...atchmakingSelectPanel.CardContentRandom.cs | 67 +-- .../BeatmapSelect/MatchmakingSelectPanel.cs | 15 +- .../MatchmakingSelectPanelRandom.cs | 10 +- .../Matchmaking/Match/MatchmakingAvatar.cs | 2 +- 6 files changed, 245 insertions(+), 255 deletions(-) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs index 4b2e470f36..905c4d63e5 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestSceneBeatmapSelectPanel.cs @@ -55,6 +55,11 @@ namespace osu.Game.Tests.Visual.Matchmaking new APIBeatmapTag { TagId = 23, VoteCount = 5 }, ]; + beatmap.BeatmapSet!.HasExplicitContent = true; + beatmap.BeatmapSet!.HasVideo = true; + beatmap.BeatmapSet!.HasStoryboard = true; + beatmap.BeatmapSet.FeaturedInSpotlight = true; + beatmap.BeatmapSet.TrackId = 1; beatmap.BeatmapSet!.RelatedTags = [ new APITag @@ -128,6 +133,12 @@ namespace osu.Game.Tests.Visual.Matchmaking }; }); + AddStep("add peppy", () => panel!.AddUser(new APIUser + { + Id = 2, + Username = "peppy", + })); + AddToggleStep("allow selection", value => panel!.AllowSelection = value); AddStep("reveal beatmap", () => panel!.PresentAsChosenBeatmap(new MatchmakingPlaylistItem(new MultiplayerPlaylistItem(), CreateAPIBeatmap(), []))); diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs index ee7b2740a6..f8c8b9a5aa 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -28,7 +28,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Resources.Localisation.Web; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play.HUD; using osuTK; @@ -47,9 +46,6 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect [Resolved] private BeatmapSetOverlay? beatmapSetOverlay { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } = null!; - private readonly IBindable downloadState = new Bindable(); private readonly IBindableNumber downloadProgress = new BindableDouble(); private readonly Bindable favouriteState = new Bindable(); @@ -77,157 +73,66 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect private void load(OsuColour colours) { FillFlowContainer leftIconArea; - FillFlowContainer titleBadgeArea; - GridContainer artistContainer; + Container explicitBadgeArea; InternalChildren = new Drawable[] { - new BeatmapDownloadTracker(beatmap.BeatmapSet!) + new Container { - State = { BindTarget = downloadState }, - Progress = { BindTarget = downloadProgress }, - }, - thumbnail = new BeatmapCardThumbnail(beatmapSet, beatmapSet, keepLoaded: true) - { - Name = @"Left (icon) area", - Size = new Vector2(MatchmakingSelectPanel.HEIGHT), - Padding = new MarginPadding { Right = BeatmapCard.CORNER_RADIUS }, - Child = leftIconArea = new FillFlowContainer - { - Margin = new MarginPadding(4), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(1) - } - }, - buttonContainer = new CollapsibleButtonContainer(beatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true) - { - X = MatchmakingSelectPanel.HEIGHT - BeatmapCard.CORNER_RADIUS, - Width = BeatmapCard.WIDTH - MatchmakingSelectPanel.HEIGHT + BeatmapCard.CORNER_RADIUS, - FavouriteState = { BindTarget = favouriteState }, - ButtonsCollapsedWidth = 0, - ButtonsExpandedWidth = 24, + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, Children = new Drawable[] { - new FillFlowContainer + new BeatmapDownloadTracker(beatmap.BeatmapSet!) { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize), - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new Drawable[] - { - new TruncatingSpriteText - { - Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), - Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - }, - titleBadgeArea = new FillFlowContainer - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - } - } - } - }, - artistContainer = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new[] - { - new TruncatingSpriteText - { - Text = BeatmapsetsStrings.ShowDetailsByArtist(new RomanisableString(beatmapSet.ArtistUnicode, beatmapSet.Artist)), - Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - }, - Empty() - }, - } - }, - new LinkFlowContainer(s => - { - s.Shadow = false; - s.Font = OsuFont.GetFont(size: 11f, weight: FontWeight.SemiBold); - }).With(d => - { - d.AutoSizeAxes = Axes.Both; - d.Margin = new MarginPadding { Top = 1 }; - d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); - d.AddUserLink(beatmapSet.Author); - }), - } + State = { BindTarget = downloadState }, + Progress = { BindTarget = downloadProgress }, }, - new FillFlowContainer + thumbnail = new BeatmapCardThumbnail(beatmapSet, beatmapSet, keepLoaded: true) { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2), + Name = @"Left (icon) area", + Size = new Vector2(MatchmakingSelectPanel.HEIGHT), + Padding = new MarginPadding { Right = BeatmapCard.CORNER_RADIUS }, Children = new Drawable[] { - new TopTagPill(beatmap) + leftIconArea = new FillFlowContainer { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - }, - beatmapAttributesText = new OsuTextFlowContainer - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(4), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1) + }, + explicitBadgeArea = new Container + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(4), } } }, - new Container + buttonContainer = new CollapsibleButtonContainer(beatmapSet, allowNavigationToBeatmap: false, keepBackgroundLoaded: true) { - Name = @"Bottom content", - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + X = MatchmakingSelectPanel.HEIGHT - BeatmapCard.CORNER_RADIUS, + Width = BeatmapCard.WIDTH - MatchmakingSelectPanel.HEIGHT + BeatmapCard.CORNER_RADIUS, + FavouriteState = { BindTarget = favouriteState }, + ButtonsCollapsedWidth = 0, + ButtonsExpandedWidth = 24, Children = new Drawable[] { - idleBottomContent = new FillFlowContainer + new FillFlowContainer { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.Both, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 2), - AlwaysPresent = true, Children = new Drawable[] { + new TruncatingSpriteText + { + Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), + Font = OsuFont.Default.With(size: 18f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + }, new GridContainer { RelativeSizeAxes = Axes.X, @@ -245,80 +150,160 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect { new Drawable[] { - new Container + new TruncatingSpriteText { - Masking = true, - CornerRadius = BeatmapCard.CORNER_RADIUS, + Text = BeatmapsetsStrings.ShowDetailsByArtist(new RomanisableString(beatmapSet.ArtistUnicode, beatmapSet.Artist)), + Font = OsuFont.Default.With(size: 14f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Children = new Drawable[] - { - new Box - { - Colour = colours.ForStarDifficulty(beatmap.StarRating).Darken(0.8f), - RelativeSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - Padding = new MarginPadding(4), - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(6, 0), - Children = new Drawable[] - { - new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small, animated: true) - { - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Scale = new Vector2(0.9f), - }, - new TruncatingSpriteText - { - Text = beatmap.DifficultyName, - Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - } - }, - } }, - new Container + new TopTagPill(beatmap) { - AutoSizeAxes = Axes.Both, - Alpha = mods.Length > 0 ? 1 : 0, - Child = new ModFlowDisplay - { - AutoSizeAxes = Axes.Both, - Scale = new Vector2(0.5f), - Margin = new MarginPadding { Left = 5 }, - Current = { Value = mods }, - } + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, } }, } }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new LinkFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Margin = new MarginPadding { Top = 1 }; + d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); + d.AddUserLink(beatmapSet.Author); + }), + beatmapAttributesText = new OsuTextFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + } + } + } } }, - downloadProgressBar = new BeatmapCardDownloadProgressBar + new Container { + Name = @"Bottom content", RelativeSizeAxes = Axes.X, - Height = 5, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - State = { BindTarget = downloadState }, - Progress = { BindTarget = downloadProgress } + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Children = new Drawable[] + { + idleBottomContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 2), + AlwaysPresent = true, + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + Colour = colours.ForStarDifficulty(beatmap.StarRating).Darken(0.8f), + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + Padding = new MarginPadding(4), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(6, 0), + Children = new Drawable[] + { + new StarRatingDisplay(new StarDifficulty(beatmap.StarRating, 0), StarRatingDisplaySize.Small, animated: true) + { + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Scale = new Vector2(0.9f), + }, + new TruncatingSpriteText + { + Text = beatmap.DifficultyName, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + } + }, + new Container + { + AutoSizeAxes = Axes.Both, + Alpha = mods.Length > 0 ? 1 : 0, + Child = new ModFlowDisplay + { + AutoSizeAxes = Axes.Both, + Scale = new Vector2(0.5f), + Margin = new MarginPadding { Left = 5 }, + Current = { Value = mods }, + } + } + }, + } + }, + } + }, + downloadProgressBar = new BeatmapCardDownloadProgressBar + { + RelativeSizeAxes = Axes.X, + Height = 5, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { BindTarget = downloadState }, + Progress = { BindTarget = downloadProgress } + } + } + }, + selectionOverlay = new AvatarOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Top = -20 } } } }, - selectionOverlay = new AvatarOverlay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - } } - } + }, + selectionOverlay.CreateProxy() }; if (beatmapSet.HasVideo) @@ -327,36 +312,14 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect if (beatmapSet.HasStoryboard) leftIconArea.Add(new StoryboardIconPill { IconSize = new Vector2(16) }); - if (beatmapSet.FeaturedInSpotlight) - { - titleBadgeArea.Add(new SpotlightBeatmapBadge - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 4 } - }); - } - if (beatmapSet.HasExplicitContent) { - titleBadgeArea.Add(new ExplicitContentBeatmapBadge + explicitBadgeArea.Add(new ExplicitContentBeatmapBadge { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, Margin = new MarginPadding { Left = 4 } }); } - if (beatmapSet.TrackId != null) - { - artistContainer.Content[0][1] = new FeaturedArtistBeatmapBadge - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding { Left = 4 } - }; - } - bool firstAttribute = true; foreach (var attribute in getBeatmapAttributes()) @@ -494,10 +457,12 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect }, new OsuSpriteText { - Padding = new MarginPadding { Vertical = 2, Horizontal = 8 }, + Padding = new MarginPadding { Vertical = 3, Horizontal = 8 }, Text = beatmap.GetTopUserTags().FirstOrDefault()?.Name ?? string.Empty, + AlwaysPresent = true, Colour = colourProvider.Content2, - Font = OsuFont.Style.Caption2 + Font = OsuFont.Style.Caption2, + UseFullGlyphHeight = false, } } }; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs index 0fc5a9fa46..3e4130fa8b 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentRandom.cs @@ -3,9 +3,11 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Utils; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; @@ -29,37 +31,44 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect [BackgroundDependencyLoader] private void load() { - InternalChildren = new Drawable[] + InternalChild = new Container { - new Box + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Dark5, - }, - new TrianglesV2 - { - RelativeSizeAxes = Axes.Both, - Alpha = 0.1f, - }, - Label = new OsuSpriteText - { - Y = 20, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = "Random" - }, - Dice = new SpriteIcon - { - Y = -10, - Size = new Vector2(28), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Icon = randomDiceIcon(), - }, - selectionOverlay = new AvatarOverlay - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark5, + }, + new TrianglesV2 + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.1f, + }, + Label = new OsuSpriteText + { + Y = 20, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = "Random" + }, + Dice = new SpriteIcon + { + Y = -10, + Size = new Vector2(28), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = randomDiceIcon(), + }, + selectionOverlay = new AvatarOverlay + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding { Right = 5 } + } } }; diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs index 23d5f8cfb0..4bee234786 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.cs @@ -61,21 +61,18 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect { new Container { + RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = BeatmapCard.CORNER_RADIUS, CornerExponent = 10, - RelativeSizeAxes = Axes.Both, - Children = new[] + Child = lighting = new Box { - Content, - lighting = new Box - { - Blending = BlendingParameters.Additive, - RelativeSizeAxes = Axes.Both, - Alpha = 0, - }, + Blending = BlendingParameters.Additive, + RelativeSizeAxes = Axes.Both, + Alpha = 0, } }, + Content, border = new Container { Alpha = 0, diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs index 6389766b46..d7ec134066 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanelRandom.cs @@ -6,9 +6,11 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Transforms; using osu.Framework.Input.Events; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osuTK; @@ -69,7 +71,13 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect AddRange(new Drawable[] { new CardContentBeatmap(playlistItem.Beatmap, playlistItem.Mods), - flashLayer, + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Child = flashLayer + } }); foreach (var user in users) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs index e0f46d89f0..194d0d578b 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/MatchmakingAvatar.cs @@ -51,7 +51,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match AddInternal(new Container { - Padding = new MarginPadding(2), + Padding = new MarginPadding(isOwnUser ? 2 : 0), RelativeSizeAxes = Axes.Both, Child = new CircularContainer { From a6e1713514b6d04e3bca99a76c3fc482d07c3cc7 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 17:24:29 +0900 Subject: [PATCH 028/133] Fix panel depth --- .../Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs index a3ee24d479..27cb78e579 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/BeatmapSelectGrid.cs @@ -94,6 +94,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Action = i => ItemSelected?.Invoke(i), + Depth = -(float)item.PlaylistItem.StarRating }; panelGridContainer.Add(panel); From e34d2669875e53c27cf8a94ff7b6feec6fbd6cba Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 17:37:57 +0900 Subject: [PATCH 029/133] Fix code style --- .../BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs index f8c8b9a5aa..8a5d81c018 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -75,7 +75,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect FillFlowContainer leftIconArea; Container explicitBadgeArea; - InternalChildren = new Drawable[] + InternalChildren = new[] { new Container { From 2e324fe856bd6a9c7f12c0e2aa00a580a104980c Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 17:45:31 +0900 Subject: [PATCH 030/133] Show vote count in tag tooltip --- osu.Game/Online/API/Requests/Responses/APIBeatmap.cs | 4 ++-- osu.Game/Overlays/BeatmapSet/Info.cs | 2 +- .../MatchmakingSelectPanel.CardContentBeatmap.cs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 6fd8416270..3a0afcd0ab 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -117,7 +117,7 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"owners")] public BeatmapOwner[] BeatmapOwners { get; set; } = Array.Empty(); - public APITag[] GetTopUserTags() + public (APITag Tag, int VoteCount)[] GetTopUserTags() { if (TopTags == null || TopTags.Length == 0 || BeatmapSet?.RelatedTags == null) return []; @@ -130,7 +130,7 @@ namespace osu.Game.Online.API.Requests.Responses // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria .OrderByDescending(t => t.topTag.VoteCount) .ThenBy(t => t.relatedTag!.Name) - .Select(t => t.relatedTag!) + .Select(t => (t.relatedTag!, t.topTag.VoteCount)) .ToArray(); } diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index f7e3fa93c5..9a21995234 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -129,7 +129,7 @@ namespace osu.Game.Overlays.BeatmapSet private void updateUserTags() { - userTags.Metadata = Beatmap.Value.GetTopUserTags().Select(t => t.Name).ToArray(); + userTags.Metadata = Beatmap.Value.GetTopUserTags().Select(t => t.Tag.Name).ToArray(); } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs index 8a5d81c018..e2d5fa7890 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/MatchmakingSelectPanel.CardContentBeatmap.cs @@ -458,7 +458,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect new OsuSpriteText { Padding = new MarginPadding { Vertical = 3, Horizontal = 8 }, - Text = beatmap.GetTopUserTags().FirstOrDefault()?.Name ?? string.Empty, + Text = beatmap.GetTopUserTags().FirstOrDefault().Tag?.Name ?? string.Empty, AlwaysPresent = true, Colour = colourProvider.Content2, Font = OsuFont.Style.Caption2, @@ -468,7 +468,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect }; } - public LocalisableString TooltipText => string.Join('\n', beatmap.GetTopUserTags().Select(t => t.Name)); + public LocalisableString TooltipText => string.Join('\n', beatmap.GetTopUserTags().Select(t => $"{t.Tag.Name} ({t.VoteCount})")); } } } From 8ff2329e22af78c1d81acb8be0d44c8443363b9b Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 29 Dec 2025 19:13:39 +0900 Subject: [PATCH 031/133] Fix tests --- osu.Game/Overlays/BeatmapSet/Info.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 9a21995234..e25c1370c5 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -129,7 +129,7 @@ namespace osu.Game.Overlays.BeatmapSet private void updateUserTags() { - userTags.Metadata = Beatmap.Value.GetTopUserTags().Select(t => t.Tag.Name).ToArray(); + userTags.Metadata = Beatmap.Value?.GetTopUserTags().Select(t => t.Tag.Name).ToArray(); } [BackgroundDependencyLoader] From 6e41332ea311f1880545e87eeee23b19733acb4b Mon Sep 17 00:00:00 2001 From: ILW8 Date: Tue, 30 Dec 2025 00:55:22 +0000 Subject: [PATCH 032/133] Change last year placing from integer to string --- osu.Game.Tournament.Tests/TournamentTestScene.cs | 2 +- osu.Game.Tournament/Models/TournamentTeam.cs | 6 +----- osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs | 9 +-------- osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs | 2 +- 4 files changed, 4 insertions(+), 15 deletions(-) diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index 4106556ee1..e459bb60c6 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -66,7 +66,7 @@ namespace osu.Game.Tournament.Tests Acronym = { Value = "JPN" }, FlagName = { Value = "JP" }, FullName = { Value = "Japan" }, - LastYearPlacing = { Value = 10 }, + LastYearPlacing = { Value = "#10" }, Seed = { Value = "#12" }, SeedingResults = { diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index 95858240a8..b6d9486b68 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -49,11 +49,7 @@ namespace osu.Game.Tournament.Models public Bindable Seed = new Bindable(string.Empty); - public Bindable LastYearPlacing = new BindableInt - { - MinValue = 0, - MaxValue = 256 - }; + public Bindable LastYearPlacing = new Bindable("N/A"); [JsonProperty] public BindableList Players { get; } = new BindableList(); diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 162379f4aa..05ffaf710d 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -10,9 +10,7 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Settings; using osu.Game.Tournament.Models; @@ -132,7 +130,7 @@ namespace osu.Game.Tournament.Screens.Editors Width = 0.2f, Current = Model.Seed }, - new SettingsSlider + new SettingsTextBox { LabelText = "Last Year Placement", Width = 0.33f, @@ -200,11 +198,6 @@ namespace osu.Game.Tournament.Screens.Editors }, true); } - private partial class LastYearPlacementSlider : RoundedSliderBar - { - public override LocalisableString TooltipText => Current.Value == 0 ? "N/A" : base.TooltipText; - } - public partial class PlayerEditor : CompositeDrawable { private readonly TournamentTeam team; diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 899d462e4e..ddfff54ded 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -274,7 +274,7 @@ namespace osu.Game.Tournament.Screens.TeamIntro new TeamDisplay(team) { Margin = new MarginPadding { Bottom = 30 } }, new RowDisplay("Average Rank:", $"#{team.AverageRank:#,0}"), new RowDisplay("Seed:", team.Seed.Value), - new RowDisplay("Last year's placing:", team.LastYearPlacing.Value > 0 ? $"#{team.LastYearPlacing:#,0}" : "N/A"), + new RowDisplay("Last year's placing:", team.LastYearPlacing.Value), new Container { Margin = new MarginPadding { Bottom = 30 } }, } }, From 71be574c2b48efba36e80bcfeeeb71f315e01df0 Mon Sep 17 00:00:00 2001 From: ILW8 Date: Tue, 30 Dec 2025 00:55:55 +0000 Subject: [PATCH 033/133] Update seeding screen tests to include last year placing --- .../Screens/TestSceneSeedingScreen.cs | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs index a3890bbff0..f0a81a71b4 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs @@ -23,6 +23,8 @@ namespace osu.Game.Tournament.Tests.Screens { FullName = { Value = @"Japan" }, Acronym = { Value = "JPN" }, + Seed = { Value = "#28" }, + LastYearPlacing = { Value = "#17-24" }, SeedingResults = { new SeedingResult @@ -36,20 +38,38 @@ namespace osu.Game.Tournament.Tests.Screens Seed = { Value = 8 } } } + }, + new TournamentTeam + { + Acronym = { Value = "USA" }, + FlagName = { Value = "US" }, + FullName = { Value = "United States" }, } } }; - [Test] - public void TestBasic() + [BackgroundDependencyLoader] + private void load() { - AddStep("create seeding screen", () => Add(new SeedingScreen + Add(new SeedingScreen { FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f - })); + }); + } - AddStep("set team to Japan", () => this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single()); + [Test] + public void TestBasic() + { + AddStep("set team to Japan", () => + this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single(t => t.FullName.Value == "Japan")); + } + + [Test] + public void TestNoSeed() + { + AddStep("set team to USA", () => + this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single(t => t.FullName.Value == "United States")); } } } From 1cff386b3a377a0966735da5bdfe316947a4b28f Mon Sep 17 00:00:00 2001 From: ILW8 Date: Tue, 30 Dec 2025 01:55:42 +0000 Subject: [PATCH 034/133] Adjust seed and last year placing textbox positioning in team editor --- .../Screens/Editors/TeamEditorScreen.cs | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs index 05ffaf710d..56333eb2a3 100644 --- a/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/TeamEditorScreen.cs @@ -109,43 +109,43 @@ namespace osu.Game.Tournament.Screens.Editors new SettingsTextBox { LabelText = "Name", - Width = 0.2f, + Width = 0.33f, Current = Model.FullName }, acronymTextBox = new SettingsTextBox { LabelText = "Acronym", - Width = 0.2f, + Width = 0.25f, Current = Model.Acronym }, new SettingsTextBox { LabelText = "Flag", - Width = 0.2f, + Width = 0.25f, Current = Model.FlagName }, - new SettingsTextBox - { - LabelText = "Seed", - Width = 0.2f, - Current = Model.Seed - }, - new SettingsTextBox - { - LabelText = "Last Year Placement", - Width = 0.33f, - Current = Model.LastYearPlacing - }, new SettingsButton { - Width = 0.2f, - Margin = new MarginPadding(10), + Width = 0.33f, + Margin = new MarginPadding { Top = 20 }, Text = "Edit seeding results", Action = () => { sceneManager?.SetScreen(new SeedingEditorScreen(team, parent)); } }, + new SettingsTextBox + { + LabelText = "Seed", + Width = 0.25f, + Current = Model.Seed + }, + new SettingsTextBox + { + LabelText = "Last Year Placement", + Width = 0.25f, + Current = Model.LastYearPlacing + }, playerEditor, new SettingsButton { From 191ec072a87eb335301857f34f00e4ed856f6534 Mon Sep 17 00:00:00 2001 From: ILW8 Date: Tue, 30 Dec 2025 02:31:11 +0000 Subject: [PATCH 035/133] Add conversion from `0` last year placing value to new default "N/A" --- osu.Game.Tournament/Models/TournamentTeam.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index b6d9486b68..ab353d771a 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -49,8 +49,26 @@ namespace osu.Game.Tournament.Models public Bindable Seed = new Bindable(string.Empty); + [JsonIgnore] public Bindable LastYearPlacing = new Bindable("N/A"); + /// + /// Previously, a value of 0 was meant to indicate "no placement last year". + /// This will convert the number 0 from an old bracket.json file back to the "N/A" string (new default). + /// + [JsonProperty("LastYearPlacing")] + private object lastYearPlacing + { + get => LastYearPlacing.Value; + set + { + if (value is long oldValue && oldValue == 0) + LastYearPlacing.Value = LastYearPlacing.Default; + else + LastYearPlacing.Value = value.ToString() ?? LastYearPlacing.Default; + } + } + [JsonProperty] public BindableList Players { get; } = new BindableList(); From e2a245b04948d8aabd43ca8a122c1f669e542b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 30 Dec 2025 09:57:31 +0100 Subject: [PATCH 036/133] Add minimal spread display control for beatmap set panels --- .../UserInterface/OsuAnimatedButton.cs | 3 +- .../SelectV2/PanelBeatmapSet.SpreadDisplay.cs | 204 ++++++++++++++++++ osu.Game/Screens/SelectV2/PanelBeatmapSet.cs | 11 +- 3 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 67a4cf6890..9eda065495 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -93,7 +93,8 @@ namespace osu.Game.Graphics.UserInterface base.LoadComplete(); Colour = dimColour; - Enabled.BindValueChanged(_ => this.FadeColour(dimColour, 200, Easing.OutQuint)); + Enabled.BindValueChanged(_ => this.FadeColour(dimColour, 200, Easing.OutQuint), true); + FinishTransforms(true); } private Color4 dimColour => Enabled.Value ? Color4.White : colours.Gray9; diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs new file mode 100644 index 0000000000..177d7c7162 --- /dev/null +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs @@ -0,0 +1,204 @@ +// 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.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.Input.Events; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class PanelBeatmapSet + { + public partial class SpreadDisplay : OsuAnimatedButton + { + public Bindable BeatmapSet { get; } = new Bindable(); + public BindableBool Expanded { get; } = new BindableBool(); + + private readonly Bindable scopedBeatmapSet = new Bindable(); + private readonly Bindable showConvertedBeatmaps = new Bindable(); + + private const double transition_duration = 200; + + [Resolved] + private Bindable ruleset { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private FillFlowContainer flow = null!; + private OsuSpriteText countText = null!; // TODO + private SpriteIcon icon = null!; + + public SpreadDisplay() + { + AutoSizeAxes = Axes.X; + Height = 14; + Content.CornerRadius = 5; + } + + [BackgroundDependencyLoader] + private void load(ISongSelect? songSelect, OsuConfigManager configManager) + { + Add(new FillFlowContainer + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Padding = new MarginPadding { Horizontal = 5 }, + Children = new Drawable[] + { + flow = new FillFlowContainer + { + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2), + }, + countText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Caption2, + }, + icon = new SpriteIcon + { + Size = new Vector2(12), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Eye, + Alpha = 0, + } + } + }); + + if (songSelect != null) + scopedBeatmapSet.BindTo(songSelect.ScopedBeatmapSet); + + configManager.BindWith(OsuSetting.ShowConvertedBeatmaps, showConvertedBeatmaps); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + BeatmapSet.BindValueChanged(_ => updateBeatmapSet()); + showConvertedBeatmaps.BindValueChanged(_ => updateBeatmapSet(), true); + Expanded.BindValueChanged(_ => updateEnabled()); + scopedBeatmapSet.BindValueChanged(_ => updateEnabled(), true); + Enabled.BindValueChanged(_ => updateAppearance(), true); + FinishTransforms(true); + } + + private void updateBeatmapSet() + { + if (BeatmapSet.Value == null) + { + this.FadeOut(transition_duration, Easing.OutQuint); + return; + } + + flow.Clear(); + + var starDifficulties = BeatmapSet.Value.Beatmaps + .Where(b => b.AllowGameplayWithRuleset(ruleset.Value, showConvertedBeatmaps.Value)) + .OrderBy(b => b.Ruleset.OnlineID) + .ThenBy(b => b.StarRating) + .Select(b => b.StarRating) + .ToList(); + this.FadeTo(starDifficulties.Count > 0 ? 1 : 0, transition_duration, Easing.OutQuint); + + if (starDifficulties.Count == 0) + return; + + // TODO: figure overflow later + + foreach (double starDifficulty in starDifficulties) + { + var circle = new Circle + { + Size = new Vector2(5, 10), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colours.ForStarDifficulty(starDifficulty) + }; + flow.Add(circle); + flow.SetLayoutPosition(circle, (float)starDifficulty); + } + + Action = () => scopedBeatmapSet.Value = BeatmapSet.Value; + updateEnabled(); + } + + private void updateEnabled() + { + Enabled.Value = Expanded.Value && scopedBeatmapSet.Value == null; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!Enabled.Value) + return false; + + base.OnMouseDown(e); + return true; + } + + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + return false; + + // this is a crude workaround with an issue with `OsuAnimatedButton` that isn't easily fixable. + // the issue is that when wanting to turn off the hover layer upon click, `HoverColour` can be set to a transparent colour, + // *but* this has to happen *before* `base.OnClick()`. + // this is because `base.OnClick()` uses `FlashColour()` to flash the button on click, + // but that `FlashColour()` call implicitly copies `hoverColour` *at the point of call* into the transform that ends the flash. + updateAppearance(false); + return base.OnClick(e); + } + + protected override bool OnHover(HoverEvent e) + { + updateAppearance(); + + if (!Enabled.Value) + return false; + + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + updateAppearance(); + + if (!Enabled.Value) + return; + + base.OnHoverLost(e); + } + + private void updateAppearance(bool? isInteractable = null) + { + isInteractable ??= Enabled.Value && IsHovered; + + HoverColour = isInteractable.Value ? Colour4.White.Opacity(0.1f) : Colour4.Transparent; + icon.FadeTo(isInteractable.Value ? 1 : 0, transition_duration, Easing.OutQuint); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs index befdba1b2b..95262e16e1 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -47,7 +47,7 @@ namespace osu.Game.Screens.SelectV2 private Drawable chevronIcon = null!; private PanelUpdateBeatmapButton updateButton = null!; private BeatmapSetOnlineStatusPill statusPill = null!; - private DifficultySpectrumDisplay difficultiesDisplay = null!; + private SpreadDisplay spreadDisplay = null!; [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -151,10 +151,11 @@ namespace osu.Game.Screens.SelectV2 Origin = Anchor.CentreLeft, Margin = new MarginPadding { Right = 5f, Top = -2f }, }, - difficultiesDisplay = new DifficultySpectrumDisplay + spreadDisplay = new SpreadDisplay { - Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Expanded = { BindTarget = Expanded } }, }, } @@ -200,7 +201,7 @@ namespace osu.Game.Screens.SelectV2 artistText.Text = new RomanisableString(beatmapSet.Metadata.ArtistUnicode, beatmapSet.Metadata.Artist); updateButton.BeatmapSet = beatmapSet; statusPill.Status = beatmapSet.Status; - difficultiesDisplay.BeatmapSet = beatmapSet; + spreadDisplay.BeatmapSet.Value = beatmapSet; } protected override void FreeAfterUse() @@ -211,7 +212,7 @@ namespace osu.Game.Screens.SelectV2 scheduledBackgroundRetrieval = null; setBackground.Beatmap = null; updateButton.BeatmapSet = null; - difficultiesDisplay.BeatmapSet = null; + spreadDisplay.BeatmapSet.Value = null; } [Resolved] From e07b828f30731ca4416c531cb936844d759534f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 30 Dec 2025 10:22:33 +0100 Subject: [PATCH 037/133] Add support for showing which beatmaps in a set are currently filtered out --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 18 +++++++++-- .../SelectV2/PanelBeatmapSet.SpreadDisplay.cs | 31 +++++++++++-------- osu.Game/Screens/SelectV2/PanelBeatmapSet.cs | 5 ++- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index aacebe4e88..4519e594c6 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -532,6 +532,9 @@ namespace osu.Game.Screens.SelectV2 // If a group was selected that is not the one containing the selection, attempt to reselect it. if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _)) setExpandedGroup(groupForReselection); + + foreach (var item in Scroll.Panels.OfType().Where(p => p.Item != null)) + updateVisibleBeatmaps((GroupedBeatmapSet)item.Item!.Model, item); } private void selectRecommendedDifficultyForBeatmapSet(GroupedBeatmapSet set) @@ -959,13 +962,24 @@ namespace osu.Game.Screens.SelectV2 return beatmapPanelPool.Get(); - case GroupedBeatmapSet: - return setPanelPool.Get(); + case GroupedBeatmapSet groupedBeatmapSet: + var setPanel = setPanelPool.Get(); + updateVisibleBeatmaps(groupedBeatmapSet, setPanel); + return setPanel; } throw new InvalidOperationException(); } + private void updateVisibleBeatmaps(GroupedBeatmapSet groupedBeatmapSet, PanelBeatmapSet setPanel) + { + HashSet visibleBeatmaps = []; + if (grouping.SetItems.TryGetValue(groupedBeatmapSet, out var visibleItems)) + visibleBeatmaps = visibleItems.Where(i => i.Model is GroupedBeatmap).Select(i => ((GroupedBeatmap)i.Model).Beatmap).ToHashSet(); + + setPanel.VisibleBeatmaps.Value = visibleBeatmaps; + } + #endregion #region Random selection handling diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs index 177d7c7162..3023471ee5 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -24,6 +25,8 @@ namespace osu.Game.Screens.SelectV2 public partial class SpreadDisplay : OsuAnimatedButton { public Bindable BeatmapSet { get; } = new Bindable(); + public Bindable?> VisibleBeatmaps { get; } = new Bindable?>(); + public BindableBool Expanded { get; } = new BindableBool(); private readonly Bindable scopedBeatmapSet = new Bindable(); @@ -67,7 +70,7 @@ namespace osu.Game.Screens.SelectV2 AutoSizeAxes = Axes.X, RelativeSizeAxes = Axes.Y, Direction = FillDirection.Horizontal, - Spacing = new Vector2(2), + Spacing = new Vector2(1), }, countText = new OsuSpriteText { @@ -97,6 +100,7 @@ namespace osu.Game.Screens.SelectV2 base.LoadComplete(); BeatmapSet.BindValueChanged(_ => updateBeatmapSet()); + VisibleBeatmaps.BindValueChanged(_ => updateBeatmapSet()); showConvertedBeatmaps.BindValueChanged(_ => updateBeatmapSet(), true); Expanded.BindValueChanged(_ => updateEnabled()); scopedBeatmapSet.BindValueChanged(_ => updateEnabled(), true); @@ -114,30 +118,31 @@ namespace osu.Game.Screens.SelectV2 flow.Clear(); - var starDifficulties = BeatmapSet.Value.Beatmaps - .Where(b => b.AllowGameplayWithRuleset(ruleset.Value, showConvertedBeatmaps.Value)) - .OrderBy(b => b.Ruleset.OnlineID) - .ThenBy(b => b.StarRating) - .Select(b => b.StarRating) - .ToList(); - this.FadeTo(starDifficulties.Count > 0 ? 1 : 0, transition_duration, Easing.OutQuint); + var beatmaps = BeatmapSet.Value.Beatmaps + .Where(b => b.AllowGameplayWithRuleset(ruleset.Value, showConvertedBeatmaps.Value)) + .OrderBy(b => b.Ruleset.OnlineID) + .ThenBy(b => b.StarRating) + .ToList(); + this.FadeTo(beatmaps.Count > 0 ? 1 : 0, transition_duration, Easing.OutQuint); - if (starDifficulties.Count == 0) + if (beatmaps.Count == 0) return; // TODO: figure overflow later - foreach (double starDifficulty in starDifficulties) + foreach (var beatmap in beatmaps) { + bool visible = VisibleBeatmaps.Value?.Contains(beatmap) != false; + var circle = new Circle { - Size = new Vector2(5, 10), + Size = visible ? new Vector2(7, 12) : new Vector2(5, 10), + Alpha = visible ? 1 : 0.5f, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Colour = colours.ForStarDifficulty(starDifficulty) + Colour = colours.ForStarDifficulty(beatmap.StarRating) }; flow.Add(circle); - flow.SetLayoutPosition(circle, (float)starDifficulty); } Action = () => scopedBeatmapSet.Value = BeatmapSet.Value; diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs index 95262e16e1..fb31d7a790 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.cs @@ -38,6 +38,8 @@ namespace osu.Game.Screens.SelectV2 { public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 1.6f; + public Bindable?> VisibleBeatmaps { get; } = new Bindable?>(); + private Box chevronBackground = null!; private PanelSetBackground setBackground = null!; private ScheduledDelegate? scheduledBackgroundRetrieval; @@ -155,7 +157,8 @@ namespace osu.Game.Screens.SelectV2 { Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, - Expanded = { BindTarget = Expanded } + Expanded = { BindTarget = Expanded }, + VisibleBeatmaps = { BindTarget = VisibleBeatmaps }, }, }, } From e2ed5208fec1a489e0242a8ab49c200be8bd4393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 30 Dec 2025 11:42:41 +0100 Subject: [PATCH 038/133] Bring back ruleset grouping & overflow handling to spread display --- .../SelectV2/PanelBeatmapSet.SpreadDisplay.cs | 95 +++++++++++++++---- 1 file changed, 74 insertions(+), 21 deletions(-) diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs index 3023471ee5..e7ecc9ceb7 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs @@ -5,11 +5,13 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; 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.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; @@ -40,8 +42,10 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + private FillFlowContainer flow = null!; - private OsuSpriteText countText = null!; // TODO private SpriteIcon icon = null!; public SpreadDisplay() @@ -72,12 +76,6 @@ namespace osu.Game.Screens.SelectV2 Direction = FillDirection.Horizontal, Spacing = new Vector2(1), }, - countText = new OsuSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Font = OsuFont.Style.Caption2, - }, icon = new SpriteIcon { Size = new Vector2(12), @@ -118,31 +116,86 @@ namespace osu.Game.Screens.SelectV2 flow.Clear(); + const int max_difficulties_before_collapsing = 12; + var beatmaps = BeatmapSet.Value.Beatmaps .Where(b => b.AllowGameplayWithRuleset(ruleset.Value, showConvertedBeatmaps.Value)) - .OrderBy(b => b.Ruleset.OnlineID) - .ThenBy(b => b.StarRating) .ToList(); this.FadeTo(beatmaps.Count > 0 ? 1 : 0, transition_duration, Easing.OutQuint); if (beatmaps.Count == 0) return; - // TODO: figure overflow later + bool showVisible = VisibleBeatmaps.Value == null || VisibleBeatmaps.Value?.Count <= max_difficulties_before_collapsing; + bool showHidden = beatmaps.Count <= max_difficulties_before_collapsing; - foreach (var beatmap in beatmaps) + var beatmapsByRuleset = beatmaps.GroupBy(beatmap => beatmap.Ruleset.OnlineID).OrderBy(group => group.Key); + + foreach (var rulesetGrouping in beatmapsByRuleset) { - bool visible = VisibleBeatmaps.Value?.Contains(beatmap) != false; - - var circle = new Circle + int rulesetId = rulesetGrouping.Key; + var rulesetIcon = rulesets.GetRuleset(rulesetId)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }; + flow.Add(rulesetIcon.With(i => { - Size = visible ? new Vector2(7, 12) : new Vector2(5, 10), - Alpha = visible ? 1 : 0.5f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Colour = colours.ForStarDifficulty(beatmap.StarRating) - }; - flow.Add(circle); + i.Size = new Vector2(14); + i.Anchor = i.Origin = Anchor.CentreLeft; + i.Margin = new MarginPadding { Left = flow.Count > 0 ? 9 : 0 }; + })); + + int overflowVisible = 0; + int overflowHidden = 0; + bool? lastBeatmapVisible = null; + + foreach (var beatmap in rulesetGrouping.OrderBy(beatmap => beatmap.StarRating)) + { + bool visible = VisibleBeatmaps.Value?.Contains(beatmap) != false; + + if ((visible && showVisible) || (!visible && showHidden)) + { + var circle = new Circle + { + Size = visible ? new Vector2(7, 12) : new Vector2(5, 10), + Alpha = visible ? 1 : 0.5f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Colour = colours.ForStarDifficulty(beatmap.StarRating), + Margin = new MarginPadding { Left = lastBeatmapVisible != null && lastBeatmapVisible != visible ? 1 : 0 } + }; + flow.Add(circle); + + lastBeatmapVisible = visible; + } + else + { + if (visible) + overflowVisible++; + else + overflowHidden++; + } + } + + if (overflowVisible > 0) + { + flow.Add(new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Caption2, + Text = overflowVisible.ToLocalisableString(), + }); + } + + if (overflowHidden > 0) + { + flow.Add(new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Caption2, + Text = LocalisableString.Interpolate($@"+{overflowHidden}"), + Alpha = 0.7f, + }); + } } Action = () => scopedBeatmapSet.Value = BeatmapSet.Value; From ebb898f67cb18214ad59050a18b00f22630a2963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 30 Dec 2025 12:30:47 +0100 Subject: [PATCH 039/133] Add test case --- .../TestSceneSongSelectFiltering.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs index e5d537d84e..a83ec33778 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs @@ -379,6 +379,25 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkMatchedBeatmaps(6); } + [Test] + public void TestScopeToBeatmapWhenDifficultiesGroupedBySet() + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(SortMode.Artist); + checkMatchedBeatmaps(6); + + AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); + WaitForFiltering(); + checkMatchedBeatmaps(3); + + AddStep("press Escape", () => InputManager.Key(Key.Escape)); + WaitForFiltering(); + checkMatchedBeatmaps(6); + } + private NoResultsPlaceholder? getPlaceholder() => SongSelect.ChildrenOfType().FirstOrDefault(); private void checkMatchedBeatmaps(int expected) => AddUntilStep($"{expected} matching shown", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); From 24a0de1020c6cb3bb5fdda1926303f4f036d7472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 30 Dec 2025 13:40:27 +0100 Subject: [PATCH 040/133] Allow clicking anywhere on the scoped beatmap set indicator to dismiss it --- .../FilterControl.ScopedBeatmapSetDisplay.cs | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs b/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs index d09c0ff3e3..650ef4a1a9 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.ScopedBeatmapSetDisplay.cs @@ -20,7 +20,7 @@ namespace osu.Game.Screens.SelectV2 { public partial class FilterControl { - public partial class ScopedBeatmapSetDisplay : CompositeDrawable, IKeyBindingHandler + public partial class ScopedBeatmapSetDisplay : OsuClickableContainer, IKeyBindingHandler { public Bindable ScopedBeatmapSet { @@ -29,20 +29,27 @@ namespace osu.Game.Screens.SelectV2 } private readonly BindableWithCurrent scopedBeatmapSet = new BindableWithCurrent(); + private Box flashLayer = null!; private Container content = null!; private OsuTextFlowContainer text = null!; - private ShearedButton goBackButton = null!; + + private const float transition_duration = 300; + + public ScopedBeatmapSetDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + CornerRadius = 8f; + Masking = true; + } [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - AutoSizeEasing = Easing.OutQuint; - AutoSizeDuration = 200; - CornerRadius = 8f; - Masking = true; - InternalChildren = new Drawable[] + Content.AutoSizeEasing = Easing.OutQuint; + Content.AutoSizeDuration = transition_duration; + + AddRange(new Drawable[] { new Box { @@ -71,18 +78,26 @@ namespace osu.Game.Screens.SelectV2 Colour = colourProvider.Background6, Padding = new MarginPadding { Right = 80, Vertical = 5 } }, - goBackButton = new ShearedButton(80) + new ShearedButton(80) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, Text = CommonStrings.Back, RelativeSizeAxes = Axes.Y, Height = 1, - Action = () => scopedBeatmapSet.Value = null, + Action = () => Action?.Invoke(), } } - } - }; + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White, + Blending = BlendingParameters.Additive, + Alpha = 0, + }, + }); + Action = () => scopedBeatmapSet.Value = null; } protected override void LoadComplete() @@ -94,22 +109,26 @@ namespace osu.Game.Screens.SelectV2 private void updateState() { - content.BypassAutoSizeAxes = scopedBeatmapSet.Value != null ? Axes.None : Axes.Y; - if (scopedBeatmapSet.Value != null) { + content.BypassAutoSizeAxes = Axes.None; text.Clear(); text.AddText(SongSelectStrings.TemporarilyShowingAllBeatmapsIn); text.AddText(@" "); text.AddText(scopedBeatmapSet.Value.Metadata.GetDisplayTitleRomanisable(), t => t.Font = OsuFont.Style.Body.With(weight: FontWeight.Bold)); } + else + { + flashLayer.FadeOutFromOne(transition_duration, Easing.OutQuint); + content.BypassAutoSizeAxes = Axes.Y; + } } public bool OnPressed(KeyBindingPressEvent e) { if (scopedBeatmapSet.Value != null && e.Action == GlobalAction.Back && !e.Repeat) { - goBackButton.TriggerClick(); + TriggerClick(); return true; } From 853836a48bb8dd232e94d3398977a74d0f5d9b7d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 30 Dec 2025 23:16:08 +0900 Subject: [PATCH 041/133] Fix editor seeks not being debounced enough Could lead to huge slowdowns when running multi-threaded and performing a long drag. --- .../Timelines/Summary/Parts/MarkerPart.cs | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs index d3d5de389e..1fedd6f589 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs @@ -63,16 +63,24 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts /// Whether the seek should be instant (drag end, mouse button press) or debounced (drag in progress). private void seekToPosition(Vector2 screenPosition, bool instant) { - float markerPos = Math.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth); - double seekDestination = markerPos / DrawWidth * editorClock.TrackLength; - marker.X = (float)seekDestination; + // Debounce seeks to ensure we only run one per update frame at most. + // + // Without this, we could end up seeking 1000+ times per second, leading to + // unexpected performance overheads as the editor tries to prepare for displaying + // each of the destinations. + Scheduler.AddOnce(data => + { + float markerPos = Math.Clamp(ToLocalSpace(data.screenPosition).X, 0, DrawWidth); + double seekDestination = markerPos / DrawWidth * editorClock.TrackLength; + marker.X = (float)seekDestination; - if (editorClock.IsRunning && !instant && lastSeekTime != null && Time.Current - lastSeekTime < NowPlayingOverlay.TRACK_DRAG_SEEK_DEBOUNCE) - return; + if (editorClock.IsRunning && !data.instant && lastSeekTime != null && Time.Current - lastSeekTime < NowPlayingOverlay.TRACK_DRAG_SEEK_DEBOUNCE) + return; - editorClock.Seek(seekDestination); + editorClock.Seek(seekDestination); - lastSeekTime = instant ? null : Time.Current; + lastSeekTime = data.instant ? null : Time.Current; + }, (screenPosition, instant)); } protected override void Update() From a23024d80e6790edbf9821ebf6a35cf2b1957e75 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Tue, 30 Dec 2025 18:54:28 +0300 Subject: [PATCH 042/133] Display an old-style notification if multiple users should be displayed --- osu.Game/Online/FriendPresenceNotifier.cs | 96 ++++++++++++------- .../Notifications/UserAvatarNotification.cs | 9 +- 2 files changed, 65 insertions(+), 40 deletions(-) diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 5ba5b48e59..f8e9189ec9 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -158,7 +158,13 @@ namespace osu.Game.Online return; } - notifications.Post(new FriendOnlineNotification(onlineAlertQueue.ToArray())); + var users = onlineAlertQueue.ToArray(); + + notifications.Post(users.Length switch + { + 1 => new SingleFriendOnlineNotification(users.Single()), + _ => new MultipleFriendsOnlineNotification(users), + }); onlineAlertQueue.Clear(); lastOnlineAlertTime = null; @@ -178,60 +184,84 @@ namespace osu.Game.Online return; } - notifications.Post(new FriendOfflineNotification(offlineAlertQueue.ToArray())); + var users = offlineAlertQueue.ToArray(); + + notifications.Post(users.Length switch + { + 1 => new SingleFriendOfflineNotification(users.Single()), + _ => new MultipleFriendsOfflineNotification(users), + }); offlineAlertQueue.Clear(); lastOfflineAlertTime = null; } - public partial class FriendOnlineNotification : UserAvatarNotification + public partial class SingleFriendOnlineNotification : UserAvatarNotification { - private readonly ICollection users; - - public FriendOnlineNotification(ICollection users) - : base(users.Count == 1 ? users.Single() : null) + public SingleFriendOnlineNotification(APIUser user) + : base(user) { - this.users = users; - Transient = true; IsImportant = false; - Text = $"Online: {string.Join(@", ", users.Select(u => u.Username))}"; + Text = $"Online: {User.Username}"; } [BackgroundDependencyLoader] - private void load(OsuColour colours, ChannelManager channelManager, ChatOverlay chatOverlay) + private void load(ChannelManager channelManager, ChatOverlay chatOverlay) { - if (users.Count > 1) + Activated = () => { - Icon = FontAwesome.Solid.User; - IconColour = colours.GrayD; - } - else - { - Activated = () => - { - channelManager.OpenPrivateChannel(users.Single()); - chatOverlay.Show(); + channelManager.OpenPrivateChannel(User); + chatOverlay.Show(); - return true; - }; - } + return true; + }; } public override string PopInSampleName => "UI/notification-friend-online"; } - public partial class FriendOfflineNotification : UserAvatarNotification + public partial class MultipleFriendsOnlineNotification : SimpleNotification { - private readonly ICollection users; - - public FriendOfflineNotification(ICollection users) - : base(users.Count == 1 ? users.Single() : null) + public MultipleFriendsOnlineNotification(ICollection users) { - this.users = users; + Text = $"Online: {string.Join(@", ", users.Select(u => u.Username))}"; + } + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Icon = FontAwesome.Solid.User; + IconColour = colours.Green; + } + + public override string PopInSampleName => "UI/notification-friend-online"; + } + + public partial class SingleFriendOfflineNotification : UserAvatarNotification + { + public SingleFriendOfflineNotification(APIUser user) + : base(user) + { Transient = true; IsImportant = false; + Text = $"Offline: {User.Username}"; + } + + [BackgroundDependencyLoader] + private void load() + { + Icon = FontAwesome.Solid.UserSlash; + Avatar.Colour = Color4.White.Opacity(0.25f); + } + + public override string PopInSampleName => "UI/notification-friend-offline"; + } + + public partial class MultipleFriendsOfflineNotification : SimpleNotification + { + public MultipleFriendsOfflineNotification(ICollection users) + { Text = $"Offline: {string.Join(@", ", users.Select(u => u.Username))}"; } @@ -239,11 +269,7 @@ namespace osu.Game.Online private void load(OsuColour colours) { Icon = FontAwesome.Solid.UserSlash; - - if (users.Count == 1) - Avatar.Colour = Color4.White.Opacity(0.25f); - else - IconColour = colours.Gray3; + IconColour = colours.Red; } public override string PopInSampleName => "UI/notification-friend-offline"; diff --git a/osu.Game/Overlays/Notifications/UserAvatarNotification.cs b/osu.Game/Overlays/Notifications/UserAvatarNotification.cs index fcc1d59dde..8ea79623c7 100644 --- a/osu.Game/Overlays/Notifications/UserAvatarNotification.cs +++ b/osu.Game/Overlays/Notifications/UserAvatarNotification.cs @@ -12,14 +12,13 @@ namespace osu.Game.Overlays.Notifications { public abstract partial class UserAvatarNotification : SimpleNotification { - private readonly APIUser? user; + protected readonly APIUser User; protected DrawableAvatar Avatar { get; private set; } = null!; - protected UserAvatarNotification(APIUser? user, LocalisableString text = default) + protected UserAvatarNotification(APIUser user, LocalisableString text = default) { - this.user = user; - + User = user; Icon = default; Text = text; } @@ -31,7 +30,7 @@ namespace osu.Game.Overlays.Notifications IconContent.CornerRadius = CORNER_RADIUS; IconContent.ChangeChildDepth(IconDrawable, float.MinValue); - LoadComponentAsync(Avatar = new DrawableAvatar(user) + LoadComponentAsync(Avatar = new DrawableAvatar(User) { FillMode = FillMode.Fill, }, IconContent.Add); From dad138223b9937138c210e8a6e23675a1c2343df Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Tue, 30 Dec 2025 19:36:43 +0300 Subject: [PATCH 043/133] Small refactoring --- osu.Game/Online/FriendPresenceNotifier.cs | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index f8e9189ec9..53c8ff7b5a 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -158,13 +158,10 @@ namespace osu.Game.Online return; } - var users = onlineAlertQueue.ToArray(); - - notifications.Post(users.Length switch - { - 1 => new SingleFriendOnlineNotification(users.Single()), - _ => new MultipleFriendsOnlineNotification(users), - }); + if (onlineAlertQueue.Count == 1) + notifications.Post(new SingleFriendOnlineNotification(onlineAlertQueue.Single())); + else + notifications.Post(new MultipleFriendsOnlineNotification(onlineAlertQueue.ToArray())); onlineAlertQueue.Clear(); lastOnlineAlertTime = null; @@ -184,13 +181,10 @@ namespace osu.Game.Online return; } - var users = offlineAlertQueue.ToArray(); - - notifications.Post(users.Length switch - { - 1 => new SingleFriendOfflineNotification(users.Single()), - _ => new MultipleFriendsOfflineNotification(users), - }); + if (offlineAlertQueue.Count == 1) + notifications.Post(new SingleFriendOfflineNotification(offlineAlertQueue.Single())); + else + notifications.Post(new MultipleFriendsOfflineNotification(offlineAlertQueue.ToArray())); offlineAlertQueue.Clear(); lastOfflineAlertTime = null; From 1cf14952de28ec111d2356cf0e4e8cda9e1cb697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 09:25:42 +0100 Subject: [PATCH 044/133] Privatise setter of weirdly exposed field --- osu.Game/Screens/Edit/ExternalEditScreen.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index e906d74855..19d8a7f46c 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -48,7 +48,7 @@ namespace osu.Game.Screens.Edit private Task? fileMountOperation; - public ExternalEditOperation? EditOperation; + public ExternalEditOperation? EditOperation { get; private set; } private FillFlowContainer flow = null!; From 9f40d630dc593bce5db3613b1a625bcc0c8fa0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 09:26:00 +0100 Subject: [PATCH 045/133] Do not allow multiple concurrent finishes of external beatmap edit --- osu.Game/Screens/Edit/ExternalEditScreen.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index 19d8a7f46c..baaff20c2b 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -50,6 +50,8 @@ namespace osu.Game.Screens.Edit public ExternalEditOperation? EditOperation { get; private set; } + private Task? finishOperation; + private FillFlowContainer flow = null!; [BackgroundDependencyLoader] @@ -98,11 +100,15 @@ namespace osu.Game.Screens.Edit if (fileMountOperation?.IsCompleted == false) return true; + // Similarly do not allow interrupting an ongoing finish. + if (finishOperation?.IsCompleted == false) + return true; + // If the operation completed successfully, ensure that we finish the operation before exiting. // The finish() call will subsequently call Exit() when done. - if (EditOperation != null) + if (EditOperation != null && finishOperation == null) { - finish().FireAndForget(); + (finishOperation = finish()).FireAndForget(); return true; } @@ -161,7 +167,7 @@ namespace osu.Game.Screens.Edit Width = 350, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Action = () => finish().FireAndForget(), + Action = () => (finishOperation = finish()).FireAndForget(), Enabled = { Value = false } } }; From 7e823af70b0dfafc597ae6a1c9496998a407a273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 09:26:08 +0100 Subject: [PATCH 046/133] Hide back button when finishing external edit --- osu.Game/Screens/Edit/ExternalEditScreen.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index baaff20c2b..ef72c7e1b8 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -191,6 +191,7 @@ namespace osu.Game.Screens.Edit private async Task finish() { + BackButtonVisibility.Value = false; string originalDifficulty = editor.Beatmap.Value.Beatmap.BeatmapInfo.DifficultyName; showSpinner("Cleaning up..."); From a6545bea684b40addb800cc3aec6d7c77934816b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 10:05:59 +0100 Subject: [PATCH 047/133] Remove dim effect from disabled spread displays on song select panels --- osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs | 6 +++--- osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs | 2 ++ .../SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 9eda065495..f77d00e6e7 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -92,12 +92,12 @@ namespace osu.Game.Graphics.UserInterface { base.LoadComplete(); - Colour = dimColour; - Enabled.BindValueChanged(_ => this.FadeColour(dimColour, 200, Easing.OutQuint), true); + Colour = Enabled.Value ? Colour4.White : DimColour; + Enabled.BindValueChanged(_ => this.FadeColour(DimColour, 200, Easing.OutQuint), true); FinishTransforms(true); } - private Color4 dimColour => Enabled.Value ? Color4.White : colours.Gray9; + protected virtual Colour4 DimColour => colours.Gray9; protected override bool OnHover(HoverEvent e) { diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs index e7ecc9ceb7..4cbe4521bb 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapSet.SpreadDisplay.cs @@ -31,6 +31,8 @@ namespace osu.Game.Screens.SelectV2 public BindableBool Expanded { get; } = new BindableBool(); + protected override Colour4 DimColour => Colour4.White; + private readonly Bindable scopedBeatmapSet = new Bindable(); private readonly Bindable showConvertedBeatmaps = new Bindable(); diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs index 32a48996a7..b3fa2fa01e 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.SpreadDisplay.cs @@ -27,6 +27,8 @@ namespace osu.Game.Screens.SelectV2 public Bindable Beatmap { get; } = new Bindable(); public Bindable StarDifficulty { get; } = new Bindable(); + protected override Colour4 DimColour => Colour4.White; + private readonly Bindable scopedBeatmapSet = new Bindable(); private readonly Bindable showConvertedBeatmaps = new Bindable(); From bd29c46bd74f030a51263e514ef6cc57f64eb17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 12:53:59 +0100 Subject: [PATCH 048/133] Fix tests --- osu.Game/Screens/Edit/ExternalEditScreen.cs | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Edit/ExternalEditScreen.cs b/osu.Game/Screens/Edit/ExternalEditScreen.cs index ef72c7e1b8..21eff36558 100644 --- a/osu.Game/Screens/Edit/ExternalEditScreen.cs +++ b/osu.Game/Screens/Edit/ExternalEditScreen.cs @@ -50,7 +50,8 @@ namespace osu.Game.Screens.Edit public ExternalEditOperation? EditOperation { get; private set; } - private Task? finishOperation; + private bool operationFinishStarted; + private bool operationFinished; private FillFlowContainer flow = null!; @@ -101,14 +102,14 @@ namespace osu.Game.Screens.Edit return true; // Similarly do not allow interrupting an ongoing finish. - if (finishOperation?.IsCompleted == false) + if (operationFinishStarted && !operationFinished) return true; // If the operation completed successfully, ensure that we finish the operation before exiting. // The finish() call will subsequently call Exit() when done. - if (EditOperation != null && finishOperation == null) + if (EditOperation != null && !operationFinishStarted) { - (finishOperation = finish()).FireAndForget(); + finish().FireAndForget(); return true; } @@ -167,7 +168,7 @@ namespace osu.Game.Screens.Edit Width = 350, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Action = () => (finishOperation = finish()).FireAndForget(), + Action = () => finish().FireAndForget(), Enabled = { Value = false } } }; @@ -191,6 +192,11 @@ namespace osu.Game.Screens.Edit private async Task finish() { + if (operationFinishStarted) + return; + + operationFinishStarted = true; + BackButtonVisibility.Value = false; string originalDifficulty = editor.Beatmap.Value.Beatmap.BeatmapInfo.DifficultyName; @@ -213,7 +219,11 @@ namespace osu.Game.Screens.Edit EditOperation = null; if (beatmap == null) + { + // has to be set before `Exit()` call to ensure the exit isn't blocked in `OnExiting()` + operationFinished = true; this.Exit(); + } else { // the `ImportAsUpdate()` flow will yield beatmap(sets) with online status of `None` if online lookup fails. @@ -230,6 +240,8 @@ namespace osu.Game.Screens.Edit beatmap.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == originalDifficulty) ?? beatmap.Value.Beatmaps.First(); + // has to be set before `SwitchToDifficulty()` call to ensure the exit isn't blocked in `OnExiting()` + operationFinished = true; editor.SwitchToDifficulty(closestMatchingBeatmap); } } From 04bd381ee3a1d2eff58ab79cb1a6d6cce32bbbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 31 Dec 2025 12:33:31 +0000 Subject: [PATCH 049/133] Fix number conversion setter hack used by LastYearPlacing --- osu.Game.Tournament/Models/TournamentTeam.cs | 55 +++++++++++++------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/osu.Game.Tournament/Models/TournamentTeam.cs b/osu.Game.Tournament/Models/TournamentTeam.cs index ab353d771a..4368c2fda8 100644 --- a/osu.Game.Tournament/Models/TournamentTeam.cs +++ b/osu.Game.Tournament/Models/TournamentTeam.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; @@ -49,25 +50,9 @@ namespace osu.Game.Tournament.Models public Bindable Seed = new Bindable(string.Empty); - [JsonIgnore] - public Bindable LastYearPlacing = new Bindable("N/A"); - - /// - /// Previously, a value of 0 was meant to indicate "no placement last year". - /// This will convert the number 0 from an old bracket.json file back to the "N/A" string (new default). - /// - [JsonProperty("LastYearPlacing")] - private object lastYearPlacing - { - get => LastYearPlacing.Value; - set - { - if (value is long oldValue && oldValue == 0) - LastYearPlacing.Value = LastYearPlacing.Default; - else - LastYearPlacing.Value = value.ToString() ?? LastYearPlacing.Default; - } - } + [JsonProperty] + [JsonConverter(typeof(LastYearPlacingConverter))] + public Bindable LastYearPlacing = new Bindable(@"N/A"); [JsonProperty] public BindableList Players { get; } = new BindableList(); @@ -90,5 +75,37 @@ namespace osu.Game.Tournament.Models } public override string ToString() => FullName.Value ?? Acronym.Value; + + public class LastYearPlacingConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(Bindable); + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + => serializer.Serialize(writer, ((Bindable)value!).Value); + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var lastYearPlacing = existingValue as Bindable; + Debug.Assert(lastYearPlacing != null); + + switch (reader.TokenType) + { + case JsonToken.String: + lastYearPlacing.Value = (string?)reader.Value ?? lastYearPlacing.Default; + break; + + case JsonToken.Integer: + long value = (long)reader.Value!; + lastYearPlacing.Value = value > 0 ? $@"#{value}" : lastYearPlacing.Default; + break; + + default: + reader.Read(); + break; + } + + return lastYearPlacing; + } + } } } From 0a2fc12061885c57b21046447600c3c3adceae37 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 31 Dec 2025 23:58:33 +0900 Subject: [PATCH 050/133] Fix/remove tests broken by screen behaviour change --- .../Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs | 8 -------- .../Visual/Navigation/TestSceneScreenNavigation.cs | 3 ++- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs index 1d78896ce7..6d2d507bf1 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelectV2.cs @@ -89,14 +89,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); } - [Test] - public void TestItemNotAddedIfExistingOnStart() - { - AddStep("create new item", () => songSelect.AddNewItem()); - AddStep("finalise selection", () => InputManager.Key(Key.Enter)); - AddAssert("playlist has 1 item", () => room.Playlist.Count == 1); - } - [Test] public void TestAddSameItemMultipleTimes() { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index a55fe4c89e..b1c9ab1dfb 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -99,12 +99,13 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for selection", () => !Game.Beatmap.IsDefault); AddStep("add item", () => InputManager.Key(Key.Enter)); + AddStep("exit screen", () => InputManager.Key(Key.Escape)); AddUntilStep("wait for return to playlist screen", () => playlistScreen.CurrentSubScreen is PlaylistsRoomSubScreen); AddStep("go back to song select", () => { - InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single(b => b.Text == "Edit playlist")); + InputManager.MoveMouseTo(playlistScreen.ChildrenOfType().Single(b => b.Text == "+ Add more beatmaps")); InputManager.Click(MouseButton.Left); }); From 19cfe9abe6afca6adffcb16f9322e6264e41f2a0 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Wed, 31 Dec 2025 11:28:19 -0500 Subject: [PATCH 051/133] Fix button spacing in account creation overlay --- osu.Game/Overlays/AccountCreation/ScreenWarning.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index c24bd32bb4..d9c8a20470 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -71,7 +71,7 @@ namespace osu.Game.Overlays.AccountCreation Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Padding = new MarginPadding(20), - Spacing = new Vector2(0, 5), + Spacing = new Vector2(0, 7), Children = new Drawable[] { new Container From e539660b14ff8d4fc26a2de11a2f65d3c1ee6cd0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 1 Jan 2026 19:41:14 +0900 Subject: [PATCH 052/133] Attempt to fix flaky test --- .../Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs index 636b3f54d8..7ded3467c6 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneLocallyModifyingOnlineBeatmaps.cs @@ -34,7 +34,7 @@ namespace osu.Game.Tests.Visual.Editing SaveEditor(); ReloadEditorToSameBeatmap(); - AddAssert("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); + AddUntilStep("beatmap marked as locally modified", () => EditorBeatmap.BeatmapInfo.Status, () => Is.EqualTo(BeatmapOnlineStatus.LocallyModified)); AddAssert("beatmap hash changed", () => EditorBeatmap.BeatmapInfo.MD5Hash, () => Is.Not.EqualTo(initialHash)); } } From 833617e27924799a234ee69233125caba3a45c56 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 1 Jan 2026 08:42:41 -0500 Subject: [PATCH 053/133] Fix casing in random selection algorithm dropdown --- osu.Game/Localisation/UserInterfaceStrings.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Localisation/UserInterfaceStrings.cs b/osu.Game/Localisation/UserInterfaceStrings.cs index facea0f66c..9883ffe173 100644 --- a/osu.Game/Localisation/UserInterfaceStrings.cs +++ b/osu.Game/Localisation/UserInterfaceStrings.cs @@ -165,9 +165,9 @@ namespace osu.Game.Localisation public static LocalisableString NeverRepeat => new TranslatableString(getKey(@"never_repeat_random"), @"Never repeat"); /// - /// "True Random" + /// "True random" /// - public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True Random"); + public static LocalisableString TrueRandom => new TranslatableString(getKey(@"true_random"), @"True random"); /// /// "Selected Mods" From 2fc621b4f3e20d331a7a5720d2f2c522f9668770 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Thu, 1 Jan 2026 20:47:35 +0300 Subject: [PATCH 054/133] Fix incorrect `OsuAnimatedButton`'s `DimColour` for child classes without override --- osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index f77d00e6e7..31ac777eaa 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -92,12 +92,12 @@ namespace osu.Game.Graphics.UserInterface { base.LoadComplete(); - Colour = Enabled.Value ? Colour4.White : DimColour; + Colour = DimColour; Enabled.BindValueChanged(_ => this.FadeColour(DimColour, 200, Easing.OutQuint), true); FinishTransforms(true); } - protected virtual Colour4 DimColour => colours.Gray9; + protected virtual Colour4 DimColour => Enabled.Value ? Color4.White : colours.Gray9; protected override bool OnHover(HoverEvent e) { From 212973bd632a03a836deeb16ad4e8881315901f4 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Thu, 1 Jan 2026 21:26:48 +0300 Subject: [PATCH 055/133] Localise friend presence notifications --- osu.Game/Localisation/NotificationsStrings.cs | 10 ++++++++++ osu.Game/Online/FriendPresenceNotifier.cs | 9 +++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 66250d1629..ae9267990d 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -135,6 +135,16 @@ Click to see what's new!", version); /// public static LocalisableString Mention => new TranslatableString(getKey(@"mention"), @"Mention"); + /// + /// "Online: {0}" + /// + public static LocalisableString FriendOnline(string info) => new TranslatableString(getKey(@"friend_online"), @"Online: {0}", info); + + /// + /// "Offline: {0}" + /// + public static LocalisableString FriendOffline(string info) => new TranslatableString(getKey(@"friend_offline"), @"Offline: {0}", info); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 53c8ff7b5a..8329f3b46b 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; @@ -197,7 +198,7 @@ namespace osu.Game.Online { Transient = true; IsImportant = false; - Text = $"Online: {User.Username}"; + Text = NotificationsStrings.FriendOnline(User.Username); } [BackgroundDependencyLoader] @@ -219,7 +220,7 @@ namespace osu.Game.Online { public MultipleFriendsOnlineNotification(ICollection users) { - Text = $"Online: {string.Join(@", ", users.Select(u => u.Username))}"; + Text = NotificationsStrings.FriendOnline(string.Join(@", ", users.Select(u => u.Username))); } [BackgroundDependencyLoader] @@ -239,7 +240,7 @@ namespace osu.Game.Online { Transient = true; IsImportant = false; - Text = $"Offline: {User.Username}"; + Text = NotificationsStrings.FriendOffline(User.Username); } [BackgroundDependencyLoader] @@ -256,7 +257,7 @@ namespace osu.Game.Online { public MultipleFriendsOfflineNotification(ICollection users) { - Text = $"Offline: {string.Join(@", ", users.Select(u => u.Username))}"; + Text = NotificationsStrings.FriendOffline(string.Join(@", ", users.Select(u => u.Username))); } [BackgroundDependencyLoader] From 1fcae16be90dbeb6f48b5889930c1d3b95940554 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 2 Jan 2026 16:39:37 +0900 Subject: [PATCH 056/133] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 3a0d771fbf..6d667fb814 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index 5a2f7f5f18..ec09350de6 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + From 00560cbfbab084b1de0c40f0ca40edcdc82266dc Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Sun, 4 Jan 2026 20:54:50 +0300 Subject: [PATCH 057/133] Localise notifications in `LegacyCollectionImporter` --- osu.Game/Database/LegacyCollectionImporter.cs | 9 +++++---- osu.Game/Localisation/NotificationsStrings.cs | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/osu.Game/Database/LegacyCollectionImporter.cs b/osu.Game/Database/LegacyCollectionImporter.cs index 6d3e3fb76a..b8a515b96f 100644 --- a/osu.Game/Database/LegacyCollectionImporter.cs +++ b/osu.Game/Database/LegacyCollectionImporter.cs @@ -10,6 +10,7 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Collections; using osu.Game.IO.Legacy; +using osu.Game.Localisation; using osu.Game.Overlays.Notifications; namespace osu.Game.Database @@ -63,7 +64,7 @@ namespace osu.Game.Database var notification = new ProgressNotification { State = ProgressNotificationState.Active, - Text = "Collections import is initialising..." + Text = NotificationsStrings.CollectionsImportInitialising, }; PostNotification?.Invoke(notification); @@ -71,7 +72,7 @@ namespace osu.Game.Database var importedCollections = readCollections(stream, notification); await importCollections(importedCollections).ConfigureAwait(false); - notification.CompletionText = $"Imported {importedCollections.Count} collections"; + notification.CompletionText = NotificationsStrings.CollectionsImportProgress(importedCollections.Count); notification.State = ProgressNotificationState.Completed; } @@ -115,7 +116,7 @@ namespace osu.Game.Database { if (notification != null) { - notification.Text = "Reading collections..."; + notification.Text = NotificationsStrings.ReadingCollections; notification.Progress = 0; } @@ -150,7 +151,7 @@ namespace osu.Game.Database if (notification != null) { - notification.Text = $"Imported {i + 1} of {collectionCount} collections"; + notification.Text = NotificationsStrings.CollectionsImportProgressTotal(i + 1, collectionCount); notification.Progress = (float)(i + 1) / collectionCount; } diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index ae9267990d..0a97806b72 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -145,6 +145,26 @@ Click to see what's new!", version); /// public static LocalisableString FriendOffline(string info) => new TranslatableString(getKey(@"friend_offline"), @"Offline: {0}", info); + /// + /// "Collections import is initialising..." + /// + public static LocalisableString CollectionsImportInitialising => new TranslatableString(getKey(@"collections_import_initialising"), @"Collections import is initialising..."); + + /// + /// "Reading collections..." + /// + public static LocalisableString ReadingCollections => new TranslatableString(getKey(@"reading_collections"), @"Reading collections..."); + + /// + /// "Imported {0} collections" + /// + public static LocalisableString CollectionsImportProgress(int count) => new TranslatableString(getKey(@"collections_import_progress"), @"Imported {0} collections", count); + + /// + /// "Imported {0} of {1} collections" + /// + public static LocalisableString CollectionsImportProgressTotal(int count, int totalCount) => new TranslatableString(getKey(@"collections_import_progress_total"), @"Imported {0} of {1} collections", count, totalCount); + private static string getKey(string key) => $@"{prefix}:{key}"; } } From 351a717795b27ed4802aba041fc3af4fb99b399e Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 5 Jan 2026 01:52:13 +0100 Subject: [PATCH 058/133] Alternate buttons in OsuAutoPlay generator even when time difference is exactly zero. Previously, there if two consecutive hitobjects were 50ms apart both mechanisms to make sure that the input buttons are alternated would fail. This produced a replay frame which lifts the current button, followed by a frame which presses the same button again, at the same time as it was lifted. The key-up frame would always get skipped without frame-stability, leading to hitsounds and hit animations not getting played in the editor. --- osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index d43e6092c2..9bf93dfd1b 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -266,7 +266,7 @@ namespace osu.Game.Rulesets.Osu.Replays } // Start alternating once the time separation is too small (faster than ~225BPM). - if (timeDifference > 0 && timeDifference < 266) + if (timeDifference >= 0 && timeDifference < 266) buttonIndex++; else buttonIndex = 0; From 3bd20509548ab78624c4594b1929fd55e8ce0f16 Mon Sep 17 00:00:00 2001 From: Marvin Date: Mon, 5 Jan 2026 03:15:45 +0100 Subject: [PATCH 059/133] Fix interpolation not being applied when previous frame-up frame is at the same time as current frame --- osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 9bf93dfd1b..6520b9a50c 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -245,7 +245,7 @@ namespace osu.Game.Rulesets.Osu.Replays double timeDifference = ApplyModsToTimeDelta(lastFrame.Time, h.StartTime); OsuReplayFrame? lastLastFrame = Frames.Count >= 2 ? (OsuReplayFrame)Frames[^2] : null; - if (timeDifference > 0) + if (timeDifference >= 0) { // If the last frame is a key-up frame and there has been no wait period, adjust the last frame's position such that it begins eased movement instantaneously. if (lastLastFrame != null && lastFrame is OsuKeyUpReplayFrame && !hasWaited) From af08416880cc84ae0410878db94615db821c188f Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 5 Jan 2026 13:16:25 +0900 Subject: [PATCH 060/133] Add test coverage --- .../TestSceneAutoGeneration.cs | 63 +++++++++++++++++++ .../Replays/OsuAutoGenerator.cs | 4 +- 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.cs diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.cs new file mode 100644 index 0000000000..e2c66af19f --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneAutoGeneration.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Replays; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Osu.Tests +{ + [TestFixture] + [HeadlessTest] + public partial class TestSceneAutoGeneration : OsuTestScene + { + [TestCase(-1, true)] + [TestCase(0, false)] + [TestCase(1, false)] + public void TestAlternating(double offset, bool shouldAlternate) + { + const double first_object_time = 1000; + double secondObjectTime = first_object_time + AutoGenerator.KEY_UP_DELAY + OsuAutoGenerator.MIN_FRAME_SEPARATION_FOR_ALTERNATING + offset; + + var beatmap = new OsuBeatmap(); + beatmap.HitObjects.Add(new HitCircle { StartTime = first_object_time }); + beatmap.HitObjects.Add(new HitCircle { StartTime = secondObjectTime }); + + var generated = new OsuAutoGenerator(beatmap, []).Generate(); + var frames = generated.Frames.OfType().ToList(); + + Assert.That(frames.Exists(f => f.Time == first_object_time && f.Actions.SingleOrDefault() == OsuAction.LeftButton)); + Assert.That(frames.Exists(f => f.Time == first_object_time + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + + Assert.That(frames.Exists(f => f.Time == secondObjectTime && f.Actions.SingleOrDefault() == (shouldAlternate ? OsuAction.RightButton : OsuAction.LeftButton))); + Assert.That(frames.Exists(f => f.Time == secondObjectTime + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + } + + [TestCase(300)] + [TestCase(600)] + [TestCase(1200)] + public void TestAlternatingSpecificBPM(double bpm) + { + const double first_object_time = 1000; + double secondObjectTime = first_object_time + 60000 / bpm; + + var beatmap = new OsuBeatmap(); + beatmap.HitObjects.Add(new HitCircle { StartTime = first_object_time }); + beatmap.HitObjects.Add(new HitCircle { StartTime = secondObjectTime }); + + var generated = new OsuAutoGenerator(beatmap, []).Generate(); + var frames = generated.Frames.OfType().ToList(); + + Assert.That(frames.Exists(f => f.Time == first_object_time && f.Actions.SingleOrDefault() == OsuAction.LeftButton)); + Assert.That(frames.Exists(f => f.Time == first_object_time + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + + Assert.That(frames.Exists(f => f.Time == secondObjectTime && f.Actions.SingleOrDefault() == OsuAction.RightButton)); + Assert.That(frames.Exists(f => f.Time == secondObjectTime + AutoGenerator.KEY_UP_DELAY && !f.Actions.Any())); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs index 6520b9a50c..f08e813323 100644 --- a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Osu.Replays { public class OsuAutoGenerator : OsuAutoGeneratorBase { + public const double MIN_FRAME_SEPARATION_FOR_ALTERNATING = 266; + public new OsuBeatmap Beatmap => (OsuBeatmap)base.Beatmap; #region Parameters @@ -266,7 +268,7 @@ namespace osu.Game.Rulesets.Osu.Replays } // Start alternating once the time separation is too small (faster than ~225BPM). - if (timeDifference >= 0 && timeDifference < 266) + if (timeDifference >= 0 && timeDifference < MIN_FRAME_SEPARATION_FOR_ALTERNATING) buttonIndex++; else buttonIndex = 0; From ebf9c19ad35573740eedc870e4ec6bf78ff0b304 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 14:10:23 +0900 Subject: [PATCH 061/133] Revert no longer necessary change Additionally to fix the options button, I could either cache the interface in PlaylistsSongSelectV2 or make the interface cache itself. I went with the latter option. --- .../Visual/SongSelectV2/TestSceneScreenFooter.cs | 2 +- osu.Game/Screens/SelectV2/FooterButtonOptions.cs | 8 ++------ osu.Game/Screens/SelectV2/ISongSelect.cs | 2 ++ osu.Game/Screens/SelectV2/SongSelect.cs | 3 +-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs index bb0fb16dcf..e247b92f52 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneScreenFooter.cs @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { new FooterButtonMods(modOverlay) { Current = SelectedMods }, new FooterButtonRandom(), - new FooterButtonOptions(null), + new FooterButtonOptions(), }); }); diff --git a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs b/osu.Game/Screens/SelectV2/FooterButtonOptions.cs index 8de0908af1..4da40559e9 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonOptions.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonOptions.cs @@ -24,12 +24,8 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IBindable workingBeatmap { get; set; } = null!; - private readonly ISongSelect? songSelect; - - public FooterButtonOptions(ISongSelect? songSelect) - { - this.songSelect = songSelect; - } + [Resolved] + private ISongSelect? songSelect { get; set; } [Resolved] private RealmAccess realm { get; set; } = null!; diff --git a/osu.Game/Screens/SelectV2/ISongSelect.cs b/osu.Game/Screens/SelectV2/ISongSelect.cs index 3fede4401f..6280e4048a 100644 --- a/osu.Game/Screens/SelectV2/ISongSelect.cs +++ b/osu.Game/Screens/SelectV2/ISongSelect.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; @@ -12,6 +13,7 @@ namespace osu.Game.Screens.SelectV2 /// /// Actions exposed by song select which are used by subcomponents to perform top-level operations. /// + [Cached] public interface ISongSelect { /// diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 6cfe7206f8..49d9bab9c2 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -63,7 +63,6 @@ namespace osu.Game.Screens.SelectV2 /// This screen is intended to house all components introduced in the new song select design to add transitions and examine the overall look. /// This will be gradually built upon and ultimately replace once everything is in place. /// - [Cached(typeof(ISongSelect))] public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect, IHandlePresentBeatmap { /// @@ -352,7 +351,7 @@ namespace osu.Game.Screens.SelectV2 errorSample?.Play(); } }, - new FooterButtonOptions(this) + new FooterButtonOptions { Hotkey = GlobalAction.ToggleBeatmapOptions, } From 593cc37ea693d5bece683a76fa81e1f15a7835bf Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 14:35:34 +0900 Subject: [PATCH 062/133] Adjust styling of freestyle button --- .../OnlinePlay/FooterButtonFreestyleV2.cs | 67 +------------------ 1 file changed, 2 insertions(+), 65 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs index 03d590ac6e..5178073632 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreestyleV2.cs @@ -4,24 +4,15 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Effects; -using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; using osu.Game.Screens.Footer; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay { public partial class FooterButtonFreestyleV2 : ScreenFooterButton { - private const float bar_height = 30f; - public readonly Bindable Freestyle = new Bindable(); public new Action Action @@ -32,12 +23,6 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private OsuColour colours { get; set; } = null!; - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - private Drawable statusBackground = null!; - private OsuSpriteText statusText = null!; - public FooterButtonFreestyleV2() { // Overwrite any external behaviour as we delegate the main toggle action to a sub-button. @@ -50,63 +35,15 @@ namespace osu.Game.Screens.OnlinePlay Text = "Freestyle"; Icon = FontAwesome.Solid.ExchangeAlt; AccentColour = colours.Lime1; - - AddRange(new[] - { - new Container - { - Y = -5f, - Depth = float.MaxValue, - Origin = Anchor.BottomLeft, - Shear = OsuGame.SHEAR, - CornerRadius = CORNER_RADIUS, - Size = new Vector2(BUTTON_WIDTH, bar_height), - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 4, - // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. - Colour = Colour4.Black.Opacity(0.25f), - Offset = new Vector2(0, 2), - }, - Children = new[] - { - statusBackground = new Box - { - Colour = colourProvider.Background3, - RelativeSizeAxes = Axes.Both, - }, - statusText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Torus.With(size: 14f, weight: FontWeight.Bold), - Shear = -OsuGame.SHEAR, - }, - } - }, - }); } protected override void LoadComplete() { base.LoadComplete(); - Freestyle.BindValueChanged(v => + Freestyle.BindValueChanged(active => { - if (v.NewValue) - { - statusBackground.Colour = colours.Yellow; - statusText.Text = "ON"; - statusText.Colour = Color4.Black; - } - else - { - statusBackground.Colour = colourProvider.Background3; - statusText.Text = "OFF"; - statusText.Colour = Color4.White; - } + OverlayState.Value = active.NewValue ? Visibility.Visible : Visibility.Hidden; }, true); } } From ff820f730e821c67b02f18b00b079ecbd564aaca Mon Sep 17 00:00:00 2001 From: Jul Date: Mon, 5 Jan 2026 06:54:25 +0100 Subject: [PATCH 063/133] Fix play button starting wrong beatmap before selection loads (#36104) * Fix play button starting wrong beatmap before selection loads When clicking the osu! cookie (play button) before a newly selected beatmap finishes loading, the previous beatmap would be played instead of the currently selected one. This was caused by the cookie reading from the global beatmap state which is debounced by 150ms, while the Enter key correctly used the carousel's current selection. The fix makes the cookie use the same beatmap source as Enter - the carousel's current selection - which is always up-to-date regardless of debounce timing. Closes #36074 * Use ensureGlobalBeatmapValid() for logo and Enter key actions * Add test for beatmap selection timing bug Tests the fix for issue #36074 where clicking the play button immediately after selecting a different difficulty would start the wrong beatmap due to the 150ms selection debounce. --- .../SongSelectV2/TestSceneSongSelect.cs | 45 +++++++++++++++++++ osu.Game/Screens/SelectV2/SongSelect.cs | 2 + 2 files changed, 47 insertions(+) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs index a480e51adf..f47eafc937 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -659,6 +659,51 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("options disabled", () => !this.ChildrenOfType().Single().Enabled.Value); } + /// + /// tests that clicking the osu! logo immediately after selecting a different difficulty + /// (before the selection debounce completes) starts the correct beatmap. + /// this tests the fix for https://github.com/ppy/osu/issues/36074 + /// + [Test] + public void TestPlayCorrectBeatmapWhenSelectionNotFullyLoaded() + { + // import a beatmap set with multiple difficulties + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + + // wait for initial beatmap to be selected + AddUntilStep("wait for first beatmap selected", () => !Beatmap.IsDefault); + + BeatmapInfo? firstBeatmap = null; + AddStep("store first difficulty", () => firstBeatmap = Beatmap.Value.BeatmapInfo); + + // start loading the first difficulty + AddStep("click logo to start loading", () => this.ChildrenOfType().Single().TriggerClick()); + AddUntilStep("wait for player loader", () => Stack.CurrentScreen is PlayerLoader); + + // return to song select + AddStep("press escape to return", () => InputManager.Key(Key.Escape)); + AddUntilStep("wait for return to song select", () => SongSelect.IsCurrentScreen()); + + // press down and schedule logo click to happen shortly after (but before 150ms debounce) + // this reproduces the race condition where Beatmap.Value hasn't updated yet + AddStep("select next difficulty and click logo immediately", () => + { + InputManager.Key(Key.Down); + Schedule(() => this.ChildrenOfType().Single().TriggerClick()); + }); + + AddUntilStep("wait for player loader", () => Stack.CurrentScreen is PlayerLoader); + + // verify we're loading the second difficulty, not the first + // without the fix, this would fail because Beatmap.Value still has the old value + AddAssert("player is loading second difficulty", () => + Beatmap.Value.BeatmapInfo.ID != firstBeatmap!.ID); + + AddUntilStep("wait for return to song select", () => SongSelect.IsCurrentScreen()); + } + #endregion } } diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 944c93bd5f..4bee63b57c 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -756,6 +756,7 @@ namespace osu.Game.Screens.SelectV2 logo.Action = () => { + ensureGlobalBeatmapValid(); SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); return false; }; @@ -999,6 +1000,7 @@ namespace osu.Game.Screens.SelectV2 // one of which is filtering out all visible beatmaps and attempting to start gameplay. // in that case, users still expect a `Select` press to advance to gameplay anyway, using the ambient selected beatmap if there is one, // which matches the behaviour resulting from clicking the osu! cookie in that scenario. + ensureGlobalBeatmapValid(); SelectAndRun(Beatmap.Value.BeatmapInfo, OnStart); return true; From 88f80799c3e33ee55eeb56686bd48c6c23b37f63 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 15:21:49 +0900 Subject: [PATCH 064/133] Add test --- .../Matchmaking/TestScenePlayerPanel.cs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs index 897d59657c..8d98eba317 100644 --- a/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs +++ b/osu.Game.Tests/Visual/Matchmaking/TestScenePlayerPanel.cs @@ -122,5 +122,51 @@ namespace osu.Game.Tests.Visual.Matchmaking AddStep("set download progress 90%", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.Downloading(0.9f))); AddStep("set locally available", () => MultiplayerClient.ChangeUserBeatmapAvailability(2, BeatmapAvailability.LocallyAvailable())); } + + [Test] + public void TestLongUsername() + { + AddStep("set long username", () => + { + MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState + { + Users = + { + UserDictionary = + { + { + 2, new MatchmakingUser + { + UserId = 2, + Placement = 1 + } + } + } + } + }).WaitSafely(); + + Child = panel = new PlayerPanel(new MultiplayerRoomUser(2) + { + User = new APIUser + { + Username = @"ThisIsALongUsername", + Id = 2, + Colour = "99EB47", + CountryCode = CountryCode.AU, + CoverUrl = @"https://assets.ppy.sh/user-profile-covers/2/baba245ef60834b769694178f8f6d4f6166c5188c740de084656ad2b80f1eea7.jpeg", + Statistics = new UserStatistics { GlobalRank = null, CountryRank = null } + } + }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }; + }); + + foreach (var layout in Enum.GetValues()) + { + AddStep($"set layout to {layout}", () => panel.DisplayMode = layout); + } + } } } From b7b3e028abba3d5c59db7636c8955e4fdae4a23e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 15:22:03 +0900 Subject: [PATCH 065/133] Adjust quick play player panels for long usernames --- .../Match/BeatmapSelect/SubScreenBeatmapSelect.cs | 2 +- osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs index 9574fec554..c7b0c66f46 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/BeatmapSelect/SubScreenBeatmapSelect.cs @@ -44,7 +44,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect new Container { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 200 }, + Padding = new MarginPadding { Horizontal = 250 }, Children = new Drawable[] { beatmapSelectGrid = new BeatmapSelectGrid diff --git a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs index 9f35099516..58d29220dc 100644 --- a/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Matchmaking/Match/PlayerPanel.cs @@ -47,7 +47,7 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match /// public partial class PlayerPanel : OsuClickableContainer, IHasContextMenu { - private static readonly Vector2 size_horizontal = new Vector2(250, 100); + private static readonly Vector2 size_horizontal = new Vector2(300, 100); private static readonly Vector2 size_vertical = new Vector2(150, 200); private static readonly Vector2 avatar_size = new Vector2(80); @@ -236,13 +236,14 @@ namespace osu.Game.Screens.OnlinePlay.Matchmaking.Match Text = "-", Font = OsuFont.Style.Title.With(size: 55), }, - username = new OsuSpriteText + username = new TruncatingSpriteText { Alpha = 0, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, Text = User.Username, Font = OsuFont.Style.Heading1, + MaxWidth = 120 }, scoreText = new OsuSpriteText { From 25dab7258c904e41676e97151041d830511a802e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 5 Jan 2026 15:24:15 +0900 Subject: [PATCH 066/133] Add test coverage of editor placement scenarios --- .../Editing/TestScenePlacementBlueprint.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs index 822c045355..675de2f57a 100644 --- a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -37,6 +37,42 @@ namespace osu.Game.Tests.Visual.Editing private GlobalActionContainer globalActionContainer => this.ChildrenOfType().Single(); + [Test] + public void TestPlaceThenUndo() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + AddStep("place circle", () => InputManager.Click(MouseButton.Left)); + + AddAssert("one circle added", () => EditorBeatmap.HitObjects, () => Has.One.Items); + + AddStep("undo", () => Editor.Undo()); + + AddAssert("circle removed", () => EditorBeatmap.HitObjects, () => Is.Empty); + } + + [Test] + public void TestTimingLost() + { + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + AddStep("move mouse to center of playfield", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); + + AddAssert("placement ready", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Not.Null); + + AddStep("nuke timing", () => EditorBeatmap.ControlPointInfo.Clear()); + + AddAssert("placement not available", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Null); + + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + + AddAssert("placement not available", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Null); + + AddStep("add back timing", () => EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("select circle placement tool", () => InputManager.Key(Key.Number2)); + + AddAssert("placement ready", () => this.ChildrenOfType().Single().CurrentPlacement, () => Is.Not.Null); + } + [Test] public void TestDeleteUsingMiddleMouse() { From 9916e5bb1c96f7086c684c705c77f712558fa392 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 5 Jan 2026 15:26:12 +0900 Subject: [PATCH 067/133] Fix editor crashing on undoing after hit object placement Happens when initial timing is undone. Regressed in https://github.com/ppy/osu/commit/12170df80add87e8f292545f67566f8d25465efc. Closes https://github.com/ppy/osu/issues/36228. --- osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs index 4688c7c8ca..7b378edda6 100644 --- a/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/HitObjectPlacementBlueprint.cs @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Edit private HitObject? getPreviousHitObject() => beatmap.HitObjects.TakeWhile(h => h.StartTime <= startTimeBindable.Value).LastOrDefault(); - protected override bool IsValidForPlacement => HitObject.StartTime >= beatmap.ControlPointInfo.TimingPoints[0].Time; + protected override bool IsValidForPlacement => HitObject.StartTime >= beatmap.ControlPointInfo.TimingPoints.FirstOrDefault()?.Time; [Resolved] private IPlacementHandler placementHandler { get; set; } = null!; From a4fe0c924d5fdc8607cb8e64096b8c21927249d5 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Mon, 5 Jan 2026 10:12:20 +0300 Subject: [PATCH 068/133] Localise `DownloadNotification` (#36224) Co-authored-by: Dean Herbert --- osu.Game/Database/ModelDownloader.cs | 3 ++- osu.Game/Localisation/NotificationsStrings.cs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index 8e89db4d06..235c30b589 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Humanizer; using osu.Framework.Logging; using osu.Game.Extensions; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays.Notifications; @@ -55,7 +56,7 @@ namespace osu.Game.Database DownloadNotification notification = new DownloadNotification { - Text = $"Downloading {request.Model.GetDisplayString()}", + Text = NotificationsStrings.Downloading(request.Model.GetDisplayString()), }; request.DownloadProgressed += progress => diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 0a97806b72..d641a85ebe 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -145,6 +145,11 @@ Click to see what's new!", version); /// public static LocalisableString FriendOffline(string info) => new TranslatableString(getKey(@"friend_offline"), @"Offline: {0}", info); + /// + /// "Downloading {0}" + /// + public static LocalisableString Downloading(string info) => new TranslatableString(getKey(@"downloading"), @"Downloading {0}", info); + /// /// "Collections import is initialising..." /// From ff3397b6b75299cc8bc8e3983714634865091fa0 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Mon, 5 Jan 2026 10:15:01 +0300 Subject: [PATCH 069/133] Localise notifications in `OnlineStatusNotifier` (#36223) Co-authored-by: Dean Herbert --- osu.Game/Localisation/NotificationsStrings.cs | 20 +++++++++++++++++++ osu.Game/Online/OnlineStatusNotifier.cs | 9 +++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index d641a85ebe..75f1a39a69 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -145,6 +145,26 @@ Click to see what's new!", version); /// public static LocalisableString FriendOffline(string info) => new TranslatableString(getKey(@"friend_offline"), @"Offline: {0}", info); + /// + /// "Connection to API was lost. Can't continue with online play." + /// + public static LocalisableString APIDisconnect => new TranslatableString(getKey(@"api_disconnect"), @"Connection to API was lost. Can't continue with online play."); + + /// + /// "Connection to the multiplayer server was lost. Exiting multiplayer." + /// + public static LocalisableString MultiplayerDisconnect => new TranslatableString(getKey(@"multiplayer_disconnect"), @"Connection to the multiplayer server was lost. Exiting multiplayer."); + + /// + /// "You have been logged out on this device due to a login to your account on another device." + /// + public static LocalisableString AnotherDeviceDisconnect => new TranslatableString(getKey(@"another_device_disconnect"), @"You have been logged out on this device due to a login to your account on another device."); + + /// + /// "You have been logged out due to a change to your account. Please log in again." + /// + public static LocalisableString AccountChangeDisconnect => new TranslatableString(getKey(@"account_change_disconnect"), @"You have been logged out due to a change to your account. Please log in again."); + /// /// "Downloading {0}" /// diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index 10d766c729..da58dc3d46 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -8,6 +8,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Screens; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; @@ -88,7 +89,7 @@ namespace osu.Game.Online notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = "Connection to API was lost. Can't continue with online play." + Text = NotificationsStrings.APIDisconnect, }); } }); @@ -109,7 +110,7 @@ namespace osu.Game.Online notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = "Connection to the multiplayer server was lost. Exiting multiplayer." + Text = NotificationsStrings.MultiplayerDisconnect, }); } })); @@ -128,7 +129,7 @@ namespace osu.Game.Online notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = "You have been logged out on this device due to a login to your account on another device." + Text = NotificationsStrings.AnotherDeviceDisconnect, }); } @@ -142,7 +143,7 @@ namespace osu.Game.Online notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = "You have been logged out due to a change to your account. Please log in again." + Text = NotificationsStrings.AccountChangeDisconnect, }); } From ea289460b07c48b87e8b1acb655fc8be69bec711 Mon Sep 17 00:00:00 2001 From: pishifat <31551350+pishifat@users.noreply.github.com> Date: Sun, 4 Jan 2026 23:16:04 -0800 Subject: [PATCH 070/133] remove map from list (#36218) --- osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs index b61b2358b0..36c40e6908 100644 --- a/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs +++ b/osu.Game/Beatmaps/Drawables/BundledBeatmapDownloader.cs @@ -467,7 +467,6 @@ namespace osu.Game.Beatmaps.Drawables @"2055329 miraie & blackwinterwells - facade.osz", @"2069877 Sephid - Thunderstrike 1988.osz", @"2119716 Aethoro - Snowy.osz", - @"2120379 Synthion - VIVIDVELOCITY.osz", @"2124805 Frums (unknown ""lambda"") - 19ZZ.osz", @"2127811 Wiklund - Joy of Living (Cut Ver.).osz", }; From 2b90cbf59f32c9cf98933a895890c3af1e9b05ca Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 16:44:34 +0900 Subject: [PATCH 071/133] Attempt to fix display issues at high UI scales --- .../Multiplayer/MultiplayerPlayerLoader.cs | 24 ------ osu.Game/Screens/Play/PlayerLoader.cs | 76 +++++++++++++++---- 2 files changed, 61 insertions(+), 39 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs index 4e8a2e08ce..b13069a436 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerPlayerLoader.cs @@ -4,13 +4,10 @@ using System; using System.Threading.Tasks; using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; using osu.Game.Screens.Play; -using osu.Game.Screens.Play.HUD; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -33,27 +30,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { } - [BackgroundDependencyLoader] - private void load() - { - PlayerSettings.Add(new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new HoldForMenuButton(true) - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Margin = new MarginPadding(10), - Action = () => - { - if (this.IsCurrentScreen()) - this.Exit(); - } - } - }); - } - protected override bool ReadyForGameplay => ( // The user is ready to enter gameplay. diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 3209ee21fe..34577c1e56 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -31,7 +31,9 @@ using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Volume; using osu.Game.Performance; +using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; +using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; @@ -72,7 +74,7 @@ namespace osu.Game.Screens.Play /// /// A fill flow containing the player settings groups, exposed for the ability to hide it from inheritors of the player loader. /// - protected FillFlowContainer PlayerSettings { get; private set; } = null!; + protected FillFlowContainer PlayerSettings { get; private set; } = null!; protected VisualSettings VisualSettings { get; private set; } = null!; @@ -135,6 +137,8 @@ namespace osu.Game.Screens.Play // or if a child of a focused overlay is focused, like settings' search textbox. && inputManager.FocusedDrawable?.FindClosestParent() == null; + private bool holdForMenuExitButton => !AllowUserExit; + private readonly Func createPlayer; /// @@ -225,27 +229,69 @@ namespace osu.Game.Screens.Play Padding = new MarginPadding(padding), Spacing = new Vector2(20), }, - settingsScroll = new OsuScrollContainer + new GridContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, RelativeSizeAxes = Axes.Y, Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, - Padding = new MarginPadding { Vertical = padding }, - Masking = false, - Child = PlayerSettings = new FillFlowContainer + Padding = new MarginPadding { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Padding = new MarginPadding { Horizontal = padding }, - Children = new PlayerSettingsGroup[] - { - VisualSettings = new VisualSettings(), - AudioSettings = new AudioSettings(), - new InputSettings() - } + Top = padding, + Bottom = ScreenFooter.HEIGHT + padding }, + RowDimensions = + [ + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + ], + Content = new[] + { + new Drawable[] + { + settingsScroll = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Masking = holdForMenuExitButton, + Child = PlayerSettings = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Padding = new MarginPadding { Horizontal = padding }, + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + AudioSettings = new AudioSettings(), + new InputSettings() + } + }, + } + }, + new Drawable[] + { + new Container + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = holdForMenuExitButton ? 1 : 0, + AutoSizeAxes = Axes.Both, + Child = new HoldForMenuButton(true) + { + Margin = new MarginPadding + { + Top = 20, + Horizontal = padding, + }, + Action = () => + { + if (this.IsCurrentScreen()) + this.Exit(); + } + } + } + } + } }, idleTracker = new IdleTracker(1500), sampleRestart = new SkinnableSound(new SampleInfo(@"Gameplay/restart", @"pause-retry-click")) From 78a5654e1f55869acd7dbd08dedade01b4d63a79 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 17:15:18 +0900 Subject: [PATCH 072/133] Adjust beatmap query to fix potential crash --- osu.Game/Beatmaps/BeatmapManager.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index d50862a369..7860dba075 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -334,7 +334,11 @@ namespace osu.Game.Beatmaps /// A matching local beatmap info if existing and in a valid state. public BeatmapInfo? QueryOnlineBeatmapId(int id) => Realm.Run(r => r.All() - .ForOnlineId(id).SingleOrDefault()?.Detach()); + .ForOnlineId(id) + // See https://github.com/ppy/osu/issues/36234 for why this isn't a SingleOrDefault(). + .FirstOrDefault() + ?.Detach() + ); /// /// A default representation of a WorkingBeatmap to use when no beatmap is available. From 8b49572c44f654f7cd59036c204b3975fc2e91c7 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 17:44:41 +0900 Subject: [PATCH 073/133] Fix title overlap Uses the same method of displaying the mod selection as the old song select (as an overlay of the entire game). --- .../Playlists/PlaylistsSongSelectV2.cs | 40 ++++++++++++------- osu.Game/Screens/SelectV2/SongSelect.cs | 18 ++++++++- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index 586898f414..20c08ce2d1 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -1,6 +1,7 @@ // 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 Humanizer; @@ -31,12 +32,17 @@ namespace osu.Game.Screens.OnlinePlay.Playlists protected readonly Bindable Freestyle = new Bindable(true); private readonly Bindable> freeMods = new Bindable>([]); + [Resolved] + private IOverlayManager? overlayManager { get; set; } + private readonly AddToPlaylistFooterButton addToPlaylistFooterButton; private readonly Room room; private ModSelectOverlay modSelect = null!; private FreeModSelectOverlay freeModSelect = null!; + private IDisposable? modSelectOverlayRegistration; + public PlaylistsSongSelectV2(Room room) { this.room = room; @@ -61,30 +67,30 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load() { - AddRangeInternal(new Drawable[] + AddInternal(new PlaylistTray(room) { - freeModSelect = new FreeModSelectOverlay + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Margin = new MarginPadding { - SelectedMods = { BindTarget = freeMods }, - IsValidMod = isValidAllowedMod, - }, - new PlaylistTray(room) - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Margin = new MarginPadding - { - Bottom = ScreenFooterButton.HEIGHT, - Right = OsuGame.SCREEN_EDGE_MARGIN - } + Bottom = ScreenFooterButton.HEIGHT, + Right = OsuGame.SCREEN_EDGE_MARGIN } }); + + LoadComponent(freeModSelect = new FreeModSelectOverlay + { + SelectedMods = { BindTarget = freeMods }, + IsValidMod = isValidAllowedMod, + }); } protected override void LoadComplete() { base.LoadComplete(); + modSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(freeModSelect); + Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); Freestyle.BindValueChanged(onFreestyleChanged); @@ -250,5 +256,11 @@ namespace osu.Game.Screens.OnlinePlay.Playlists && Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must be compatible with all the required mods. && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + modSelectOverlayRegistration?.Dispose(); + } } } diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 49d9bab9c2..c467ee6d50 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -146,6 +146,9 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IDialogOverlay? dialogOverlay { get; set; } + [Resolved] + private IOverlayManager? overlayManager { get; set; } + private InputManager inputManager = null!; private readonly RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource(); @@ -153,6 +156,8 @@ namespace osu.Game.Screens.SelectV2 private Bindable configBackgroundBlur = null!; private Bindable showConvertedBeatmaps = null!; + private IDisposable? modSelectOverlayRegistration; + [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager config) { @@ -288,10 +293,11 @@ namespace osu.Game.Screens.SelectV2 Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, }, - modSpeedHotkeyHandler = new ModSpeedHotkeyHandler(), - modSelectOverlay = CreateModSelectOverlay(), + modSpeedHotkeyHandler = new ModSpeedHotkeyHandler() }); + LoadComponent(modSelectOverlay = CreateModSelectOverlay()); + configBackgroundBlur = config.GetBindable(OsuSetting.SongSelectBackgroundBlur); configBackgroundBlur.BindValueChanged(e => { @@ -361,6 +367,8 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); + modSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(modSelectOverlay); + inputManager = GetContainingInputManager()!; filterControl.CriteriaChanged += criteriaChanged; @@ -1210,5 +1218,11 @@ namespace osu.Game.Screens.SelectV2 public Bindable ScopedBeatmapSet => filterControl.ScopedBeatmapSet; #endregion + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + modSelectOverlayRegistration?.Dispose(); + } } } From 61c4a3dba0722005afa71e74c8142b63e2775365 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 17:55:36 +0900 Subject: [PATCH 074/133] Fix footer button overlap The scheduled events don't fire because the content is hidden. --- osu.Game/Screens/Footer/ScreenFooter.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/Footer/ScreenFooter.cs b/osu.Game/Screens/Footer/ScreenFooter.cs index 5dbc7a55ab..8a08f786c7 100644 --- a/osu.Game/Screens/Footer/ScreenFooter.cs +++ b/osu.Game/Screens/Footer/ScreenFooter.cs @@ -291,7 +291,9 @@ namespace osu.Game.Screens.Footer return; Debug.Assert(activeOverlayContent != null); + activeOverlayContent.Hide(); + activeOverlayContent.Expire(); double timeUntilRun = activeOverlayContent.LatestTransformEndTime - Time.Current; @@ -299,6 +301,7 @@ namespace osu.Game.Screens.Footer { var button = temporarilyHiddenButtons[i]; hiddenButtonsContainer.Remove(button, false); + // temporarily bypass autosize on the X axis to prevent the buttons taking space // immediately upon being moved back to the flow. // this prevents the overlay content jumping to the right during its fade-out. @@ -312,12 +315,13 @@ namespace osu.Game.Screens.Footer updateColourScheme(OverlayColourScheme.Aquamarine.GetHue()); - activeOverlayContent.Delay(timeUntilRun).Schedule(() => + Scheduler.AddDelayed(() => { // overlay content is done displaying, re-enable autosize on all active buttons foreach (var button in buttonsFlow) button.BypassAutoSizeAxes = Axes.None; - }).Expire(); + }, timeUntilRun); + activeOverlayContent = null; ActiveOverlay = null; } From fad53d91340bffb43920ddf599c96f3fdb01ba1e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 18:03:46 +0900 Subject: [PATCH 075/133] Hide mods wedge when empty --- .../OnlinePlay/FooterButtonFreeModsV2.cs | 115 +++++++++--------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs index d9cb0acb4f..84c9334b45 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeModsV2.cs @@ -41,6 +41,7 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + private Drawable modsWedge = null!; private ModDisplay modDisplay = null!; private Container modContainer = null!; private ModCountText overflowModCountDisplay = null!; @@ -57,62 +58,60 @@ namespace osu.Game.Screens.OnlinePlay Icon = FontAwesome.Solid.ExchangeAlt; AccentColour = colours.Lime1; - AddRange(new[] + Add(modsWedge = new Container { - new Container + Y = -5f, + Depth = float.MaxValue, + Origin = Anchor.BottomLeft, + Shear = OsuGame.SHEAR, + CornerRadius = CORNER_RADIUS, + Size = new Vector2(BUTTON_WIDTH, bar_height), + Masking = true, + EdgeEffect = new EdgeEffectParameters { - Y = -5f, - Depth = float.MaxValue, - Origin = Anchor.BottomLeft, - Shear = OsuGame.SHEAR, - CornerRadius = CORNER_RADIUS, - Size = new Vector2(BUTTON_WIDTH, bar_height), - Masking = true, - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Shadow, - Radius = 4, - // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. - Colour = Colour4.Black.Opacity(0.25f), - Offset = new Vector2(0, 2), - }, - Children = new Drawable[] - { - new Box - { - Colour = colourProvider.Background4, - RelativeSizeAxes = Axes.Both, - }, - modContainer = new Container - { - CornerRadius = CORNER_RADIUS, - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - new Box - { - Colour = colourProvider.Background3, - RelativeSizeAxes = Axes.Both, - }, - modDisplay = new ModDisplay(showExtendedInformation: true) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Shear = -OsuGame.SHEAR, - Scale = new Vector2(0.5f), - Current = { BindTarget = FreeMods }, - ExpansionMode = ExpansionMode.AlwaysContracted, - }, - overflowModCountDisplay = new ModCountText - { - Mods = { BindTarget = FreeMods }, - Freestyle = { BindTarget = Freestyle } - }, - } - }, - } + Type = EdgeEffectType.Shadow, + Radius = 4, + // Figma says 50% opacity, but it does not match up visually if taken at face value, and looks bad. + Colour = Colour4.Black.Opacity(0.25f), + Offset = new Vector2(0, 2), }, + Alpha = 0, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background4, + RelativeSizeAxes = Axes.Both, + }, + modContainer = new Container + { + CornerRadius = CORNER_RADIUS, + RelativeSizeAxes = Axes.Both, + Masking = true, + Children = new Drawable[] + { + new Box + { + Colour = colourProvider.Background3, + RelativeSizeAxes = Axes.Both, + }, + modDisplay = new ModDisplay(showExtendedInformation: true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = -OsuGame.SHEAR, + Scale = new Vector2(0.5f), + Current = { BindTarget = FreeMods }, + ExpansionMode = ExpansionMode.AlwaysContracted, + }, + overflowModCountDisplay = new ModCountText + { + Mods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = Freestyle } + }, + } + }, + } }); } @@ -121,15 +120,19 @@ namespace osu.Game.Screens.OnlinePlay base.LoadComplete(); Freestyle.BindValueChanged(f => Enabled.Value = !f.NewValue, true); + FreeMods.BindValueChanged(m => + { + if (m.NewValue.Count == 0) + modsWedge.FadeOut(200); + else + modsWedge.FadeIn(200); + }, true); } protected override void Update() { base.Update(); - if (FreeMods.Value.Count == 0) - return; - if (modDisplay.DrawWidth * modDisplay.Scale.X > modContainer.DrawWidth) overflowModCountDisplay.Show(); else From 59b3afe5aa4cb0d1d41d70a8bf0e941fe1215c74 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 5 Jan 2026 18:24:48 +0900 Subject: [PATCH 076/133] Hide add button when mod selects are shown The important one is the required mod selection, whose overlay contents overlap with the add button. The free mod selection would otherwise be fine, if not for consistency. --- .../OnlinePlay/Playlists/PlaylistsSongSelectV2.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs index 20c08ce2d1..58d472fb99 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.cs @@ -8,6 +8,7 @@ using Humanizer; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Game.Localisation; using osu.Game.Online.API; @@ -91,6 +92,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists modSelectOverlayRegistration = overlayManager?.RegisterBlockingOverlay(freeModSelect); + modSelect.State.BindValueChanged(onModSelectStateChanged, true); + freeModSelect.State.BindValueChanged(onModSelectStateChanged, true); + Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); Freestyle.BindValueChanged(onFreestyleChanged); @@ -105,6 +109,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists room.Playlist = room.Playlist.Append(createItem()).ToArray(); } + private void onModSelectStateChanged(ValueChangedEvent state) + { + if (state.NewValue == Visibility.Visible) + addToPlaylistFooterButton.Disappear(); + else + addToPlaylistFooterButton.Appear(); + } + private void onGlobalModsChanged(ValueChangedEvent> mods) { updateValidMods(); From 4f2dfccb0f3f92781e77a9efaacd33c7e48025bd Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 5 Jan 2026 16:13:36 -0500 Subject: [PATCH 077/133] Add test cases requiring text wrapping --- .../UserInterface/TestSceneFormControls.cs | 379 ++++++++++-------- 1 file changed, 222 insertions(+), 157 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs index a1872b30de..22b3753320 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormControls.cs @@ -42,171 +42,236 @@ namespace osu.Game.Tests.Visual.UserInterface RelativeSizeAxes = Axes.Both, Child = new FillFlowContainer { - AutoSizeAxes = Axes.Y, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 400, - Direction = FillDirection.Vertical, - Spacing = new Vector2(5), - Padding = new MarginPadding(10), - Children = new Drawable[] + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Horizontal, + Children = new[] { - new FormTextBox + new FillFlowContainer { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - TabbableContentContainer = this, - }, - new FormTextBox - { - Caption = "Artist", - HintText = "Poot artist here!", - PlaceholderText = "Here is an artist", - Current = { Disabled = true }, - TabbableContentContainer = this, - }, - new FormNumberBox(allowDecimals: true) - { - Caption = "Number", - HintText = "Insert your favourite number", - PlaceholderText = "Mine is 42!", - TabbableContentContainer = this, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Disabled = true }, - }, - new FormCheckBox - { - Caption = EditorSetupStrings.LetterboxDuringBreaks, - HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, - Current = { Value = true, Disabled = true }, - }, - new FormSliderBar - { - Caption = "Slider", - HintText = "Slider hint", - Current = new BindableFloat + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] { - MinValue = 0, - MaxValue = 10, - Value = 5, - Precision = 0.1f, + new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + TabbableContentContainer = this, + }, + new FormTextBox + { + Caption = "Artist", + HintText = "Poot artist here!", + PlaceholderText = "Here is an artist", + Current = { Disabled = true }, + TabbableContentContainer = this, + }, + new FormNumberBox(allowDecimals: true) + { + Caption = "Number", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Disabled = true }, + }, + new FormCheckBox + { + Caption = EditorSetupStrings.LetterboxDuringBreaks, + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + Current = { Value = true, Disabled = true }, + }, + new FormSliderBar + { + Caption = "Slider", + HintText = "Slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider", + HintText = "Slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + Disabled = true, + }, + TransferValueOnCommit = true, + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider (percentage)", + HintText = "Percentage slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Value = 0.2f, + Precision = 0.0001f, + }, + DisplayAsPercentage = true, + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider (custom)", + HintText = "Custom slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Value = 0.2f, + Precision = 0.0001f, + }, + LabelFormat = v => $"{v * 100:0.00} funometer", + TooltipFormat = v => $"This setting has the value set to {v * 100:0.00} funometer.", + TabbableContentContainer = this, + }, + new FormSliderBar + { + Caption = "Slider (custom)", + HintText = "Custom slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 1, + Value = 0.2f, + Precision = 0.0001f, + Disabled = true, + }, + TransferValueOnCommit = true, + LabelFormat = v => $"{v * 100:0.00} funometer", + TooltipFormat = v => $"This setting has the value set to {v * 100:0.00} funometer.", + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + Current = { Disabled = true }, + }, + new FormFileSelector + { + Caption = "File selector", + PlaceholderText = "Select a file", + }, + new FormBeatmapFileSelector(true) + { + Caption = "File selector with intermediate choice dialog", + PlaceholderText = "Select a file", + }, + new FormColourPalette + { + Caption = "Combo colours", + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, + new FormButton + { + Caption = "No text in button", + Action = () => { }, + }, }, - TabbableContentContainer = this, }, - new FormSliderBar + new FillFlowContainer { - Caption = "Slider", - HintText = "Slider hint", - Current = new BindableFloat + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] { - MinValue = 0, - MaxValue = 10, - Value = 5, - Precision = 0.1f, - Disabled = true, + new FormNumberBox(allowDecimals: true) + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = "Insert your favourite number", + PlaceholderText = "Mine is 42!", + TabbableContentContainer = this, + }, + new FormCheckBox + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.LetterboxDuringBreaksDescription, + }, + new FormSliderBar + { + Caption = "Lorem ipsum dolor sit amet, conse adipiscing elit, sed do eiusmod", + HintText = "Slider hint", + Current = new BindableFloat + { + MinValue = 0, + MaxValue = 10, + Value = 5, + Precision = 0.1f, + }, + TabbableContentContainer = this, + }, + new FormEnumDropdown + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormFileSelector + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.CountdownDescription, + PlaceholderText = "Select a file", + }, + new FormColourPalette + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + HintText = EditorSetupStrings.CountdownDescription, + Colours = + { + Colour4.Red, + Colour4.Green, + Colour4.Blue, + Colour4.Yellow, + } + }, }, - TransferValueOnCommit = true, - TabbableContentContainer = this, - }, - new FormSliderBar - { - Caption = "Slider (percentage)", - HintText = "Percentage slider hint", - Current = new BindableFloat - { - MinValue = 0, - MaxValue = 1, - Value = 0.2f, - Precision = 0.0001f, - }, - DisplayAsPercentage = true, - TabbableContentContainer = this, - }, - new FormSliderBar - { - Caption = "Slider (custom)", - HintText = "Custom slider hint", - Current = new BindableFloat - { - MinValue = 0, - MaxValue = 1, - Value = 0.2f, - Precision = 0.0001f, - }, - LabelFormat = v => $"{v * 100:0.00} funometer", - TooltipFormat = v => $"This setting has the value set to {v * 100:0.00} funometer.", - TabbableContentContainer = this, - }, - new FormSliderBar - { - Caption = "Slider (custom)", - HintText = "Custom slider hint", - Current = new BindableFloat - { - MinValue = 0, - MaxValue = 1, - Value = 0.2f, - Precision = 0.0001f, - Disabled = true, - }, - TransferValueOnCommit = true, - LabelFormat = v => $"{v * 100:0.00} funometer", - TooltipFormat = v => $"This setting has the value set to {v * 100:0.00} funometer.", - TabbableContentContainer = this, - }, - new FormEnumDropdown - { - Caption = EditorSetupStrings.EnableCountdown, - HintText = EditorSetupStrings.CountdownDescription, - }, - new FormEnumDropdown - { - Caption = EditorSetupStrings.EnableCountdown, - HintText = EditorSetupStrings.CountdownDescription, - Current = { Disabled = true }, - }, - new FormFileSelector - { - Caption = "File selector", - PlaceholderText = "Select a file", - }, - new FormBeatmapFileSelector(true) - { - Caption = "File selector with intermediate choice dialog", - PlaceholderText = "Select a file", - }, - new FormColourPalette - { - Caption = "Combo colours", - Colours = - { - Colour4.Red, - Colour4.Green, - Colour4.Blue, - Colour4.Yellow, - } - }, - new FormButton - { - Caption = "No text in button", - Action = () => { }, - }, - new FormButton - { - Caption = "Text in button which is pretty long and is very likely to wrap", - ButtonText = "Foo the bar", - Action = () => { }, - }, + } }, }, }, From 465ec88371102dc388ef6fca2a902e19a9999029 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 5 Jan 2026 16:13:21 -0500 Subject: [PATCH 078/133] Expose switch button width --- osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs index 1c1dae0f31..5bb6aca7f4 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs @@ -19,6 +19,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 { public partial class SwitchButton : Checkbox { + public const float WIDTH = 45; + private const float border_thickness = 4.5f; private const float padding = 1.25f; @@ -35,7 +37,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 public SwitchButton() { - Size = new Vector2(45, 20); + Size = new Vector2(WIDTH, 20); InternalChild = content = new CircularContainer { From 08364a7b97c50779c022879661d4cf81939397c3 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 5 Jan 2026 16:13:51 -0500 Subject: [PATCH 079/133] Support text wrapping in form controls --- .../Graphics/UserInterfaceV2/FormCheckBox.cs | 34 ++++---- .../Graphics/UserInterfaceV2/FormDropdown.cs | 33 ++++---- .../UserInterfaceV2/FormFieldCaption.cs | 76 +++++++++++------- .../UserInterfaceV2/FormFileSelector.cs | 57 ++++++++------ .../Graphics/UserInterfaceV2/FormSliderBar.cs | 78 ++++++++++++------- .../Graphics/UserInterfaceV2/FormTextBox.cs | 11 +-- 6 files changed, 173 insertions(+), 116 deletions(-) diff --git a/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs index f54cc7cc7c..dfe4f6c439 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormCheckBox.cs @@ -18,6 +18,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Localisation; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -56,7 +57,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void load(AudioManager audio) { RelativeSizeAxes = Axes.X; - Height = 50; + AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 5; @@ -71,22 +72,30 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding(9), Children = new Drawable[] { - caption = new FormFieldCaption - { - Caption = Caption, - TooltipText = HintText, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - }, - text = new OsuSpriteText + new FillFlowContainer { RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding { Right = SwitchButton.WIDTH + 5 }, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + text = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + }, + }, }, new SwitchButton { @@ -97,7 +106,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, }, }; - sampleChecked = audio.Samples.Get(@"UI/check-on"); sampleUnchecked = audio.Samples.Get(@"UI/check-off"); sampleDisabled = audio.Samples.Get(@"UI/default-select-disabled"); diff --git a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs index 1c595e1386..d7e7344681 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; @@ -140,30 +141,30 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader] private void load() { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.None; - Height = 50; - Masking = true; CornerRadius = 5; - Foreground.AutoSizeAxes = Axes.None; - Foreground.RelativeSizeAxes = Axes.Both; Foreground.Padding = new MarginPadding(9); Foreground.Children = new Drawable[] { - caption = new FormFieldCaption - { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Caption = Caption, - TooltipText = HintText, - }, - label = new OsuSpriteText + new FillFlowContainer { RelativeSizeAxes = Axes.X, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 4), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + label = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + }, + } }, chevron = new SpriteIcon { diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs b/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs index 75c27618e9..3bc54359b8 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFieldCaption.cs @@ -2,19 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { public partial class FormFieldCaption : CompositeDrawable, IHasTooltip { + private OsuTextFlowContainer textFlow = null!; + private LocalisableString caption; public LocalisableString Caption @@ -24,45 +25,60 @@ namespace osu.Game.Graphics.UserInterfaceV2 { caption = value; - if (captionText.IsNotNull()) - captionText.Text = value; + if (IsLoaded) + updateDisplay(); } } - private OsuSpriteText captionText = null!; + private LocalisableString tooltipText; - public LocalisableString TooltipText { get; set; } + public LocalisableString TooltipText + { + get => tooltipText; + set + { + tooltipText = value; + + if (IsLoaded) + updateDisplay(); + } + } [BackgroundDependencyLoader] private void load() { - AutoSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; - InternalChild = new FillFlowContainer + InternalChild = textFlow = new OsuTextFlowContainer(t => t.Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold)) { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Children = new Drawable[] - { - captionText = new OsuSpriteText - { - Text = caption, - Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new SpriteIcon - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Alpha = TooltipText == default ? 0 : 1, - Size = new Vector2(10), - Icon = FontAwesome.Solid.QuestionCircle, - Margin = new MarginPadding { Top = 1, }, - } - }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + textFlow.Text = caption; + + if (TooltipText != default) + { + textFlow.AddArbitraryDrawable(new SpriteIcon + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Size = new Vector2(10), + Icon = FontAwesome.Solid.QuestionCircle, + Margin = new MarginPadding { Left = 5 }, + Y = 1f, + }); + } + } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs index fe4e764836..8f402e4da9 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormFileSelector.cs @@ -117,34 +117,46 @@ namespace osu.Game.Graphics.UserInterfaceV2 new Container { RelativeSizeAxes = Axes.X, - Height = 50, + AutoSizeAxes = Axes.Y, Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Padding = new MarginPadding(9), Children = new Drawable[] { - caption = new FormFieldCaption + new FillFlowContainer { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - Caption = Caption, - TooltipText = HintText, - }, - placeholderText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - Width = 1, - Text = PlaceholderText, - Colour = colourProvider.Foreground1, - }, - filenameText = new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Width = 1, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + caption = new FormFieldCaption + { + Caption = Caption, + TooltipText = HintText, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + placeholderText = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Width = 1, + Text = PlaceholderText, + Colour = colourProvider.Foreground1, + }, + filenameText = new OsuSpriteText + { + RelativeSizeAxes = Axes.X, + Width = 1, + }, + } + } + }, }, new SpriteIcon { @@ -242,7 +254,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 Task ICanAcceptFiles.Import(ImportTask[] tasks, ImportParameters parameters) => throw new NotImplementedException(); - protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) => new FileChooserPopover(handledExtensions, current, chooserPath); + protected virtual FileChooserPopover CreatePopover(string[] handledExtensions, Bindable current, string? chooserPath) => + new FileChooserPopover(handledExtensions, current, chooserPath); public Popover GetPopover() { diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index 310be77f31..625967dc66 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -22,6 +22,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays; +using Vector2 = osuTK.Vector2; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -143,7 +144,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void load(OsuColour colours, OsuGame? game) { RelativeSizeAxes = Axes.X; - Height = 50; + AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 5; @@ -162,47 +163,64 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, new Container { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding { - Vertical = 9, + Vertical = 5, Left = 9, Right = 5, }, Children = new Drawable[] { - captionText = new FormFieldCaption + new FillFlowContainer { - Anchor = Anchor.TopLeft, - Origin = Anchor.TopLeft, - TooltipText = HintText, - }, - textBox = new FormNumberBox.InnerNumberBox(allowDecimals: true) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), Width = 0.5f, - // the textbox is hidden when the control is unfocused, - // but clicking on the label should reach the textbox, - // therefore make it always present. - AlwaysPresent = true, - CommitOnFocusLost = true, - SelectAllOnFocus = true, - OnInputError = () => + Padding = new MarginPadding { - flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); - flashLayer.FadeOutFromOne(200, Easing.OutQuint); + Right = 10, + Vertical = 4, + }, + Children = new Drawable[] + { + captionText = new FormFieldCaption + { + TooltipText = HintText, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + textBox = new FormNumberBox.InnerNumberBox(allowDecimals: true) + { + RelativeSizeAxes = Axes.X, + // the textbox is hidden when the control is unfocused, + // but clicking on the label should reach the textbox, + // therefore make it always present. + AlwaysPresent = true, + CommitOnFocusLost = true, + SelectAllOnFocus = true, + OnInputError = () => + { + flashLayer.Colour = ColourInfo.GradientVertical(colours.Red3.Opacity(0), colours.Red3); + flashLayer.FadeOutFromOne(200, Easing.OutQuint); + }, + TabbableContentContainer = tabbableContentContainer, + }, + valueLabel = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Right = 5 }, + }, + }, + }, }, - TabbableContentContainer = tabbableContentContainer, - }, - valueLabel = new TruncatingSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - RelativeSizeAxes = Axes.X, - Width = 0.5f, - Padding = new MarginPadding { Right = 5 }, }, slider = new InnerSlider { diff --git a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs index 6429eb6e96..b85ee3d2c8 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormTextBox.cs @@ -19,6 +19,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -89,7 +90,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void load(OsuColour colours) { RelativeSizeAxes = Axes.X; - Height = 50; + AutoSizeAxes = Axes.Y; Masking = true; CornerRadius = 5; @@ -107,10 +108,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 RelativeSizeAxes = Axes.Both, Colour = Colour4.Transparent, }, - new Container + new FillFlowContainer { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Padding = new MarginPadding(9), + Spacing = new Vector2(0, 4), Children = new Drawable[] { caption = new FormFieldCaption @@ -122,8 +125,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, textBox = CreateTextBox().With(t => { - t.Anchor = Anchor.BottomRight; - t.Origin = Anchor.BottomRight; t.RelativeSizeAxes = Axes.X; t.Width = 1; t.PlaceholderText = PlaceholderText; From 61d0b5a03c4bccb179ae08a0eac43660ff6524ef Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 6 Jan 2026 15:01:28 +0900 Subject: [PATCH 080/133] Update resources --- osu.Game/osu.Game.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index f7360ab1d8..b282963aac 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - + From 86220622fbd477a31ce8fc2d5135642e2dc6c876 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 6 Jan 2026 17:20:28 +0900 Subject: [PATCH 081/133] Remove HoldForMenuButton player dependency --- osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs | 2 +- .../Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs | 2 +- .../Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs | 2 +- .../Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs | 2 +- osu.Game/Screens/Play/HUD/HoldForMenuButton.cs | 6 +++--- osu.Game/Screens/Play/HUDOverlay.cs | 6 ++++-- osu.Game/Screens/Play/Player.cs | 2 +- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index d51c9b3f88..1e6e0007b5 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -270,7 +270,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("create overlay", () => { - hudOverlay = new HUDOverlay(null, Array.Empty()); + hudOverlay = new HUDOverlay(null, Array.Empty(), new PlayerConfiguration()); // Add any key just to display the key counter visually. hudOverlay.InputCountController.Add(new KeyCounterKeyboardTrigger(Key.Space)); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs index 47791dd462..e67c2d9f09 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs @@ -119,7 +119,7 @@ namespace osu.Game.Tests.Visual.Gameplay Children = new Drawable[] { drawableRuleset, - new HUDOverlay(drawableRuleset, []) + new HUDOverlay(drawableRuleset, [], new PlayerConfiguration()) { RelativeSizeAxes = Axes.Both, } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 00369ade18..76daffbbad 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.Gameplay var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap, mods); - var hudOverlay = new HUDOverlay(drawableRuleset, mods) + var hudOverlay = new HUDOverlay(drawableRuleset, mods, new PlayerConfiguration()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 754ec841d8..6e4f3af70a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -96,7 +96,7 @@ namespace osu.Game.Tests.Visual.Gameplay { SetContents(_ => { - hudOverlay = new HUDOverlay(new DrawableOsuRuleset(new OsuRuleset(), new OsuBeatmap()), Array.Empty()); + hudOverlay = new HUDOverlay(new DrawableOsuRuleset(new OsuRuleset(), new OsuBeatmap()), Array.Empty(), new PlayerConfiguration()); action?.Invoke(hudOverlay); diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index c271adc057..01314c5de6 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -57,8 +57,8 @@ namespace osu.Game.Screens.Play.HUD AlwaysPresent = true; } - [BackgroundDependencyLoader(true)] - private void load(Player player, OsuConfigManager config) + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) { Children = new Drawable[] { @@ -68,7 +68,7 @@ namespace osu.Game.Screens.Play.HUD Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft }, - button = new HoldButton(isDangerousAction || player?.Configuration.AllowRestart == false) + button = new HoldButton(isDangerousAction) { HoverGained = () => text.FadeIn(500, Easing.OutQuint), HoverLost = () => text.FadeOut(500, Easing.OutQuint), diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 806e593729..d7283b227f 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -73,6 +73,7 @@ namespace osu.Game.Screens.Play private readonly DrawableRuleset drawableRuleset; private readonly IReadOnlyList mods; + private readonly PlayerConfiguration configuration; /// /// Whether the elements that can optionally be hidden should be visible. @@ -113,12 +114,13 @@ namespace osu.Game.Screens.Play /// internal readonly Drawable PlayfieldSkinLayer; - public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods) + public HUDOverlay([CanBeNull] DrawableRuleset drawableRuleset, IReadOnlyList mods, PlayerConfiguration configuration) { Container rightSettings; this.drawableRuleset = drawableRuleset; this.mods = mods; + this.configuration = configuration; RelativeSizeAxes = Axes.Both; @@ -391,7 +393,7 @@ namespace osu.Game.Screens.Play ShowHealth = { BindTarget = ShowHealthBar } }; - protected HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton + protected HoldForMenuButton CreateHoldForMenuButton() => new HoldForMenuButton(!configuration.AllowRestart) { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 21e0d3c8b9..ba543db996 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -474,7 +474,7 @@ namespace osu.Game.Screens.Play Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), - HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods) + HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration) { HoldToQuit = { From 679c6d022954eef1310b0774299070a990a3188e Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 6 Jan 2026 17:25:08 +0900 Subject: [PATCH 082/133] Remove `Masking` definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartłomiej Dach --- osu.Game/Screens/Play/PlayerLoader.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 34577c1e56..bfec95018f 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -237,8 +237,7 @@ namespace osu.Game.Screens.Play Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, Padding = new MarginPadding { - Top = padding, - Bottom = ScreenFooter.HEIGHT + padding + Bottom = ScreenFooter.HEIGHT }, RowDimensions = [ @@ -252,13 +251,16 @@ namespace osu.Game.Screens.Play settingsScroll = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, - Masking = holdForMenuExitButton, Child = PlayerSettings = new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), - Padding = new MarginPadding { Horizontal = padding }, + Padding = new MarginPadding + { + Horizontal = padding, + Vertical = padding, + }, Children = new PlayerSettingsGroup[] { VisualSettings = new VisualSettings(), @@ -282,6 +284,7 @@ namespace osu.Game.Screens.Play { Top = 20, Horizontal = padding, + Bottom = padding, }, Action = () => { From 53a5157a2f9278ec0f0b1d730edc86315b772260 Mon Sep 17 00:00:00 2001 From: Jonas Schips <104238285+jonaasdev@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:10:34 +0100 Subject: [PATCH 083/133] feat: added whitespace between "fps"/"ms" and it's value to prevent clipping --- osu.Game/Graphics/UserInterface/FPSCounter.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Graphics/UserInterface/FPSCounter.cs b/osu.Game/Graphics/UserInterface/FPSCounter.cs index 000b85b900..190d88a6e4 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounter.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounter.cs @@ -232,14 +232,14 @@ namespace osu.Game.Graphics.UserInterface private void updateFpsDisplay() { counterDrawFPS.Colour = getColour(displayedFpsCount / aimDrawFPS); - counterDrawFPS.Text = $"{displayedFpsCount:#,0}fps"; + counterDrawFPS.Text = $"{displayedFpsCount:#,0} fps"; } private void updateFrameTimeDisplay() { counterUpdateFrameTime.Text = displayedFrameTime < 5 - ? $"{displayedFrameTime:N1}ms" - : $"{displayedFrameTime:N0}ms"; + ? $"{displayedFrameTime:N1} ms" + : $"{displayedFrameTime:N0} ms"; counterUpdateFrameTime.Colour = getColour((1000 / displayedFrameTime) / aimUpdateFPS); } From 86a3c19547634d5d8314b371856a47e4a67a5c3a Mon Sep 17 00:00:00 2001 From: Jonas Schips <104238285+jonaasdev@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:37:10 +0100 Subject: [PATCH 084/133] feat: added whitespaces between "fps"/"ms" and it's value in FPSCounterTooltip.cs --- osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs index e64a4c6c07..2faf03d2f4 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -82,7 +82,7 @@ namespace osu.Game.Graphics.UserInterface ? $"/{(clock.MaximumUpdateHz > 0 && clock.MaximumUpdateHz < 10000 ? clock.MaximumUpdateHz.ToString("0") : "∞"),4}" : string.Empty; - textFlow.AddParagraph($"{clock.FramesPerSecond:0}{maximum}fps ({clock.ElapsedFrameTime:0.00}ms)"); + textFlow.AddParagraph($"{clock.FramesPerSecond:0}{maximum} fps ({clock.ElapsedFrameTime:0.00} ms)"); } } } From 8cbcb0e74f8aea2cc0e4830ab489f882233ba234 Mon Sep 17 00:00:00 2001 From: UltraDrakon Date: Tue, 6 Jan 2026 14:51:07 +0100 Subject: [PATCH 085/133] Hide cursor during background reveal in song select - Implement IProvideCursor in SongSelect to hide cursor when background is revealed - Cursor reappears on mouse movement and hides again after 1 second of inactivity - Fix MenuCursorContainer to preserve drag rotation state during hide/show cycles --- .../Graphics/Cursor/MenuCursorContainer.cs | 10 +++++--- osu.Game/Screens/SelectV2/SongSelect.cs | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs index 696ea62b42..2ea30d86bd 100644 --- a/osu.Game/Graphics/Cursor/MenuCursorContainer.cs +++ b/osu.Game/Graphics/Cursor/MenuCursorContainer.cs @@ -220,16 +220,18 @@ namespace osu.Game.Graphics.Cursor { activeCursor.FadeTo(1, 250, Easing.OutQuint); activeCursor.ScaleTo(1, 400, Easing.OutQuint); - activeCursor.RotateTo(0, 400, Easing.OutQuint); - dragRotationState = DragRotationState.NotDragging; + + if (dragRotationState == DragRotationState.NotDragging) + activeCursor.RotateTo(0, 400, Easing.OutQuint); } protected override void PopOut() { activeCursor.FadeTo(0, 250, Easing.OutQuint); activeCursor.ScaleTo(0.6f, 250, Easing.In); - activeCursor.RotateTo(0, 400, Easing.OutQuint); - dragRotationState = DragRotationState.NotDragging; + + if (dragRotationState == DragRotationState.NotDragging) + activeCursor.RotateTo(0, 400, Easing.OutQuint); } private void playTapSample(double baseFrequency = 1f) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 4bee63b57c..e0e6e8842a 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -64,7 +64,7 @@ namespace osu.Game.Screens.SelectV2 /// This will be gradually built upon and ultimately replace once everything is in place. /// [Cached(typeof(ISongSelect))] - public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect, IHandlePresentBeatmap + public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect, IHandlePresentBeatmap, IProvideCursor { /// /// A debounce that governs how long after a panel is selected before the rest of song select (and the game at large) @@ -911,6 +911,23 @@ namespace osu.Game.Screens.SelectV2 #region Input private ScheduledDelegate? revealingBackground; + private bool isRevealingBackground; + private double? lastCursorMoveTimeDuringReveal; + + private bool cursorRecentlyMoved => lastCursorMoveTimeDuringReveal.HasValue && + Clock.CurrentTime - lastCursorMoveTimeDuringReveal.Value < 1000; + + public CursorContainer? Cursor => null; + public bool ProvidingUserCursor => isRevealingBackground && !cursorRecentlyMoved; + + protected override bool OnHover(HoverEvent e) => true; + + protected override bool OnMouseMove(MouseMoveEvent e) + { + if (isRevealingBackground) + lastCursorMoveTimeDuringReveal = Clock.CurrentTime; + return base.OnMouseMove(e); + } private GridContainer mainGridContainer = null!; @@ -948,6 +965,9 @@ namespace osu.Game.Screens.SelectV2 updateBackgroundDim(); Footer?.Hide(); + + isRevealingBackground = true; + lastCursorMoveTimeDuringReveal = null; }, 200); } @@ -981,6 +1001,9 @@ namespace osu.Game.Screens.SelectV2 revealingBackground.Cancel(); revealingBackground = null; + isRevealingBackground = false; + lastCursorMoveTimeDuringReveal = null; + updateBackgroundDim(); } From d05acc3d4f0a890074823a7134617402eabe1e4b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 6 Jan 2026 22:54:27 +0900 Subject: [PATCH 086/133] Fix tray showing first item too far to left --- .../Playlists/PlaylistsSongSelectV2.PlaylistTray.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs index 1b44106e36..f634daf481 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelectV2.PlaylistTray.cs @@ -29,6 +29,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private FillFlowContainer flow = null!; private OsuSpriteText text = null!; + private const float item_width = 250; + public PlaylistTray(Room room) { this.room = room; @@ -107,6 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { RelativeSizeAxes = Axes.Y, AutoSizeAxes = Axes.X, + Padding = new MarginPadding { Left = item_width }, Spacing = new Vector2(5), Direction = FillDirection.Horizontal } @@ -150,7 +153,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists var newItem = new DrawableRoomPlaylistItem(room.Playlist[^1], loadImmediately: true) { RelativeSizeAxes = Axes.None, - Width = 250, + Width = item_width, AllowReordering = false, }; @@ -159,7 +162,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists flow.Add(newItem); - scroll.ScrollToStart(animated: false); + if (scroll.IsLoaded) + scroll.ScrollToStart(animated: false); ScheduleAfterChildren(() => scroll.ScrollToEnd()); Scheduler.AddDelayed(() => text.Text = $"{room.Playlist.Count} item(s)", 100); From 85747f88660b8381688edadeb2f268f9d9cb1c61 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 6 Jan 2026 23:24:31 +0900 Subject: [PATCH 087/133] Fix replay settings appearing momentarily when replay is not loaded Closes #36237. --- osu.Game/Screens/Play/HUDOverlay.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 806e593729..c2e5a4327d 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -173,7 +173,10 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - PlayerSettingsOverlay = new PlayerSettingsOverlay(), + PlayerSettingsOverlay = new PlayerSettingsOverlay + { + Alpha = 0, + } } }, TopLeftElements = new FillFlowContainer @@ -344,6 +347,11 @@ namespace osu.Game.Screens.Play private void updateVisibility() { + if (configSettingsOverlay.Value && replayLoaded.Value) + PlayerSettingsOverlay.Show(); + else + PlayerSettingsOverlay.Hide(); + if (ShowHud.Disabled) return; @@ -353,11 +361,6 @@ namespace osu.Game.Screens.Play return; } - if (configSettingsOverlay.Value && replayLoaded.Value) - PlayerSettingsOverlay.Show(); - else - PlayerSettingsOverlay.Hide(); - switch (configVisibilityMode.Value) { case HUDVisibilityMode.Never: From 163ca2590786a3fee04b0ac6ba5392799a790983 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 6 Jan 2026 23:24:49 +0900 Subject: [PATCH 088/133] Fix settings toggle for replay settings overlay not being obeyed in multiplayer spectator Noticed in passing. --- .../Spectate/MultiSpectatorScreen.cs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index fb9343c519..f0b0709ab2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -6,9 +6,11 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -63,6 +65,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate private readonly Room room; + private PlayerSettingsOverlay playerSettingsOverlay = null!; + private Bindable configSettingsOverlay = null!; + /// /// Creates a new . /// @@ -78,8 +83,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate } [BackgroundDependencyLoader] - private void load() + private void load(OsuConfigManager config) { + configSettingsOverlay = config.GetBindable(OsuSetting.ReplaySettingsOverlay); + FillFlowContainer leaderboardFlow; Container scoreDisplayContainer; @@ -131,7 +138,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { ReadyToStart = performInitialSeek, }, - new PlayerSettingsOverlay() + playerSettingsOverlay = new PlayerSettingsOverlay + { + Alpha = 0, + } }; for (int i = 0; i < Users.Count; i++) @@ -172,6 +182,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate // Start with adjustments from the first player to keep a sane state. bindAudioAdjustments(instances.First()); + + configSettingsOverlay.BindValueChanged(_ => updateVisibility(), true); + } + + private void updateVisibility() + { + if (configSettingsOverlay.Value) + playerSettingsOverlay.Show(); + else + playerSettingsOverlay.Hide(); } protected override void Update() From 3ae98fd6649c72e561dcdde498ecd519c75f35d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 7 Jan 2026 10:21:04 +0100 Subject: [PATCH 089/133] Fix broken transition of player loader right side content (#36261) before: https://github.com/user-attachments/assets/dd9bfef0-d653-4aa6-b48d-fe01774d3f89 after: https://github.com/user-attachments/assets/bb3541f9-65f2-4c34-9943-ba2b63b7dbf8 regressed in 2b90cbf59f32c9cf98933a895890c3af1e9b05ca --- osu.Game/Screens/Play/PlayerLoader.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index bfec95018f..df535169b3 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -85,7 +85,7 @@ namespace osu.Game.Screens.Play protected Task? DisposalTask { get; private set; } private FillFlowContainer disclaimers = null!; - private OsuScrollContainer settingsScroll = null!; + private GridContainer sideContent = null!; private Bindable showStoryboards = null!; @@ -229,7 +229,7 @@ namespace osu.Game.Screens.Play Padding = new MarginPadding(padding), Spacing = new Vector2(20), }, - new GridContainer + sideContent = new GridContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, @@ -248,7 +248,7 @@ namespace osu.Game.Screens.Play { new Drawable[] { - settingsScroll = new OsuScrollContainer + new OsuScrollContainer { RelativeSizeAxes = Axes.Both, Child = PlayerSettings = new FillFlowContainer @@ -354,7 +354,7 @@ namespace osu.Game.Screens.Play // Start side content off-screen. disclaimers.MoveToX(-disclaimers.DrawWidth); - settingsScroll.MoveToX(settingsScroll.DrawWidth); + sideContent.MoveToX(sideContent.DrawWidth); content.ScaleTo(0.7f); @@ -600,8 +600,8 @@ namespace osu.Game.Screens.Play using (BeginDelayedSequence(delayBeforeSideDisplays)) { - settingsScroll.FadeInFromZero(500, Easing.Out) - .MoveToX(0, 500, Easing.OutQuint); + sideContent.FadeInFromZero(500, Easing.Out) + .MoveToX(0, 500, Easing.OutQuint); disclaimers.FadeInFromZero(500, Easing.Out) .MoveToX(0, 500, Easing.OutQuint); @@ -641,8 +641,8 @@ namespace osu.Game.Screens.Play disclaimers.FadeOut(CONTENT_OUT_DURATION, Easing.Out) .MoveToX(-disclaimers.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); - settingsScroll.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) - .MoveToX(settingsScroll.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + sideContent.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint) + .MoveToX(sideContent.DrawWidth, CONTENT_OUT_DURATION * 2, Easing.OutQuint); lowPassFilter?.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION); highPassFilter?.CutoffTo(0, CONTENT_OUT_DURATION); From 7f99650a047074d33c6b223e31d5f78a381db6fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 7 Jan 2026 11:08:51 +0100 Subject: [PATCH 090/133] Add test covering desired behaviour of dismissing beatmap set scope --- .../TestSceneSongSelectFiltering.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs index a83ec33778..3eb97cf2dd 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs @@ -398,6 +398,30 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkMatchedBeatmaps(6); } + [Test] + public void TestDismissingScopeDoesNotClearSearchTextBox() + { + ImportBeatmapForRuleset(0); + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + SortBy(SortMode.Artist); + checkMatchedBeatmaps(6); + + AddStep("set text filter", () => filterTextBox.Current.Value = Beatmaps.GetAllUsableBeatmapSets().First().Metadata.Title); + WaitForFiltering(); + checkMatchedBeatmaps(3); + + AddStep("click spread indicator", () => this.ChildrenOfType().Single(d => d.Enabled.Value).TriggerClick()); + WaitForFiltering(); + checkMatchedBeatmaps(3); + + AddStep("press Escape", () => InputManager.Key(Key.Escape)); + WaitForFiltering(); + checkMatchedBeatmaps(3); + AddAssert("text filter not emptied", () => filterTextBox.Current.Value, () => Is.Not.Empty); + } + private NoResultsPlaceholder? getPlaceholder() => SongSelect.ChildrenOfType().FirstOrDefault(); private void checkMatchedBeatmaps(int expected) => AddUntilStep($"{expected} matching shown", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); From 9b26b83d55a4bba4204e60a8786da779c818feeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 7 Jan 2026 11:13:33 +0100 Subject: [PATCH 091/133] Fix beatmap scope dismiss bar showing on top of filter control dropdowns closes https://github.com/ppy/osu/issues/36259 --- osu.Game/Screens/SelectV2/FilterControl.cs | 32 ++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/FilterControl.cs b/osu.Game/Screens/SelectV2/FilterControl.cs index 88a527b66c..268e937167 100644 --- a/osu.Game/Screens/SelectV2/FilterControl.cs +++ b/osu.Game/Screens/SelectV2/FilterControl.cs @@ -19,6 +19,7 @@ using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; @@ -114,6 +115,7 @@ namespace osu.Game.Screens.SelectV2 { RelativeSizeAxes = Axes.X, HoldFocus = true, + ScopedBeatmapSet = ScopedBeatmapSet, }, }, new GridContainer @@ -189,7 +191,6 @@ namespace osu.Game.Screens.SelectV2 new ScopedBeatmapSetDisplay { ScopedBeatmapSet = ScopedBeatmapSet, - Depth = float.MinValue, // hack to ensure that the scoped display handles `GlobalAction.Back` input before the filter control } }, } @@ -330,12 +331,39 @@ namespace osu.Game.Screens.SelectV2 internal partial class SongSelectSearchTextBox : ShearedFilterTextBox { - protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox(); + public Bindable ScopedBeatmapSet + { + get => scopedBeatmapSet.Current; + set => scopedBeatmapSet.Current = value; + } + + private readonly BindableWithCurrent scopedBeatmapSet = new BindableWithCurrent(); + + protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox + { + ScopedBeatmapSet = ScopedBeatmapSet, + }; private partial class InnerTextBox : InnerFilterTextBox { + public Bindable ScopedBeatmapSet + { + get => scopedBeatmapSet.Current; + set => scopedBeatmapSet.Current = value; + } + + private readonly BindableWithCurrent scopedBeatmapSet = new BindableWithCurrent(); + public override bool HandleLeftRightArrows => false; + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Back && scopedBeatmapSet.Value != null) + return false; + + return base.OnPressed(e); + } + public override bool OnPressed(KeyBindingPressEvent e) { // Conflicts with default group navigation keys (shift-left shift-right). From 48de70e719e8fab05ea7abc81794128808aef66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 7 Jan 2026 13:03:20 +0100 Subject: [PATCH 092/133] Log version hash to sentry This is the counterpart to https://github.com/ppy/osu-server-spectator/pull/413. The goal is to log the value which is seemingly failing to work correctly client-side as well. The reason for doing that is two-fold: - To eliminate possibility of freakazoid issues wherein some computers miscalculate the version hash somehow - To eliminate possibility of the version hash somehow getting lost in transit (e.g. present client-side but no longer present server-side). --- osu.Game/Utils/SentryLogger.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index aa1fd429a7..9b3b78cd0e 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -176,6 +176,7 @@ namespace osu.Game.Utils scope.SetTag(@"beatmap", $"{beatmap.OnlineID}"); scope.SetTag(@"ruleset", ruleset.ShortName); scope.SetTag(@"os", $"{RuntimeInfo.OS} ({Environment.OSVersion})"); + scope.SetTag(@"version hash", game.VersionHash); scope.SetTag(@"processor count", Environment.ProcessorCount.ToString()); }); } From 3157e82f51d1711817597a9adfc8322ca736ecf7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 8 Jan 2026 01:45:02 +0900 Subject: [PATCH 093/133] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 6d667fb814..146becc7c9 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index ec09350de6..3e2f63780e 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + From 2963ebae963596b36356c0928170ae245c78c223 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 8 Jan 2026 02:04:18 +0900 Subject: [PATCH 094/133] Update resources --- osu.Game/osu.Game.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 3bd38bb2a4..4e0138afd1 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - + From 3f577aae602fbd27b8c462ea7b6c419c8632c59b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 9 Jan 2026 15:18:29 +0900 Subject: [PATCH 095/133] Fix now playing overlay buttons not showing toggle colour correctly Closes https://github.com/ppy/osu/issues/36280. --- osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 31ac777eaa..8faa532216 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -93,7 +93,7 @@ namespace osu.Game.Graphics.UserInterface base.LoadComplete(); Colour = DimColour; - Enabled.BindValueChanged(_ => this.FadeColour(DimColour, 200, Easing.OutQuint), true); + Enabled.BindValueChanged(_ => content.FadeColour(DimColour, 200, Easing.OutQuint), true); FinishTransforms(true); } From 23c68cbfea109c7fc38f7239ee4458e81aea4495 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 6 Jan 2026 19:28:33 +0900 Subject: [PATCH 096/133] Add support for global rank parsing in /users/ batch lookups See https://github.com/ppy/osu-web/pull/12651 for web-side implementation. To be used to fix https://github.com/ppy/osu/pull/33649/changes#diff-32784a778b34c671e1f72c00e1f4161a5e774e849aae5631ee71b31fc32e5d42R218 (have tested this works there). Use simple set-get rather than transforming to `APIUser.Statistics` --- osu.Game/Online/API/Requests/LookupUsersRequest.cs | 9 +++++++-- osu.Game/Online/API/Requests/Responses/APIUser.cs | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/osu.Game/Online/API/Requests/LookupUsersRequest.cs b/osu.Game/Online/API/Requests/LookupUsersRequest.cs index 6e98ce064e..99c1c551e4 100644 --- a/osu.Game/Online/API/Requests/LookupUsersRequest.cs +++ b/osu.Game/Online/API/Requests/LookupUsersRequest.cs @@ -10,21 +10,26 @@ namespace osu.Game.Online.API.Requests /// Looks up users with the given . /// In comparison to , the response here does not contain , /// but in exchange is subject to less stringent rate limiting, making it suitable for mass user listings. + /// + /// Providing a ruleset ID will give `global_rank`s in the response. /// public class LookupUsersRequest : APIRequest { public readonly int[] UserIds; + public readonly int? RulesetId; + private const int max_ids_per_request = 50; - public LookupUsersRequest(int[] userIds) + public LookupUsersRequest(int[] userIds, int? rulesetId = null) { if (userIds.Length > max_ids_per_request) throw new ArgumentException($"{nameof(LookupUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); UserIds = userIds; + RulesetId = rulesetId; } - protected override string Target => @"users/lookup/?ids[]=" + string.Join(@"&ids[]=", UserIds); + protected override string Target => @"users/lookup/?ids[]=" + string.Join(@"&ids[]=", UserIds) + (RulesetId != null ? "&ruleset_id=" + RulesetId : ""); } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUser.cs b/osu.Game/Online/API/Requests/Responses/APIUser.cs index 6f122c58af..fa90e5cd50 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUser.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUser.cs @@ -247,6 +247,20 @@ namespace osu.Game.Online.API.Requests.Responses } } + // Only provided via /users/ batch lookups. Usually implicitly comes inside `UserStatistics`. + [JsonProperty(@"global_rank")] + [CanBeNull] + public GlobalRank Rank { get; set; } + + public class GlobalRank + { + [JsonProperty(@"rank")] + public int? Rank; + + [JsonProperty(@"ruleset_id")] + public int RulesetId; + } + [JsonProperty(@"rank_history")] private APIRankHistory rankHistory { From a2d2c3287bc90836ecd383e678abd9170382a482 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 9 Jan 2026 16:27:33 +0900 Subject: [PATCH 097/133] Remove unnecessary and incorrect colour application --- osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs index 8faa532216..87aa4547d4 100644 --- a/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs +++ b/osu.Game/Graphics/UserInterface/OsuAnimatedButton.cs @@ -92,7 +92,6 @@ namespace osu.Game.Graphics.UserInterface { base.LoadComplete(); - Colour = DimColour; Enabled.BindValueChanged(_ => content.FadeColour(DimColour, 200, Easing.OutQuint), true); FinishTransforms(true); } From 5e4e28ef00073a76f27e05faaadaae75575bced2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 9 Jan 2026 16:15:36 +0900 Subject: [PATCH 098/133] Fix online lookup cache not recovering from faulted tasks --- osu.Game/Database/OnlineLookupCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs index 3b54804fec..cedb406048 100644 --- a/osu.Game/Database/OnlineLookupCache.cs +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -81,7 +81,7 @@ namespace osu.Game.Database pendingTasks.Enqueue((id, tcs)); // Create a request task if there's not already one. - if (pendingRequestTask == null) + if (pendingRequestTask == null || pendingRequestTask.IsFaulted) createNewTask(); return tcs.Task; From 576be6793bc7aac3fcc9d42ff1b71fdf84276a9c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 9 Jan 2026 16:52:04 +0900 Subject: [PATCH 099/133] Add proper logging of failed scenario in `OnlineLookupCache` --- osu.Game/Database/OnlineLookupCache.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs index cedb406048..a8bd5cbff2 100644 --- a/osu.Game/Database/OnlineLookupCache.cs +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -8,6 +8,8 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Extensions; +using osu.Framework.Extensions.ExceptionExtensions; +using osu.Framework.Logging; using osu.Game.Online.API; namespace osu.Game.Database @@ -163,6 +165,14 @@ namespace osu.Game.Database } } - private void createNewTask() => pendingRequestTask = Task.Run(performLookup); + private void createNewTask() + { + var nextTask = Task.Run(performLookup); + nextTask.ContinueWith(t => + { + Logger.Error(t.Exception.AsSingular(), $"{nameof(OnlineLookupCache)} lookup request failed!"); + }, TaskContinuationOptions.OnlyOnFaulted); + pendingRequestTask = nextTask; + } } } From 1c1a3fbe8d8f347abd5a7764085c44808c56c291 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 9 Jan 2026 18:00:12 +0900 Subject: [PATCH 100/133] Simplify logic --- osu.Game/Screens/SelectV2/SongSelect.cs | 48 +++++++++---------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index e0e6e8842a..e25597b642 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -119,6 +119,8 @@ namespace osu.Game.Screens.SelectV2 private Container mainContent = null!; private SkinnableContainer skinnableContent = null!; + private GridContainer mainGridContainer = null!; + private NoResultsPlaceholder noResultsPlaceholder = null!; public override bool? ApplyModTrackAdjustments => true; @@ -812,7 +814,7 @@ namespace osu.Game.Screens.SelectV2 // Probably needs more thought because this needs to be in every `ApplyToBackground` currently to restore sane defaults. backgroundModeBeatmap.FadeColour(Color4.White, 250); - bool backgroundRevealActive = revealingBackground?.State == ScheduledDelegate.RunState.Running || revealingBackground?.State == ScheduledDelegate.RunState.Complete; + bool backgroundRevealActive = revealBackgroundDelegate?.State == ScheduledDelegate.RunState.Running || revealBackgroundDelegate?.State == ScheduledDelegate.RunState.Complete; backgroundModeBeatmap.BlurAmount.Value = configBackgroundBlur.Value && !backgroundRevealActive ? 20 : 0f; }); @@ -908,29 +910,15 @@ namespace osu.Game.Screens.SelectV2 #endregion - #region Input + #region Background reveal - private ScheduledDelegate? revealingBackground; - private bool isRevealingBackground; - private double? lastCursorMoveTimeDuringReveal; - - private bool cursorRecentlyMoved => lastCursorMoveTimeDuringReveal.HasValue && - Clock.CurrentTime - lastCursorMoveTimeDuringReveal.Value < 1000; + private ScheduledDelegate? revealBackgroundDelegate; public CursorContainer? Cursor => null; - public bool ProvidingUserCursor => isRevealingBackground && !cursorRecentlyMoved; + bool IProvideCursor.ProvidingUserCursor => revealBackgroundDelegate?.Completed == true; protected override bool OnHover(HoverEvent e) => true; - protected override bool OnMouseMove(MouseMoveEvent e) - { - if (isRevealingBackground) - lastCursorMoveTimeDuringReveal = Clock.CurrentTime; - return base.OnMouseMove(e); - } - - private GridContainer mainGridContainer = null!; - protected override bool OnMouseDown(MouseDownEvent e) { var containingInputManager = GetContainingInputManager(); @@ -944,13 +932,13 @@ namespace osu.Game.Screens.SelectV2 // For simplicity, disable this functionality on mobile. bool isTouchInput = e.CurrentState.Mouse.LastSource is ISourcedFromTouch; - if (!carousel.AbsoluteScrolling && !isTouchInput && mouseDownPriority && revealingBackground == null) + if (!carousel.AbsoluteScrolling && !isTouchInput && mouseDownPriority && revealBackgroundDelegate == null) { - revealingBackground = Scheduler.AddDelayed(() => + revealBackgroundDelegate = Scheduler.AddDelayed(() => { if (containingInputManager.DraggedDrawable != null) { - revealingBackground = null; + revealBackgroundDelegate = null; return; } @@ -965,9 +953,6 @@ namespace osu.Game.Screens.SelectV2 updateBackgroundDim(); Footer?.Hide(); - - isRevealingBackground = true; - lastCursorMoveTimeDuringReveal = null; }, 200); } @@ -982,10 +967,10 @@ namespace osu.Game.Screens.SelectV2 private void restoreBackground() { - if (revealingBackground == null) + if (revealBackgroundDelegate == null) return; - if (revealingBackground.State == ScheduledDelegate.RunState.Complete) + if (revealBackgroundDelegate.State == ScheduledDelegate.RunState.Complete) { mainContent.ResizeWidthTo(1f, 500, Easing.OutQuint); mainContent.ScaleTo(1, 500, Easing.OutQuint); @@ -998,15 +983,16 @@ namespace osu.Game.Screens.SelectV2 Footer?.Show(); } - revealingBackground.Cancel(); - revealingBackground = null; - - isRevealingBackground = false; - lastCursorMoveTimeDuringReveal = null; + revealBackgroundDelegate.Cancel(); + revealBackgroundDelegate = null; updateBackgroundDim(); } + #endregion + + #region Input + public virtual bool OnPressed(KeyBindingPressEvent e) { if (!this.IsCurrentScreen()) return false; From dda1c7f75b3f9dd630473ef46d4c866d9be190af Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 9 Jan 2026 18:13:48 +0900 Subject: [PATCH 101/133] Fix failing test case due to mod overlay being moved to game level --- .../Navigation/TestSceneScreenNavigation.cs | 18 +++++++----------- .../TestSceneSkinEditorNavigation.cs | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index b1c9ab1dfb..e46d8f74a5 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -142,13 +142,12 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestExitSongSelectWithEscape() { - SoloSongSelect songSelect = null; ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new SoloSongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("Show mods overlay", () => { - modSelect = songSelect!.ChildrenOfType().Single(); + modSelect = Game!.ChildrenOfType().Single(); modSelect.Show(); }); AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); @@ -310,11 +309,9 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestOpenModSelectOverlayUsingAction() { - SoloSongSelect songSelect = null; - - PushAndConfirm(() => songSelect = new SoloSongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("Show mods overlay", () => InputManager.Key(Key.F1)); - AddAssert("Overlay was shown", () => songSelect!.ChildrenOfType().Single().State.Value == Visibility.Visible); + AddAssert("Overlay was shown", () => Game!.ChildrenOfType().Single().State.Value == Visibility.Visible); } [Test] @@ -731,7 +728,7 @@ namespace osu.Game.Tests.Visual.Navigation PushAndConfirm(() => songSelect = new SoloSongSelect()); AddStep("Show mods overlay", () => { - modSelect = songSelect!.ChildrenOfType().Single(); + modSelect = Game!.ChildrenOfType().Single(); modSelect.Show(); }); AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); @@ -806,13 +803,12 @@ namespace osu.Game.Tests.Visual.Navigation { AddUntilStep("Wait for toolbar to load", () => Game.Toolbar.IsLoaded); - SoloSongSelect songSelect = null; ModSelectOverlay modSelect = null; - PushAndConfirm(() => songSelect = new SoloSongSelect()); + PushAndConfirm(() => new SoloSongSelect()); AddStep("Show mods overlay", () => { - modSelect = songSelect!.ChildrenOfType().Single(); + modSelect = Game!.ChildrenOfType().Single(); modSelect.Show(); }); AddAssert("Overlay was shown", () => modSelect.State.Value == Visibility.Visible); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs index 0e1fa63439..9ddeb7870b 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSkinEditorNavigation.cs @@ -38,7 +38,7 @@ namespace osu.Game.Tests.Visual.Navigation public partial class TestSceneSkinEditorNavigation : OsuGameTestScene { private SoloSongSelect songSelect; - private ModSelectOverlay modSelect => songSelect.ChildrenOfType().First(); + private ModSelectOverlay modSelect => Game.ChildrenOfType().First(); private SkinEditor skinEditor => Game.ChildrenOfType().FirstOrDefault(); From 3e7f0f4c619adfc5133dc443df14a1b7574223db Mon Sep 17 00:00:00 2001 From: StanR <8269193+stanriders@users.noreply.github.com> Date: Sat, 10 Jan 2026 20:30:48 +0500 Subject: [PATCH 102/133] Add star rating text gradient (#36292) Implements https://github.com/ppy/osu-web/pull/12363 There's one less colour in the spectrum than in the [web code](https://github.com/ppy/osu-web/pull/12363/changes#diff-a9bdefd7233ca98f7f89cd76213aba5d869ae0424c8e79d1e322abd3e43462fbR31) because the spectrum was actually defined incorrectly and has [one less domain entry than it should](https://github.com/ppy/osu-web/pull/12363/changes#diff-a9bdefd7233ca98f7f89cd76213aba5d869ae0424c8e79d1e322abd3e43462fbR29). I've chose to not add it because of consistency with the web and because it looked pretty ugly (it was pretty much unreadable) image image --- .../Colours/TestSceneStarDifficultyColours.cs | 41 ++++++++----------- .../Beatmaps/Drawables/StarRatingDisplay.cs | 15 +++---- osu.Game/Graphics/OsuColour.cs | 28 +++++++++++++ osu.Game/Screens/SelectV2/PanelBeatmap.cs | 13 +++--- .../SelectV2/PanelBeatmapStandalone.cs | 5 +-- 5 files changed, 60 insertions(+), 42 deletions(-) diff --git a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs index 7f07563dfd..8ad7bf47b4 100644 --- a/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs +++ b/osu.Game.Tests/Visual/Colours/TestSceneStarDifficultyColours.cs @@ -7,7 +7,9 @@ using osu.Framework.Allocation; 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.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -31,7 +33,7 @@ namespace osu.Game.Tests.Visual.Colours AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5f), - ChildrenEnumerable = Enumerable.Range(0, 10).Select(i => new FillFlowContainer + ChildrenEnumerable = Enumerable.Range(0, 15).Select(i => new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -40,7 +42,9 @@ namespace osu.Game.Tests.Visual.Colours Spacing = new Vector2(10f), ChildrenEnumerable = Enumerable.Range(0, 10).Select(j => { - var colour = colours.ForStarDifficulty(1f * i + 0.1f * j); + float difficulty = 1f * i + 0.1f * j; + var colour = colours.ForStarDifficulty(difficulty); + var textColour = colours.ForStarDifficultyText(difficulty); return new FillFlowContainer { @@ -48,36 +52,27 @@ namespace osu.Game.Tests.Visual.Colours Origin = Anchor.Centre, AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, - Spacing = new Vector2(0f, 10f), + Spacing = new Vector2(0f, 5f), Children = new Drawable[] { - new CircularContainer + new OsuSpriteText { - Masking = true, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Size = new Vector2(75f, 25f), - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = colour, - }, - new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Colour = OsuColour.ForegroundTextColourFor(colour), - Text = colour.ToHex(), - }, - } + Font = FontUsage.Default.With(size: 10), + Text = $"BG: {colour.ToHex()}", }, new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = $"*{(1f * i + 0.1f * j):0.00}", + Font = FontUsage.Default.With(size: 10), + Text = $"Text: {textColour.ToHex()}", + }, + new StarRatingDisplay(new StarDifficulty(difficulty, 0)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, } } }; diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index c9f2f8a4b1..991654638f 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -4,7 +4,6 @@ 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; @@ -12,7 +11,6 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; using osu.Game.Utils; using osuTK; using osuTK.Graphics; @@ -43,6 +41,12 @@ namespace osu.Game.Beatmaps.Drawables /// public Color4 DisplayedDifficultyColour => background.Colour; + /// + /// The difficulty text colour currently displayed. + /// Can be used to have other components match the spectrum animation. + /// + public Color4 DisplayedDifficultyTextColour => starsText.Colour; + private readonly Bindable displayedStars = new BindableDouble(); /// @@ -54,9 +58,6 @@ namespace osu.Game.Beatmaps.Drawables [Resolved] private OsuColour colours { get; set; } = null!; - [Resolved] - private OverlayColourProvider? colourProvider { get; set; } - /// /// Creates a new using an already computed . /// @@ -160,8 +161,8 @@ namespace osu.Game.Beatmaps.Drawables background.Colour = colours.ForStarDifficulty(s.NewValue); - starIcon.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); - starsText.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); + starIcon.Colour = colours.ForStarDifficultyText(s.NewValue); + starsText.Colour = colours.ForStarDifficultyText(s.NewValue); }, true); } } diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 0eca359060..5dd6dc0c53 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -25,6 +25,11 @@ namespace osu.Game.Graphics /// public const float STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF = 6.5f; + /// + /// Star rating at which display text switches from static colours to a gradient. + /// + public const float STAR_DIFFICULTY_TEXT_GRADIENT_CUTOFF = 9.0f; + public static readonly (float, Color4)[] STAR_DIFFICULTY_SPECTRUM = { (0.1f, Color4Extensions.FromHex("aaaaaa")), @@ -42,11 +47,34 @@ namespace osu.Game.Graphics (10.0f, Color4.Black), }; + public static readonly (float, Color4)[] STAR_DIFFICULTY_TEXT_SPECTRUM = + { + (9.0f, Color4Extensions.FromHex("f6f05c")), + (9.9f, Color4Extensions.FromHex("ff8068")), + (10.6f, Color4Extensions.FromHex("ff4e6f")), + (11.5f, Color4Extensions.FromHex("c645b8")), + (12.4f, Color4Extensions.FromHex("6563de")), + }; + /// /// Retrieves the colour for a given point in the star range. /// public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(STAR_DIFFICULTY_SPECTRUM, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); + /// + /// Retrieves the colour for the text inside the star rating display. + /// + public Color4 ForStarDifficultyText(double starDifficulty) + { + if (starDifficulty < STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF) + return Color4.Black.Opacity(0.75f); + + if (starDifficulty < STAR_DIFFICULTY_TEXT_GRADIENT_CUTOFF) + return Orange1; + + return ColourUtils.SampleFromLinearGradient(STAR_DIFFICULTY_TEXT_SPECTRUM, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); + } + /// /// Retrieves the colour for a . /// diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index 59603e145d..6dc2286f44 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -53,12 +53,6 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IRulesetStore rulesets { get; set; } = null!; - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; @@ -278,10 +272,13 @@ namespace osu.Game.Screens.SelectV2 backgroundBorder.Colour = diffColour; backgroundDifficultyTint.Colour = ColourInfo.GradientHorizontal(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); - difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; - triangles.Colour = ColourInfo.GradientVertical(diffColour.Opacity(0.25f), diffColour.Opacity(0f)); } + + if (difficultyIcon.Colour != starRatingDisplay.DisplayedDifficultyTextColour) + { + difficultyIcon.Colour = starRatingDisplay.DisplayedDifficultyTextColour; + } } private void updateKeyCount() diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index fdea457551..5071fe4aaa 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -45,9 +45,6 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private BeatmapManager beatmaps { get; set; } = null!; - [Resolved] - private OsuColour colours { get; set; } = null!; - [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; @@ -293,7 +290,7 @@ namespace osu.Game.Screens.SelectV2 spreadDisplay.Current.Colour = diffColour; backgroundBorder.Colour = diffColour; - difficultyIcon.Colour = starRatingDisplay.DisplayedStars.Value > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5; + difficultyIcon.Colour = starRatingDisplay.DisplayedDifficultyTextColour; } private void updateKeyCount() From ee5cf25628161db4901391774e637732f966f765 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Sun, 11 Jan 2026 15:58:59 +0300 Subject: [PATCH 103/133] Localise notifications in `MobileUpdateNotifier` and `NoActionUpdateManager` --- osu.Game/Localisation/NotificationsStrings.cs | 15 +++++++++++++++ osu.Game/Updater/MobileUpdateNotifier.cs | 5 +++-- osu.Game/Updater/NoActionUpdateManager.cs | 5 +++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 75f1a39a69..b6c38ebdca 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -190,6 +190,21 @@ Click to see what's new!", version); /// public static LocalisableString CollectionsImportProgressTotal(int count, int totalCount) => new TranslatableString(getKey(@"collections_import_progress_total"), @"Imported {0} of {1} collections", count, totalCount); + /// + /// "A newer release of osu! has been found ({0} → {1})." + /// + public static LocalisableString UpdateAvailable(string version, string latestTagName) => new TranslatableString(getKey(@"update_available"), @"A newer release of osu! has been found ({0} → {1}).", version, latestTagName); + + /// + /// "Click here to download the new version, which can be installed over the top of your existing installation" + /// + public static LocalisableString UpdateMobile => new TranslatableString(getKey(@"update_mobile"), @"Click here to download the new version, which can be installed over the top of your existing installation"); + + /// + /// "Check with your package manager / provider to bring osu! up-to-date!" + /// + public static LocalisableString UpdateNoAction => new TranslatableString(getKey(@"update_no_action"), @"Check with your package manager / provider to bring osu! up-to-date!"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 3a290c9a63..804d61c8de 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -9,8 +9,10 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Online.API; namespace osu.Game.Updater @@ -57,8 +59,7 @@ namespace osu.Game.Updater { Notifications.Post(new UpdateAvailableNotification(cancellationToken) { - Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" - + "Click here to download the new version, which can be installed over the top of your existing installation", + Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateMobile}"), Icon = FontAwesome.Solid.Download, Activated = () => { diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 0710797b60..9aef5ba2b1 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -6,7 +6,9 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Online.API; namespace osu.Game.Updater @@ -51,8 +53,7 @@ namespace osu.Game.Updater { Notifications.Post(new UpdateAvailableNotification(cancellationToken) { - Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" - + "Check with your package manager / provider to bring osu! up-to-date!", + Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateNoAction}"), }); return true; From f3205922299507e8a950ce010254ead4d58515b8 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Sun, 11 Jan 2026 16:17:09 +0300 Subject: [PATCH 104/133] Localise notification in `PerformFromMenuRunner` --- osu.Game/Localisation/NotificationsStrings.cs | 5 +++++ osu.Game/PerformFromMenuRunner.cs | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 75f1a39a69..35d4310b46 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -190,6 +190,11 @@ Click to see what's new!", version); /// public static LocalisableString CollectionsImportProgressTotal(int count, int totalCount) => new TranslatableString(getKey(@"collections_import_progress_total"), @"Imported {0} of {1} collections", count, totalCount); + /// + /// "An action was interrupted due to a dialog being displayed." + /// + public static LocalisableString ActionInterruptedByDialog => new TranslatableString(getKey(@"action_interrupted_by_dialog"), @"An action was interrupted due to a dialog being displayed."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/PerformFromMenuRunner.cs b/osu.Game/PerformFromMenuRunner.cs index 21beadf366..006430c427 100644 --- a/osu.Game/PerformFromMenuRunner.cs +++ b/osu.Game/PerformFromMenuRunner.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Threading; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Notifications; @@ -165,7 +166,11 @@ namespace osu.Game // the last dialog encountered has been dismissed but the screen has not changed, abort. Cancel(); - notifications.Post(new SimpleNotification { Text = @"An action was interrupted due to a dialog being displayed." }); + notifications.Post(new SimpleNotification + { + Text = NotificationsStrings.ActionInterruptedByDialog + }); + return true; } From 8a21c6814cf13a879ace7ee4bf1970d9ca64bae1 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Sun, 11 Jan 2026 16:53:44 +0300 Subject: [PATCH 105/133] Localise notification in `OsuGame` --- osu.Game/Localisation/NotificationsStrings.cs | 5 +++++ osu.Game/OsuGame.cs | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 75f1a39a69..c4df6e4fcc 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -190,6 +190,11 @@ Click to see what's new!", version); /// public static LocalisableString CollectionsImportProgressTotal(int count, int totalCount) => new TranslatableString(getKey(@"collections_import_progress_total"), @"Imported {0} of {1} collections", count, totalCount); + /// + /// "This error has been automatically reported to the devs." + /// + public static LocalisableString ErrorAutomaticallyReported => new TranslatableString(getKey(@"error_automatically_reported"), @"This error has been automatically reported to the devs."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4ea9fae183..0649846b97 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1383,7 +1383,7 @@ namespace osu.Game Schedule(() => Notifications.Post(new SimpleErrorNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, - Text = entry.Message.Truncate(256) + (entry.Exception != null && IsDeployedBuild ? "\n\nThis error has been automatically reported to the devs." : string.Empty), + Text = LocalisableString.Interpolate($"{entry.Message.Truncate(256)}{(entry.Exception != null && IsDeployedBuild ? LocalisableString.Interpolate($"\n\n{NotificationsStrings.ErrorAutomaticallyReported}") : string.Empty)}"), })); } else if (generalLogRecentCount == short_term_display_limit) From ae284fcf057cee8652069d3b80b6a46951e4b58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 12 Jan 2026 09:34:53 +0100 Subject: [PATCH 106/133] Fix broken date formatting in some languages on several overlays Fixes https://osu.ppy.sh/community/forums/topics/2169438?n=1. --- .../Statistics/BeatmapCardDateStatistic.cs | 4 +- osu.Game/Overlays/News/NewsCard.cs | 3 +- .../Header/Components/GlobalRankDisplay.cs | 3 +- osu.Game/Utils/FormatUtils.cs | 82 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs index 2948e89e60..861ec9f027 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Statistics/BeatmapCardDateStatistic.cs @@ -2,10 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; +using osu.Game.Utils; namespace osu.Game.Beatmaps.Drawables.Cards.Statistics { @@ -18,7 +18,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Statistics this.dateTime = dateTime; Icon = FontAwesome.Regular.CheckCircle; - Text = dateTime.ToLocalisableString(@"d MMM yyyy"); + Text = dateTime.ToLocalisedMediumDate(); } public override object TooltipContent => dateTime; diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index 8a579a5ccc..863e0db648 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -16,6 +16,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Utils; namespace osu.Game.Overlays.News { @@ -143,7 +144,7 @@ namespace osu.Game.Overlays.News }, new OsuSpriteText { - Text = date.ToLocalisableString(@"d MMM yyyy").ToUpper(), + Text = date.ToLocalisedMediumDate().ToUpper(), Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), Margin = new MarginPadding { diff --git a/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs index f48d467d87..2e5374fdb7 100644 --- a/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/GlobalRankDisplay.cs @@ -13,6 +13,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Overlays.Profile.Header.Components { @@ -123,7 +124,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { var rankHighestText = UsersStrings.ShowRankHighest( rankHighest.Rank.ToLocalisableString("\\##,##0"), - rankHighest.UpdatedAt.ToLocalisableString(@"d MMM yyyy")); + rankHighest.UpdatedAt.ToLocalisedMediumDate()); if (result == null) result = rankHighestText; diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index 29e402144a..823c6b376d 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using Humanizer; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Localisation; +using osu.Game.Extensions; +using osu.Game.Localisation; namespace osu.Game.Utils { @@ -73,5 +76,84 @@ namespace osu.Game.Utils /// The base BPM to round. /// Rate adjustment, if applicable. public static int RoundBPM(double baseBpm, double rate = 1) => (int)Math.Round(baseBpm * rate); + + public static LocalisableString ToLocalisedMediumDate(this DateTimeOffset dateTime) + => new LocalisableString(new MediumFormattedDate(dateTime)); + + /// + /// This class is supposed to provide date formatting roughly equivalent to + /// + /// moment().format('ll'); + /// + /// which is used in several places on the website, and as such needs to be mirrored to the relevant game overlays reimplementing those places. + /// + private class MediumFormattedDate : ILocalisableStringData + { + public readonly DateTimeOffset Date; + + public MediumFormattedDate(DateTimeOffset date) + { + Date = date; + } + + public bool Equals(ILocalisableStringData? other) + => other is MediumFormattedDate date && Date.Equals(date.Date); + + // reference: individual language files in https://github.com/moment/moment/tree/18aba135ab927ffe7f868ee09276979bed6993a6/locale + private static readonly Dictionary format_mapping = new Dictionary + { + [Language.en] = @"d MMM yyyy", + [Language.be] = @"d MMM yyyy 'г.'", + [Language.bg] = @"d MMM yyyy", + [Language.ca] = @"d MMM yyyy", + [Language.cs] = @"d. MMM yyyy", + [Language.da] = @"d. MMM yyyy", + [Language.de] = @"d. MMM yyyy", + [Language.el] = @"d MMM yyyy", + [Language.es] = @"d 'de' MMM 'de' yyyy", + [Language.fi] = @"d. MMM yyyy", + [Language.fr] = @"d MMM yyyy", + [Language.hr_hr] = @"d. MMM yyyy", + [Language.hu] = @"yyyy. MMM d.", + [Language.id] = @"d MMM yyyy", + [Language.it] = @"d MMM yyyy", + [Language.ja] = @"yyyy年M月d日", + [Language.ko] = @"yyyy년 MMMM d일", + [Language.lt] = @"yyyy 'm.' MMM d 'd.'", + [Language.lv_lv] = @"yyyy. 'gada' d. MMM", + [Language.ms_my] = @"d MMM yyyy", + [Language.nl] = @"d MMM yyyy", + [Language.no] = @"d. MMM yyyy", // look under `nb` (Norsk Bokmål) and `nn` (Nynorsk) in momentjs source + [Language.pl] = @"d MMM yyyy", + [Language.pt] = @"d 'de' MMM 'de' yyyy", + [Language.pt_br] = @"d 'de' MMM 'de' yyyy", + [Language.ro] = @"d MMM yyyy", + [Language.ru] = @"d MMM yyyy 'г.'", + [Language.sk] = @"d. MMM yyyy", + [Language.sl] = @"d. MMM yyyy", + [Language.sr] = @"d. MMM yyyy.", + [Language.sv] = @"d MMM yyyy", + [Language.th] = @"d MMM yyyy", + [Language.tr] = @"d MMM yyyy", + [Language.uk] = @"d MMM yyyy 'р.'", + [Language.vi] = @"d MMM yyyy", + [Language.zh] = @"yyyy年M月d日", + [Language.zh_hant] = @"yyyy年M月d日", + }; + + public string GetLocalised(LocalisationParameters parameters) + { + string? cultureCode = parameters.Store?.EffectiveCulture.Name.ToLowerInvariant(); + + if (!string.IsNullOrEmpty(cultureCode) + && LanguageExtensions.TryParseCultureCode(cultureCode, out var language) + && format_mapping.TryGetValue(language, out string? format)) + { + return Date.ToString(format); + } + + return Date.ToString(@"d MMM yyyy"); + } + } } } From 57f16646a343f5911feabc7cce13ba879b87c404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 12 Jan 2026 11:11:11 +0100 Subject: [PATCH 107/133] Add failing tests --- .../Mods/TestSceneOsuModFreezeFrame.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs index 57d2b94188..31498295da 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModFreezeFrame.cs @@ -2,7 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; namespace osu.Game.Rulesets.Osu.Tests.Mods { @@ -18,5 +21,39 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Autoplay = false, }); } + + [Test] + public void TestSkipToFirstCircleNotSuppressed() + { + CreateModTest(new ModTestData + { + Mod = new OsuModFreezeFrame(), + CreateBeatmap = () => new OsuBeatmap + { + HitObjects = + { + new HitCircle { StartTime = 5000, Position = OsuPlayfield.BASE_SIZE / 2 } + } + }, + PassCondition = () => Player.GameplayClockContainer.GameplayStartTime > 0 + }); + } + + [Test] + public void TestSkipToFirstSpinnerNotSuppressed() + { + CreateModTest(new ModTestData + { + Mod = new OsuModFreezeFrame(), + CreateBeatmap = () => new OsuBeatmap + { + HitObjects = + { + new Spinner { StartTime = 5000, Position = OsuPlayfield.BASE_SIZE / 2 } + } + }, + PassCondition = () => Player.GameplayClockContainer.GameplayStartTime > 0 + }); + } } } From 38c89a914b7d57ea29edfb167b985eccbc4c018f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 12 Jan 2026 11:11:38 +0100 Subject: [PATCH 108/133] Fix Freeze Frame mod suppressing skip if the first object is a spinner Fixes https://osu.ppy.sh/community/forums/topics/2169899?n=1. `TimePreempt` doesn't affect the appearance of a spinner, but it *will* affect the value of `GameplayStartTime`: https://github.com/ppy/osu/blob/4bf90a5571bb508444178f3d98a2b2a10c549534/osu.Game.Rulesets.Osu/UI/DrawableOsuRuleset.cs#L82-L91 While at parsing it is enforced that every object following a spinner has `NewCombo` set, it is *not* enforced that every spinner has `NewCombo` set. See also: https://github.com/ppy/osu/issues/24156. But that's sort of orthogonal to the entire issue as well because why touch the preempt of an object that's not visually affected by preempt to begin with. --- osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs index e75ed24a7d..368d76d1ba 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModFreezeFrame.cs @@ -57,7 +57,8 @@ namespace osu.Game.Rulesets.Osu.Mods void applyFadeInAdjustment(OsuHitObject osuObject) { - osuObject.TimePreempt += osuObject.StartTime - lastNewComboTime; + if (osuObject is not Spinner) + osuObject.TimePreempt += osuObject.StartTime - lastNewComboTime; foreach (var nested in osuObject.NestedHitObjects.OfType()) { From a81a77c60f070ba7a25fd50d660d2282f9ae91bc Mon Sep 17 00:00:00 2001 From: Linus Genz Date: Mon, 12 Jan 2026 15:40:13 +0100 Subject: [PATCH 109/133] Fix underline size at song select details panel not matching after changing language (#36303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #36274 **Change:** Registered a `BindValueChanged` callback on each TabItem’s `Text.Current` to ensure the underline indicator (strip) updates whenever the displayed tab text changes and calling `updateDisplay` accordingly to update the underline. **Result:** https://github.com/user-attachments/assets/6fb95a46-c768-46ec-a68a-a5e394e08a78 --------- Signed-off-by: Linus Genz Co-authored-by: Dean Herbert --- .../BeatmapDetailsArea.WedgeSelector.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs index b5cdeee792..95f60fb423 100644 --- a/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs +++ b/osu.Game/Screens/SelectV2/BeatmapDetailsArea.WedgeSelector.cs @@ -5,6 +5,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; @@ -13,6 +14,7 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Overlays; using osuTK; @@ -25,6 +27,8 @@ namespace osu.Game.Screens.SelectV2 { private Circle strip = null!; + private Bindable currentLanguage = null!; + protected override Dropdown? CreateDropdown() => null; protected override TabItem CreateTabItem(T value) => new TabItem(value); @@ -37,7 +41,7 @@ namespace osu.Game.Screens.SelectV2 } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, OsuGameBase game) { AddInternal(strip = new Circle { @@ -49,6 +53,8 @@ namespace osu.Game.Screens.SelectV2 foreach (var type in Enum.GetValues()) AddItem(type); + + currentLanguage = game.CurrentLanguage.GetBoundCopy(); } protected override void LoadComplete() @@ -57,11 +63,14 @@ namespace osu.Game.Screens.SelectV2 Current.BindValueChanged(_ => updateDisplay()); - ScheduleAfterChildren(() => + currentLanguage.BindValueChanged(_ => { - updateDisplay(); - FinishTransforms(true); - }); + ScheduleAfterChildren(() => + { + updateDisplay(); + FinishTransforms(true); + }); + }, true); } private void updateDisplay() From a8989eb117ec3b2bc2db11716ae9e9a502cfdf93 Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Mon, 12 Jan 2026 18:03:20 +0300 Subject: [PATCH 110/133] Expand settings in `ReplayPlayer` by default (#36308) ~i recently saw an suggestion to do this, but don't remember where~ - addresses https://github.com/ppy/osu/discussions/36189 i think it's logical, since the settings have been displayed in a separate overlay for more than six months, and not on top of the gameplay itself, and for example, it can be difficult to expand section on phones | master | pr | |-|-| | osu_2026-01-12_09-40-23 | osu_2026-01-12_09-39-40 | --- osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs | 8 +------- osu.Game/Screens/Play/ReplayPlayer.cs | 6 +----- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index 635d140a4a..4fe207a6f9 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -21,8 +21,6 @@ namespace osu.Game.Screens.Play.HUD { public partial class PlayerSettingsOverlay : ExpandingContainer { - public VisualSettings VisualSettings { get; private set; } - private const float padding = 10; public const float EXPANDED_WIDTH = player_settings_width + padding * 2; @@ -66,11 +64,7 @@ namespace osu.Game.Screens.Play.HUD Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), Margin = new MarginPadding(padding), - Children = new PlayerSettingsGroup[] - { - VisualSettings = new VisualSettings { Expanded = { Value = false } }, - new AudioSettings { Expanded = { Value = false } } - } + Children = new PlayerSettingsGroup[] { new VisualSettings(), new AudioSettings() } }); // For future consideration, this icon should probably not exist. diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 7fec8d6332..b9a6de54b4 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -77,11 +77,7 @@ namespace osu.Game.Screens.Play /// Add a settings group to the HUD overlay. Intended to be used by rulesets to add replay-specific settings. /// /// The settings group to be shown. - public void AddSettings(PlayerSettingsGroup settings) => Schedule(() => - { - settings.Expanded.Value = false; - HUDOverlay.PlayerSettingsOverlay.Add(settings); - }); + public void AddSettings(PlayerSettingsGroup settings) => Schedule(() => HUDOverlay.PlayerSettingsOverlay.Add(settings)); [BackgroundDependencyLoader] private void load(OsuConfigManager config) From 5de23e41cf1dc26708e6f008c894fadbfe95193f Mon Sep 17 00:00:00 2001 From: Joseph Madamba Date: Mon, 12 Jan 2026 14:20:10 -0800 Subject: [PATCH 111/133] Fix skin section buttons disappearing when searching for plural "skins" --- osu.Game/Overlays/Settings/Sections/SkinSection.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 93cab9e8de..4d7c0117e2 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -42,6 +42,8 @@ namespace osu.Game.Overlays.Settings.Sections Icon = OsuIcon.SkinB }; + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "skins" }); + private static readonly Live random_skin_info = new SkinInfo { ID = SkinInfo.RANDOM_SKIN, @@ -69,7 +71,6 @@ namespace osu.Game.Overlays.Settings.Sections AllowNonContiguousMatching = true, LabelText = SkinSettingsStrings.CurrentSkin, Current = skins.CurrentSkinInfo, - Keywords = new[] { @"skins" }, }, new FillFlowContainer { From 18fd4758d736b2a14aead9a11ad9a1256df186c7 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 12 Jan 2026 23:28:10 -0500 Subject: [PATCH 112/133] Update form button UI/UX and support text wrapping --- .../Graphics/UserInterfaceV2/FormButton.cs | 176 ++++++++++++++---- 1 file changed, 137 insertions(+), 39 deletions(-) diff --git a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs index 85198191b8..d10134911f 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -12,6 +13,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; @@ -28,62 +30,137 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// public LocalisableString Caption { get; init; } + /// + /// Sets text inside the button. + /// public LocalisableString ButtonText { get; init; } - public Action? Action { get; init; } + /// + /// Sets a custom button icon. Not shown when is set. + /// + public IconUsage ButtonIcon { get; init; } = FontAwesome.Solid.ChevronRight; + + private Color4? backgroundColour; + + /// + /// Sets a custom background colour for the button. + /// + public Color4? BackgroundColour + { + get => backgroundColour; + set + { + backgroundColour = value; + + if (IsLoaded && value != null) + button.BackgroundColour = value.Value; + } + } + + /// + /// The action to invoke when the button is clicked. + /// + public Action? Action { get; set; } + + /// + /// Whether the button is enabled. + /// + public readonly BindableBool Enabled = new BindableBool(true); [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + private Container content = null!; + private Box background = null!; + private OsuTextFlowContainer text = null!; + private Button button = null!; + [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.X; - Height = 50; + AutoSizeAxes = Axes.Y; - Masking = true; - CornerRadius = 5; - CornerExponent = 2.5f; - - InternalChildren = new Drawable[] + InternalChild = content = new Container { - new Box + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 5, + CornerExponent = 2.5f, + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - new Container - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding + background = new Box { - Left = 9, - Right = 5, - Vertical = 5, + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, }, - Children = new Drawable[] + new TrianglesV2 { - new OsuTextFlowContainer + SpawnRatio = 0.5f, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background5), + }, + new HoverClickSounds(HoverSampleSet.Button) + { + Enabled = { BindTarget = Enabled }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Width = 0.45f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Text = Caption, + Left = 9, + Right = 5, + Vertical = 5, }, - new Button + Children = new Drawable[] { - Action = Action, - Text = ButtonText, - RelativeSizeAxes = ButtonText == default ? Axes.None : Axes.X, - Width = ButtonText == default ? 90 : 0.45f, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - } + text = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = Caption, + }, + button = new Button + { + Action = () => Action?.Invoke(), + Text = ButtonText, + Icon = ButtonIcon, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Enabled = { BindTarget = Enabled }, + } + }, }, - }, + } }; + + if (ButtonText == default) + { + text.Padding = new MarginPadding { Right = 100 }; + button.Width = 90; + } + else + { + text.Width = 0.55f; + text.Padding = new MarginPadding { Right = 10 }; + button.RelativeSizeAxes = Axes.X; + button.Width = 0.45f; + } + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + if (BackgroundColour != null) + button.BackgroundColour = BackgroundColour.Value; + + Enabled.BindValueChanged(_ => updateState(), true); } protected override bool OnHover(HoverEvent e) @@ -98,12 +175,31 @@ namespace osu.Game.Graphics.UserInterfaceV2 updateState(); } + protected override bool OnClick(ClickEvent e) + { + if (Enabled.Value) + { + background.FlashColour(ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark2), 800, Easing.OutQuint); + button.TriggerClick(); + } + + return true; + } + private void updateState() { - BorderThickness = IsHovered ? 2 : 0; + text.Colour = !Enabled.Value ? colourProvider.Background1 : colourProvider.Content1; - if (IsHovered) - BorderColour = colourProvider.Light4; + background.FadeColour(IsHovered + ? ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4) + : colourProvider.Background5, 200, Easing.OutQuint); + + content.BorderThickness = IsHovered ? 2 : 0; + + if (!Enabled.Value) + content.BorderColour = BackgroundColour != null ? Interpolation.ValueAt(0.75, BackgroundColour.Value, colourProvider.Dark1, 0, 1) : colourProvider.Dark1; + else + content.BorderColour = BackgroundColour ?? colourProvider.Light4; } public partial class Button : OsuButton @@ -125,6 +221,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } + public IconUsage Icon { get; init; } + [BackgroundDependencyLoader] private void load(OverlayColourProvider overlayColourProvider) { @@ -135,7 +233,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { Add(new SpriteIcon { - Icon = FontAwesome.Solid.ChevronRight, + Icon = Icon, Size = new Vector2(16), Shadow = true, Anchor = Anchor.Centre, From df1304af9e1042823352f9e6b9ce325601a311e2 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 12 Jan 2026 23:28:14 -0500 Subject: [PATCH 113/133] Add visual test --- .../UserInterface/TestSceneFormButton.cs | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs new file mode 100644 index 0000000000..a22607e781 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormButton.cs @@ -0,0 +1,139 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormButton : ThemeComparisonTestScene + { + public TestSceneFormButton() + : base(false) + { + } + + protected override Drawable CreateContent() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new BackgroundBox + { + RelativeSizeAxes = Axes.Both, + }, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormButton + { + Caption = "Button with default style", + Action = () => { }, + }, + new FormButton + { + Caption = "Button with default style", + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Button with custom style", + BackgroundColour = new OsuColour().DangerousButtonColour, + ButtonIcon = FontAwesome.Solid.Hamburger, + Action = () => { }, + }, + new FormButton + { + Caption = "Button with custom style", + BackgroundColour = new OsuColour().DangerousButtonColour, + ButtonIcon = FontAwesome.Solid.Hamburger, + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua", + BackgroundColour = new OsuColour().Blue3, + ButtonIcon = FontAwesome.Solid.Book, + Action = () => { }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + Action = () => { }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().DangerousButtonColour, + Action = () => { }, + }, + new FormButton + { + Caption = "Button with text inside", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().DangerousButtonColour, + Enabled = { Value = false }, + }, + new FormButton + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().Blue3, + Action = () => { }, + }, + new FormButton + { + Caption = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor", + ButtonText = "Text in button", + BackgroundColour = new OsuColour().Blue3, + Enabled = { Value = false }, + }, + }, + }, + }, + } + } + }; + + private partial class BackgroundBox : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + } + } + } +} From 37257a7a028608faf891a885e7cf2f16217a50bc Mon Sep 17 00:00:00 2001 From: Denis Titovets Date: Tue, 13 Jan 2026 07:47:25 +0300 Subject: [PATCH 114/133] Make edits based on reviews --- osu.Game/Localisation/NotificationsStrings.cs | 8 ++++---- osu.Game/Updater/MobileUpdateNotifier.cs | 2 +- osu.Game/Updater/NoActionUpdateManager.cs | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index b8dde9dde9..55a89d9cee 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -198,17 +198,17 @@ Click to see what's new!", version); /// /// "A newer release of osu! has been found ({0} → {1})." /// - public static LocalisableString UpdateAvailable(string version, string latestTagName) => new TranslatableString(getKey(@"update_available"), @"A newer release of osu! has been found ({0} → {1}).", version, latestTagName); + public static LocalisableString UpdateAvailable(string oldVersion, string newVersion) => new TranslatableString(getKey(@"update_available"), @"A newer release of osu! has been found ({0} → {1}).", oldVersion, newVersion); /// - /// "Click here to download the new version, which can be installed over the top of your existing installation" + /// "Click here to download the new version, which can be installed over the top of your existing installation." /// - public static LocalisableString UpdateMobile => new TranslatableString(getKey(@"update_mobile"), @"Click here to download the new version, which can be installed over the top of your existing installation"); + public static LocalisableString UpdateAvailableManualInstall => new TranslatableString(getKey(@"update_available_manual_install"), @"Click here to download the new version, which can be installed over the top of your existing installation."); /// /// "Check with your package manager / provider to bring osu! up-to-date!" /// - public static LocalisableString UpdateNoAction => new TranslatableString(getKey(@"update_no_action"), @"Check with your package manager / provider to bring osu! up-to-date!"); + public static LocalisableString UpdateAvailablePackageManaged => new TranslatableString(getKey(@"update_available_package_managed"), @"Check with your package manager / provider to bring osu! up-to-date!"); /// /// "An action was interrupted due to a dialog being displayed." diff --git a/osu.Game/Updater/MobileUpdateNotifier.cs b/osu.Game/Updater/MobileUpdateNotifier.cs index 804d61c8de..43e4df5324 100644 --- a/osu.Game/Updater/MobileUpdateNotifier.cs +++ b/osu.Game/Updater/MobileUpdateNotifier.cs @@ -59,7 +59,7 @@ namespace osu.Game.Updater { Notifications.Post(new UpdateAvailableNotification(cancellationToken) { - Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateMobile}"), + Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateAvailableManualInstall}"), Icon = FontAwesome.Solid.Download, Activated = () => { diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 9aef5ba2b1..bc9d00b804 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -53,7 +53,7 @@ namespace osu.Game.Updater { Notifications.Post(new UpdateAvailableNotification(cancellationToken) { - Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateNoAction}"), + Text = LocalisableString.Interpolate($"{NotificationsStrings.UpdateAvailable(version, latestTagName)}\n\n{NotificationsStrings.UpdateAvailablePackageManaged}"), }); return true; From d3ed93280a66b51789c040a5699e4fa7042c67c0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 13 Jan 2026 16:10:40 +0900 Subject: [PATCH 115/133] Make error message string construction actually understandable --- osu.Game/Localisation/NotificationsStrings.cs | 4 ++-- osu.Game/OsuGame.cs | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 55a89d9cee..0d22d98eb1 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -191,9 +191,9 @@ Click to see what's new!", version); public static LocalisableString CollectionsImportProgressTotal(int count, int totalCount) => new TranslatableString(getKey(@"collections_import_progress_total"), @"Imported {0} of {1} collections", count, totalCount); /// - /// "This error has been automatically reported to the devs." + /// "This error has been automatically reported to the dev team." /// - public static LocalisableString ErrorAutomaticallyReported => new TranslatableString(getKey(@"error_automatically_reported"), @"This error has been automatically reported to the devs."); + public static LocalisableString ErrorAutomaticallyReported => new TranslatableString(getKey(@"error_automatically_reported"), @"This error has been automatically reported to the dev team."); /// /// "A newer release of osu! has been found ({0} → {1})." diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 0649846b97..f707f0d198 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1380,10 +1380,17 @@ namespace osu.Game if (generalLogRecentCount < short_term_display_limit) { + LocalisableString message; + + if (entry.Exception != null && IsDeployedBuild) + message = LocalisableString.Interpolate($"{entry.Message.Truncate(256)}\n\n{NotificationsStrings.ErrorAutomaticallyReported}"); + else + message = entry.Message.Truncate(256); + Schedule(() => Notifications.Post(new SimpleErrorNotification { Icon = entry.Level == LogLevel.Important ? FontAwesome.Solid.ExclamationCircle : FontAwesome.Solid.Bomb, - Text = LocalisableString.Interpolate($"{entry.Message.Truncate(256)}{(entry.Exception != null && IsDeployedBuild ? LocalisableString.Interpolate($"\n\n{NotificationsStrings.ErrorAutomaticallyReported}") : string.Empty)}"), + Text = message })); } else if (generalLogRecentCount == short_term_display_limit) From 0ba4e9e2a42ce041c0a58da42ba4cdb4656ac7ec Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Tue, 13 Jan 2026 02:13:00 -0500 Subject: [PATCH 116/133] Fix mod footer button with unranked badge not resizing on localisation changes (#33810) - Closes https://github.com/ppy/osu/issues/33789 --- osu.Game/Screens/SelectV2/FooterButtonMods.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/osu.Game/Screens/SelectV2/FooterButtonMods.cs b/osu.Game/Screens/SelectV2/FooterButtonMods.cs index 4720c11731..13192df85d 100644 --- a/osu.Game/Screens/SelectV2/FooterButtonMods.cs +++ b/osu.Game/Screens/SelectV2/FooterButtonMods.cs @@ -65,6 +65,11 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; + [Resolved] + private OsuGameBase game { get; set; } = null!; + + private IBindable currentLanguage = null!; + public FooterButtonMods(ModSelectOverlay overlay) : base(overlay) { @@ -156,6 +161,9 @@ namespace osu.Game.Screens.SelectV2 { base.LoadComplete(); + currentLanguage = game.CurrentLanguage.GetBoundCopy(); + currentLanguage.BindValueChanged(_ => ScheduleAfterChildren(updateDisplay)); + Current.BindValueChanged(m => { modSettingChangeTracker?.Dispose(); From f892d5fbaab4c723b8dbf46c720753b3c3b5edf3 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Tue, 13 Jan 2026 02:20:03 -0500 Subject: [PATCH 117/133] Improve form dropdown UX (#36325) Addresses concerns in https://github.com/ppy/osu/pull/36193#issuecomment-3728177951 (excluding the last part, that is too involved and I can't imagine any workaround for it due to how strict the `Dropdown` structure is). Also adds truncation/padding to header label and search bar. Preview: https://github.com/user-attachments/assets/8885cb90-44dc-42ee-af21-cb33f7723e63 --- .../UserInterface/TestSceneFormDropdown.cs | 104 ++++++++++++++++++ .../Graphics/UserInterfaceV2/FormDropdown.cs | 19 +++- 2 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs new file mode 100644 index 0000000000..69d6057b9a --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormDropdown.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneFormDropdown : ThemeComparisonTestScene + { + public TestSceneFormDropdown() + : base(false) + { + } + + protected override Drawable CreateContent() => new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new BackgroundBox + { + RelativeSizeAxes = Axes.Both, + }, + new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Padding = new MarginPadding(10), + Children = new Drawable[] + { + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + }, + new FormEnumDropdown + { + Caption = EditorSetupStrings.EnableCountdown, + HintText = EditorSetupStrings.CountdownDescription, + Current = { Disabled = true }, + }, + new FormDropdown + { + Caption = "Custom dropdown", + HintText = "Custom dropdown hint", + Items = new[] + { + "A verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "B verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "C verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "D verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + }, + }, + new FormDropdown + { + Caption = "Custom dropdown", + HintText = "Custom dropdown hint", + AlwaysShowSearchBar = true, + Items = new[] + { + "A verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "B verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "C verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + "D verrry looooongggg thiiiinngggggg toooooo fittttt iiinnnn thhiisssss droooppdddoowwwnn", + }, + }, + }, + }, + }, + } + } + }; + + private partial class BackgroundBox : Box + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Colour = colourProvider.Background4; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs index d7e7344681..91ea036ec6 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormDropdown.cs @@ -15,6 +15,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 @@ -160,9 +161,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 Caption = Caption, TooltipText = HintText, }, - label = new OsuSpriteText + label = new TruncatingSpriteText { RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Right = 25 }, + AlwaysPresent = true, }, } }, @@ -213,8 +216,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void updateState() { - label.Alpha = string.IsNullOrEmpty(SearchBar.SearchTerm.Value) ? 1 : 0; - caption.Colour = Dropdown.Current.Disabled ? colourProvider.Background1 : colourProvider.Content2; label.Colour = Dropdown.Current.Disabled ? colourProvider.Background1 : colourProvider.Content1; chevron.Colour = Dropdown.Current.Disabled ? colourProvider.Background1 : colourProvider.Content1; @@ -222,6 +223,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 bool dropdownOpen = Dropdown.Menu.State == MenuState.Open; + if (dropdownOpen) + label.Alpha = AlwaysShowSearchBar || !string.IsNullOrEmpty(SearchBar.SearchTerm.Value) ? 0 : 1; + else + label.Alpha = 1; + BorderThickness = IsHovered || dropdownOpen ? 2 : 0; if (Dropdown.Current.Disabled) @@ -251,7 +257,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override void PopIn() => this.FadeIn(); protected override void PopOut() => this.FadeOut(); - protected override TextBox CreateTextBox() => TextBox = new FormTextBox.InnerTextBox(); + protected override TextBox CreateTextBox() => TextBox = new FormTextBox.InnerTextBox + { + PlaceholderText = HomeStrings.SearchPlaceholder, + }; [BackgroundDependencyLoader] private void load() @@ -259,7 +268,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 TextBox.Anchor = Anchor.BottomLeft; TextBox.Origin = Anchor.BottomLeft; TextBox.RelativeSizeAxes = Axes.X; - TextBox.Margin = new MarginPadding(9); + Padding = new MarginPadding { Left = 9, Bottom = 9, Right = 34 }; } } From eeb4680795bfca35b28d2d640eba53eb5b5a3c9c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 13 Jan 2026 16:57:50 +0900 Subject: [PATCH 118/133] Remove unused button --- .../TestSceneFooterButtonPlaylistV2.cs | 35 -------- .../OnlinePlay/FooterButtonPlaylistV2.cs | 87 ------------------- 2 files changed, 122 deletions(-) delete mode 100644 osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs delete mode 100644 osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs diff --git a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs b/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs deleted file mode 100644 index 730696c363..0000000000 --- a/osu.Game.Tests/Visual/Playlists/TestSceneFooterButtonPlaylistV2.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; -using osu.Game.Online.Rooms; -using osu.Game.Overlays; -using osu.Game.Screens.OnlinePlay; -using osu.Game.Tests.Visual.OnlinePlay; - -namespace osu.Game.Tests.Visual.Playlists -{ - public partial class TestSceneFooterButtonPlaylistV2 : OnlinePlayTestScene - { - [Cached] - private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); - - public TestSceneFooterButtonPlaylistV2() - { - Room room = new Room(); - - Add(new PopoverContainer - { - RelativeSizeAxes = Axes.Both, - Child = new FooterButtonPlaylistV2(room) - { - Anchor = Anchor.Centre, - Origin = Anchor.CentreLeft, - X = -100, - } - }); - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs b/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs deleted file mode 100644 index f5f5024d4f..0000000000 --- a/osu.Game/Screens/OnlinePlay/FooterButtonPlaylistV2.cs +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.ComponentModel; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Cursor; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Graphics.UserInterface; -using osu.Game.Graphics; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Online.Rooms; -using osu.Game.Overlays; -using osu.Game.Screens.Footer; -using osu.Game.Screens.OnlinePlay.Playlists; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay -{ - public partial class FooterButtonPlaylistV2 : ScreenFooterButton, IHasPopover - { - private readonly Room room; - - public FooterButtonPlaylistV2(Room room) - { - this.room = room; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colour) - { - Text = "Playlist"; - Icon = FontAwesome.Solid.List; - AccentColour = colour.Purple1; - - Action = this.ShowPopover; - } - - public Popover GetPopover() => new PlaylistPopover(room); - - private partial class PlaylistPopover : OsuPopover - { - private readonly Room room; - private PlaylistsRoomSettingsPlaylist playlist = null!; - - [Cached] - private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Plum); - - public PlaylistPopover(Room room) - { - this.room = room; - } - - [BackgroundDependencyLoader] - private void load() - { - Content.Padding = new MarginPadding(10); - - Child = playlist = new PlaylistsRoomSettingsPlaylist - { - Size = new Vector2(300) - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - playlist.Items.BindCollectionChanged((_, __) => room.Playlist = playlist.Items.ToArray()); - - room.PropertyChanged += onRoomPropertyChanged; - updateRoomPlaylist(); - } - - private void onRoomPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(Room.Playlist)) - updateRoomPlaylist(); - } - - private void updateRoomPlaylist() - => playlist.Items.ReplaceRange(0, playlist.Items.Count, room.Playlist); - } - } -} From d5aa5b61ad2eac7eb9c06b7b67d4309a8ea07d7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Jan 2026 10:55:51 +0100 Subject: [PATCH 119/133] Replace manual specification of `Authorization` header with SignalR-provided provider property The property used here is listed in SignalR documentation: https://learn.microsoft.com/en-us/aspnet/core/signalr/authn-and-authz?view=aspnetcore-10.0#bearer-token-authentication While in practice this probably has zero bearing on anything, theoretically the way proposed in this commit is more correct. As the documentation states, with some transports the token may need to be renewed if it expires, which providing it via a header as done previously would not achieve, while going through `API.AccessToken` every time will perform a token refresh if one is needed. --- osu.Game/Online/HubClientConnector.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index e6391e8810..7ccf763e62 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -65,7 +65,7 @@ namespace osu.Game.Online options.Proxy.Credentials = CredentialCache.DefaultCredentials; } - options.Headers.Add(@"Authorization", @$"Bearer {API.AccessToken}"); + options.AccessTokenProvider = () => Task.FromResult(API.AccessToken); // non-standard header name kept for backwards compatibility, can be removed after server side has migrated to `VERSION_HASH_HEADER` options.Headers.Add(@"OsuVersionHash", versionHash); options.Headers.Add(VERSION_HASH_HEADER, versionHash); From 5abc0f93fe67b424a97ae5869535535ae8af330a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Jan 2026 11:00:45 +0100 Subject: [PATCH 120/133] Remove no longer needed header alias for version hash Clean-up from an old change (see https://github.com/ppy/osu/pull/28892#discussion_r1681136250). --- osu.Game/Online/HubClientConnector.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 7ccf763e62..9dba778e41 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -66,8 +66,6 @@ namespace osu.Game.Online } options.AccessTokenProvider = () => Task.FromResult(API.AccessToken); - // non-standard header name kept for backwards compatibility, can be removed after server side has migrated to `VERSION_HASH_HEADER` - options.Headers.Add(@"OsuVersionHash", versionHash); options.Headers.Add(VERSION_HASH_HEADER, versionHash); options.Headers.Add(CLIENT_SESSION_ID_HEADER, API.SessionIdentifier.ToString()); }); From 9c8d6e63a7473dec0bf80f35fa9e76a89e57ac34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 13 Jan 2026 11:16:10 +0100 Subject: [PATCH 121/133] Simplify proxy configuration More clean-up. --- osu.Game/Online/HubClientConnector.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 9dba778e41..c12043c727 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Net; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR.Client; @@ -57,13 +57,7 @@ namespace osu.Game.Online { // Configuring proxies is not supported on iOS, see https://github.com/xamarin/xamarin-macios/issues/14632. if (RuntimeInfo.OS != RuntimeInfo.Platform.iOS) - { - // Use HttpClient.DefaultProxy once on net6 everywhere. - // The credential setter can also be removed at this point. - options.Proxy = WebRequest.DefaultWebProxy; - if (options.Proxy != null) - options.Proxy.Credentials = CredentialCache.DefaultCredentials; - } + options.Proxy = HttpClient.DefaultProxy; options.AccessTokenProvider = () => Task.FromResult(API.AccessToken); options.Headers.Add(VERSION_HASH_HEADER, versionHash); From 3f4d1b798e0ca5919d2078ea78d6c6ce74165132 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Wed, 14 Jan 2026 01:33:42 -0500 Subject: [PATCH 122/133] Update button background colour in update function --- .../Graphics/UserInterfaceV2/FormButton.cs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs index d10134911f..7b95a7e976 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormButton.cs @@ -40,7 +40,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// public IconUsage ButtonIcon { get; init; } = FontAwesome.Solid.ChevronRight; - private Color4? backgroundColour; + private readonly Color4? backgroundColour; /// /// Sets a custom background colour for the button. @@ -48,12 +48,12 @@ namespace osu.Game.Graphics.UserInterfaceV2 public Color4? BackgroundColour { get => backgroundColour; - set + init { backgroundColour = value; - if (IsLoaded && value != null) - button.BackgroundColour = value.Value; + if (IsLoaded) + updateState(); } } @@ -156,10 +156,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 protected override void LoadComplete() { base.LoadComplete(); - - if (BackgroundColour != null) - button.BackgroundColour = BackgroundColour.Value; - Enabled.BindValueChanged(_ => updateState(), true); } @@ -188,7 +184,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 private void updateState() { - text.Colour = !Enabled.Value ? colourProvider.Background1 : colourProvider.Content1; + text.Colour = Enabled.Value ? colourProvider.Content1 : colourProvider.Background1; background.FadeColour(IsHovered ? ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4) @@ -196,10 +192,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 content.BorderThickness = IsHovered ? 2 : 0; - if (!Enabled.Value) - content.BorderColour = BackgroundColour != null ? Interpolation.ValueAt(0.75, BackgroundColour.Value, colourProvider.Dark1, 0, 1) : colourProvider.Dark1; + if (BackgroundColour != null) + { + button.BackgroundColour = BackgroundColour.Value; + content.BorderColour = Enabled.Value ? BackgroundColour.Value : Interpolation.ValueAt(0.75, BackgroundColour.Value, colourProvider.Dark1, 0, 1); + } else - content.BorderColour = BackgroundColour ?? colourProvider.Light4; + content.BorderColour = Enabled.Value ? colourProvider.Light4 : colourProvider.Dark1; } public partial class Button : OsuButton From 79f1d77fedbd47f1ec8cd8ababff92088b95721e Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 14 Jan 2026 20:02:11 +0900 Subject: [PATCH 123/133] Fix copy paste failure in `MultiplayerMatchSettingsOverlay` --- .../Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 018d36069e..80179bd1dd 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -370,7 +370,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match break; case nameof(Room.Type): - updateRoomName(); + updateRoomType(); break; case nameof(Room.QueueMode): From 3302e2e180778bb52c51ef81d109f097d4845152 Mon Sep 17 00:00:00 2001 From: De4n <55669793+tadatomix@users.noreply.github.com> Date: Thu, 15 Jan 2026 10:00:27 +0300 Subject: [PATCH 124/133] Use new star rating text gradient for the difficulty name, "mapped by" text and difficulty bars (#36345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #36312 I think that's exactly what needed to be done to fix this issue. |master|this PR| |:---:|:---:| |изображение|изображение| |изображение|изображение| |изображение|изображение |изображение|изображение --------- Co-authored-by: Dean Herbert --- .../Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs index 55ed488d87..65e55ff277 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.DifficultyDisplay.cs @@ -48,9 +48,6 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private BeatmapDifficultyCache difficultyCache { get; set; } = null!; - [Resolved] - private OsuColour colours { get; set; } = null!; - private StarRatingDisplay starRatingDisplay = null!; private FillFlowContainer nameLine = null!; private OsuSpriteText difficultyText = null!; @@ -304,7 +301,7 @@ namespace osu.Game.Screens.SelectV2 difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); // Use difficulty colour until it gets too dark to be visible against dark backgrounds. - Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? starRatingDisplay.DisplayedDifficultyTextColour : starRatingDisplay.DisplayedDifficultyColour; difficultyText.Colour = col; mappedByText.Colour = col; From 2bf0dcf398f2f62a00158a37801ca74ff18c2492 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 16:31:15 +0900 Subject: [PATCH 125/133] Adjust friend notification logic to fix a few flaws (#36348) Just a couple of things I noticed in passing: - When changing the configuration setting, things were not reset. Likewise, if the setting was off the queues would still be added to but never flushed. - When the setting is toggled, a stale next notification time was still present due to the `??=` and lack of resetting. This should no longer be the case. --- osu.Game/Online/FriendPresenceNotifier.cs | 65 ++++++++++++----------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index 8329f3b46b..0297aea556 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -45,14 +45,24 @@ namespace osu.Game.Online private readonly HashSet onlineAlertQueue = new HashSet(); private readonly HashSet offlineAlertQueue = new HashSet(); - private double? lastOnlineAlertTime; - private double? lastOfflineAlertTime; + private double? nextOnlineAlertTime; + private double? nextOfflineAlertTime; + + private const double debounce_time_before_notification = 1000; protected override void LoadComplete() { base.LoadComplete(); config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange); + notifyOnFriendPresenceChange.BindValueChanged(_ => + { + onlineAlertQueue.Clear(); + offlineAlertQueue.Clear(); + + nextOfflineAlertTime = null; + nextOnlineAlertTime = null; + }); friends.BindTo(api.LocalUserState.Friends); friends.BindCollectionChanged(onFriendsChanged, true); @@ -65,8 +75,11 @@ namespace osu.Game.Online { base.Update(); - alertOnlineUsers(); - alertOfflineUsers(); + if (notifyOnFriendPresenceChange.Value) + { + alertOnlineUsers(); + alertOfflineUsers(); + } } private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e) @@ -132,7 +145,7 @@ namespace osu.Game.Online if (!offlineAlertQueue.Remove(user)) { onlineAlertQueue.Add(user); - lastOnlineAlertTime ??= Time.Current; + nextOnlineAlertTime ??= Time.Current + debounce_time_before_notification; } } @@ -141,57 +154,45 @@ namespace osu.Game.Online if (!onlineAlertQueue.Remove(user)) { offlineAlertQueue.Add(user); - lastOfflineAlertTime ??= Time.Current; + nextOfflineAlertTime ??= Time.Current + debounce_time_before_notification; } } private void alertOnlineUsers() { - if (onlineAlertQueue.Count == 0) + if (nextOnlineAlertTime == null || Time.Current < nextOnlineAlertTime) return; - if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000) - return; - - if (!notifyOnFriendPresenceChange.Value) - { - lastOnlineAlertTime = null; - return; - } + // If a user quickly switches online-offline, we might reach here without actually having a notification + // to fire. Importantly, we should still reset the next alert time in such a scenario. if (onlineAlertQueue.Count == 1) notifications.Post(new SingleFriendOnlineNotification(onlineAlertQueue.Single())); - else + else if (onlineAlertQueue.Count > 1) notifications.Post(new MultipleFriendsOnlineNotification(onlineAlertQueue.ToArray())); onlineAlertQueue.Clear(); - lastOnlineAlertTime = null; + nextOnlineAlertTime = null; } private void alertOfflineUsers() { - if (offlineAlertQueue.Count == 0) + if (nextOfflineAlertTime == null || Time.Current < nextOfflineAlertTime) return; - if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000) - return; - - if (!notifyOnFriendPresenceChange.Value) - { - lastOfflineAlertTime = null; - return; - } + // If a user quickly switches offline-online, we might reach here without actually having a notification + // to fire. Importantly, we should still reset the next alert time in such a scenario. if (offlineAlertQueue.Count == 1) notifications.Post(new SingleFriendOfflineNotification(offlineAlertQueue.Single())); - else + else if (offlineAlertQueue.Count > 1) notifications.Post(new MultipleFriendsOfflineNotification(offlineAlertQueue.ToArray())); offlineAlertQueue.Clear(); - lastOfflineAlertTime = null; + nextOfflineAlertTime = null; } - public partial class SingleFriendOnlineNotification : UserAvatarNotification + private partial class SingleFriendOnlineNotification : UserAvatarNotification { public SingleFriendOnlineNotification(APIUser user) : base(user) @@ -216,7 +217,7 @@ namespace osu.Game.Online public override string PopInSampleName => "UI/notification-friend-online"; } - public partial class MultipleFriendsOnlineNotification : SimpleNotification + private partial class MultipleFriendsOnlineNotification : SimpleNotification { public MultipleFriendsOnlineNotification(ICollection users) { @@ -233,7 +234,7 @@ namespace osu.Game.Online public override string PopInSampleName => "UI/notification-friend-online"; } - public partial class SingleFriendOfflineNotification : UserAvatarNotification + private partial class SingleFriendOfflineNotification : UserAvatarNotification { public SingleFriendOfflineNotification(APIUser user) : base(user) @@ -253,7 +254,7 @@ namespace osu.Game.Online public override string PopInSampleName => "UI/notification-friend-offline"; } - public partial class MultipleFriendsOfflineNotification : SimpleNotification + private partial class MultipleFriendsOfflineNotification : SimpleNotification { public MultipleFriendsOfflineNotification(ICollection users) { From 0551c75c6bb435a7d8f64deff52d38fd6b2b57d9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 16:21:50 +0900 Subject: [PATCH 126/133] Adjust API disconnection text to be usable in more scenarios --- osu.Game/Localisation/NotificationsStrings.cs | 4 ++-- osu.Game/Online/OnlineStatusNotifier.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 0d22d98eb1..91d2615e47 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -146,9 +146,9 @@ Click to see what's new!", version); public static LocalisableString FriendOffline(string info) => new TranslatableString(getKey(@"friend_offline"), @"Offline: {0}", info); /// - /// "Connection to API was lost. Can't continue with online play." + /// "Connection to online services was interrupted. osu! will be operating with limited functionality." /// - public static LocalisableString APIDisconnect => new TranslatableString(getKey(@"api_disconnect"), @"Connection to API was lost. Can't continue with online play."); + public static LocalisableString APIConnectionInterrupted => new TranslatableString(getKey(@"api_connection_interrupted"), @"Connection to online services was interrupted. osu! will be operating with limited functionality."); /// /// "Connection to the multiplayer server was lost. Exiting multiplayer." diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index da58dc3d46..0c4f6df96a 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -89,7 +89,7 @@ namespace osu.Game.Online notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = NotificationsStrings.APIDisconnect, + Text = NotificationsStrings.APIConnectionInterrupted, }); } }); From 15cb3b7a278ad12a9dff6550f5948cb73b162e02 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 16:32:06 +0900 Subject: [PATCH 127/133] Use same message for multiplayer disconnects to simplify things further --- osu.Game/Localisation/NotificationsStrings.cs | 5 ----- osu.Game/Online/OnlineStatusNotifier.cs | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/osu.Game/Localisation/NotificationsStrings.cs b/osu.Game/Localisation/NotificationsStrings.cs index 91d2615e47..1d6b69a007 100644 --- a/osu.Game/Localisation/NotificationsStrings.cs +++ b/osu.Game/Localisation/NotificationsStrings.cs @@ -150,11 +150,6 @@ Click to see what's new!", version); /// public static LocalisableString APIConnectionInterrupted => new TranslatableString(getKey(@"api_connection_interrupted"), @"Connection to online services was interrupted. osu! will be operating with limited functionality."); - /// - /// "Connection to the multiplayer server was lost. Exiting multiplayer." - /// - public static LocalisableString MultiplayerDisconnect => new TranslatableString(getKey(@"multiplayer_disconnect"), @"Connection to the multiplayer server was lost. Exiting multiplayer."); - /// /// "You have been logged out on this device due to a login to your account on another device." /// diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index 0c4f6df96a..70020f4b84 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -110,7 +110,7 @@ namespace osu.Game.Online notificationOverlay?.Post(new SimpleErrorNotification { Icon = FontAwesome.Solid.ExclamationCircle, - Text = NotificationsStrings.MultiplayerDisconnect, + Text = NotificationsStrings.APIConnectionInterrupted, }); } })); From c646b4e5ec55bae66abf1bd8fd44a430082d0fa1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 17:11:29 +0900 Subject: [PATCH 128/133] Alert when spectator server disconnects during gameplay This can cause issues liek loss of replays, so it's worth notifying the user and keeping things visible. --- osu.Game/Online/OnlineStatusNotifier.cs | 57 ++++++++++++++----------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index 70020f4b84..66282e48fd 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -17,6 +17,7 @@ using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.Play; namespace osu.Game.Online { @@ -75,22 +76,16 @@ namespace osu.Game.Online apiState.BindValueChanged(state => { - if (state.NewValue == APIState.Online) + switch (state.NewValue) { - userNotified = false; - return; - } + case APIState.Online: + userNotified = false; + return; - if (userNotified) return; - - if (state.NewValue == APIState.Offline && getCurrentScreen() is OnlinePlayScreen) - { - userNotified = true; - notificationOverlay?.Post(new SimpleErrorNotification - { - Icon = FontAwesome.Solid.ExclamationCircle, - Text = NotificationsStrings.APIConnectionInterrupted, - }); + case APIState.Offline: + if (getCurrentScreen() is OnlinePlayScreen) + notifyApiDisconnection(); + break; } }); @@ -102,22 +97,32 @@ namespace osu.Game.Online return; } - if (userNotified) return; - if (multiplayerClient.Room != null) - { - userNotified = true; - notificationOverlay?.Post(new SimpleErrorNotification - { - Icon = FontAwesome.Solid.ExclamationCircle, - Text = NotificationsStrings.APIConnectionInterrupted, - }); - } + notifyApiDisconnection(); })); - spectatorState.BindValueChanged(_ => + spectatorState.BindValueChanged(connected => Schedule(() => { - // TODO: handle spectator server failure somehow? + if (connected.NewValue) + { + userNotified = false; + return; + } + + if (getCurrentScreen() is Player) + notifyApiDisconnection(); + })); + } + + private void notifyApiDisconnection() + { + if (userNotified) return; + + userNotified = true; + notificationOverlay?.Post(new SimpleErrorNotification + { + Icon = FontAwesome.Solid.ExclamationCircle, + Text = NotificationsStrings.APIConnectionInterrupted, }); } From b4ba327c1c0595de0bb4f2ea586dc052949290b0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 17:52:30 +0900 Subject: [PATCH 129/133] Fix toasts showing "no key bound" for operations which can't have keys bound Supersedes and closes https://github.com/ppy/osu/pull/35781. Closes https://github.com/ppy/osu/issues/36294. --- .../UserInterface/TestSceneOnScreenDisplay.cs | 5 ++-- .../Overlays/Music/MusicKeyBindingHandler.cs | 5 ++-- .../Overlays/OSD/CopiedToClipboardToast.cs | 2 +- osu.Game/Overlays/OSD/SpeedChangeToast.cs | 11 +++++++-- osu.Game/Overlays/OSD/Toast.cs | 24 ++++++++++++------- osu.Game/Overlays/OSD/TrackedSettingToast.cs | 6 +++-- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 3 ++- .../Edit/ComposerDistanceSnapProvider.cs | 4 ++-- osu.Game/Screens/Edit/Editor.cs | 3 ++- .../Screens/Select/ModSpeedHotkeyHandler.cs | 8 ++----- 10 files changed, 42 insertions(+), 29 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs index 4bd3a883f1..34795f3b1f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOnScreenDisplay.cs @@ -96,7 +96,7 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class EmptyToast : Toast { public EmptyToast() - : base("", "", "") + : base("", "") { } } @@ -104,8 +104,9 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class LengthyToast : Toast { public LengthyToast() - : base("Toast with a very very very long text", "A very very very very very very long text also", "A very very very very very long shortcut") + : base("Toast with a very very very long text", "A very very very very very very long text also") { + ExtraText = "A very very very very very long shortcut"; } } diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index 8cec85b748..8fa2520e8f 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -3,7 +3,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; @@ -86,7 +85,7 @@ namespace osu.Game.Overlays.Music private readonly GlobalAction action; public MusicActionToast(LocalisableString value, GlobalAction action) - : base(ToastStrings.MusicPlayback, value, string.Empty) + : base(ToastStrings.MusicPlayback, value) { this.action = action; } @@ -94,7 +93,7 @@ namespace osu.Game.Overlays.Music [BackgroundDependencyLoader] private void load(RealmKeyBindingStore keyBindingStore) { - ShortcutText.Text = keyBindingStore.GetBindingsStringFor(action).ToUpper(); + ExtraText = keyBindingStore.GetBindingsStringFor(action); } } } diff --git a/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs index 4059a274ad..455d93d7ad 100644 --- a/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs +++ b/osu.Game/Overlays/OSD/CopiedToClipboardToast.cs @@ -8,7 +8,7 @@ namespace osu.Game.Overlays.OSD public partial class CopiedToClipboardToast : Toast { public CopiedToClipboardToast() - : base(CommonStrings.General, ToastStrings.CopiedToClipboard, "") + : base(CommonStrings.General, ToastStrings.CopiedToClipboard) { } } diff --git a/osu.Game/Overlays/OSD/SpeedChangeToast.cs b/osu.Game/Overlays/OSD/SpeedChangeToast.cs index 652c043357..48cf6d2b0a 100644 --- a/osu.Game/Overlays/OSD/SpeedChangeToast.cs +++ b/osu.Game/Overlays/OSD/SpeedChangeToast.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Allocation; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.Localisation; @@ -9,9 +10,15 @@ namespace osu.Game.Overlays.OSD { public partial class SpeedChangeToast : Toast { - public SpeedChangeToast(RealmKeyBindingStore keyBindingStore, double newSpeed) - : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed), keyBindingStore.GetBindingsStringFor(GlobalAction.IncreaseModSpeed) + " / " + keyBindingStore.GetBindingsStringFor(GlobalAction.DecreaseModSpeed)) + public SpeedChangeToast(double newSpeed) + : base(ModSelectOverlayStrings.ModCustomisation, ToastStrings.SpeedChangedTo(newSpeed)) { } + + [BackgroundDependencyLoader] + private void load(RealmKeyBindingStore keyBindingStore) + { + ExtraText = keyBindingStore.GetBindingsStringFor(GlobalAction.IncreaseModSpeed) + " / " + keyBindingStore.GetBindingsStringFor(GlobalAction.DecreaseModSpeed); + } } } diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 7df534d90d..a809c37800 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -10,23 +10,30 @@ using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; -using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { public abstract partial class Toast : Container { + /// + /// Extra text to be shown at the bottom of the toast. Usually a key binding if available. + /// + public LocalisableString ExtraText + { + get => extraText.Text; + set => extraText.Text = value.ToUpper(); + } + private const int toast_minimum_width = 240; private readonly Container content; protected override Container Content => content; - protected readonly OsuSpriteText ValueText; + protected readonly OsuSpriteText ValueSpriteText; + private readonly OsuSpriteText extraText; - protected readonly OsuSpriteText ShortcutText; - - protected Toast(LocalisableString description, LocalisableString value, LocalisableString shortcut) + protected Toast(LocalisableString description, LocalisableString value) { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -65,7 +72,7 @@ namespace osu.Game.Overlays.OSD Origin = Anchor.TopCentre, Text = description.ToUpper() }, - ValueText = new OsuSpriteText + ValueSpriteText = new OsuSpriteText { Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), Padding = new MarginPadding { Horizontal = 10 }, @@ -74,15 +81,14 @@ namespace osu.Game.Overlays.OSD Origin = Anchor.Centre, Text = value }, - ShortcutText = new OsuSpriteText + extraText = new OsuSpriteText { Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Name = "Shortcut", + Name = "Extra Text", Alpha = 0.3f, Margin = new MarginPadding { Bottom = 15, Horizontal = 10 }, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = string.IsNullOrEmpty(shortcut.ToString()) ? ToastStrings.NoKeyBound.ToUpper() : shortcut.ToUpper() }, }; } diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 1aa6de423e..73fed25b1a 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -35,8 +35,10 @@ namespace osu.Game.Overlays.OSD private Bindable lastPlaybackTime; public TrackedSettingToast(SettingDescription description) - : base(description.Name, description.Value, description.Shortcut) + : base(description.Name, description.Value) { + ExtraText = description.Shortcut; + FillFlowContainer optionLights; Children = new Drawable[] @@ -75,7 +77,7 @@ namespace osu.Game.Overlays.OSD break; } - ValueText.Origin = optionCount > 0 ? Anchor.BottomCentre : Anchor.Centre; + ValueSpriteText.Origin = optionCount > 0 ? Anchor.BottomCentre : Anchor.Centre; for (int i = 0; i < optionCount; i++) optionLights.Add(new OptionLight { Glowing = i == selectedOption }); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 2903c867f7..193d570a21 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -769,8 +769,9 @@ namespace osu.Game.Overlays.SkinEditor private partial class SkinEditorToast : Toast { public SkinEditorToast(LocalisableString value, string skinDisplayName) - : base(SkinSettingsStrings.SkinLayoutEditor, value, skinDisplayName) + : base(SkinSettingsStrings.SkinLayoutEditor, value) { + ExtraText = skinDisplayName; } } diff --git a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 64f938ba6c..0b8df0ad41 100644 --- a/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -308,7 +308,7 @@ namespace osu.Game.Rulesets.Edit private readonly ValueChangedEvent change; public DistanceSpacingToast(LocalisableString value, ValueChangedEvent change) - : base(getAction(change).GetLocalisableDescription(), value, string.Empty) + : base(getAction(change).GetLocalisableDescription(), value) { this.change = change; } @@ -316,7 +316,7 @@ namespace osu.Game.Rulesets.Edit [BackgroundDependencyLoader] private void load(RealmKeyBindingStore keyBindingStore) { - ShortcutText.Text = keyBindingStore.GetBindingsStringFor(getAction(change)).ToUpper(); + ExtraText = keyBindingStore.GetBindingsStringFor(getAction(change)); } private static GlobalAction getAction(ValueChangedEvent change) => change.NewValue - change.OldValue > 0 diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 03a0942e19..991f10497d 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -1620,8 +1620,9 @@ namespace osu.Game.Screens.Edit private partial class BeatmapEditorToast : Toast { public BeatmapEditorToast(LocalisableString value, string beatmapDisplayName) - : base(InputSettingsStrings.EditorSection, value, beatmapDisplayName) + : base(InputSettingsStrings.EditorSection, value) { + ExtraText = beatmapDisplayName; } } diff --git a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs index 998f94849c..6d9f58bcfd 100644 --- a/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs +++ b/osu.Game/Screens/Select/ModSpeedHotkeyHandler.cs @@ -8,7 +8,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Configuration; -using osu.Game.Input; using osu.Game.Overlays; using osu.Game.Overlays.OSD; using osu.Game.Rulesets.Mods; @@ -21,9 +20,6 @@ namespace osu.Game.Screens.Select [Resolved] private Bindable> selectedMods { get; set; } = null!; - [Resolved] - private RealmKeyBindingStore keyBindingStore { get; set; } = null!; - [Resolved] private OnScreenDisplay? onScreenDisplay { get; set; } @@ -56,7 +52,7 @@ namespace osu.Game.Screens.Select if (Precision.AlmostEquals(targetSpeed, 1, 0.005)) { selectedMods.Value = selectedMods.Value.Where(m => m is not ModRateAdjust).ToList(); - onScreenDisplay?.Display(new SpeedChangeToast(keyBindingStore, targetSpeed)); + onScreenDisplay?.Display(new SpeedChangeToast(targetSpeed)); return true; } @@ -109,7 +105,7 @@ namespace osu.Game.Screens.Select return false; selectedMods.Value = intendedMods; - onScreenDisplay?.Display(new SpeedChangeToast(keyBindingStore, targetMod.SpeedChange.Value)); + onScreenDisplay?.Display(new SpeedChangeToast(targetMod.SpeedChange.Value)); return true; } } From e8154080b3423de67d98e5885fe9992227524383 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 17:31:42 +0900 Subject: [PATCH 130/133] Stop websocket handshake failures from being shown to users --- .../Multiplayer/MultiplayerClientExtensions.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs index 1cc5a8e70a..61824914b5 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClientExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Diagnostics; +using System.Net.WebSockets; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using osu.Framework.Extensions.ExceptionExtensions; @@ -20,13 +21,23 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(t.Exception != null); Exception exception = t.Exception.AsSingular(); + onError?.Invoke(exception); + + if (exception is WebSocketException wse && wse.Message == @"The remote party closed the WebSocket connection without completing the close handshake.") + { + // OnlineStatusNotifier is already letting users know about interruptions to connections. + // Silence these because it gets very spammy otherwise. + return; + } + if (exception.GetHubExceptionMessage() is string message) + { // Hub exceptions generally contain something we can show the user directly. Logger.Log(message, level: LogLevel.Important); - else - Logger.Error(exception, $"Unobserved exception occurred via {nameof(FireAndForget)} call: {exception.Message}"); + return; + } - onError?.Invoke(exception); + Logger.Error(exception, $"Unobserved exception occurred via {nameof(FireAndForget)} call: {exception.Message}"); } else { From 13138833948d8e07751c40e49948656cc00400ff Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 15 Jan 2026 18:08:41 +0900 Subject: [PATCH 131/133] Scope player handling to specific screens which have issues --- osu.Game/Online/OnlineStatusNotifier.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/osu.Game/Online/OnlineStatusNotifier.cs b/osu.Game/Online/OnlineStatusNotifier.cs index 66282e48fd..f4c33c67aa 100644 --- a/osu.Game/Online/OnlineStatusNotifier.cs +++ b/osu.Game/Online/OnlineStatusNotifier.cs @@ -109,8 +109,13 @@ namespace osu.Game.Online return; } - if (getCurrentScreen() is Player) - notifyApiDisconnection(); + switch (getCurrentScreen()) + { + case SpectatorPlayer: // obvious issues + case SubmittingPlayer: // replay sending issues + notifyApiDisconnection(); + break; + } })); } From a8ada5054943bb6f72bf5aa379b418432ed5e8c3 Mon Sep 17 00:00:00 2001 From: Joseph Madamba Date: Fri, 16 Jan 2026 02:48:36 -0800 Subject: [PATCH 132/133] Fix double-clicking form slider bar not propagating default to other bindables when `TransferValueOnCommit` is true (#36354) - Addresses https://github.com/ppy/osu/pull/36346#discussion_r2692322683 The inner slider bar binds its `Current` to `currentNumberInstantaneous`: https://github.com/ppy/osu/blob/1add946db486c866cc214c5eb3d728f308aad637/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs#L225-L236 But the current bindable of the form component doesn't update because of this. https://github.com/ppy/osu/blob/1add946db486c866cc214c5eb3d728f308aad637/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs#L285-L293 Fixed it by just moving the `ResetToDefault` action up another level. --------- Co-authored-by: Dean Herbert --- .../UserInterface/TestSceneFormSliderBar.cs | 9 +++++++-- .../Graphics/UserInterfaceV2/FormSliderBar.cs | 17 ++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs index a962cd4a6f..c7b9975301 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFormSliderBar.cs @@ -64,9 +64,11 @@ namespace osu.Game.Tests.Visual.UserInterface }); } - [Test] - public void TestNubDoubleClickRevertToDefault() + [TestCase(false)] + [TestCase(true)] + public void TestNubDoubleClickRevertToDefault(bool transferValueOnCommit) { + OsuSpriteText text; FormSliderBar slider = null!; AddStep("create content", () => @@ -81,9 +83,11 @@ namespace osu.Game.Tests.Visual.UserInterface Spacing = new Vector2(10), Children = new Drawable[] { + text = new OsuSpriteText(), slider = new FormSliderBar { Caption = "Slider", + TransferValueOnCommit = transferValueOnCommit, Current = new BindableFloat { MinValue = 0, @@ -94,6 +98,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, } }; + slider.Current.BindValueChanged(_ => text.Text = $"Current value is: {slider.Current.Value}", true); }); AddStep("set slider to 1", () => slider.Current.Value = 1); diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index 625967dc66..84fda8ecc1 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -233,6 +233,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 TooltipFormat = TooltipFormat, DisplayAsPercentage = DisplayAsPercentage, PlaySamplesOnAdjust = PlaySamplesOnAdjust, + ResetToDefault = () => + { + if (!IsDisabled) + SetDefault(); + } } }, }, @@ -400,9 +405,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 { public BindableBool Focused { get; } = new BindableBool(); - public BindableBool IsDragging { get; set; } = new BindableBool(); + public BindableBool IsDragging { get; } = new BindableBool(); - public Action? OnCommit { get; set; } + public Action? ResetToDefault { get; init; } + + public Action? OnCommit { get; init; } public sealed override LocalisableString TooltipText => base.TooltipText; @@ -453,11 +460,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 Padding = new MarginPadding { Horizontal = RangePadding, }, Child = nub = new InnerSliderNub { - ResetToDefault = () => - { - if (!Current.Disabled) - Current.SetDefault(); - } + ResetToDefault = ResetToDefault, } }, sounds = new HoverClickSounds() From e05b6f44b9941ac6e88f7a11b12840b7543cb8ad Mon Sep 17 00:00:00 2001 From: De4n <55669793+tadatomix@users.noreply.github.com> Date: Fri, 16 Jan 2026 14:08:52 +0300 Subject: [PATCH 133/133] Update editor slider controls to new design (#36346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (partially) Closes: #36233 Surpasses: #36244 This PR meant to be one of the last steps that finally make editor use the new forms. Initially it meant to only change one SliderWithTextBoxInput in "Effects section" in timing screen, however soon after it was obvious that there's many other places that still using it. This currently won't affect IndeterminateSliderWithTextBoxInput that is being used in hitsounds, for example, since I think it needs more consideration. Anyways, with this PR, SliderWithTextBoxInput, will no longer be used at all, as it's going to be replaced with modern FormSliderBar Comparison: |master|this PR| |:---:|:---:| |532203751-eb965923-d3a8-441d-a7c8-5c364a6328ad|535466527-3a700a8b-bc3c-4610-998f-a4e55ee03eed| |534509844-f00e4da4-53c4-45e8-80ea-1be62da6c83b|изображение| |534509421-a6ac950f-16e8-4a16-bca6-1a781f82135f|изображение| |534509478-80222623-7766-481d-8682-088276d415ee|изображение| |534509935-58954060-7ac1-4392-8754-a58f909e86aa|изображение| --- .../Edit/PolygonGenerationPopover.cs | 30 ++-- .../Edit/PreciseMovementPopover.cs | 18 +-- .../Edit/PreciseRotationPopover.cs | 9 +- .../Edit/PreciseScalePopover.cs | 9 +- .../TestSceneSliderWithTextBoxInput.cs | 131 --------------- .../Graphics/UserInterfaceV2/FormSliderBar.cs | 2 + .../UserInterfaceV2/SliderWithTextBoxInput.cs | 151 ------------------ osu.Game/Screens/Edit/Timing/EffectSection.cs | 15 +- .../IndeterminateSliderWithTextBoxInput.cs | 2 +- 9 files changed, 48 insertions(+), 319 deletions(-) delete mode 100644 osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs delete mode 100644 osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs diff --git a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs index 046f57c0a5..fe5e0581ec 100644 --- a/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PolygonGenerationPopover.cs @@ -25,10 +25,10 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class PolygonGenerationPopover : OsuPopover { - private SliderWithTextBoxInput distanceSnapInput = null!; - private SliderWithTextBoxInput offsetAngleInput = null!; - private SliderWithTextBoxInput repeatCountInput = null!; - private SliderWithTextBoxInput pointInput = null!; + private FormSliderBar distanceSnapInput { get; set; } = null!; + private FormSliderBar offsetAngleInput { get; set; } = null!; + private FormSliderBar repeatCountInput { get; set; } = null!; + private FormSliderBar pointInput { get; set; } = null!; private RoundedButton commitButton = null!; private readonly List insertedCircles = new List(); @@ -64,11 +64,12 @@ namespace osu.Game.Rulesets.Osu.Edit { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - distanceSnapInput = new SliderWithTextBoxInput("Distance snap:") + distanceSnapInput = new FormSliderBar { + Caption = "Distance snap", Current = new BindableNumber(1) { MinValue = 0.1, @@ -76,37 +77,40 @@ namespace osu.Game.Rulesets.Osu.Edit Precision = 0.1, Value = ((OsuHitObjectComposer)composer).DistanceSnapProvider.DistanceSpacingMultiplier.Value, }, - Instantaneous = true + TabbableContentContainer = this }, - offsetAngleInput = new SliderWithTextBoxInput("Offset angle:") + offsetAngleInput = new FormSliderBar { + Caption = "Offset angle", Current = new BindableNumber { MinValue = 0, MaxValue = 180, Precision = 1 }, - Instantaneous = true + TabbableContentContainer = this }, - repeatCountInput = new SliderWithTextBoxInput("Repeats:") + repeatCountInput = new FormSliderBar { + Caption = "Repeats", Current = new BindableNumber(1) { MinValue = 1, MaxValue = 10, Precision = 1 }, - Instantaneous = true + TabbableContentContainer = this }, - pointInput = new SliderWithTextBoxInput("Vertices:") + pointInput = new FormSliderBar { + Caption = "Vertices", Current = new BindableNumber(3) { MinValue = 3, MaxValue = 32, Precision = 1, }, - Instantaneous = true + TabbableContentContainer = this }, commitButton = new RoundedButton { diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs index f3739ab445..caac51632b 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Edit private BindableNumber xBindable = null!; private BindableNumber yBindable = null!; - private SliderWithTextBoxInput xInput = null!; + private FormSliderBar xInput { get; set; } = null!; private OsuCheckbox relativeCheckbox = null!; public PreciseMovementPopover() @@ -52,31 +52,31 @@ namespace osu.Game.Rulesets.Osu.Edit { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - xInput = new SliderWithTextBoxInput("X:") + xInput = new FormSliderBar { + Caption = "X", Current = xBindable = new BindableNumber { Precision = 1, }, - Instantaneous = true, - TabbableContentContainer = this, + TabbableContentContainer = this }, - new SliderWithTextBoxInput("Y:") + new FormSliderBar { + Caption = "Y", Current = yBindable = new BindableNumber { Precision = 1, }, - Instantaneous = true, - TabbableContentContainer = this, + TabbableContentContainer = this }, relativeCheckbox = new OsuCheckbox(false) { RelativeSizeAxes = Axes.X, - LabelText = "Relative movement", + LabelText = "Relative movement" } } }; diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs index e2cde1a325..959963ed33 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, EditorOrigin.GridCentre)); - private SliderWithTextBoxInput angleInput = null!; + private FormSliderBar angleInput { get; set; } = null!; private EditorRadioButtonCollection rotationOrigin = null!; private RadioButton gridCentreButton = null!; @@ -54,11 +54,12 @@ namespace osu.Game.Rulesets.Osu.Edit { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - angleInput = new SliderWithTextBoxInput("Angle (degrees):") + angleInput = new FormSliderBar { + Caption = "Angle (degrees)", Current = new BindableNumber { MinValue = -360, @@ -66,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Edit Precision = 1 }, KeyboardStep = 1f, - Instantaneous = true + TabbableContentContainer = this }, rotationOrigin = new EditorRadioButtonCollection { diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs index ca4a99b9cd..88bf92ecb5 100644 --- a/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs +++ b/osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Bindable scaleInfo = new Bindable(new PreciseScaleInfo(1, EditorOrigin.GridCentre, true, true)); - private SliderWithTextBoxInput scaleInput = null!; + private FormSliderBar scaleInput { get; set; } = null!; private BindableNumber scaleInputBindable = null!; private EditorRadioButtonCollection scaleOrigin = null!; @@ -66,11 +66,12 @@ namespace osu.Game.Rulesets.Osu.Edit { Width = 220, AutoSizeAxes = Axes.Y, - Spacing = new Vector2(20), + Spacing = new Vector2(5), Children = new Drawable[] { - scaleInput = new SliderWithTextBoxInput("Scale:") + scaleInput = new FormSliderBar { + Caption = "Scale", Current = scaleInputBindable = new BindableNumber { MinValue = 0.05f, @@ -80,7 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit Default = 1, }, KeyboardStep = 0.01f, - Instantaneous = true + TabbableContentContainer = this }, scaleOrigin = new EditorRadioButtonCollection { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs deleted file mode 100644 index 06b9623508..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using NUnit.Framework; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Input; -using osu.Framework.Testing; -using osu.Game.Graphics.UserInterface; -using osu.Game.Graphics.UserInterfaceV2; -using osuTK.Input; - -namespace osu.Game.Tests.Visual.UserInterface -{ - public partial class TestSceneSliderWithTextBoxInput : OsuManualInputManagerTestScene - { - private SliderWithTextBoxInput sliderWithTextBoxInput = null!; - - private OsuSliderBar slider => sliderWithTextBoxInput.ChildrenOfType>().Single(); - private Nub nub => sliderWithTextBoxInput.ChildrenOfType().Single(); - private OsuTextBox textBox => sliderWithTextBoxInput.ChildrenOfType().Single(); - - [SetUpSteps] - public void SetUpSteps() - { - AddStep("create slider", () => Child = sliderWithTextBoxInput = new SliderWithTextBoxInput("Test Slider") - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - Current = new BindableFloat - { - MinValue = -5, - MaxValue = 5, - Precision = 0.2f - } - }); - } - - [Test] - public void TestNonInstantaneousMode() - { - AddStep("set instantaneous to false", () => sliderWithTextBoxInput.Instantaneous = false); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("change text", () => textBox.Text = "3"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.Zero); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub)); - AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft)); - AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("3")); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("lose focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - } - - [Test] - public void TestInstantaneousMode() - { - AddStep("set instantaneous to true", () => sliderWithTextBoxInput.Instantaneous = true); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("change text", () => textBox.Text = "3"); - AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(3)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); - - AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub)); - AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft)); - AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); - AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); - AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("commit text", () => InputManager.Key(Key.Enter)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("focus textbox", () => ((IFocusManager)InputManager).ChangeFocus(textBox)); - AddStep("set text to invalid", () => textBox.Text = "garbage"); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - - AddStep("lose focus", () => ((IFocusManager)InputManager).ChangeFocus(null)); - AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); - AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); - AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); - } - } -} diff --git a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs index 84fda8ecc1..cc0e7d5052 100644 --- a/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs +++ b/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs @@ -134,6 +134,8 @@ namespace osu.Game.Graphics.UserInterfaceV2 private readonly Bindable currentLanguage = new Bindable(); + public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; + public FormSliderBar() { LabelFormat ??= defaultLabelFormat; diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs deleted file mode 100644 index 2fbe3ae89b..0000000000 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Numerics; -using System.Globalization; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; -using osu.Game.Overlays.Settings; -using osu.Game.Utils; -using Vector2 = osuTK.Vector2; - -namespace osu.Game.Graphics.UserInterfaceV2 -{ - public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue - where T : struct, INumber, IMinMaxValue - { - /// - /// A custom step value for each key press which actuates a change on this control. - /// - public float KeyboardStep - { - get => slider.KeyboardStep; - set => slider.KeyboardStep = value; - } - - public Bindable Current - { - get => slider.Current; - set => slider.Current = value; - } - - public CompositeDrawable TabbableContentContainer - { - set => textBox.TabbableContentContainer = value; - } - - private bool instantaneous; - - /// - /// Whether changes to the slider should instantaneously transfer to the text box (and vice versa). - /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider drag end. - /// - public bool Instantaneous - { - get => instantaneous; - set - { - instantaneous = value; - slider.TransferValueOnCommit = !instantaneous; - } - } - - private readonly SettingsSlider slider; - private readonly LabelledTextBox textBox; - - public SliderWithTextBoxInput(LocalisableString labelText) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] - { - textBox = new LabelledTextBox - { - Label = labelText, - SelectAllOnFocus = true, - }, - slider = new SettingsSlider - { - TransferValueOnCommit = true, - RelativeSizeAxes = Axes.X, - } - } - }, - }; - - textBox.OnCommit += textCommitted; - textBox.Current.BindValueChanged(textChanged); - - Current.BindValueChanged(updateTextBoxFromSlider, true); - } - - public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true; - - private bool updatingFromTextBox; - - private void textChanged(ValueChangedEvent change) - { - if (!instantaneous) return; - - tryUpdateSliderFromTextBox(); - } - - private void textCommitted(TextBox t, bool isNew) - { - tryUpdateSliderFromTextBox(); - - // If the attempted update above failed, restore text box to match the slider. - Current.TriggerChange(); - } - - private void tryUpdateSliderFromTextBox() - { - updatingFromTextBox = true; - - try - { - switch (slider.Current) - { - case Bindable bindableInt: - bindableInt.Value = int.Parse(textBox.Current.Value); - break; - - case Bindable bindableDouble: - bindableDouble.Value = double.Parse(textBox.Current.Value); - break; - - default: - slider.Current.Parse(textBox.Current.Value, CultureInfo.CurrentCulture); - break; - } - } - catch - { - // ignore parsing failures. - // sane state will eventually be restored by a commit (either explicit, or implicit via focus loss). - } - - updatingFromTextBox = false; - } - - private void updateTextBoxFromSlider(ValueChangedEvent _) - { - if (updatingFromTextBox) return; - - decimal decimalValue = decimal.CreateTruncating(slider.Current.Value); - textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); - } - } -} diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index f9ef460232..325d0cfaf6 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -11,21 +11,24 @@ namespace osu.Game.Screens.Edit.Timing { internal partial class EffectSection : Section { - private LabelledSwitchButton kiai = null!; + private FormCheckBox kiai = null!; - private SliderWithTextBoxInput scrollSpeedSlider = null!; + private FormSliderBar scrollSpeedSlider { get; set; } = null!; [BackgroundDependencyLoader] private void load() { Flow.AddRange(new Drawable[] { - kiai = new LabelledSwitchButton { Label = "Kiai Time" }, - scrollSpeedSlider = new SliderWithTextBoxInput("Scroll Speed") + kiai = new FormCheckBox { Caption = "Kiai Time" }, + scrollSpeedSlider = new FormSliderBar { + Caption = "Scroll Speed", Current = new EffectControlPoint().ScrollSpeedBindable, - KeyboardStep = 0.1f - } + KeyboardStep = 0.1f, + TransferValueOnCommit = true, + TabbableContentContainer = this + }, }); } diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs index 00cf2e3493..0e7b4767a7 100644 --- a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -17,7 +17,7 @@ using Vector2 = osuTK.Vector2; namespace osu.Game.Screens.Edit.Timing { /// - /// Analogous to , but supports scenarios + /// Analogous to SliderWithTextBoxInput, but supports scenarios /// where multiple objects with multiple different property values are selected /// by providing an "indeterminate state". ///