2021-04-28 14:14:48 +08:00
// 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 ;
using System.Collections.Generic ;
using System.Linq ;
2021-06-06 13:18:30 +08:00
using System.Runtime.CompilerServices ;
2021-05-14 15:03:22 +08:00
using osu.Framework.Allocation ;
2021-06-06 13:18:30 +08:00
using osu.Framework.Bindables ;
2021-05-13 16:51:57 +08:00
using osu.Framework.Extensions.EnumExtensions ;
2021-04-28 14:14:48 +08:00
using osu.Framework.Graphics ;
2021-05-20 17:24:25 +08:00
using osu.Framework.Graphics.Primitives ;
2021-04-28 14:14:48 +08:00
using osu.Framework.Graphics.UserInterface ;
2021-06-06 13:18:30 +08:00
using osu.Framework.Localisation ;
2021-05-21 14:02:36 +08:00
using osu.Framework.Utils ;
2021-04-29 14:29:25 +08:00
using osu.Game.Extensions ;
2021-04-28 14:14:48 +08:00
using osu.Game.Graphics.UserInterface ;
using osu.Game.Rulesets.Edit ;
using osu.Game.Screens.Edit.Compose.Components ;
using osuTK ;
2021-06-06 13:18:30 +08:00
using Humanizer ;
using osu.Game.Localisation ;
2021-04-28 14:14:48 +08:00
namespace osu.Game.Skinning.Editor
{
2021-05-13 16:06:00 +08:00
public class SkinSelectionHandler : SelectionHandler < ISkinnableDrawable >
2021-04-28 14:14:48 +08:00
{
2021-06-06 13:18:30 +08:00
/// <summary>
/// <p>Keeps track of whether a <see cref="Drawable"/> is using the closest <see cref="Drawable.Anchor">anchor point</see> within its <see cref="Drawable.Parent">parent</see>,
/// or whether the user is overriding its anchor point.</p>
/// <p>Each <see cref="KeyValuePair{TKey,TValue}.Key">key</see> is either a direct cast of an Anchor value, or it is equal to <see cref="hash_of_closest_anchor_menu_item"/>. This is done
/// because the "Closest" menu item is not a valid anchor, so something other than an anchor must be used.</p>
/// <p>Each <see cref="KeyValuePair{TKey,TValue}.Value">value</see> is a <see cref="BindableBool"/>. If the <see cref="Bindable{T}.Value"/> is <see langword="false"/>, the user has
/// overridden the anchor point.
/// If <see langword="true"/>, the closest anchor point is assigned to the Drawable when it is either dragged by the user via <see cref="HandleMovement"/>, or when "Closest" is assigned from
/// the anchor context menu via <see cref="applyAnchor"/>.</p>
/// </summary>
/// <remarks>
/// <p>A <see cref="ConditionalWeakTable{TKey,TValue}">ConditionalWeakTable</see> is preferable to a <see cref="Dictionary{TKey,TValue}">Dictionary</see> because a Dictionary will keep
/// orphaned references to a Drawable forever, unless manually pruned</p>
/// <p><see cref="BindableBool"/> is used as a thin wrapper around <see cref="Boolean">bool</see> because ConditionalWeakTable requires a reference type as both a key and a value.</p>
/// </remarks>
private readonly ConditionalWeakTable < Drawable , BindableBool > isDrawableUsingClosestAnchorLookup = new ConditionalWeakTable < Drawable , BindableBool > ( ) ;
/// <summary>
/// The hash code of the "Closest" menu item in the anchor point context menu.
/// </summary>
/// <remarks>This does not need to change with locale; it need only be constant and distinct from any <see cref="Anchor"/> values.</remarks>
private static readonly int hash_of_closest_anchor_menu_item = @"Closest" . GetHashCode ( ) ;
/// <remarks>Used by <see cref="load"/> to populate <see cref="localisedAnchorMenuItems"/> and <see cref="localisedOriginMenuItems"/>.</remarks>
private static readonly LocalisableString [ ] unbound_anchor_menu_items =
{
SkinEditorStrings . Closest ,
SkinEditorStrings . TopLeft ,
SkinEditorStrings . TopCentre ,
SkinEditorStrings . TopRight ,
SkinEditorStrings . CentreLeft ,
SkinEditorStrings . Centre ,
SkinEditorStrings . CentreRight ,
SkinEditorStrings . BottomLeft ,
SkinEditorStrings . BottomCentre ,
SkinEditorStrings . BottomRight ,
} ;
/// <remarks>Used by <see cref="load"/> to populate <see cref="localisedAnchorMenuItems"/> and <see cref="localisedOriginMenuItems"/>.</remarks>
private static readonly int [ ] anchor_menu_hashes =
new [ ]
{
Anchor . TopLeft ,
Anchor . TopCentre ,
Anchor . TopRight ,
Anchor . CentreLeft ,
Anchor . Centre ,
Anchor . CentreRight ,
Anchor . BottomLeft ,
Anchor . BottomCentre ,
Anchor . BottomRight ,
}
. Cast < int > ( )
. Prepend ( hash_of_closest_anchor_menu_item )
. ToArray ( ) ;
private Dictionary < int , ILocalisedBindableString > localisedAnchorMenuItems ;
private Dictionary < int , ILocalisedBindableString > localisedOriginMenuItems ;
private ILocalisedBindableString localisedAnchor ;
private ILocalisedBindableString localisedOrigin ;
[BackgroundDependencyLoader]
private void load ( LocalisationManager localisation )
{
localisedAnchor = localisation . GetLocalisedString ( SkinEditorStrings . Anchor ) ;
localisedOrigin = localisation . GetLocalisedString ( SkinEditorStrings . Origin ) ;
var boundAnchorMenuItems = unbound_anchor_menu_items . Select ( localisation . GetLocalisedString ) . ToArray ( ) ;
var anchorPairs = anchor_menu_hashes . Zip ( boundAnchorMenuItems , ( k , v ) = > new KeyValuePair < int , ILocalisedBindableString > ( k , v ) ) . ToArray ( ) ;
localisedAnchorMenuItems = new Dictionary < int , ILocalisedBindableString > ( anchorPairs ) ;
var originPairs = anchorPairs . Where ( pair = > pair . Key ! = hash_of_closest_anchor_menu_item ) ;
localisedOriginMenuItems = new Dictionary < int , ILocalisedBindableString > ( originPairs ) ;
}
private Anchor getClosestAnchorForDrawable ( Drawable drawable )
{
var parent = drawable . Parent ;
if ( parent = = null )
return drawable . Anchor ;
// If there is a better way to get this information, let me know. Was taken from LogoTrackingContainer.ComputeLogoTrackingPosition
// I tried a lot of different things, such as just using Position / ChildSize, but none of them worked properly.
var screenPosition = getOriginPositionFromQuad ( drawable . ScreenSpaceDrawQuad , drawable . Origin ) ;
var absolutePosition = parent . ToLocalSpace ( screenPosition ) ;
var factor = parent . RelativeToAbsoluteFactor ;
var result = default ( Anchor ) ;
static Anchor getTieredComponent ( float component , Anchor tier0 , Anchor tier1 , Anchor tier2 ) = >
component > = 2 / 3f
? tier2
: component > = 1 / 3f
? tier1
: tier0 ;
result | = getTieredComponent ( absolutePosition . X / factor . X , Anchor . x0 , Anchor . x1 , Anchor . x2 ) ;
result | = getTieredComponent ( absolutePosition . Y / factor . Y , Anchor . y0 , Anchor . y1 , Anchor . y2 ) ;
return result ;
}
private Vector2 getOriginPositionFromQuad ( in Quad quad , Anchor origin )
{
var result = quad . TopLeft ;
if ( origin . HasFlagFast ( Anchor . x2 ) )
result . X + = quad . Width ;
else if ( origin . HasFlagFast ( Anchor . x1 ) )
result . X + = quad . Width / 2f ;
if ( origin . HasFlagFast ( Anchor . y2 ) )
result . Y + = quad . Height ;
else if ( origin . HasFlagFast ( Anchor . y1 ) )
result . Y + = quad . Height / 2f ;
return result ;
}
/// <remarks>Defaults to <see langword="true"/>, meaning anchors are closest by default.</remarks>
private BindableBool isDrawableUsingClosestAnchor ( Drawable drawable ) = > isDrawableUsingClosestAnchorLookup . GetValue ( drawable , _ = > new BindableBool ( true ) ) ;
// There may be a more generalised form of this somewhere in the codebase. If so, use that.
private static string getSentenceCaseLocalisedString ( ILocalisedBindableString ls ) = > ls . Value . Transform ( To . SentenceCase ) ;
2021-05-14 15:03:22 +08:00
[Resolved]
private SkinEditor skinEditor { get ; set ; }
2021-05-03 14:15:00 +08:00
public override bool HandleRotation ( float angle )
{
2021-05-20 17:21:16 +08:00
if ( SelectedBlueprints . Count = = 1 )
{
// for single items, rotate around the origin rather than the selection centre.
( ( Drawable ) SelectedBlueprints . First ( ) . Item ) . Rotation + = angle ;
}
else
{
2021-05-20 17:24:25 +08:00
var selectionQuad = getSelectionQuad ( ) ;
2021-05-20 17:21:16 +08:00
foreach ( var b in SelectedBlueprints )
{
var drawableItem = ( Drawable ) b . Item ;
2021-05-22 20:17:58 +08:00
var rotatedPosition = RotatePointAroundOrigin ( b . ScreenSpaceSelectionPoint , selectionQuad . Centre , angle ) ;
2021-05-20 17:35:13 +08:00
updateDrawablePosition ( drawableItem , rotatedPosition ) ;
2021-05-22 20:17:58 +08:00
2021-05-20 17:21:16 +08:00
drawableItem . Rotation + = angle ;
}
}
2021-05-03 14:15:00 +08:00
2021-05-20 17:21:16 +08:00
// this isn't always the case but let's be lenient for now.
return true ;
2021-05-03 14:15:00 +08:00
}
public override bool HandleScale ( Vector2 scale , Anchor anchor )
{
2021-05-20 00:47:31 +08:00
// convert scale to screen space
scale = ToScreenSpace ( scale ) - ToScreenSpace ( Vector2 . Zero ) ;
2021-05-03 14:15:00 +08:00
adjustScaleFromAnchor ( ref scale , anchor ) ;
2021-05-21 14:02:36 +08:00
// the selection quad is always upright, so use an AABB rect to make mutating the values easier.
2021-05-22 19:52:28 +08:00
var selectionRect = getSelectionQuad ( ) . AABBFloat ;
2021-05-19 20:46:41 +08:00
2021-05-25 03:36:42 +08:00
// If the selection has no area we cannot scale it
2021-05-26 14:20:47 +08:00
if ( selectionRect . Area = = 0 )
2021-05-25 03:36:42 +08:00
return false ;
2021-05-21 14:02:36 +08:00
// copy to mutate, as we will need to compare to the original later on.
var adjustedRect = selectionRect ;
2021-05-19 20:46:41 +08:00
2021-05-21 14:02:36 +08:00
// first, remove any scale axis we are not interested in.
if ( anchor . HasFlagFast ( Anchor . x1 ) ) scale . X = 0 ;
if ( anchor . HasFlagFast ( Anchor . y1 ) ) scale . Y = 0 ;
2021-05-19 20:46:41 +08:00
2021-05-21 14:02:36 +08:00
bool shouldAspectLock =
// for now aspect lock scale adjustments that occur at corners..
( ! anchor . HasFlagFast ( Anchor . x1 ) & & ! anchor . HasFlagFast ( Anchor . y1 ) )
// ..or if any of the selection have been rotated.
// this is to avoid requiring skew logic (which would likely not be the user's expected transform anyway).
2021-05-21 14:06:53 +08:00
| | SelectedBlueprints . Any ( b = > ! Precision . AlmostEquals ( ( ( Drawable ) b . Item ) . Rotation , 0 ) ) ;
2021-05-19 18:58:55 +08:00
2021-05-21 14:02:36 +08:00
if ( shouldAspectLock )
2021-05-19 20:46:41 +08:00
{
2021-05-21 14:02:36 +08:00
if ( anchor . HasFlagFast ( Anchor . x1 ) )
// if dragging from the horizontal centre, only a vertical component is available.
scale . X = scale . Y / selectionRect . Height * selectionRect . Width ;
else
// in all other cases (arbitrarily) use the horizontal component for aspect lock.
scale . Y = scale . X / selectionRect . Width * selectionRect . Height ;
2021-05-19 20:46:41 +08:00
}
2021-05-19 18:58:55 +08:00
2021-05-21 14:02:36 +08:00
if ( anchor . HasFlagFast ( Anchor . x0 ) ) adjustedRect . X - = scale . X ;
if ( anchor . HasFlagFast ( Anchor . y0 ) ) adjustedRect . Y - = scale . Y ;
adjustedRect . Width + = scale . X ;
adjustedRect . Height + = scale . Y ;
// scale adjust applied to each individual item should match that of the quad itself.
2021-05-19 20:46:41 +08:00
var scaledDelta = new Vector2 (
2021-05-21 14:02:36 +08:00
adjustedRect . Width / selectionRect . Width ,
adjustedRect . Height / selectionRect . Height
2021-05-19 20:46:41 +08:00
) ;
2021-05-19 18:58:55 +08:00
2021-05-19 20:46:41 +08:00
foreach ( var b in SelectedBlueprints )
{
var drawableItem = ( Drawable ) b . Item ;
// each drawable's relative position should be maintained in the scaled quad.
var screenPosition = b . ScreenSpaceSelectionPoint ;
2021-05-19 18:58:55 +08:00
2021-05-19 20:46:41 +08:00
var relativePositionInOriginal =
new Vector2 (
2021-05-21 14:02:36 +08:00
( screenPosition . X - selectionRect . TopLeft . X ) / selectionRect . Width ,
( screenPosition . Y - selectionRect . TopLeft . Y ) / selectionRect . Height
2021-05-19 20:46:41 +08:00
) ;
2021-05-19 18:58:55 +08:00
2021-05-19 20:46:41 +08:00
var newPositionInAdjusted = new Vector2 (
adjustedRect . TopLeft . X + adjustedRect . Width * relativePositionInOriginal . X ,
adjustedRect . TopLeft . Y + adjustedRect . Height * relativePositionInOriginal . Y
) ;
2021-05-19 18:58:55 +08:00
2021-05-20 17:35:13 +08:00
updateDrawablePosition ( drawableItem , newPositionInAdjusted ) ;
2021-05-20 00:47:31 +08:00
drawableItem . Scale * = scaledDelta ;
2021-05-19 18:58:55 +08:00
}
2021-05-03 14:15:00 +08:00
return true ;
}
2021-05-13 16:51:57 +08:00
public override bool HandleFlip ( Direction direction )
{
2021-05-20 17:31:51 +08:00
var selectionQuad = getSelectionQuad ( ) ;
2021-05-18 17:34:06 +08:00
foreach ( var b in SelectedBlueprints )
2021-05-13 16:51:57 +08:00
{
2021-05-18 17:34:06 +08:00
var drawableItem = ( Drawable ) b . Item ;
2021-05-20 17:35:13 +08:00
var flippedPosition = GetFlippedPosition ( direction , selectionQuad , b . ScreenSpaceSelectionPoint ) ;
updateDrawablePosition ( drawableItem , flippedPosition ) ;
2021-05-18 17:34:06 +08:00
drawableItem . Scale * = new Vector2 (
2021-05-13 16:51:57 +08:00
direction = = Direction . Horizontal ? - 1 : 1 ,
direction = = Direction . Vertical ? - 1 : 1
) ;
}
return true ;
}
2021-05-13 16:06:00 +08:00
public override bool HandleMovement ( MoveSelectionEvent < ISkinnableDrawable > moveEvent )
2021-05-03 14:15:00 +08:00
{
foreach ( var c in SelectedBlueprints )
{
Drawable drawable = ( Drawable ) c . Item ;
drawable . Position + = drawable . ScreenSpaceDeltaToParentSpace ( moveEvent . ScreenSpaceDelta ) ;
2021-06-06 13:18:30 +08:00
updateDrawableAnchorIfUsingClosest ( drawable ) ;
2021-05-03 14:15:00 +08:00
}
return true ;
}
2021-06-06 13:18:30 +08:00
private void updateDrawableAnchorIfUsingClosest ( Drawable drawable )
{
if ( ! isDrawableUsingClosestAnchor ( drawable ) . Value ) return ;
var closestAnchor = getClosestAnchorForDrawable ( drawable ) ;
if ( closestAnchor = = drawable . Anchor ) return ;
updateDrawableAnchor ( drawable , closestAnchor ) ;
}
2021-05-03 14:15:00 +08:00
protected override void OnSelectionChanged ( )
{
base . OnSelectionChanged ( ) ;
SelectionBox . CanRotate = true ;
SelectionBox . CanScaleX = true ;
SelectionBox . CanScaleY = true ;
SelectionBox . CanReverse = false ;
}
2021-05-14 15:03:22 +08:00
protected override void DeleteItems ( IEnumerable < ISkinnableDrawable > items ) = >
skinEditor . DeleteItems ( items . ToArray ( ) ) ;
2021-04-28 14:14:48 +08:00
2021-05-13 16:06:00 +08:00
protected override IEnumerable < MenuItem > GetContextMenuItemsForSelection ( IEnumerable < SelectionBlueprint < ISkinnableDrawable > > selection )
2021-04-28 14:14:48 +08:00
{
2021-06-06 13:18:30 +08:00
int checkAnchor ( Drawable drawable ) = >
isDrawableUsingClosestAnchor ( drawable ) . Value
? hash_of_closest_anchor_menu_item
: ( int ) drawable . Anchor ;
yield return new OsuMenuItem ( getSentenceCaseLocalisedString ( localisedAnchor ) )
2021-04-28 14:14:48 +08:00
{
2021-06-06 13:18:30 +08:00
Items = createAnchorItems ( localisedAnchorMenuItems , checkAnchor , applyAnchor ) . ToArray ( )
2021-05-11 13:09:56 +08:00
} ;
2021-06-06 13:18:30 +08:00
yield return new OsuMenuItem ( getSentenceCaseLocalisedString ( localisedOrigin ) )
2021-05-11 13:09:56 +08:00
{
2021-06-06 13:18:30 +08:00
// Origins can't be "closest" so we just cast to int
Items = createAnchorItems ( localisedOriginMenuItems , d = > ( int ) d . Origin , applyOrigin ) . ToArray ( )
2021-04-28 14:14:48 +08:00
} ;
foreach ( var item in base . GetContextMenuItemsForSelection ( selection ) )
yield return item ;
2021-06-06 13:18:30 +08:00
IEnumerable < TernaryStateMenuItem > createAnchorItems ( IDictionary < int , ILocalisedBindableString > items , Func < Drawable , int > checkFunction , Action < int > applyFunction ) = >
items . Select ( pair = >
2021-04-28 14:14:48 +08:00
{
2021-06-06 13:18:30 +08:00
var ( hash , ls ) = pair ;
2021-04-28 14:14:48 +08:00
2021-06-06 13:18:30 +08:00
return new TernaryStateRadioMenuItem ( getSentenceCaseLocalisedString ( ls ) , MenuItemType . Standard , _ = > applyFunction ( hash ) )
2021-04-28 14:14:48 +08:00
{
2021-06-06 13:18:30 +08:00
State = { Value = GetStateFromSelection ( selection , c = > checkFunction ( ( Drawable ) c . Item ) = = hash ) }
2021-04-28 14:14:48 +08:00
} ;
} ) ;
}
2021-05-20 17:35:13 +08:00
private static void updateDrawablePosition ( Drawable drawable , Vector2 screenSpacePosition )
{
drawable . Position =
drawable . Parent . ToLocalSpace ( screenSpacePosition ) - drawable . AnchorPosition ;
}
2021-06-06 13:18:30 +08:00
private void applyOrigin ( int hash )
2021-05-11 13:09:56 +08:00
{
2021-06-06 13:18:30 +08:00
var anchor = ( Anchor ) hash ;
2021-05-11 13:09:56 +08:00
foreach ( var item in SelectedItems )
2021-05-12 14:30:52 +08:00
{
var drawable = ( Drawable ) item ;
var previousOrigin = drawable . OriginPosition ;
drawable . Origin = anchor ;
drawable . Position + = drawable . OriginPosition - previousOrigin ;
2021-06-06 13:18:30 +08:00
updateDrawableAnchorIfUsingClosest ( drawable ) ;
2021-05-12 14:30:52 +08:00
}
2021-05-11 13:09:56 +08:00
}
2021-05-20 17:24:25 +08:00
/// <summary>
/// A screen-space quad surrounding all selected drawables, accounting for their full displayed size.
/// </summary>
/// <returns></returns>
private Quad getSelectionQuad ( ) = >
GetSurroundingQuad ( SelectedBlueprints . SelectMany ( b = > b . Item . ScreenSpaceDrawQuad . GetVertices ( ) . ToArray ( ) ) ) ;
2021-06-06 13:18:30 +08:00
private void applyAnchor ( int hash )
2021-04-28 14:14:48 +08:00
{
foreach ( var item in SelectedItems )
2021-05-12 14:30:52 +08:00
{
var drawable = ( Drawable ) item ;
2021-06-06 13:18:30 +08:00
var anchor = getAnchorFromHashAndDrawableAndRecordWhetherUsingClosestAnchor ( hash , drawable ) ;
updateDrawableAnchor ( drawable , anchor ) ;
}
}
private static void updateDrawableAnchor ( Drawable drawable , Anchor anchor )
{
var previousAnchor = drawable . AnchorPosition ;
drawable . Anchor = anchor ;
drawable . Position - = drawable . AnchorPosition - previousAnchor ;
}
private Anchor getAnchorFromHashAndDrawableAndRecordWhetherUsingClosestAnchor ( int hash , Drawable drawable )
{
var isUsingClosestAnchor = isDrawableUsingClosestAnchor ( drawable ) ;
if ( hash = = hash_of_closest_anchor_menu_item )
{
isUsingClosestAnchor . Value = true ;
return getClosestAnchorForDrawable ( drawable ) ;
2021-05-12 14:30:52 +08:00
}
2021-06-06 13:18:30 +08:00
isUsingClosestAnchor . Value = false ;
return ( Anchor ) hash ;
2021-04-28 14:14:48 +08:00
}
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 ;
}
}
}