diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs index a678582dd6..4012a672ed 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -136,7 +136,7 @@ namespace osu.Game.Tests.Visual.Editing private void velocityPopoverHasSingleValue(double velocity) => AddUntilStep($"velocity popover has {velocity}", () => { var popover = this.ChildrenOfType().SingleOrDefault(); - var slider = popover?.ChildrenOfType>().Single(); + var slider = popover?.ChildrenOfType>().Single(); return slider?.Current.Value == velocity; }); @@ -144,7 +144,7 @@ namespace osu.Game.Tests.Visual.Editing private void velocityPopoverHasIndeterminateValue() => AddUntilStep("velocity popover has indeterminate value", () => { var popover = this.ChildrenOfType().SingleOrDefault(); - var slider = popover?.ChildrenOfType>().Single(); + var slider = popover?.ChildrenOfType>().Single(); return slider != null && slider.Current.Value == null; }); @@ -158,7 +158,7 @@ namespace osu.Game.Tests.Visual.Editing private void setVelocityViaPopover(double velocity) => AddStep($"set {velocity} via popover", () => { var popover = this.ChildrenOfType().Single(); - var slider = popover.ChildrenOfType>().Single(); + var slider = popover.ChildrenOfType>().Single(); slider.Current.Value = velocity; }); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index ae1884d295..76a8243e05 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -1,9 +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.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -49,9 +51,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public class DifficultyEditPopover : OsuPopover { private readonly HitObject hitObject; - private readonly DifficultyControlPoint point; - private SliderWithTextBoxInput sliderVelocitySlider; + private IndeterminateSliderWithTextBoxInput sliderVelocitySlider; [Resolved(canBeNull: true)] private EditorBeatmap beatmap { get; set; } @@ -59,7 +60,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public DifficultyEditPopover(HitObject hitObject) { this.hitObject = hitObject; - point = hitObject.DifficultyControlPoint; } [BackgroundDependencyLoader] @@ -74,9 +74,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline AutoSizeAxes = Axes.Y, Children = new Drawable[] { - sliderVelocitySlider = new SliderWithTextBoxInput("Velocity") + sliderVelocitySlider = new IndeterminateSliderWithTextBoxInput("Velocity", new DifficultyControlPoint().SliderVelocityBindable) { - Current = new DifficultyControlPoint().SliderVelocityBindable, KeyboardStep = 0.1f }, new OsuTextFlowContainer @@ -89,17 +88,37 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } }; - var selectedPointBindable = point.SliderVelocityBindable; + // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. + // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. + var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); + var relevantControlPoints = relevantObjects.Select(h => h.DifficultyControlPoint).ToArray(); - // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). - // generally that level of precision could only be set by externally editing the .osu file, so at the point - // a user is looking to update this within the editor it should be safe to obliterate this additional precision. - double expectedPrecision = new DifficultyControlPoint().SliderVelocityBindable.Precision; - if (selectedPointBindable.Precision < expectedPrecision) - selectedPointBindable.Precision = expectedPrecision; + // even if there are multiple objects selected, we can still display a value if they all have the same value. + var selectedPointBindable = relevantControlPoints.Select(point => point.SliderVelocity).Distinct().Count() == 1 ? relevantControlPoints.First().SliderVelocityBindable : null; - sliderVelocitySlider.Current = selectedPointBindable; - sliderVelocitySlider.Current.BindValueChanged(_ => beatmap?.Update(hitObject)); + if (selectedPointBindable != null) + { + // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). + // generally that level of precision could only be set by externally editing the .osu file, so at the point + // a user is looking to update this within the editor it should be safe to obliterate this additional precision. + sliderVelocitySlider.Current.Value = selectedPointBindable.Value; + } + + sliderVelocitySlider.Current.BindValueChanged(val => + { + if (val.NewValue == null) + return; + + beatmap.BeginChange(); + + foreach (var h in relevantObjects) + { + h.DifficultyControlPoint.SliderVelocity = val.NewValue.Value; + beatmap.Update(h); + } + + beatmap.EndChange(); + }); } } } diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs new file mode 100644 index 0000000000..a5c682e56a --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -0,0 +1,115 @@ +// 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.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; + +namespace osu.Game.Screens.Edit.Timing +{ + /// + /// Analogous to , but supports scenarios + /// where multiple objects with multiple different property values are selected + /// by providing an "indeterminate state". + /// + public class IndeterminateSliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue + where T : struct, IEquatable, IComparable, IConvertible + { + /// + /// 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; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly SettingsSlider slider; + private readonly LabelledTextBox textbox; + + /// + /// Creates an . + /// + /// The label text for the slider and text box. + /// + /// Bindable to use for the slider until a non-null value is set for . + /// In particular, it can be used to control min/max bounds and precision in the case of s. + /// + public IndeterminateSliderWithTextBoxInput(LocalisableString labelText, Bindable indeterminateValue) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + textbox = new LabelledTextBox + { + Label = labelText, + PlaceholderText = "(multiple)" + }, + slider = new SettingsSlider + { + TransferValueOnCommit = true, + RelativeSizeAxes = Axes.X, + Current = indeterminateValue + } + } + }, + }; + + textbox.OnCommit += (t, isNew) => + { + if (!isNew) return; + + try + { + slider.Current.Parse(t.Text); + } + catch + { + // TriggerChange below will restore the previous text value on failure. + } + + // This is run regardless of parsing success as the parsed number may not actually trigger a change + // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state. + Current.TriggerChange(); + }; + slider.Current.BindValueChanged(val => Current.Value = val.NewValue); + + Current.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (Current.Value is T nonNullValue) + { + slider.Current.Value = nonNullValue; + textbox.Text = slider.Current.ToString(); + } + else + { + textbox.Text = null; + } + } + } +}