From ebca2e4b4ffc2bee95016e4fac4063dc5bc78405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 17 Jan 2025 13:33:59 +0100 Subject: [PATCH] Implement precise movement tool As mentioned in one of the points in https://github.com/ppy/osu/discussions/31263. --- .../Edit/PreciseMovementPopover.cs | 190 ++++++++++++++++++ .../Edit/TransformToolboxGroup.cs | 25 ++- .../UserInterfaceV2/SliderWithTextBoxInput.cs | 5 + .../Input/Bindings/GlobalActionContainer.cs | 6 +- .../GlobalActionKeyBindingStrings.cs | 5 + 5 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs new file mode 100644 index 0000000000..151ca31ac0 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs @@ -0,0 +1,190 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Input.Events; +using osu.Game.Extensions; +using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class PreciseMovementPopover : OsuPopover + { + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + private readonly Dictionary initialPositions = new Dictionary(); + private RectangleF initialSurroundingQuad; + + private BindableNumber xBindable = null!; + private BindableNumber yBindable = null!; + + private SliderWithTextBoxInput xInput = null!; + private OsuCheckbox relativeCheckbox = null!; + + public PreciseMovementPopover() + { + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + Width = 220, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Children = new Drawable[] + { + xInput = new SliderWithTextBoxInput("X:") + { + Current = xBindable = new BindableNumber + { + Precision = 1, + }, + Instantaneous = true, + TabbableContentContainer = this, + }, + new SliderWithTextBoxInput("Y:") + { + Current = yBindable = new BindableNumber + { + Precision = 1, + }, + Instantaneous = true, + TabbableContentContainer = this, + }, + relativeCheckbox = new OsuCheckbox(false) + { + RelativeSizeAxes = Axes.X, + LabelText = "Relative movement", + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => + { + xInput.TakeFocus(); + xInput.SelectAll(); + }); + } + + protected override void PopIn() + { + base.PopIn(); + editorBeatmap.BeginChange(); + initialPositions.AddRange(editorBeatmap.SelectedHitObjects.Where(ho => ho is not Spinner).Select(ho => new KeyValuePair(ho, ((IHasPosition)ho).Position))); + initialSurroundingQuad = GeometryUtils.GetSurroundingQuad(initialPositions.Keys.Cast()).AABBFloat; + + Debug.Assert(initialPositions.Count > 0); + + if (initialPositions.Count > 1) + { + relativeCheckbox.Current.Value = true; + relativeCheckbox.Current.Disabled = true; + } + + relativeCheckbox.Current.BindValueChanged(_ => relativeChanged(), true); + xBindable.BindValueChanged(_ => applyPosition()); + yBindable.BindValueChanged(_ => applyPosition()); + } + + protected override void PopOut() + { + base.PopOut(); + if (IsLoaded) editorBeatmap.EndChange(); + } + + private void relativeChanged() + { + // reset bindable bounds to something that is guaranteed to be larger than any previous value. + // this prevents crashes that can happen in the middle of changing the bounds, as updating both bound ends at the same is not atomic - + // if the old and new bounds are disjoint, assigning X first can produce a situation where MinValue > MaxValue. + (xBindable.MinValue, xBindable.MaxValue) = (float.MinValue, float.MaxValue); + (yBindable.MinValue, yBindable.MaxValue) = (float.MinValue, float.MaxValue); + + float previousX = xBindable.Value; + float previousY = yBindable.Value; + + if (relativeCheckbox.Current.Value) + { + (xBindable.MinValue, xBindable.MaxValue) = (0 - initialSurroundingQuad.TopLeft.X, OsuPlayfield.BASE_SIZE.X - initialSurroundingQuad.BottomRight.X); + (yBindable.MinValue, yBindable.MaxValue) = (0 - initialSurroundingQuad.TopLeft.Y, OsuPlayfield.BASE_SIZE.Y - initialSurroundingQuad.BottomRight.Y); + + xBindable.Default = yBindable.Default = 0; + + if (initialPositions.Count == 1) + { + var initialPosition = initialPositions.Single().Value; + xBindable.Value = previousX - initialPosition.X; + yBindable.Value = previousY - initialPosition.Y; + } + } + else + { + Debug.Assert(initialPositions.Count == 1); + var initialPosition = initialPositions.Single().Value; + + var quadRelativeToPosition = new RectangleF(initialSurroundingQuad.Location - initialPosition, initialSurroundingQuad.Size); + + (xBindable.MinValue, xBindable.MaxValue) = (0 - quadRelativeToPosition.TopLeft.X, OsuPlayfield.BASE_SIZE.X - quadRelativeToPosition.BottomRight.X); + (yBindable.MinValue, yBindable.MaxValue) = (0 - quadRelativeToPosition.TopLeft.Y, OsuPlayfield.BASE_SIZE.Y - quadRelativeToPosition.BottomRight.Y); + + xBindable.Default = initialPosition.X; + yBindable.Default = initialPosition.Y; + + xBindable.Value = xBindable.Default + previousX; + yBindable.Value = yBindable.Default + previousY; + } + } + + private void applyPosition() + { + editorBeatmap.PerformOnSelection(ho => + { + if (!initialPositions.TryGetValue(ho, out var initialPosition)) + return; + + var pos = new Vector2(xBindable.Value, yBindable.Value); + if (relativeCheckbox.Current.Value) + ((IHasPosition)ho).Position = initialPosition + pos; + else + ((IHasPosition)ho).Position = pos; + }); + } + + public override bool OnPressed(KeyBindingPressEvent e) + { + if (e.Action == GlobalAction.Select && !e.Repeat) + { + this.HidePopover(); + return true; + } + + return base.OnPressed(e); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs index a41412cbe3..440e06598d 100644 --- a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -1,6 +1,7 @@ // 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; @@ -10,6 +11,9 @@ using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -18,9 +22,12 @@ namespace osu.Game.Rulesets.Osu.Edit { public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler { + private readonly BindableList selectedHitObjects = new BindableList(); + private readonly BindableBool canMove = new BindableBool(); private readonly AggregateBindable canRotate = new AggregateBindable((x, y) => x || y); private readonly AggregateBindable canScale = new AggregateBindable((x, y) => x || y); + private EditorToolButton moveButton = null!; private EditorToolButton rotateButton = null!; private EditorToolButton scaleButton = null!; @@ -35,7 +42,7 @@ namespace osu.Game.Rulesets.Osu.Edit } [BackgroundDependencyLoader] - private void load() + private void load(EditorBeatmap editorBeatmap) { Child = new FillFlowContainer { @@ -44,20 +51,27 @@ namespace osu.Game.Rulesets.Osu.Edit Spacing = new Vector2(5), Children = new Drawable[] { + moveButton = new EditorToolButton("Move", + () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, + () => new PreciseMovementPopover()), rotateButton = new EditorToolButton("Rotate", () => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, () => new PreciseRotationPopover(RotationHandler, GridToolbox)), scaleButton = new EditorToolButton("Scale", - () => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt }, + () => new SpriteIcon { Icon = FontAwesome.Solid.ExpandArrowsAlt }, () => new PreciseScalePopover(ScaleHandler, GridToolbox)) } }; + + selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); } protected override void LoadComplete() { base.LoadComplete(); + selectedHitObjects.BindCollectionChanged((_, _) => canMove.Value = selectedHitObjects.Any(ho => ho is not Spinner), true); + canRotate.AddSource(RotationHandler.CanRotateAroundPlayfieldOrigin); canRotate.AddSource(RotationHandler.CanRotateAroundSelectionOrigin); @@ -67,6 +81,7 @@ namespace osu.Game.Rulesets.Osu.Edit // bindings to `Enabled` on the buttons are decoupled on purpose // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set. + canMove.BindValueChanged(move => moveButton.Enabled.Value = move.NewValue, true); canRotate.Result.BindValueChanged(rotate => rotateButton.Enabled.Value = rotate.NewValue, true); canScale.Result.BindValueChanged(scale => scaleButton.Enabled.Value = scale.NewValue, true); } @@ -77,6 +92,12 @@ namespace osu.Game.Rulesets.Osu.Edit switch (e.Action) { + case GlobalAction.EditorToggleMoveControl: + { + moveButton.TriggerClick(); + return true; + } + case GlobalAction.EditorToggleRotateControl: { if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value) diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs index 50d8d763e1..c16a6c612d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs @@ -32,6 +32,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 set => slider.Current = value; } + public CompositeDrawable TabbableContentContainer + { + set => textBox.TabbableContentContainer = value; + } + private bool instantaneous; /// diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 5e509d2035..6c130ff309 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -144,6 +144,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing), new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelDown }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.MouseWheelUp }, GlobalAction.EditorCycleNextBeatSnapDivisor), + new KeyBinding(InputKey.None, GlobalAction.EditorToggleMoveControl), new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), new KeyBinding(new[] { InputKey.Control, InputKey.E }, GlobalAction.EditorToggleScaleControl), new KeyBinding(new[] { InputKey.Control, InputKey.Left }, GlobalAction.EditorSeekToPreviousHitObject), @@ -493,7 +494,10 @@ namespace osu.Game.Input.Bindings EditorSeekToNextBookmark, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))] - AbsoluteScrollSongList + AbsoluteScrollSongList, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))] + EditorToggleMoveControl, } public enum GlobalActionCategory diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 436a2be648..5713df57c9 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -454,6 +454,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list"); + /// + /// "Toggle movement control" + /// + public static LocalisableString EditorToggleMoveControl => new TranslatableString(getKey(@"editor_toggle_move_control"), @"Toggle movement control"); + private static string getKey(string key) => $@"{prefix}:{key}"; } }