1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 07:22:38 +08:00
Files
osu-lazer/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs
T
Bartłomiej Dach b7559f93f2 Work around flaky TestSceneFirstRunSetupOverlay tests
See inline commentary. I don't really have any energy left to provide
anything else, other than maybe a demonstration of how this dies in
framework:

diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStackUnbindOnExit.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStackUnbindOnExit.cs
new file mode 100644
index 000000000..c74ce6636
--- /dev/null
+++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneScreenStackUnbindOnExit.cs
@@ -0,0 +1,59 @@
+// 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 NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Screens;
+
+namespace osu.Framework.Tests.Visual.UserInterface
+{
+    public partial class TestSceneScreenStackUnbindOnExit : FrameworkTestScene
+    {
+        [Cached]
+        private ScreenStack screenStack = new ScreenStack();
+
+        [Test]
+        public void TestScreenExitUnbindDoesNotInterruptLoadComplete()
+        {
+            AddStep("set up the scenario", () =>
+            {
+                Child = screenStack;
+                screenStack.Push(new Screen());
+                screenStack.Push(new BrokenScreen());
+            });
+            AddUntilStep("wait to get to target screen", () => screenStack.CurrentScreen, Is.InstanceOf<Screen>);
+        }
+
+        private partial class BrokenSlider : BasicSliderBar<float>
+        {
+            [Resolved]
+            private ScreenStack screenStack { get; set; } = null!;
+
+            protected override void LoadComplete()
+            {
+                // exiting the current screen provokes the behaviour of unbinding all bindables in the screen's subtree
+                screenStack.CurrentScreen.Exit();
+
+                // ...but the following calls should still take correct effect inside `SliderBar`
+                // (namely one consisting of propagating `{Min,Max}Value` into `currentNumberInstantaneous`)
+                // so that it doesn't have its internal invariants violated
+                CurrentNumber.MinValue = -10;
+                CurrentNumber.MaxValue = 10;
+
+                // this notably calls `Scheduler.AddOnce(updateValue)` inside, which will happen *in the imminent future, in the same frame as `LoadComplete()` here.
+                // if the above mutations of `{Min,Max}Value` don't correctly propagate inside the slider bar due to an overly eager unbind, this will cause a crash.
+                base.LoadComplete();
+            }
+        }
+
+        private partial class BrokenScreen : Screen
+        {
+            [BackgroundDependencyLoader]
+            private void load()
+            {
+                InternalChild = new BrokenSlider();
+            }
+        }
+    }
+}

I attempted to address what I perceive to be the root issue here which
is that `ScreenStack` is allowed to arbitrarily unbind bindables under
drawables which are by all means still in the scene graph. The attempt
consisted of scheduling the unbind until after children of the screen
stack, but that caused 150 game-side tests to fail, seemingly on
something relevant to bindable leases, so I give up.
2026-01-23 13:07:42 +01:00

