diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
index cea2adc6e2..c36b535bfa 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
@@ -3,7 +3,6 @@
using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
@@ -25,15 +24,6 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSelectionHandler : EditorSelectionHandler
{
- [Resolved(CanBeNull = true)]
- private IDistanceSnapProvider? snapProvider { get; set; }
-
- ///
- /// During a transform, the initial path types of a single selected slider are stored so they
- /// can be maintained throughout the operation.
- ///
- private List? referencePathTypes;
-
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
@@ -46,12 +36,6 @@ namespace osu.Game.Rulesets.Osu.Edit
SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider);
}
- protected override void OnOperationEnded()
- {
- base.OnOperationEnded();
- referencePathTypes = null;
- }
-
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed)
@@ -135,96 +119,9 @@ namespace osu.Game.Rulesets.Osu.Edit
return didFlip;
}
- public override bool HandleScale(Vector2 scale, Anchor reference)
- {
- adjustScaleFromAnchor(ref scale, reference);
-
- var hitObjects = selectedMovableObjects;
-
- // for the time being, allow resizing of slider paths only if the slider is
- // the only hit object selected. with a group selection, it's likely the user
- // is not looking to change the duration of the slider but expand the whole pattern.
- if (hitObjects.Length == 1 && hitObjects.First() is Slider slider)
- scaleSlider(slider, scale);
- else
- scaleHitObjects(hitObjects, reference, scale);
-
- moveSelectionInBounds();
- return true;
- }
-
- private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference)
- {
- // cancel out scale in axes we don't care about (based on which drag handle was used).
- if ((reference & Anchor.x1) > 0) scale.X = 0;
- if ((reference & Anchor.y1) > 0) scale.Y = 0;
-
- // reverse the scale direction if dragging from top or left.
- if ((reference & Anchor.x0) > 0) scale.X = -scale.X;
- if ((reference & Anchor.y0) > 0) scale.Y = -scale.Y;
- }
-
public override SelectionRotationHandler CreateRotationHandler() => new OsuSelectionRotationHandler();
- private void scaleSlider(Slider slider, Vector2 scale)
- {
- referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type).ToList();
-
- Quad sliderQuad = GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position));
-
- // Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0.
- scale = Vector2.ComponentMax(new Vector2(Precision.FLOAT_EPSILON), sliderQuad.Size + scale) - sliderQuad.Size;
-
- Vector2 pathRelativeDeltaScale = new Vector2(
- sliderQuad.Width == 0 ? 0 : 1 + scale.X / sliderQuad.Width,
- sliderQuad.Height == 0 ? 0 : 1 + scale.Y / sliderQuad.Height);
-
- Queue oldControlPoints = new Queue();
-
- foreach (var point in slider.Path.ControlPoints)
- {
- oldControlPoints.Enqueue(point.Position);
- point.Position *= pathRelativeDeltaScale;
- }
-
- // Maintain the path types in case they were defaulted to bezier at some point during scaling
- for (int i = 0; i < slider.Path.ControlPoints.Count; ++i)
- slider.Path.ControlPoints[i].Type = referencePathTypes[i];
-
- // Snap the slider's length to the current beat divisor
- // to calculate the final resulting duration / bounding box before the final checks.
- slider.SnapTo(snapProvider);
-
- //if sliderhead or sliderend end up outside playfield, revert scaling.
- Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider });
- (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
-
- if (xInBounds && yInBounds && slider.Path.HasValidLength)
- return;
-
- foreach (var point in slider.Path.ControlPoints)
- point.Position = oldControlPoints.Dequeue();
-
- // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap.
- slider.SnapTo(snapProvider);
- }
-
- private void scaleHitObjects(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale)
- {
- scale = getClampedScale(hitObjects, reference, scale);
- Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects);
-
- foreach (var h in hitObjects)
- h.Position = GeometryUtils.GetScaledPosition(reference, scale, selectionQuad, h.Position);
- }
-
- private (bool X, bool Y) isQuadInBounds(Quad quad)
- {
- bool xInBounds = (quad.TopLeft.X >= 0) && (quad.BottomRight.X <= DrawWidth);
- bool yInBounds = (quad.TopLeft.Y >= 0) && (quad.BottomRight.Y <= DrawHeight);
-
- return (xInBounds, yInBounds);
- }
+ public override SelectionScaleHandler CreateScaleHandler() => new OsuSelectionScaleHandler();
private void moveSelectionInBounds()
{
@@ -248,43 +145,6 @@ namespace osu.Game.Rulesets.Osu.Edit
h.Position += delta;
}
- ///
- /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip.
- ///
- /// The hitobjects to be scaled
- /// The anchor from which the scale operation is performed
- /// The scale to be clamped
- /// The clamped scale vector
- private Vector2 getClampedScale(OsuHitObject[] hitObjects, Anchor reference, Vector2 scale)
- {
- float xOffset = ((reference & Anchor.x0) > 0) ? -scale.X : 0;
- float yOffset = ((reference & Anchor.y0) > 0) ? -scale.Y : 0;
-
- Quad selectionQuad = GeometryUtils.GetSurroundingQuad(hitObjects);
-
- //todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
- Quad scaledQuad = new Quad(selectionQuad.TopLeft.X + xOffset, selectionQuad.TopLeft.Y + yOffset, selectionQuad.Width + scale.X, selectionQuad.Height + scale.Y);
-
- //max Size -> playfield bounds
- if (scaledQuad.TopLeft.X < 0)
- scale.X += scaledQuad.TopLeft.X;
- if (scaledQuad.TopLeft.Y < 0)
- scale.Y += scaledQuad.TopLeft.Y;
-
- if (scaledQuad.BottomRight.X > DrawWidth)
- scale.X -= scaledQuad.BottomRight.X - DrawWidth;
- if (scaledQuad.BottomRight.Y > DrawHeight)
- scale.Y -= scaledQuad.BottomRight.Y - DrawHeight;
-
- //min Size -> almost 0. Less than 0 causes the quad to flip, exactly 0 causes scaling to get stuck at minimum scale.
- Vector2 scaledSize = selectionQuad.Size + scale;
- Vector2 minSize = new Vector2(Precision.FLOAT_EPSILON);
-
- scale = Vector2.ComponentMax(minSize, scaledSize) - selectionQuad.Size;
-
- return scale;
- }
-
///
/// All osu! hitobjects which can be moved/rotated/scaled.
///
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs
new file mode 100644
index 0000000000..8068c73131
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionScaleHandler.cs
@@ -0,0 +1,205 @@
+// 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 System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics.Primitives;
+using osu.Game.Rulesets.Edit;
+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.Screens.Edit.Compose.Components;
+using osu.Game.Utils;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Edit
+{
+ public partial class OsuSelectionScaleHandler : SelectionScaleHandler
+ {
+ [Resolved]
+ private IEditorChangeHandler? changeHandler { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private IDistanceSnapProvider? snapProvider { get; set; }
+
+ private BindableList selectedItems { get; } = new BindableList();
+
+ [BackgroundDependencyLoader]
+ private void load(EditorBeatmap editorBeatmap)
+ {
+ selectedItems.BindTo(editorBeatmap.SelectedHitObjects);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ selectedItems.CollectionChanged += (_, __) => updateState();
+ updateState();
+ }
+
+ private void updateState()
+ {
+ var quad = GeometryUtils.GetSurroundingQuad(selectedMovableObjects);
+ CanScale.Value = quad.Width > 0 || quad.Height > 0;
+ }
+
+ private OsuHitObject[]? objectsInScale;
+
+ private Vector2? defaultOrigin;
+ private Dictionary? originalPositions;
+ private Dictionary? originalPathControlPointPositions;
+ private Dictionary? originalPathControlPointTypes;
+
+ public override void Begin()
+ {
+ if (objectsInScale != null)
+ throw new InvalidOperationException($"Cannot {nameof(Begin)} a scale operation while another is in progress!");
+
+ changeHandler?.BeginChange();
+
+ objectsInScale = selectedMovableObjects.ToArray();
+ OriginalSurroundingQuad = objectsInScale.Length == 1 && objectsInScale.First() is Slider slider
+ ? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position))
+ : GeometryUtils.GetSurroundingQuad(objectsInScale);
+ defaultOrigin = OriginalSurroundingQuad.Value.Centre;
+ originalPositions = objectsInScale.ToDictionary(obj => obj, obj => obj.Position);
+ originalPathControlPointPositions = objectsInScale.OfType().ToDictionary(
+ obj => obj,
+ obj => obj.Path.ControlPoints.Select(point => point.Position).ToArray());
+ originalPathControlPointTypes = objectsInScale.OfType().ToDictionary(
+ obj => obj,
+ obj => obj.Path.ControlPoints.Select(p => p.Type).ToArray());
+ }
+
+ public override void Update(Vector2 scale, Vector2? origin = null)
+ {
+ if (objectsInScale == null)
+ throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");
+
+ Debug.Assert(originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null && originalPathControlPointTypes != null && OriginalSurroundingQuad != null);
+
+ Vector2 actualOrigin = origin ?? defaultOrigin.Value;
+
+ // for the time being, allow resizing of slider paths only if the slider is
+ // the only hit object selected. with a group selection, it's likely the user
+ // is not looking to change the duration of the slider but expand the whole pattern.
+ if (objectsInScale.Length == 1 && objectsInScale.First() is Slider slider)
+ scaleSlider(slider, scale, originalPathControlPointPositions[slider], originalPathControlPointTypes[slider]);
+ else
+ {
+ scale = getClampedScale(OriginalSurroundingQuad.Value, actualOrigin, scale);
+
+ foreach (var ho in objectsInScale)
+ {
+ ho.Position = GeometryUtils.GetScaledPositionMultiply(scale, actualOrigin, originalPositions[ho]);
+ }
+ }
+
+ moveSelectionInBounds();
+ }
+
+ public override void Commit()
+ {
+ if (objectsInScale == null)
+ throw new InvalidOperationException($"Cannot {nameof(Commit)} a rotate operation without calling {nameof(Begin)} first!");
+
+ changeHandler?.EndChange();
+
+ objectsInScale = null;
+ OriginalSurroundingQuad = null;
+ originalPositions = null;
+ originalPathControlPointPositions = null;
+ originalPathControlPointTypes = null;
+ defaultOrigin = null;
+ }
+
+ private IEnumerable selectedMovableObjects => selectedItems.Cast()
+ .Where(h => h is not Spinner);
+
+ private void scaleSlider(Slider slider, Vector2 scale, Vector2[] originalPathPositions, PathType?[] originalPathTypes)
+ {
+ // Maintain the path types in case they were defaulted to bezier at some point during scaling
+ for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
+ {
+ slider.Path.ControlPoints[i].Position = originalPathPositions[i] * scale;
+ slider.Path.ControlPoints[i].Type = originalPathTypes[i];
+ }
+
+ // Snap the slider's length to the current beat divisor
+ // to calculate the final resulting duration / bounding box before the final checks.
+ slider.SnapTo(snapProvider);
+
+ //if sliderhead or sliderend end up outside playfield, revert scaling.
+ Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider });
+ (bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
+
+ if (xInBounds && yInBounds && slider.Path.HasValidLength)
+ return;
+
+ for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
+ slider.Path.ControlPoints[i].Position = originalPathPositions[i];
+
+ // Snap the slider's length again to undo the potentially-invalid length applied by the previous snap.
+ slider.SnapTo(snapProvider);
+ }
+
+ private (bool X, bool Y) isQuadInBounds(Quad quad)
+ {
+ bool xInBounds = (quad.TopLeft.X >= 0) && (quad.BottomRight.X <= OsuPlayfield.BASE_SIZE.X);
+ bool yInBounds = (quad.TopLeft.Y >= 0) && (quad.BottomRight.Y <= OsuPlayfield.BASE_SIZE.Y);
+
+ return (xInBounds, yInBounds);
+ }
+
+ ///
+ /// Clamp scale for multi-object-scaling where selection does not exceed playfield bounds or flip.
+ ///
+ /// The quad surrounding the hitobjects
+ /// The origin from which the scale operation is performed
+ /// The scale to be clamped
+ /// The clamped scale vector
+ private Vector2 getClampedScale(Quad selectionQuad, Vector2 origin, Vector2 scale)
+ {
+ //todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
+
+ var tl1 = Vector2.Divide(-origin, selectionQuad.TopLeft - origin);
+ var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - origin, selectionQuad.TopLeft - origin);
+ var br1 = Vector2.Divide(-origin, selectionQuad.BottomRight - origin);
+ var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - origin, selectionQuad.BottomRight - origin);
+
+ scale.X = selectionQuad.TopLeft.X - origin.X < 0 ? MathHelper.Clamp(scale.X, tl2.X, tl1.X) : MathHelper.Clamp(scale.X, tl1.X, tl2.X);
+ scale.Y = selectionQuad.TopLeft.Y - origin.Y < 0 ? MathHelper.Clamp(scale.Y, tl2.Y, tl1.Y) : MathHelper.Clamp(scale.Y, tl1.Y, tl2.Y);
+ scale.X = selectionQuad.BottomRight.X - origin.X < 0 ? MathHelper.Clamp(scale.X, br2.X, br1.X) : MathHelper.Clamp(scale.X, br1.X, br2.X);
+ scale.Y = selectionQuad.BottomRight.Y - origin.Y < 0 ? MathHelper.Clamp(scale.Y, br2.Y, br1.Y) : MathHelper.Clamp(scale.Y, br1.Y, br2.Y);
+
+ return scale;
+ }
+
+ private void moveSelectionInBounds()
+ {
+ Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!);
+
+ Vector2 delta = Vector2.Zero;
+
+ if (quad.TopLeft.X < 0)
+ delta.X -= quad.TopLeft.X;
+ if (quad.TopLeft.Y < 0)
+ delta.Y -= quad.TopLeft.Y;
+
+ if (quad.BottomRight.X > OsuPlayfield.BASE_SIZE.X)
+ delta.X -= quad.BottomRight.X - OsuPlayfield.BASE_SIZE.X;
+ if (quad.BottomRight.Y > OsuPlayfield.BASE_SIZE.Y)
+ delta.Y -= quad.BottomRight.Y - OsuPlayfield.BASE_SIZE.Y;
+
+ foreach (var h in objectsInScale!)
+ h.Position += delta;
+ }
+ }
+}
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
index 0b16941bc4..e8b3e430eb 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs
@@ -27,7 +27,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
[Resolved]
private SelectionRotationHandler? rotationHandler { get; set; }
- public Func? OnScale;
public Func? OnFlip;
public Func? OnReverse;
@@ -353,7 +352,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
var handle = new SelectionBoxScaleHandle
{
Anchor = anchor,
- HandleScale = (delta, a) => OnScale?.Invoke(delta, a)
};
handle.OperationStarted += operationStarted;
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs
index 7943065c82..56c5585ae7 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBoxScaleHandle.cs
@@ -1,19 +1,22 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osuTK;
+using osuTK.Input;
namespace osu.Game.Screens.Edit.Compose.Components
{
public partial class SelectionBoxScaleHandle : SelectionBoxDragHandle
{
- public Action HandleScale { get; set; }
+ [Resolved]
+ private SelectionBox selectionBox { get; set; } = null!;
+
+ [Resolved]
+ private SelectionScaleHandler? scaleHandler { get; set; }
[BackgroundDependencyLoader]
private void load()
@@ -21,10 +24,93 @@ namespace osu.Game.Screens.Edit.Compose.Components
Size = new Vector2(10);
}
+ protected override bool OnDragStart(DragStartEvent e)
+ {
+ if (e.Button != MouseButton.Left)
+ return false;
+
+ if (scaleHandler == null) return false;
+
+ scaleHandler.Begin();
+ return true;
+ }
+
+ private Vector2 getOriginPosition()
+ {
+ var quad = scaleHandler!.OriginalSurroundingQuad!.Value;
+ Vector2 origin = quad.TopLeft;
+
+ if ((Anchor & Anchor.x0) > 0)
+ origin.X += quad.Width;
+
+ if ((Anchor & Anchor.y0) > 0)
+ origin.Y += quad.Height;
+
+ return origin;
+ }
+
+ private Vector2 rawScale;
+
protected override void OnDrag(DragEvent e)
{
- HandleScale?.Invoke(e.Delta, Anchor);
base.OnDrag(e);
+
+ if (scaleHandler == null) return;
+
+ rawScale = convertDragEventToScaleMultiplier(e);
+
+ applyScale(shouldKeepAspectRatio: e.ShiftPressed);
+ }
+
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ if (IsDragged && (e.Key == Key.ShiftLeft || e.Key == Key.ShiftRight))
+ {
+ applyScale(shouldKeepAspectRatio: true);
+ return true;
+ }
+
+ return base.OnKeyDown(e);
+ }
+
+ protected override void OnKeyUp(KeyUpEvent e)
+ {
+ base.OnKeyUp(e);
+
+ if (IsDragged && (e.Key == Key.ShiftLeft || e.Key == Key.ShiftRight))
+ applyScale(shouldKeepAspectRatio: false);
+ }
+
+ protected override void OnDragEnd(DragEndEvent e)
+ {
+ scaleHandler?.Commit();
+ }
+
+ private Vector2 convertDragEventToScaleMultiplier(DragEvent e)
+ {
+ Vector2 scale = e.MousePosition - e.MouseDownPosition;
+ adjustScaleFromAnchor(ref scale);
+ return Vector2.Divide(scale, scaleHandler!.OriginalSurroundingQuad!.Value.Size) + Vector2.One;
+ }
+
+ private void adjustScaleFromAnchor(ref Vector2 scale)
+ {
+ // cancel out scale in axes we don't care about (based on which drag handle was used).
+ if ((Anchor & Anchor.x1) > 0) scale.X = 1;
+ if ((Anchor & Anchor.y1) > 0) scale.Y = 1;
+
+ // reverse the scale direction if dragging from top or left.
+ if ((Anchor & Anchor.x0) > 0) scale.X = -scale.X;
+ if ((Anchor & Anchor.y0) > 0) scale.Y = -scale.Y;
+ }
+
+ private void applyScale(bool shouldKeepAspectRatio)
+ {
+ var newScale = shouldKeepAspectRatio
+ ? new Vector2(MathF.Max(rawScale.X, rawScale.Y))
+ : rawScale;
+
+ scaleHandler!.Update(newScale, getOriginPosition());
}
}
}
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
index 3c859c65ff..dd6bd43f4d 100644
--- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs
@@ -57,6 +57,8 @@ namespace osu.Game.Screens.Edit.Compose.Components
public SelectionRotationHandler RotationHandler { get; private set; }
+ public SelectionScaleHandler ScaleHandler { get; private set; }
+
protected SelectionHandler()
{
selectedBlueprints = new List>();
@@ -69,6 +71,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
dependencies.CacheAs(RotationHandler = CreateRotationHandler());
+ dependencies.CacheAs(ScaleHandler = CreateScaleHandler());
return dependencies;
}
@@ -78,6 +81,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
AddRangeInternal(new Drawable[]
{
RotationHandler,
+ ScaleHandler,
SelectionBox = CreateSelectionBox(),
});
@@ -93,7 +97,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
OperationStarted = OnOperationBegan,
OperationEnded = OnOperationEnded,
- OnScale = HandleScale,
OnFlip = HandleFlip,
OnReverse = HandleReverse,
};
@@ -157,6 +160,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// Whether any items could be scaled.
public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false;
+ ///
+ /// Creates the handler to use for scale operations.
+ ///
+ public virtual SelectionScaleHandler CreateScaleHandler() => new SelectionScaleHandler();
+
///
/// Handles the selected items being flipped.
///
diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs
new file mode 100644
index 0000000000..b7c8f16a02
--- /dev/null
+++ b/osu.Game/Screens/Edit/Compose/Components/SelectionScaleHandler.cs
@@ -0,0 +1,88 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Primitives;
+using osuTK;
+
+namespace osu.Game.Screens.Edit.Compose.Components
+{
+ ///
+ /// Base handler for editor scale operations.
+ ///
+ public partial class SelectionScaleHandler : Component
+ {
+ ///
+ /// Whether the scale can currently be performed.
+ ///
+ public Bindable CanScale { get; private set; } = new BindableBool();
+
+ public Quad? OriginalSurroundingQuad { get; protected set; }
+
+ ///
+ /// Performs a single, instant, atomic scale operation.
+ ///
+ ///
+ /// This method is intended to be used in atomic contexts (such as when pressing a single button).
+ /// For continuous operations, see the -- flow.
+ ///
+ /// The scale to apply, as multiplier.
+ ///
+ /// The origin point to scale from.
+ /// If the default value is supplied, a sane implementation-defined default will be used.
+ ///
+ public void ScaleSelection(Vector2 scale, Vector2? origin = null)
+ {
+ Begin();
+ Update(scale, origin);
+ Commit();
+ }
+
+ ///
+ /// Begins a continuous scale operation.
+ ///
+ ///
+ /// This flow is intended to be used when a scale operation is made incrementally (such as when dragging a scale handle or slider).
+ /// For instantaneous, atomic operations, use the convenience method.
+ ///
+ public virtual void Begin()
+ {
+ }
+
+ ///
+ /// Updates a continuous scale operation.
+ /// Must be preceded by a call.
+ ///
+ ///
+ ///
+ /// This flow is intended to be used when a scale operation is made incrementally (such as when dragging a scale handle or slider).
+ /// As such, the values of and supplied should be relative to the state of the objects being scaled
+ /// when was called, rather than instantaneous deltas.
+ ///
+ ///
+ /// For instantaneous, atomic operations, use the convenience method.
+ ///
+ ///
+ /// The Scale to apply, as multiplier.
+ ///
+ /// The origin point to scale from.
+ /// If the default value is supplied, a sane implementation-defined default will be used.
+ ///
+ public virtual void Update(Vector2 scale, Vector2? origin = null)
+ {
+ }
+
+ ///
+ /// Ends a continuous scale operation.
+ /// Must be preceded by a call.
+ ///
+ ///
+ /// This flow is intended to be used when a scale operation is made incrementally (such as when dragging a scale handle or slider).
+ /// For instantaneous, atomic operations, use the convenience method.
+ ///
+ public virtual void Commit()
+ {
+ }
+ }
+}
diff --git a/osu.Game/Utils/GeometryUtils.cs b/osu.Game/Utils/GeometryUtils.cs
index 725e93d098..ef362d8223 100644
--- a/osu.Game/Utils/GeometryUtils.cs
+++ b/osu.Game/Utils/GeometryUtils.cs
@@ -79,6 +79,15 @@ namespace osu.Game.Utils
return position;
}
+ ///
+ /// Given a scale multiplier, an origin, and a position,
+ /// will return the scaled position in screen space coordinates.
+ ///
+ public static Vector2 GetScaledPositionMultiply(Vector2 scale, Vector2 origin, Vector2 position)
+ {
+ return origin + (position - origin) * scale;
+ }
+
///
/// Returns a quad surrounding the provided points.
///