Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Graphics/UserInterfaceV2/FormSliderBar.cs
4397 views
// Copyright (c) ppy Pty Ltd <[email protected]>. 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 osuTK.Graphics;
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 FormControlBackground 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;
            CornerExponent = 2.5f;

            InternalChildren = new Drawable[]
            {
                background = new FormControlBackground(),
                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;

            background.Flash();
        }

        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;

            if (Current.Disabled)
                background.VisualStyle = VisualStyle.Disabled;
            else if (childHasFocus)
                background.VisualStyle = VisualStyle.Focused;
            else if (IsHovered || slider.IsDragging.Value)
                background.VisualStyle = VisualStyle.Hovered;
            else
                background.VisualStyle = VisualStyle.Normal;
        }

        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!;
            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,
                        }
                    },
                };
            }

            protected override void LoadComplete()
            {
                base.LoadComplete();

                Current.BindDisabledChanged(_ => updateState(), true);
                FinishTransforms(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()
            {
                rightBox.Colour = colourProvider.Background5;

                Color4 leftColour = colourProvider.Light4;
                Color4 nubColour;

                if (IsHovered || HasFocus || IsDragged)
                    nubColour = colourProvider.Highlight1;
                else
                    nubColour = colourProvider.Highlight1.Darken(0.1f);

                if (Current.Disabled)
                {
                    nubColour = nubColour.Darken(0.4f);
                    leftColour = leftColour.Darken(0.4f);
                }

                leftBox.FadeColour(leftColour, 250, Easing.OutQuint);
                nub.FadeColour(nubColour, 250, Easing.OutQuint);
            }

            protected override void UpdateValue(float value)
            {
                nub.MoveToX(value, 250, Easing.OutElasticQuarter);
            }

            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()
            {
                CornerExponent = 2.5f;
                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;

        public float MainDrawHeight => DrawHeight;
    }
}