Skip to content

Restore previous beatmap when leaving scoped mode#36582

Open
LiquidPL wants to merge 7 commits intoppy:masterfrom
LiquidPL:song-select-unscope
Open

Restore previous beatmap when leaving scoped mode#36582
LiquidPL wants to merge 7 commits intoppy:masterfrom
LiquidPL:song-select-unscope

Conversation

@LiquidPL
Copy link
Contributor

@LiquidPL LiquidPL commented Feb 4, 2026

Resolves #36288.

If the current selection is still available after leaving scoped mode, it's left as is. If it's not, the selection from before entering scoped mode is restored.

2026-02-04.14-07-31.mp4

@monochrome22
Copy link

monochrome22 commented Feb 5, 2026

Just in case, I'll give my thoughts on why I suggested this UX in the issue post, specifically this part:

With this solution, the selected difficulty should change only if the difficulty selected in scoped mode is outside of filter range.

There are 2 solutions: either always return to the difficulty which was selected before entering scoped mode, or only return to it if the user selects a difficulty inside scoped mode that's outside filter range.
I suggested to only return if the new difficulty is outside of filter range, because automatically changing difficulties is a bit jarring and it seems to cause a slight FPS stutter, so I felt it's best to keep these occurances to a minimum. Also, by keeping the selected difficulty if it's in filter range, it allows players to more easily jump around in scrolling. I personally sometimes use this when warming up; I might play 4.5 star maps when warming up, then select a difficulty that's 5 stars with scoped mode, and when I see I'm warmed up enough to play it well, I can exit scoped mode and keep the scrolling at 5 star maps.
Though the benefit of making it always return to the difficulty which was selected before entering scoped mode is obviously that the behavior would be more consistent and potentially less confusing.

By the way, with this PR, if you enter scoped mode, then select a difficulty that's outside filter range, then exit to main menu, then enter song select, the scrolling will still be lost. I'll leave it to you if this is worth fixing (I don't know code).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I am not a fan of how this restore behaviour has smeared itself all over BeatmapCarousel. I think it's going to end badly because I tried similar once before and it resulted in carnage.

If this sort of thing is to be entertained then the "scoping" API probably has to change. It probably should not be a plain bindable anymore and instead be a method you call, something like (DISCLAIMER: I have not tried this, just spitballing):

IDisposable ScopeToBeatmap(IBeatmapSetInfo beatmapSet);

The IDisposable would be an InvokeOnDisposal() that the caller should dispose of when they wish to stop showing the scoped beatmap. This disposable would be responsible for restoring the beatmap, hopefully in a way that centralises this logic to one confined operation.

The way this is currently written just gives me little to no confidence that this flow won't fire in the wrong circumstances accidentally, will fire in all right applicable circumstances, etc.

Either way any solution here probably needs some further testing with things like changing ruleset inside scoped mode, etc. to give better guarantees that this doesn't fire in some unwanted circumstance.

@bdach
Copy link
Collaborator

bdach commented Feb 6, 2026

if you enter scoped mode, then select a difficulty that's outside filter range, then exit to main menu, then enter song select, the scrolling will still be lost

This is out of scope to fix even on a UX / user expectation level. Not sure why exiting out of song select should always preserve scroll position now.

@monochrome22
Copy link

monochrome22 commented Feb 6, 2026

This is out of scope to fix even on a UX / user expectation level. Not sure why exiting out of song select should always preserve scroll position now.

It would be better if the scroll position always got preserved; why would anyone want their scrolling to randomly be at the top? But it's fine, I personally don't care about it and it's a very minor issue, I just noticed it while testing the PR and thought I'd mention it in case it's a simple fix which might as well be included.

@pull-request-size pull-request-size bot added size/L and removed size/M labels Feb 13, 2026
@LiquidPL
Copy link
Contributor Author

I've added a bunch of tests for cases where the selection would be changed through some other way on top of the revert-on-unscope behavior implemented here (changing ruleset, filters. etc.).

Ultimately I didn't end up using InvokeOnDisposal since there's several components that can invoke unscoping the beatmapset (and they're often not the one that did initally invoke the scoping), and I felt that moving the IDisposable between them wouldn't be a good idea.


/// <summary>
/// Contains the currently scoped beatmapset. Used by external consumers for displaying its state.
/// Cannot be used to change the value, any changes must be done through <see cref="ScopeToBeatmapSet"/>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this "cannot be used to change the value" anymore then it should not be exposed as Bindable anymore. Use IBindable which is immutable (doesn't have an exposed setter on Value).

Comment on lines 415 to 416
FilterCompleted?.Invoke();
HandleFilterCompleted();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FilterCompleted should not exist. Definitely not in Carousel, possibly inside HandleFilterCompleted() in BeatmapCarousel, and hopefully not at all (though I am not holding my breath for that last one).

As is this looks turbo weird with how Carousel has essentially two ways of hooking into the same operation for little reason.


showConvertedBeatmaps = config.GetBindable<bool>(OsuSetting.ShowConvertedBeatmaps);

leasedScopedBeatmapSet = ScopedBeatmapSet.BeginLease(false);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's with the lease? I'd hope we don't have to do that at all.

Copy link
Contributor Author

@LiquidPL LiquidPL Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I misunderstood how to make a bindable read only. Will nuke this and use IBindable instead.

leasedScopedBeatmapSet.Value = null;
}

private void filterCompleted()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to hook into the filtering flow there? Can't we just do

diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs
index 1e40877666..6964c5034d 100644
--- a/osu.Game/Screens/SelectV2/SongSelect.cs
+++ b/osu.Game/Screens/SelectV2/SongSelect.cs
@@ -271,7 +271,6 @@ private void load(AudioManager audio, OsuConfigManager config)
                                                                 RequestPresentBeatmap = b => SelectAndRun(b, OnStart),
                                                                 RequestSelection = queueBeatmapSelection,
                                                                 RequestRecommendedSelection = requestRecommendedSelection,
-                                                                FilterCompleted = filterCompleted,
                                                                 NewItemsPresented = newItemsPresented,
                                                             },
                                                             noResultsPlaceholder = new NoResultsPlaceholder