638 lines
25 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.Globalization;
using System.Numerics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Extensions;
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
{
public partial class FormSliderBar<T> : CompositeDrawable, IHasCurrentValue<T>, IFormControl
where T : struct, INumber<T>, IMinMaxValue<T>
{
public Bindable<T> Current
{
get => current.Current;
set
{
current.Current = value;
// the above `Current` set could have disabled the instantaneous bindable too,
// but we still need to copy out `Default` manually,
// so lift that disable for a second and then restore it
currentNumberInstantaneous.Disabled = false;
currentNumberInstantaneous.Default = current.Default;
currentNumberInstantaneous.Disabled = current.Disabled;
}
}
private readonly BindableNumberWithCurrent<T> current = new BindableNumberWithCurrent<T>();
private readonly BindableNumber<T> currentNumberInstantaneous = new BindableNumber<T>();
private readonly InnerSlider slider;
/// <summary>
/// Whether changes to the value should instantaneously transfer to outside bindables.
/// If <see langword="false"/>, the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider commit.
/// </summary>
public bool TransferValueOnCommit { get; set; }
private CompositeDrawable? tabbableContentContainer;
public CompositeDrawable? TabbableContentContainer
{
set
{
tabbableContentContainer = value;
if (textBox.IsNotNull())
textBox.TabbableContentContainer = tabbableContentContainer;
}
}
private LocalisableString caption;
/// <summary>
/// Caption describing this slider bar, displayed on top of the controls.
/// </summary>
public LocalisableString Caption
{
get => caption;
set
{
caption = value;
if (IsLoaded)
captionText.Caption = value;
}
}
/// <summary>
/// Hint text containing an extended description of this slider bar, displayed in a tooltip when hovering the caption.
/// </summary>
public LocalisableString HintText { get; init; }
/// <summary>
/// A custom step value for each key press which actuates a change on this control.
/// </summary>
public float KeyboardStep
{
get => slider.KeyboardStep;
set => slider.KeyboardStep = value;
}
/// <summary>
/// Whether to format the tooltip as a percentage or the actual value.
/// </summary>
public bool DisplayAsPercentage { get; init; }
/// <summary>
/// Whether sound effects should play when adjusting this slider.
/// </summary>
public bool PlaySamplesOnAdjust { get; init; } = true;
/// <summary>
/// The string formatting function to use for the value label.
/// </summary>
public Func<T, LocalisableString> LabelFormat { get; init; }
/// <summary>
/// The string formatting function to use for the slider's tooltip text.
/// If not provided, <see cref="LabelFormat"/> is used.
/// </summary>
public Func<T, LocalisableString> TooltipFormat { get; init; }
private Box background = null!;
private Box flashLayer = null!;
private FormTextBox.InnerTextBox textBox = null!;
private OsuSpriteText valueLabel = null!;
private FormFieldCaption captionText = null!;
private IFocusManager focusManager = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
private readonly Bindable<Language> currentLanguage = new Bindable<Language>();
public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true;
public FormSliderBar()
{
LabelFormat ??= defaultLabelFormat;
TooltipFormat ??= v => LabelFormat(v);
// the reason why this slider is created in constructor rather than in BDL like the rest of drawable hierarchy is as follows:
// `SliderBar<T>` (the base framework class for all sliders) also does its `Current` initialisation in its ctor.
// if that precedent is not followed, it is possible to run into a crippling issue
// when a `FormSliderBar` instance is on a screen and said screen is exited before said instance's `LoadComplete()` is invoked.
// in that case, the screen exit will unbind the `InnerSlider`'s internal bindings & value change callbacks:
// https://github.com/ppy/osu-framework/blob/23ac694fa2c342ce39f563c8a1b975119249d5e9/osu.Framework/Screens/ScreenStack.cs#L353
// the callbacks are supposed to propagate `{Min,Max}Value` from `Current` to its internal `currentNumberInstantaneous` bindable:
// https://github.com/ppy/osu-framework/blob/64624795b0816261dfc5e930e1d9b9ec7e8bb8c5/osu.Framework/Graphics/UserInterface/SliderBar.cs#L62-L63
// thus, the callbacks getting unbound by the screen exit prevents `{Min,Max}Value` from ever correctly propagating, which finally causes a crash at
// https://github.com/ppy/osu-framework/blob/64624795b0816261dfc5e930e1d9b9ec7e8bb8c5/osu.Framework/Graphics/UserInterface/SliderBar.cs#L112 ->
// https://github.com/ppy/osu-framework/blob/64624795b0816261dfc5e930e1d9b9ec7e8bb8c5/osu.Framework/Graphics/UserInterface/SliderBar.cs#L88-L92.
// moving the slider creation & binding to constructor does little to fix the issue other than to make it less likely to be hit.
slider = new InnerSlider
{
Current = currentNumberInstantaneous,
OnCommit = () => current.Value = currentNumberInstantaneous.Value,
TooltipFormat = TooltipFormat,
DisplayAsPercentage = DisplayAsPercentage,
PlaySamplesOnAdjust = PlaySamplesOnAdjust,
ResetToDefault = () =>
{
if (!IsDisabled)
SetDefault();
}
};
current.ValueChanged += e =>
{
currentNumberInstantaneous.Value = e.NewValue;
ValueChanged?.Invoke();
};
current.MinValueChanged += v => currentNumberInstantaneous.MinValue = v;
current.MaxValueChanged += v => currentNumberInstantaneous.MaxValue = v;
current.PrecisionChanged += v => currentNumberInstantaneous.Precision = v;
current.DisabledChanged += disabled =>
{
if (disabled)
{
// revert any changes before disabling to make sure we are in a consistent state.
currentNumberInstantaneous.Value = current.Value;
}
currentNumberInstantaneous.Disabled = disabled;
if (IsLoaded)
updateState();
};
current.CopyTo(currentNumberInstantaneous);
}
[BackgroundDependencyLoader]
private void load(OsuColour colours, OsuGame? game)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
Masking = true;
CornerRadius = 5;
InternalChildren = new Drawable[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
flashLayer = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = Colour4.Transparent,
},
new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Padding = new MarginPadding
{
Vertical = 5,
Left = 9,
Right = 5,
},
Children = new Drawable[]
{
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(0f, 4f),
Width = 0.5f,
Padding = new MarginPadding
{
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 },
},
},
},
},
},
slider.With(s =>
{
s.Anchor = Anchor.CentreRight;
s.Origin = Anchor.CentreRight;
s.RelativeSizeAxes = Axes.X;
s.Width = 0.5f;
})
},
},
};
if (game != null)
currentLanguage.BindTo(game.CurrentLanguage);
}
protected override void LoadComplete()
{
base.LoadComplete();
captionText.Caption = caption;
focusManager = GetContainingFocusManager()!;
textBox.Focused.BindValueChanged(_ => updateState());
textBox.OnCommit += textCommitted;
textBox.Current.BindValueChanged(textChanged);
slider.IsDragging.BindValueChanged(_ => updateState());
slider.Focused.BindValueChanged(_ => updateState());
currentLanguage.BindValueChanged(_ => Schedule(updateValueDisplay));
currentNumberInstantaneous.BindDisabledChanged(_ => updateState());
currentNumberInstantaneous.BindValueChanged(e =>
{
if (!TransferValueOnCommit)
current.Value = e.NewValue;
updateState();
updateValueDisplay();
}, true);
}
private bool updatingFromTextBox;
private void textChanged(ValueChangedEvent<string> change)
{
tryUpdateSliderFromTextBox();
}
private void textCommitted(TextBox t, bool isNew)
{
tryUpdateSliderFromTextBox();
// If the attempted update above failed, restore text box to match the slider.
currentNumberInstantaneous.TriggerChange();
current.Value = currentNumberInstantaneous.Value;
flashLayer.Colour = ColourInfo.GradientVertical(colourProvider.Dark2.Opacity(0), colourProvider.Dark2);
flashLayer.FadeOutFromOne(800, Easing.OutQuint);
}
private void tryUpdateSliderFromTextBox()
{
updatingFromTextBox = true;
try
{
switch (currentNumberInstantaneous)
{
case Bindable<int> bindableInt:
bindableInt.Value = int.Parse(textBox.Current.Value);
break;
case Bindable<double> bindableDouble:
bindableDouble.Value = double.Parse(textBox.Current.Value) / (DisplayAsPercentage ? 100 : 1);
break;
case Bindable<float> bindableFloat:
bindableFloat.Value = float.Parse(textBox.Current.Value) / (DisplayAsPercentage ? 100 : 1);
break;
default:
currentNumberInstantaneous.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;
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return true;
}
protected override void OnHoverLost(HoverLostEvent e)
{
base.OnHoverLost(e);
updateState();
}
protected override bool OnClick(ClickEvent e)
{
if (!Current.Disabled)
focusManager.ChangeFocus(textBox);
return true;
}
private void updateState()
{
bool childHasFocus = slider.Focused.Value || textBox.Focused.Value;
textBox.ReadOnly = currentNumberInstantaneous.Disabled;
textBox.Alpha = textBox.Focused.Value ? 1 : 0;
valueLabel.Alpha = textBox.Focused.Value ? 0 : 1;
captionText.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background1 : colourProvider.Content2;
textBox.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background1 : colourProvider.Content1;
valueLabel.Colour = currentNumberInstantaneous.Disabled ? colourProvider.Background1 : colourProvider.Content1;
BorderThickness = childHasFocus || IsHovered || slider.IsDragging.Value ? 2 : 0;
if (Current.Disabled)
BorderColour = colourProvider.Dark1;
else
BorderColour = childHasFocus ? colourProvider.Highlight1 : colourProvider.Light4;
if (childHasFocus)
background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark3);
else if (IsHovered || slider.IsDragging.Value)
background.Colour = ColourInfo.GradientVertical(colourProvider.Background5, colourProvider.Dark4);
else
background.Colour = colourProvider.Background5;
}
private void updateValueDisplay()
{
if (updatingFromTextBox) return;
if (DisplayAsPercentage)
{
double floatValue = double.CreateTruncating(currentNumberInstantaneous.Value);
// if `DisplayAsPercentage` is true and `T` is not `int`, then `Current` / `currentNumberInstantaneous` are in the range of [0,1].
// in the text box, we want to show the percentage in the range of [0,100], but without the percentage sign.
// the reason we don't want a percentage sign is that `TextBox`es with numerical `TextInputType`s
// have framework-side limitations on which characters they accept and they won't accept a percentage sign.
//
// therefore, the instantaneous value needs to be multiplied by 100 if it's not `int`, so that `ToStandardFormattedString()`,
// which is called *intentionally* without `asPercentage: true` specified as to not emit the percentage sign, spits out the correct number.
//
// additionally note that `ToStandardFormattedString()`, when called with `asPercentage: true` specified, does the *inverse* of this,
// which is that it brings the formatted number *into* the [0,1] range,
// because .NET number formatting *automatically* multiplies the formatted number by 100 when it is told to stringify a number as percentage
// (https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings#the--custom-specifier-3).
// it's all very confusing.
if (currentNumberInstantaneous.Value is not int)
floatValue *= 100;
textBox.Text = floatValue.ToStandardFormattedString(Math.Max(0, OsuSliderBar<T>.MAX_DECIMAL_DIGITS - 2));
}
else
textBox.Text = currentNumberInstantaneous.Value.ToStandardFormattedString(OsuSliderBar<T>.MAX_DECIMAL_DIGITS);
valueLabel.Text = LabelFormat(currentNumberInstantaneous.Value);
}
private LocalisableString defaultLabelFormat(T value) => currentNumberInstantaneous.Value.ToStandardFormattedString(OsuSliderBar<T>.MAX_DECIMAL_DIGITS, DisplayAsPercentage);
public partial class InnerSlider : OsuSliderBar<T>
{
public BindableBool Focused { get; } = new BindableBool();
public BindableBool IsDragging { get; } = new BindableBool();
public Action? ResetToDefault { get; init; }
public Action? OnCommit { get; init; }
public sealed override LocalisableString TooltipText => base.TooltipText;
public required Func<T, LocalisableString> TooltipFormat { get; init; }
private Box leftBox = null!;
private Box rightBox = null!;
private InnerSliderNub nub = null!;
private HoverClickSounds sounds = null!;
public const float NUB_WIDTH = 10;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
Height = 40;
RelativeSizeAxes = Axes.X;
RangePadding = NUB_WIDTH / 2;
Children = new Drawable[]
{
new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 5,
Children = new Drawable[]
{
leftBox = new Box
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
},
rightBox = new Box
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
},
},
},
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Horizontal = RangePadding, },
Child = nub = new InnerSliderNub
{
ResetToDefault = ResetToDefault,
}
},
sounds = new HoverClickSounds()
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindDisabledChanged(_ => updateState(), true);
}
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
leftBox.Width = Math.Clamp(RangePadding + nub.DrawPosition.X, 0, Math.Max(0, DrawWidth)) / DrawWidth;
rightBox.Width = Math.Clamp(DrawWidth - nub.DrawPosition.X - RangePadding, 0, Math.Max(0, DrawWidth)) / DrawWidth;
}
protected override bool OnDragStart(DragStartEvent e)
{
bool dragging = base.OnDragStart(e);
IsDragging.Value = dragging;
updateState();
return dragging;
}
protected override void OnDragEnd(DragEndEvent e)
{
base.OnDragEnd(e);
IsDragging.Value = false;
updateState();
}
protected override bool OnHover(HoverEvent e)
{
updateState();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateState();
base.OnHoverLost(e);
}
protected override void OnFocus(FocusEvent e)
{
updateState();
Focused.Value = true;
base.OnFocus(e);
}
protected override void OnFocusLost(FocusLostEvent e)
{
updateState();
Focused.Value = false;
base.OnFocusLost(e);
}
private void updateState()
{
sounds.Enabled.Value = !Current.Disabled;
rightBox.Colour = colourProvider.Background6;
if (Current.Disabled)
{
leftBox.Colour = colourProvider.Dark3;
nub.Colour = colourProvider.Dark1;
}
else
{
leftBox.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1.Opacity(0.5f) : colourProvider.Highlight1.Opacity(0.3f);
nub.Colour = HasFocus || IsHovered || IsDragged ? colourProvider.Highlight1 : colourProvider.Light4;
}
}
protected override void UpdateValue(float value)
{
nub.MoveToX(value, 200, Easing.OutPow10);
}
protected override bool Commit()
{
bool result = base.Commit();
if (result)
OnCommit?.Invoke();
return result;
}
protected sealed override LocalisableString GetTooltipText(T value) => TooltipFormat(value);
}
public partial class InnerSliderNub : Circle
{
public Action? ResetToDefault { get; set; }
[BackgroundDependencyLoader]
private void load()
{
Width = InnerSlider.NUB_WIDTH;
RelativeSizeAxes = Axes.Y;
RelativePositionAxes = Axes.X;
Origin = Anchor.TopCentre;
}
protected override bool OnClick(ClickEvent e) => true; // must be handled for double click handler to ever fire
protected override bool OnDoubleClick(DoubleClickEvent e)
{
ResetToDefault?.Invoke();
return true;
}
}
public IEnumerable<LocalisableString> FilterTerms => new[] { Caption, HintText };
public event Action? ValueChanged;
public bool IsDefault => Current.IsDefault;
public void SetDefault() => Current.SetDefault();
public bool IsDisabled => Current.Disabled;
}
}