diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 62fdb67a30..2f20d75813 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -12,6 +12,7 @@ using osu.Game.Overlays.Settings; using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Edit; using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning; using osuTK.Input; @@ -27,6 +28,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + [Cached] + public readonly EditorClipboard Clipboard = new EditorClipboard(); + [SetUpSteps] public override void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index d5b6ac38cb..a7da8f9832 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -12,6 +12,7 @@ using osu.Game.Overlays.SkinEditor; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Edit; using osu.Game.Screens.Play; using osu.Game.Tests.Gameplay; using osuTK.Input; @@ -32,6 +33,9 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached(typeof(IGameplayClock))] private readonly IGameplayClock gameplayClock = new GameplayClockContainer(new FramedClock()); + [Cached] + public readonly EditorClipboard Clipboard = new EditorClipboard(); + [SetUpSteps] public void SetUpSteps() { diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 133ec10202..d6521e759e 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -60,6 +61,9 @@ namespace osu.Game.Overlays.SkinEditor [Resolved] private RealmAccess realm { get; set; } = null!; + [Resolved] + private EditorClipboard clipboard { get; set; } = null!; + [Resolved] private SkinEditorOverlay? skinEditorOverlay { get; set; } @@ -78,6 +82,15 @@ namespace osu.Game.Overlays.SkinEditor private EditorMenuItem undoMenuItem = null!; private EditorMenuItem redoMenuItem = null!; + private EditorMenuItem cutMenuItem = null!; + private EditorMenuItem copyMenuItem = null!; + private EditorMenuItem cloneMenuItem = null!; + private EditorMenuItem pasteMenuItem = null!; + + private readonly BindableWithCurrent canCut = new BindableWithCurrent(); + private readonly BindableWithCurrent canCopy = new BindableWithCurrent(); + private readonly BindableWithCurrent canPaste = new BindableWithCurrent(); + [Resolved] private OnScreenDisplay? onScreenDisplay { get; set; } @@ -143,6 +156,11 @@ namespace osu.Game.Overlays.SkinEditor { undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + new EditorMenuItemSpacer(), + cutMenuItem = new EditorMenuItem(CommonStrings.Cut, MenuItemType.Standard, Cut), + copyMenuItem = new EditorMenuItem(CommonStrings.Copy, MenuItemType.Standard, Copy), + pasteMenuItem = new EditorMenuItem(CommonStrings.Paste, MenuItemType.Standard, Paste), + cloneMenuItem = new EditorMenuItem(CommonStrings.Clone, MenuItemType.Standard, Clone), } }, } @@ -201,6 +219,21 @@ namespace osu.Game.Overlays.SkinEditor { base.LoadComplete(); + canCut.Current.BindValueChanged(cut => cutMenuItem.Action.Disabled = !cut.NewValue, true); + canCopy.Current.BindValueChanged(copy => + { + copyMenuItem.Action.Disabled = !copy.NewValue; + cloneMenuItem.Action.Disabled = !copy.NewValue; + }, true); + canPaste.Current.BindValueChanged(paste => pasteMenuItem.Action.Disabled = !paste.NewValue, true); + + SelectedComponents.BindCollectionChanged((_, _) => + { + canCopy.Value = canCut.Value = SelectedComponents.Any(); + }, true); + + clipboard.Content.BindValueChanged(content => canPaste.Value = !string.IsNullOrEmpty(content.NewValue), true); + Show(); game?.RegisterImportHandler(this); @@ -224,6 +257,18 @@ namespace osu.Game.Overlays.SkinEditor { switch (e.Action) { + case PlatformAction.Cut: + Cut(); + return true; + + case PlatformAction.Copy: + Copy(); + return true; + + case PlatformAction.Paste: + Paste(); + return true; + case PlatformAction.Undo: Undo(); return true; @@ -273,7 +318,14 @@ namespace osu.Game.Overlays.SkinEditor componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable) { - RequestPlacement = placeComponent + RequestPlacement = type => + { + if (!(Activator.CreateInstance(type) is ISerialisableDrawable component)) + throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISerialisableDrawable)}."); + + SelectedComponents.Clear(); + placeComponent(component); + } }; } } @@ -300,20 +352,18 @@ namespace osu.Game.Overlays.SkinEditor hasBegunMutating = true; } - private void placeComponent(Type type) - { - if (!(Activator.CreateInstance(type) is ISerialisableDrawable component)) - throw new InvalidOperationException($"Attempted to instantiate a component for placement which was not an {typeof(ISerialisableDrawable)}."); - - placeComponent(component); - } - - private void placeComponent(ISerialisableDrawable component, bool applyDefaults = true) + /// + /// Attempt to place a given component in the current target. If successful, the new component will be added to . + /// + /// The component to be placed. + /// Whether to apply default anchor / origin / position values. + /// Whether placement succeeded. Could fail if no target is available, or if the current target has missing dependency requirements for the component. + private bool placeComponent(ISerialisableDrawable component, bool applyDefaults = true) { var targetContainer = getFirstTarget(); if (targetContainer == null) - return; + return false; var drawableComponent = (Drawable)component; @@ -325,10 +375,18 @@ namespace osu.Game.Overlays.SkinEditor drawableComponent.Y = targetContainer.DrawSize.Y / 2; } - targetContainer.Add(component); + try + { + targetContainer.Add(component); + } + catch + { + // May fail if dependencies are not available, for instance. + return false; + } - SelectedComponents.Clear(); SelectedComponents.Add(component); + return true; } private void populateSettings() @@ -361,6 +419,48 @@ namespace osu.Game.Overlays.SkinEditor } } + protected void Cut() + { + Copy(); + DeleteItems(SelectedComponents.ToArray()); + } + + protected void Copy() + { + clipboard.Content.Value = JsonConvert.SerializeObject(SelectedComponents.Cast().Select(s => s.CreateSerialisedInfo()).ToArray()); + } + + protected void Clone() + { + // Avoid attempting to clone if copying is not available (as it may result in pasting something unexpected). + if (!canCopy.Value) + return; + + Copy(); + Paste(); + } + + protected void Paste() + { + changeHandler?.BeginChange(); + + var drawableInfo = JsonConvert.DeserializeObject(clipboard.Content.Value); + + if (drawableInfo == null) + return; + + var instances = drawableInfo.Select(d => d.CreateInstance()) + .OfType() + .ToArray(); + + SelectedComponents.Clear(); + + foreach (var i in instances) + placeComponent(i, false); + + changeHandler?.EndChange(); + } + protected void Undo() => changeHandler?.RestoreState(-1); protected void Redo() => changeHandler?.RestoreState(1); @@ -402,8 +502,12 @@ namespace osu.Game.Overlays.SkinEditor public void DeleteItems(ISerialisableDrawable[] items) { + changeHandler?.BeginChange(); + foreach (var item in items) availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); + + changeHandler?.EndChange(); } #region Drag & drop import handling @@ -440,6 +544,7 @@ namespace osu.Game.Overlays.SkinEditor Position = skinnableTarget.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position), }; + SelectedComponents.Clear(); placeComponent(sprite, false); SkinSelectionHandler.ApplyClosestAnchor(sprite); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index c87e60e47f..1c0ece28fe 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -11,6 +11,7 @@ using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Screens; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osuTK; @@ -28,6 +29,9 @@ namespace osu.Game.Overlays.SkinEditor private SkinEditor? skinEditor; + [Cached] + public readonly EditorClipboard Clipboard = new EditorClipboard(); + [Resolved] private OsuGame game { get; set; } = null!;