@@ -1264,18 +1263,9 @@ public void UnscopeBeatmapSet()
                 return;
 
             leasedScopedBeatmapSet.Value = null;
-        }
-
-        private void filterCompleted()
-        {
-            if (carousel.CurrentGroupedBeatmap == null)
-                return;
-
-            if (beforeScopedSelection == null)
-                return;
-
-            if (!carousel.GetCarouselItems()!.Any(i => i.Model is GroupedBeatmap g && g.Equals(carousel.CurrentGroupedBeatmap)))
+            if (beforeScopedSelection != null)
                 queueBeatmapSelection(beforeScopedSelection);
+            beforeScopedSelection = null;
         }
 
         #endregion

I guess the concern might have been that if it's done that way then the selection will change before the filter completes which looks kinda twitchy? But I dunno, I think that's fine, I'd rather have it look a little twitchy than be worse code. (Which maybe betrays that my priorities are backwards, but I'd be running this past @peppy anyway before a merge.)

Copy link
Contributor Author

@LiquidPL LiquidPL Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I did initially, but this causes the selection to be always reverted, even if the current one is still visible after unscoping. This might be fine but I feel that is something that could break expectations.

The selection changing before the filter completing didn't look that bad actually when I was testing it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think always reverting the selection to the one before scoping is probably OK? The reporter of the issue thought it was acceptable as well.

I'd personally say let's start there and we can complicate later as required.

}

public Bindable<BeatmapSetInfo?> ScopedBeatmapSet => filterControl.ScopedBeatmapSet;
private GroupedBeatmap? beforeScopedSelection;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also incidentally does anything ever clear this to null? I don't see that being done, and that spooks me. It should be cleared when scoping is off just to decrease possibility of Hijinks™.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a line that's supposed to clear it, but I must have accidentally remove it when I was experimenting with the whole "wait until filters complete to figure out if current selection will be visible after unscoping" thing.

@LiquidPL
Copy link
Contributor Author

Here's how the transition looks with the selection being reverted before the filtering happens:

2026-02-13.16-47-00.mp4

I think it actually telegraphs the intention of reverting to the pre-scope selection pretty well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exiting show all difficulties scoped mode doesn't always return to scroll location

3 participants