osu/osu.Game/Overlays/Mods/ModSelectOverlay.cs

817 lines
32 KiB
C#

// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Footer;
using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Overlays.Mods
{
public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler<PlatformAction>
{
public const int BUTTON_WIDTH = 200;
protected override string PopInSampleName => "";
protected override string PopOutSampleName => @"SongSelect/mod-select-overlay-pop-out";
[Cached]
public Bindable<IReadOnlyList<Mod>> SelectedMods { get; private set; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
/// <summary>
/// Contains a list of mods which <see cref="ModSelectOverlay"/> should read from to display effects on the selected beatmap.
/// </summary>
/// <remarks>
/// This is different from <see cref="SelectedMods"/> in screens like online-play rooms, where there are required mods activated from the playlist.
/// </remarks>
public Bindable<IReadOnlyList<Mod>> ActiveMods { get; private set; } = new Bindable<IReadOnlyList<Mod>>(Array.Empty<Mod>());
/// <summary>
/// Contains a dictionary with the current <see cref="ModState"/> of all mods applicable for the current ruleset.
/// </summary>
/// <remarks>
/// Contrary to <see cref="OsuGameBase.AvailableMods"/> and <see cref="globalAvailableMods"/>, the <see cref="Mod"/> instances
/// inside the <see cref="ModState"/> objects are owned solely by this <see cref="ModSelectOverlay"/> instance.
/// </remarks>
public Bindable<Dictionary<ModType, IReadOnlyList<ModState>>> AvailableMods { get; } =
new Bindable<Dictionary<ModType, IReadOnlyList<ModState>>>(new Dictionary<ModType, IReadOnlyList<ModState>>());
private Func<Mod, bool> isValidMod = _ => true;
/// <summary>
/// A function determining whether each mod in the column should be displayed.
/// A return value of <see langword="true"/> means that the mod is not filtered and therefore its corresponding panel should be displayed.
/// A return value of <see langword="false"/> means that the mod is filtered out and therefore its corresponding panel should be hidden.
/// </summary>
public Func<Mod, bool> IsValidMod
{
get => isValidMod;
set
{
isValidMod = value ?? throw new ArgumentNullException(nameof(value));
filterMods();
}
}
public string SearchTerm
{
get => SearchTextBox.Current.Value;
set => SearchTextBox.Current.Value = value;
}
public ShearedSearchTextBox SearchTextBox { get; private set; } = null!;
/// <summary>
/// Whether per-mod customisation controls are visible.
/// </summary>
protected virtual bool AllowCustomisation => true;
/// <summary>
/// Whether the column with available mod presets should be shown.
/// </summary>
protected virtual bool ShowPresets => false;
protected virtual ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, false);
protected virtual IReadOnlyList<Mod> ComputeNewModsFromSelection(IReadOnlyList<Mod> oldSelection, IReadOnlyList<Mod> newSelection) => newSelection;
protected virtual IReadOnlyList<Mod> ComputeActiveMods() => SelectedMods.Value;
private readonly Bindable<Dictionary<ModType, IReadOnlyList<Mod>>> globalAvailableMods = new Bindable<Dictionary<ModType, IReadOnlyList<Mod>>>();
public IEnumerable<ModState> AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value);
private Bindable<bool> textSearchStartsActive = null!;
private ColumnScrollContainer columnScroll = null!;
private ColumnFlowContainer columnFlow = null!;
private Container aboveColumnsContent = null!;
private ModCustomisationPanel customisationPanel = null!;
protected virtual SelectAllModsButton? SelectAllModsButton => null;
private Sample? columnAppearSample;
public readonly Bindable<WorkingBeatmap?> Beatmap = new Bindable<WorkingBeatmap?>();
[Resolved]
private ScreenFooter? footer { get; set; }
protected ModSelectOverlay(OverlayColourScheme colourScheme = OverlayColourScheme.Green)
: base(colourScheme)
{
}
[BackgroundDependencyLoader]
private void load(OsuGameBase game, OsuColour colours, AudioManager audio, OsuConfigManager configManager)
{
Header.Title = ModSelectOverlayStrings.ModSelectTitle;
Header.Description = ModSelectOverlayStrings.ModSelectDescription;
columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in");
MainAreaContent.Add(new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Child = new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new Container
{
Padding = new MarginPadding
{
Top = RankingInformationDisplay.HEIGHT + PADDING,
Bottom = PADDING
},
RelativeSizeAxes = Axes.Both,
RelativePositionAxes = Axes.Both,
Children = new Drawable[]
{
columnScroll = new ColumnScrollContainer
{
RelativeSizeAxes = Axes.Both,
Masking = false,
ClampExtension = 100,
ScrollbarOverlapsContent = false,
Child = columnFlow = new ColumnFlowContainer
{
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Direction = FillDirection.Horizontal,
Shear = new Vector2(OsuGame.SHEAR, 0),
RelativeSizeAxes = Axes.Y,
AutoSizeAxes = Axes.X,
Margin = new MarginPadding { Horizontal = 70 },
Padding = new MarginPadding { Bottom = 10 },
ChildrenEnumerable = createColumns()
}
}
}
},
aboveColumnsContent = new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Horizontal = 100, Bottom = 15f },
Children = new Drawable[]
{
SearchTextBox = new ShearedSearchTextBox
{
HoldFocus = false,
Width = 300,
},
customisationPanel = new ModCustomisationPanel
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Width = 400,
State = { Value = Visibility.Visible },
}
}
}
},
}
});
globalAvailableMods.BindTo(game.AvailableMods);
textSearchStartsActive = configManager.GetBindable<bool>(OsuSetting.ModSelectTextSearchStartsActive);
}
public override void Hide()
{
base.Hide();
// clear search for next user interaction with mod overlay
SearchTextBox.Current.Value = string.Empty;
}
protected override void LoadComplete()
{
// this is called before base call so that the mod state is populated early, and the transition in `PopIn()` can play out properly.
globalAvailableMods.BindValueChanged(_ => createLocalMods(), true);
base.LoadComplete();
State.BindValueChanged(_ => samplePlaybackDisabled.Value = State.Value == Visibility.Hidden, true);
// This is an optimisation to prevent refreshing the available settings controls when it can be
// reasonably assumed that the settings panel is never to be displayed (e.g. FreeModSelectOverlay).
if (AllowCustomisation)
((IBindable<IReadOnlyList<Mod>>)customisationPanel.SelectedMods).BindTo(SelectedMods);
SelectedMods.BindValueChanged(_ =>
{
updateFromExternalSelection();
updateCustomisation();
ActiveMods.Value = ComputeActiveMods();
}, true);
customisationPanel.Expanded.BindValueChanged(_ => updateCustomisationVisualState(), true);
SearchTextBox.Current.BindValueChanged(query =>
{
foreach (var column in columnFlow.Columns)
column.SearchTerm = query.NewValue;
}, true);
// Start scrolling from the end, to give the user a sense that
// there is more horizontal content available.
ScheduleAfterChildren(() =>
{
columnScroll.ScrollToEnd(false);
columnScroll.ScrollTo(0);
});
}
public new ModSelectFooterContent? DisplayedFooterContent => base.DisplayedFooterContent as ModSelectFooterContent;
public override VisibilityContainer CreateFooterContent() => new ModSelectFooterContent(this)
{
Beatmap = { BindTarget = Beatmap },
ActiveMods = { BindTarget = ActiveMods },
};
private static readonly LocalisableString input_search_placeholder = Resources.Localisation.Web.CommonStrings.InputSearch;
private static readonly LocalisableString tab_to_search_placeholder = ModSelectOverlayStrings.TabToSearch;
protected override void Update()
{
base.Update();
SearchTextBox.PlaceholderText = SearchTextBox.HasFocus ? input_search_placeholder : tab_to_search_placeholder;
aboveColumnsContent.Padding = aboveColumnsContent.Padding with { Bottom = DisplayedFooterContent?.DisplaysStackedVertically == true ? 75f : 15f };
}
/// <summary>
/// Select all visible mods in all columns.
/// </summary>
public void SelectAll()
{
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.SelectAll();
}
/// <summary>
/// Deselect all visible mods in all columns.
/// </summary>
public void DeselectAll()
{
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.DeselectAll();
}
private IEnumerable<ColumnDimContainer> createColumns()
{
if (ShowPresets)
{
yield return new ColumnDimContainer(new ModPresetColumn
{
Margin = new MarginPadding { Right = 10 }
});
}
yield return createModColumnContent(ModType.DifficultyReduction);
yield return createModColumnContent(ModType.DifficultyIncrease);
yield return createModColumnContent(ModType.Automation);
yield return createModColumnContent(ModType.Conversion);
yield return createModColumnContent(ModType.Fun);
}
private ColumnDimContainer createModColumnContent(ModType modType)
{
var column = CreateModColumn(modType).With(column =>
{
// spacing applied here rather than via `columnFlow.Spacing` to avoid uneven gaps when some of the columns are hidden.
column.Margin = new MarginPadding { Right = 10 };
});
return new ColumnDimContainer(column);
}
private void createLocalMods()
{
var newLocalAvailableMods = new Dictionary<ModType, IReadOnlyList<ModState>>();
foreach (var (modType, mods) in globalAvailableMods.Value)
{
var modStates = mods.SelectMany(ModUtils.FlattenMod)
.Select(mod => new ModState(mod.DeepClone()))
.ToArray();
foreach (var modState in modStates)
modState.Active.BindValueChanged(_ => updateFromInternalSelection());
newLocalAvailableMods[modType] = modStates;
}
AvailableMods.Value = newLocalAvailableMods;
filterMods();
foreach (var column in columnFlow.Columns.OfType<ModColumn>())
column.AvailableMods = AvailableMods.Value.GetValueOrDefault(column.ModType, Array.Empty<ModState>());
}
private void filterMods()
{
foreach (var modState in AllAvailableMods)
modState.ValidForSelection.Value = modState.Mod.Type != ModType.System && modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod);
}
private void updateCustomisation()
{
if (!AllowCustomisation)
return;
bool anyCustomisableModActive = false;
bool anyModPendingConfiguration = false;
foreach (var modState in AllAvailableMods)
{
anyCustomisableModActive |= modState.Active.Value && modState.Mod.GetSettingsSourceProperties().Any();
anyModPendingConfiguration |= modState.PendingConfiguration;
modState.PendingConfiguration = false;
}
if (anyCustomisableModActive)
{
customisationPanel.Enabled.Value = true;
if (anyModPendingConfiguration)
customisationPanel.Expanded.Value = true;
}
else
{
customisationPanel.Expanded.Value = false;
customisationPanel.Enabled.Value = false;
}
}
private void updateCustomisationVisualState()
{
if (customisationPanel.Expanded.Value)
{
columnScroll.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint);
SearchTextBox.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint);
SearchTextBox.KillFocus();
}
else
{
columnScroll.FadeColour(Color4.White, 400, Easing.OutQuint);
SearchTextBox.FadeColour(Color4.White, 400, Easing.OutQuint);
setTextBoxFocus(textSearchStartsActive.Value);
}
}
/// <summary>
/// This flag helps to determine the source of changes to <see cref="SelectedMods"/>.
/// If the value is false, then <see cref="SelectedMods"/> are changing due to a user selection on the UI.
/// If the value is true, then <see cref="SelectedMods"/> are changing due to an external <see cref="SelectedMods"/> change.
/// </summary>
private bool externalSelectionUpdateInProgress;
private void updateFromExternalSelection()
{
if (externalSelectionUpdateInProgress)
return;
externalSelectionUpdateInProgress = true;
var newSelection = new List<Mod>();
foreach (var modState in AllAvailableMods)
{
var matchingSelectedMod = SelectedMods.Value.SingleOrDefault(selected => selected.GetType() == modState.Mod.GetType());
if (matchingSelectedMod != null)
{
modState.Mod.CopyFrom(matchingSelectedMod);
modState.Active.Value = true;
newSelection.Add(modState.Mod);
}
else
{
modState.Mod.ResetSettingsToDefaults();
modState.Active.Value = false;
}
}
SelectedMods.Value = newSelection;
externalSelectionUpdateInProgress = false;
}
private void updateFromInternalSelection()
{
if (externalSelectionUpdateInProgress)
return;
var candidateSelection = AllAvailableMods.Where(modState => modState.Active.Value)
.Select(modState => modState.Mod)
.ToArray();
SelectedMods.Value = ComputeNewModsFromSelection(SelectedMods.Value, candidateSelection);
}
#region Transition handling
private const float distance = 700;
protected override void PopIn()
{
const double fade_in_duration = 400;
base.PopIn();
aboveColumnsContent
.FadeIn(fade_in_duration, Easing.OutQuint)
.MoveToY(0, fade_in_duration, Easing.OutQuint);
int nonFilteredColumnCount = 0;
for (int i = 0; i < columnFlow.Count; i++)
{
var column = columnFlow[i].Column;
bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => !modState.Visible);
double delay = allFiltered ? 0 : nonFilteredColumnCount * 30;
double duration = allFiltered ? 0 : fade_in_duration;
float startingYPosition = 0;
if (!allFiltered)
startingYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance;
column.TopLevelContent
.MoveToY(startingYPosition)
.Delay(delay)
.MoveToY(0, duration, Easing.OutQuint)
.FadeIn(duration, Easing.OutQuint);
if (allFiltered)
continue;
int columnNumber = nonFilteredColumnCount;
Scheduler.AddDelayed(() =>
{
var channel = columnAppearSample?.GetChannel();
if (channel == null) return;
// Still play sound effects for off-screen columns up to a certain point.
if (columnNumber > 5 && !column.Active.Value) return;
// use X position of the column on screen as a basis for panning the sample
float balance = column.Parent!.BoundingBox.Centre.X / RelativeToAbsoluteFactor.X;
// dip frequency and ramp volume of sample over the first 5 displayed columns
float progress = Math.Min(1, columnNumber / 5f);
channel.Frequency.Value = 1.3 - (progress * 0.3) + RNG.NextDouble(0.1);
channel.Volume.Value = Math.Max(progress, 0.2);
channel.Balance.Value = -1 + balance * 2;
channel.Play();
}, delay);
nonFilteredColumnCount += 1;
}
setTextBoxFocus(textSearchStartsActive.Value);
}
protected override void PopOut()
{
const double fade_out_duration = 500;
base.PopOut();
aboveColumnsContent
.FadeOut(fade_out_duration / 2, Easing.OutQuint)
.MoveToY(-distance, fade_out_duration / 2, Easing.OutQuint);
int nonFilteredColumnCount = 0;
for (int i = 0; i < columnFlow.Count; i++)
{
var column = columnFlow[i].Column;
bool allFiltered = false;
if (column is ModColumn modColumn)
{
allFiltered = modColumn.AvailableMods.All(modState => !modState.Visible);
modColumn.FlushPendingSelections();
}
double duration = allFiltered ? 0 : fade_out_duration;
float newYPosition = 0;
if (!allFiltered)
newYPosition = nonFilteredColumnCount % 2 == 0 ? -distance : distance;
column.TopLevelContent
.MoveToY(newYPosition, duration, Easing.OutQuint)
.FadeOut(duration, Easing.OutQuint);
if (!allFiltered)
nonFilteredColumnCount += 1;
}
customisationPanel.Expanded.Value = false;
}
#endregion
#region Input handling
public override bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Repeat)
return false;
switch (e.Action)
{
// If the customisation panel is expanded, the back action will be handled by it first.
case GlobalAction.Back:
// This is handled locally here because this overlay is being registered at the game level
// and therefore takes away keyboard focus from the screen stack.
case GlobalAction.ToggleModSelection:
hideOverlay();
return true;
// This is handled locally here due to conflicts in input handling between the search text box and the deselect all mods button.
// Attempting to handle this action locally in both places leads to a possible scenario
// wherein activating the binding will both change the contents of the search text box and deselect all mods.
case GlobalAction.DeselectAllMods:
{
if (!SearchTextBox.HasFocus && !customisationPanel.Expanded.Value)
{
DisplayedFooterContent?.DeselectAllModsButton?.TriggerClick();
return true;
}
break;
}
case GlobalAction.Select:
{
// Pressing select should select first filtered mod if a search is in progress.
// If there is no search in progress, it should exit the dialog (a bit weird, but this is the expectation from stable).
if (string.IsNullOrEmpty(SearchTerm))
{
hideOverlay();
return true;
}
ModState? firstMod = columnFlow.Columns.OfType<ModColumn>().FirstOrDefault(m => m.IsPresent)?.AvailableMods.FirstOrDefault(x => x.Visible);
if (firstMod is not null)
{
firstMod.Active.Value = !firstMod.Active.Value;
SearchTextBox.SelectAll();
}
return true;
}
}
return base.OnPressed(e);
void hideOverlay()
{
if (footer != null)
footer.BackButton.TriggerClick();
else
Hide();
}
}
/// <inheritdoc cref="IKeyBindingHandler{PlatformAction}"/>
/// <remarks>
/// This is handled locally here due to conflicts in input handling between the search text box and the select all mods button.
/// Attempting to handle this action locally in both places leads to a possible scenario
/// wherein activating the "select all" platform binding will both select all text in the search box and select all mods.
/// </remarks>
public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)
{
if (e.Repeat || e.Action != PlatformAction.SelectAll || SelectAllModsButton == null)
return false;
SelectAllModsButton.TriggerClick();
return true;
}
public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Repeat || e.Key != Key.Tab)
return false;
if (customisationPanel.Expanded.Value)
return true;
// TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`)
setTextBoxFocus(!SearchTextBox.HasFocus);
return true;
}
private void setTextBoxFocus(bool focus)
{
if (focus)
SearchTextBox.TakeFocus();
else
SearchTextBox.KillFocus();
}
#endregion
#region Sample playback control
private readonly Bindable<bool> samplePlaybackDisabled = new BindableBool(true);
IBindable<bool> ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled;
#endregion
/// <summary>
/// Manages horizontal scrolling of mod columns, along with the "active" states of each column based on visibility.
/// </summary>
[Cached]
internal partial class ColumnScrollContainer : OsuScrollContainer<ColumnFlowContainer>
{
public ColumnScrollContainer()
: base(Direction.Horizontal)
{
}
protected override void Update()
{
base.Update();
// the bounds below represent the horizontal range of scroll items to be considered fully visible/active, in the scroll's internal coordinate space.
// note that clamping is applied to the left scroll bound to ensure scrolling past extents does not change the set of active columns.
float leftVisibleBound = Math.Clamp(Current, 0, ScrollableExtent);
float rightVisibleBound = leftVisibleBound + DrawWidth;
// if a movement is occurring at this time, the bounds below represent the full range of columns that the scroll movement will encompass.
// this will be used to ensure that columns do not change state from active to inactive back and forth until they are fully scrolled past.
float leftMovementBound = Math.Min(Current, Target);
float rightMovementBound = Math.Max(Current, Target) + DrawWidth;
foreach (var column in Child)
{
// DrawWidth/DrawPosition do not include shear effects, and we want to know the full extents of the columns post-shear,
// so we have to manually compensate.
var topLeft = column.ToSpaceOfOtherDrawable(Vector2.Zero, ScrollContent);
var bottomRight = column.ToSpaceOfOtherDrawable(new Vector2(column.DrawWidth - column.DrawHeight * OsuGame.SHEAR, 0), ScrollContent);
bool isCurrentlyVisible = Precision.AlmostBigger(topLeft.X, leftVisibleBound)
&& Precision.DefinitelyBigger(rightVisibleBound, bottomRight.X);
bool isBeingScrolledToward = Precision.AlmostBigger(topLeft.X, leftMovementBound)
&& Precision.DefinitelyBigger(rightMovementBound, bottomRight.X);
column.Active.Value = isCurrentlyVisible || isBeingScrolledToward;
}
}
}
/// <summary>
/// Manages layout of mod columns.
/// </summary>
internal partial class ColumnFlowContainer : FillFlowContainer<ColumnDimContainer>
{
public IEnumerable<ModSelectColumn> Columns => Children.Select(dimWrapper => dimWrapper.Column);
public override void Add(ColumnDimContainer dimContainer)
{
base.Add(dimContainer);
Debug.Assert(dimContainer != null);
dimContainer.Column.Shear = Vector2.Zero;
}
}
/// <summary>
/// Encapsulates a column and provides dim and input blocking based on an externally managed "active" state.
/// </summary>
internal partial class ColumnDimContainer : Container
{
public ModSelectColumn Column { get; }
/// <summary>
/// Tracks whether this column is in an interactive state. Generally only the case when the column is on-screen.
/// </summary>
public readonly Bindable<bool> Active = new BindableBool();
/// <summary>
/// Invoked when the column is clicked while not active, requesting a scroll to be performed to bring it on-screen.
/// </summary>
public Action<ColumnDimContainer>? RequestScroll { get; set; }
[Resolved]
private OsuColour colours { get; set; } = null!;
public ColumnDimContainer(ModSelectColumn column)
{
AutoSizeAxes = Axes.X;
RelativeSizeAxes = Axes.Y;
Child = Column = column;
column.Active.BindTo(Active);
}
[BackgroundDependencyLoader]
private void load(ColumnScrollContainer columnScroll)
{
RequestScroll = col => columnScroll.ScrollIntoView(col, extraScroll: 140);
}
protected override void LoadComplete()
{
base.LoadComplete();
Active.BindValueChanged(_ => updateState(), true);
FinishTransforms();
}
protected override bool RequiresChildrenUpdate
{
get
{
bool result = base.RequiresChildrenUpdate;
if (Column is ModColumn modColumn)
result |= !modColumn.ItemsLoaded || modColumn.SelectionAnimationRunning;
return result;
}
}
private void updateState()
{
Colour4 targetColour;
if (Column.Active.Value)
targetColour = Colour4.White;
else
targetColour = IsHovered ? colours.GrayC : colours.Gray8;
this.FadeColour(targetColour, 800, Easing.OutQuint);
}
protected override bool OnClick(ClickEvent e)
{
if (!Active.Value)
RequestScroll?.Invoke(this);
// Killing focus is done here because it's the only feasible place on ModSelectOverlay you can click on without triggering any action.
Scheduler.Add(() => GetContainingFocusManager()!.ChangeFocus(null));
return true;
}
protected override bool OnHover(HoverEvent e)
{
base.OnHover(e);
updateState();
return Active.Value;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
updateState();
}
}
}
}