From 06accf9dc2db173001182d4985344f646bc04531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sun, 9 Jul 2023 21:00:35 +0200 Subject: [PATCH] Add precise rotation control to osu! editor --- .../Edit/OsuHitObjectComposer.cs | 5 + .../Edit/PreciseRotationPopover.cs | 107 ++++++++++++++++ .../Edit/TransformToolboxGroup.cs | 114 +++++++++++++++++ .../UserInterfaceV2/LabelledTextBox.cs | 1 + .../TernaryButtons/DrawableTernaryButton.cs | 6 +- .../Compose/Components/BlueprintContainer.cs | 2 +- .../Compose/Components/SelectionHandler.cs | 4 +- .../Components/SelectionRotationHandler.cs | 2 +- .../Edit/Timing/SliderWithTextBoxInput.cs | 116 +++++++++++------- 9 files changed, 308 insertions(+), 49 deletions(-) create mode 100644 osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs create mode 100644 osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 0b80750a0227..cff2171cbd79 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -85,6 +85,11 @@ private void load() // we may be entering the screen with a selection already active updateDistanceSnapGrid(); + + RightToolbox.Add(new TransformToolboxGroup + { + RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler + }); } protected override ComposeBlueprintContainer CreateBlueprintContainer() diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs new file mode 100644 index 000000000000..4504a0d7517b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -0,0 +1,107 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Screens.Edit.Timing; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class PreciseRotationPopover : OsuPopover + { + private readonly SelectionRotationHandler rotationHandler; + + public IBindable RotationInfo => rotationInfo; + private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre)); + + private SliderWithTextBoxInput angleInput = null!; + private EditorRadioButtonCollection rotationOrigin = null!; + + public PreciseRotationPopover(SelectionRotationHandler rotationHandler) + { + this.rotationHandler = rotationHandler; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Children = new Drawable[] + { + angleInput = new SliderWithTextBoxInput("Rotation angle (degrees):") + { + Current = new BindableNumber + { + MinValue = -180, + MaxValue = 180, + Precision = 1 + }, + TransferValueOnCommit = false + }, + rotationOrigin = new EditorRadioButtonCollection + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + new RadioButton("Playfield centre", + () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, + () => new SpriteIcon { Icon = FontAwesome.Regular.Square }), + new RadioButton("Selection centre", + () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre }, + () => new SpriteIcon { Icon = FontAwesome.Solid.ObjectGroup }) + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => angleInput.TakeFocus()); + angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue }); + rotationOrigin.Items.First().Select(); + + rotationInfo.BindValueChanged(rotation => + { + rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null); + }); + } + + protected override void PopIn() + { + base.PopIn(); + rotationHandler.Begin(); + } + + protected override void PopOut() + { + base.PopOut(); + + if (IsLoaded) + rotationHandler.Commit(); + } + } + + public enum RotationOrigin + { + PlayfieldCentre, + SelectionCentre + } + + public record PreciseRotationInfo(float Degrees, RotationOrigin Origin); +} diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs new file mode 100644 index 000000000000..db2a06d34eb7 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -0,0 +1,114 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Components.TernaryButtons; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class TransformToolboxGroup : EditorToolboxGroup + { + private readonly Bindable canRotate = new BindableBool(); + private readonly Bindable rotationState = new Bindable(); + + private readonly Bindable scaleState = new Bindable(); + + private DrawableTernaryButtonWithPopover rotateButton = null!; + + public SelectionRotationHandler RotationHandler { get; init; } = null!; + + public TransformToolboxGroup() + : base("transform") + { + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Children = new Drawable[] + { + rotateButton = new DrawableTernaryButtonWithPopover(new TernaryButton(rotationState, "Rotate", () => new SpriteIcon { Icon = FontAwesome.Solid.Undo })) + { + RelativeSizeAxes = Axes.X, + Popover = () => new PreciseRotationPopover(RotationHandler), + }, + new DrawableTernaryButton(new TernaryButton(scaleState, "Scale", () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt })) + { + RelativeSizeAxes = Axes.X, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + canRotate.BindTo(RotationHandler.CanRotate); + canRotate.BindValueChanged(_ => rotateButton.Alpha = canRotate.Value ? 1 : 0, true); + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat) return false; + + if (e.ControlPressed && e.Key == Key.R) + { + rotateButton.TriggerClick(); + return true; + } + + return base.OnKeyDown(e); + } + + private partial class DrawableTernaryButtonWithPopover : DrawableTernaryButton, IHasPopover + { + public Func? Popover { get; init; } + + public DrawableTernaryButtonWithPopover(TernaryButton button) + : base(button) + { + } + + public Popover? GetPopover() + { + var popover = Popover?.Invoke(); + + popover?.State.BindValueChanged(state => + { + if (state.NewValue == Visibility.Hidden) + Button.Bindable.Value = TernaryState.False; + }); + + return popover; + } + + protected override void UpdateSelectionState() + { + base.UpdateSelectionState(); + + if (Button.Bindable.Value == TernaryState.True) + this.ShowPopover(); + } + } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 454be02d0b79..8b9d35e34320 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -35,6 +35,7 @@ public LocalisableString PlaceholderText public string Text { + get => Component.Text; set => Component.Text = value; } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 873551db7732..f2c20e4bf2af 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { - internal partial class DrawableTernaryButton : OsuButton + public partial class DrawableTernaryButton : OsuButton { private Color4 defaultBackgroundColour; private Color4 defaultIconColour; @@ -57,7 +57,7 @@ protected override void LoadComplete() { base.LoadComplete(); - Button.Bindable.BindValueChanged(_ => updateSelectionState(), true); + Button.Bindable.BindValueChanged(_ => UpdateSelectionState(), true); Action = onAction; } @@ -67,7 +67,7 @@ private void onAction() Button.Toggle(); } - private void updateSelectionState() + protected virtual void UpdateSelectionState() { if (!IsLoaded) return; diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 2ee162bf3b28..124cc516f5ac 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -34,7 +34,7 @@ public abstract partial class BlueprintContainer : CompositeDrawable, IKeyBin public Container> SelectionBlueprints { get; private set; } - protected SelectionHandler SelectionHandler { get; private set; } + public SelectionHandler SelectionHandler { get; private set; } private readonly Dictionary> blueprintMap = new Dictionary>(); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 31ad8fa3d780..5e44ded1cc5b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -55,7 +55,7 @@ public abstract partial class SelectionHandler : CompositeDrawable, IKeyBindi [Resolved(CanBeNull = true)] protected IEditorChangeHandler ChangeHandler { get; private set; } - protected SelectionRotationHandler RotationHandler { get; private set; } + public SelectionRotationHandler RotationHandler { get; private set; } protected SelectionHandler() { @@ -87,7 +87,7 @@ public SelectionBox CreateSelectionBox() RotationHandler = RotationHandler, OnScale = HandleScale, OnFlip = HandleFlip, - OnReverse = HandleReverse, + OnReverse = HandleReverse }; /// diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs index 9d6c84d1216a..f6b88d2c8d48 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionRotationHandler.cs @@ -53,7 +53,7 @@ public virtual void Begin() /// /// /// This flow is intended to be used when a rotation operation is made incrementally (such as when dragging a rotation handle or slider). - /// As such, the values of and supplied should be relative to the state of the objects being rotated + /// As such, the values of and supplied should be relative to the state of the objects being rotated /// when was called, rather than instantaneous deltas. /// /// diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs index 1bf0e5299d8b..80516d3915a2 100644 --- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs +++ b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs @@ -19,11 +19,35 @@ public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentV where T : struct, IEquatable, IComparable, IConvertible { private readonly SettingsSlider slider; + private readonly LabelledTextBox textBox; - public SliderWithTextBoxInput(LocalisableString labelText) + /// + /// 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; + } + + public Bindable Current { - LabelledTextBox textBox; + get => slider.Current; + set => slider.Current = value; + } + public void TakeFocus() => GetContainingInputManager().ChangeFocus(textBox); + + private bool transferValueOnCommit; + + public bool TransferValueOnCommit + { + get => transferValueOnCommit; + set => slider.TransferValueOnCommit = transferValueOnCommit = value; + } + + public SliderWithTextBoxInput(LocalisableString labelText) + { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -50,57 +74,65 @@ public SliderWithTextBoxInput(LocalisableString labelText) }, }; - textBox.OnCommit += (t, isNew) => - { - if (!isNew) return; + textBox.OnCommit += textCommitted; + textBox.Current.BindValueChanged(textChanged); - try - { - switch (slider.Current) - { - case Bindable bindableInt: - bindableInt.Value = int.Parse(t.Text); - break; + Current.BindValueChanged(updateTextBoxFromSlider, true); + } - case Bindable bindableDouble: - bindableDouble.Value = double.Parse(t.Text); - break; + private bool updatingFromTextBox; - default: - slider.Current.Parse(t.Text); - break; - } - } - catch - { - // TriggerChange below will restore the previous text value on failure. - } + private void textChanged(ValueChangedEvent change) + { + if (transferValueOnCommit) return; - // 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(); - }; + tryUpdateSliderFromTextBox(); + } - Current.BindValueChanged(_ => - { - decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); - textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); - }, true); + private void textCommitted(TextBox t, bool isNew) + { + if (!isNew) return; + + tryUpdateSliderFromTextBox(); + + // If the attempted update above failed, restore text box to match the slider. + Current.TriggerChange(); } - /// - /// A custom step value for each key press which actuates a change on this control. - /// - public float KeyboardStep + private void tryUpdateSliderFromTextBox() { - get => slider.KeyboardStep; - set => slider.KeyboardStep = value; + updatingFromTextBox = true; + + try + { + switch (slider.Current) + { + case Bindable bindableInt: + bindableInt.Value = int.Parse(textBox.Current.Value); + break; + + case Bindable bindableDouble: + bindableDouble.Value = double.Parse(textBox.Current.Value); + break; + + default: + slider.Current.Parse(textBox.Current.Value); + break; + } + } + catch + { + } + + updatingFromTextBox = false; } - public Bindable Current + private void updateTextBoxFromSlider(ValueChangedEvent _) { - get => slider.Current; - set => slider.Current = value; + if (updatingFromTextBox) return; + + decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); + textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); } } }