diff --git a/osu.Game/Localisation/SkinEditorStrings.cs b/osu.Game/Localisation/SkinEditorStrings.cs new file mode 100644 index 0000000000..24a077963f --- /dev/null +++ b/osu.Game/Localisation/SkinEditorStrings.cs @@ -0,0 +1,74 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class SkinEditorStrings + { + private const string prefix = "osu.Game.Localisation.SkinEditor"; + + /// + /// "anchor" + /// + public static LocalisableString Anchor => new TranslatableString(getKey("anchor"), "anchor"); + + /// + /// "origin" + /// + public static LocalisableString Origin => new TranslatableString(getKey("origin"), "origin"); + + /// + /// "top-left" + /// + public static LocalisableString TopLeft => new TranslatableString(getKey("top_left"), "top-left"); + + /// + /// "top-centre" + /// + public static LocalisableString TopCentre => new TranslatableString(getKey("top_centre"), "top-centre"); + + /// + /// "top-right" + /// + public static LocalisableString TopRight => new TranslatableString(getKey("top_right"), "top-right"); + + /// + /// "centre-left" + /// + public static LocalisableString CentreLeft => new TranslatableString(getKey("centre_left"), "centre-left"); + + /// + /// "centre" + /// + public static LocalisableString Centre => new TranslatableString(getKey("centre"), "centre"); + + /// + /// "centre-right" + /// + public static LocalisableString CentreRight => new TranslatableString(getKey("centre_right"), "centre-right"); + + /// + /// "bottom-left" + /// + public static LocalisableString BottomLeft => new TranslatableString(getKey("bottom_left"), "bottom-left"); + + /// + /// "bottom-centre" + /// + public static LocalisableString BottomCentre => new TranslatableString(getKey("bottom_centre"), "bottom-centre"); + + /// + /// "bottom-right" + /// + public static LocalisableString BottomRight => new TranslatableString(getKey("bottom_right"), "bottom-right"); + + /// + /// "closest" + /// + public static LocalisableString Closest => new TranslatableString(getKey("closest"), "closest"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 9cca0ba2c7..956f6c79f9 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -4,22 +4,152 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Extensions; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using Humanizer; +using osu.Game.Localisation; namespace osu.Game.Skinning.Editor { public class SkinSelectionHandler : SelectionHandler { + /// + ///

Keeps track of whether a is using the closest anchor point within its parent, + /// or whether the user is overriding its anchor point.

+ ///

Each key is either a direct cast of an Anchor value, or it is equal to . This is done + /// because the "Closest" menu item is not a valid anchor, so something other than an anchor must be used.

+ ///

Each value is a . If the is , the user has + /// overridden the anchor point. + /// If , the closest anchor point is assigned to the Drawable when it is either dragged by the user via , or when "Closest" is assigned from + /// the anchor context menu via .

+ ///
+ /// + ///

A ConditionalWeakTable is preferable to a Dictionary because a Dictionary will keep + /// orphaned references to a Drawable forever, unless manually pruned

+ ///

is used as a thin wrapper around bool because ConditionalWeakTable requires a reference type as both a key and a value.

+ ///
+ private readonly ConditionalWeakTable isDrawableUsingClosestAnchorLookup = new ConditionalWeakTable(); + + /// + /// The hash code of the "Closest" menu item in the anchor point context menu. + /// + /// This does not need to change with locale; it need only be constant and distinct from any values. + private static readonly int hash_of_closest_anchor_menu_item = @"Closest".GetHashCode(); + + /// Used by to populate and . + 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, + }; + + /// Used by to populate and . + 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() + .Prepend(hash_of_closest_anchor_menu_item) + .ToArray(); + + private Dictionary localisedAnchorMenuItems; + private Dictionary 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(k, v)).ToArray(); + localisedAnchorMenuItems = new Dictionary(anchorPairs); + var originPairs = anchorPairs.Where(pair => pair.Key != hash_of_closest_anchor_menu_item); + localisedOriginMenuItems = new Dictionary(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; + } + + /// Defaults to , meaning anchors are closest by default. + 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); + [Resolved] private SkinEditor skinEditor { get; set; } @@ -151,11 +281,24 @@ namespace osu.Game.Skinning.Editor { Drawable drawable = (Drawable)c.Item; drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta); + + updateDrawableAnchorIfUsingClosest(drawable); } return true; } + private void updateDrawableAnchorIfUsingClosest(Drawable drawable) + { + if (!isDrawableUsingClosestAnchor(drawable).Value) return; + + var closestAnchor = getClosestAnchorForDrawable(drawable); + + if (closestAnchor == drawable.Anchor) return; + + updateDrawableAnchor(drawable, closestAnchor); + } + protected override void OnSelectionChanged() { base.OnSelectionChanged(); @@ -171,42 +314,35 @@ namespace osu.Game.Skinning.Editor protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable> selection) { - yield return new OsuMenuItem("Anchor") + int checkAnchor(Drawable drawable) => + isDrawableUsingClosestAnchor(drawable).Value + ? hash_of_closest_anchor_menu_item + : (int)drawable.Anchor; + + yield return new OsuMenuItem(getSentenceCaseLocalisedString(localisedAnchor)) { - Items = createAnchorItems(d => d.Anchor, applyAnchor).ToArray() + Items = createAnchorItems(localisedAnchorMenuItems, checkAnchor, applyAnchor).ToArray() }; - yield return new OsuMenuItem("Origin") + yield return new OsuMenuItem(getSentenceCaseLocalisedString(localisedOrigin)) { - Items = createAnchorItems(d => d.Origin, applyOrigin).ToArray() + // Origins can't be "closest" so we just cast to int + Items = createAnchorItems(localisedOriginMenuItems, d => (int)d.Origin, applyOrigin).ToArray() }; foreach (var item in base.GetContextMenuItemsForSelection(selection)) yield return item; - IEnumerable createAnchorItems(Func checkFunction, Action applyFunction) - { - var displayableAnchors = new[] + IEnumerable createAnchorItems(IDictionary items, Func checkFunction, Action applyFunction) => + items.Select(pair => { - Anchor.TopLeft, - Anchor.TopCentre, - Anchor.TopRight, - Anchor.CentreLeft, - Anchor.Centre, - Anchor.CentreRight, - Anchor.BottomLeft, - Anchor.BottomCentre, - Anchor.BottomRight, - }; + var (hash, ls) = pair; - return displayableAnchors.Select(a => - { - return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a)) + return new TernaryStateRadioMenuItem(getSentenceCaseLocalisedString(ls), MenuItemType.Standard, _ => applyFunction(hash)) { - State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) } + State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == hash) } }; }); - } } private static void updateDrawablePosition(Drawable drawable, Vector2 screenSpacePosition) @@ -215,8 +351,10 @@ namespace osu.Game.Skinning.Editor drawable.Parent.ToLocalSpace(screenSpacePosition) - drawable.AnchorPosition; } - private void applyOrigin(Anchor anchor) + private void applyOrigin(int hash) { + var anchor = (Anchor)hash; + foreach (var item in SelectedItems) { var drawable = (Drawable)item; @@ -224,6 +362,8 @@ namespace osu.Game.Skinning.Editor var previousOrigin = drawable.OriginPosition; drawable.Origin = anchor; drawable.Position += drawable.OriginPosition - previousOrigin; + + updateDrawableAnchorIfUsingClosest(drawable); } } @@ -234,18 +374,39 @@ namespace osu.Game.Skinning.Editor private Quad getSelectionQuad() => GetSurroundingQuad(SelectedBlueprints.SelectMany(b => b.Item.ScreenSpaceDrawQuad.GetVertices().ToArray())); - private void applyAnchor(Anchor anchor) + private void applyAnchor(int hash) { foreach (var item in SelectedItems) { var drawable = (Drawable)item; - var previousAnchor = drawable.AnchorPosition; - drawable.Anchor = anchor; - drawable.Position -= drawable.AnchorPosition - previousAnchor; + 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); + } + + isUsingClosestAnchor.Value = false; + return (Anchor)hash; + } + 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).