Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add precise movement tool to osu! editor #31547

Merged
merged 2 commits into from
Jan 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions osu.Game.Rulesets.Osu/Edit/PreciseMovementPopover.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// 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.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<HitObject, Vector2> initialPositions = new Dictionary<HitObject, Vector2>();
private RectangleF initialSurroundingQuad;

private BindableNumber<float> xBindable = null!;
private BindableNumber<float> yBindable = null!;

private SliderWithTextBoxInput<float> 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<float>("X:")
{
Current = xBindable = new BindableNumber<float>
{
Precision = 1,
},
Instantaneous = true,
TabbableContentContainer = this,
},
new SliderWithTextBoxInput<float>("Y:")
{
Current = yBindable = new BindableNumber<float>
{
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());
}

protected override void PopIn()
{
base.PopIn();
editorBeatmap.BeginChange();
initialPositions.AddRange(editorBeatmap.SelectedHitObjects.Where(ho => ho is not Spinner).Select(ho => new KeyValuePair<HitObject, Vector2>(ho, ((IHasPosition)ho).Position)));
initialSurroundingQuad = GeometryUtils.GetSurroundingQuad(initialPositions.Keys.Cast<IHasPosition>()).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<GlobalAction> e)
{
if (e.Action == GlobalAction.Select && !e.Repeat)
{
this.HidePopover();
return true;
}

return base.OnPressed(e);
}
}
}
6 changes: 1 addition & 5 deletions osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,7 @@ protected override void LoadComplete()
{
base.LoadComplete();

ScheduleAfterChildren(() =>
{
angleInput.TakeFocus();
angleInput.SelectAll();
});
ScheduleAfterChildren(() => angleInput.TakeFocus());
angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue });

rotationHandler.CanRotateAroundSelectionOrigin.BindValueChanged(e =>
Expand Down
6 changes: 1 addition & 5 deletions osu.Game.Rulesets.Osu/Edit/PreciseScalePopover.cs
Original file line number Diff line number Diff line change
Expand Up @@ -139,11 +139,7 @@ protected override void LoadComplete()
{
base.LoadComplete();

ScheduleAfterChildren(() =>
{
scaleInput.TakeFocus();
scaleInput.SelectAll();
});
ScheduleAfterChildren(() => scaleInput.TakeFocus());
scaleInput.Current.BindValueChanged(scale => scaleInfo.Value = scaleInfo.Value with { Scale = scale.NewValue });

xCheckBox.Current.BindValueChanged(_ =>
Expand Down
25 changes: 23 additions & 2 deletions osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
Expand All @@ -10,6 +11,9 @@
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;
Expand All @@ -18,9 +22,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler<GlobalAction>
{
private readonly BindableList<HitObject> selectedHitObjects = new BindableList<HitObject>();
private readonly BindableBool canMove = new BindableBool();
private readonly AggregateBindable<bool> canRotate = new AggregateBindable<bool>((x, y) => x || y);
private readonly AggregateBindable<bool> canScale = new AggregateBindable<bool>((x, y) => x || y);

private EditorToolButton moveButton = null!;
private EditorToolButton rotateButton = null!;
private EditorToolButton scaleButton = null!;

Expand All @@ -35,7 +42,7 @@ public TransformToolboxGroup()
}

[BackgroundDependencyLoader]
private void load()
private void load(EditorBeatmap editorBeatmap)
{
Child = new FillFlowContainer
{
Expand All @@ -44,20 +51,27 @@ private void load()
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);

Expand All @@ -67,6 +81,7 @@ protected override void LoadComplete()

// 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);
}
Expand All @@ -77,6 +92,12 @@ public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)

switch (e.Action)
{
case GlobalAction.EditorToggleMoveControl:
{
moveButton.TriggerClick();
return true;
}

case GlobalAction.EditorToggleRotateControl:
{
if (!RotationHandler.OperationInProgress.Value || rotateButton.Selected.Value)
Expand Down
8 changes: 6 additions & 2 deletions osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ public Bindable<T> Current
set => slider.Current = value;
}

public CompositeDrawable TabbableContentContainer
{
set => textBox.TabbableContentContainer = value;
}

private bool instantaneous;

/// <summary>
Expand Down Expand Up @@ -69,6 +74,7 @@ public SliderWithTextBoxInput(LocalisableString labelText)
textBox = new LabelledTextBox
{
Label = labelText,
SelectAllOnFocus = true,
},
slider = new SettingsSlider<T>
{
Expand All @@ -87,8 +93,6 @@ public SliderWithTextBoxInput(LocalisableString labelText)

public bool TakeFocus() => GetContainingFocusManager()?.ChangeFocus(textBox) == true;

public bool SelectAll() => textBox.SelectAll();

private bool updatingFromTextBox;

private void textChanged(ValueChangedEvent<string> change)
Expand Down
6 changes: 5 additions & 1 deletion osu.Game/Input/Bindings/GlobalActionContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ public static IEnumerable<GlobalAction> GetGlobalActionsFor(GlobalActionCategory
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),
Expand Down Expand Up @@ -493,7 +494,10 @@ public enum GlobalAction
EditorSeekToNextBookmark,

[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.AbsoluteScrollSongList))]
AbsoluteScrollSongList
AbsoluteScrollSongList,

[LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleMoveControl))]
EditorToggleMoveControl,
}

public enum GlobalActionCategory
Expand Down
5 changes: 5 additions & 0 deletions osu.Game/Localisation/GlobalActionKeyBindingStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,11 @@ public static class GlobalActionKeyBindingStrings
/// </summary>
public static LocalisableString AbsoluteScrollSongList => new TranslatableString(getKey(@"absolute_scroll_song_list"), @"Absolute scroll song list");

/// <summary>
/// "Toggle movement control"
/// </summary>
public static LocalisableString EditorToggleMoveControl => new TranslatableString(getKey(@"editor_toggle_move_control"), @"Toggle movement control");

private static string getKey(string key) => $@"{prefix}:{key}";
}
}
Loading