From d653335b6f22faae7952204d8fe807233f53f01b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 15 Feb 2023 19:26:44 +0900 Subject: [PATCH 1/9] Add basic skin editor clipboard implementation --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 89 ++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 133ec10202..9c5d6677b9 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; @@ -48,6 +49,9 @@ namespace osu.Game.Overlays.SkinEditor private Bindable currentSkin = null!; + [Cached] + public readonly EditorClipboard Clipboard = new EditorClipboard(); + [Resolved] private OsuGame? game { 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; @@ -361,6 +406,50 @@ 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; + + // This is an initial implementation just to get an idea of how people used this function. + // There are a couple of differences from osu!stable's implementation which will require more work to match: + // - The "clipboard" is not populated during the duplication process. + // - The duplicated hitobjects are inserted after the original pattern (add one beat_length and then quantize using beat snap). + // - The duplicated hitobjects are selected (but this is also applied for all paste operations so should be changed there). + Copy(); + Paste(); + } + + protected void Paste() + { + var drawableInfo = JsonConvert.DeserializeObject(Clipboard.Content.Value); + + if (drawableInfo == null) + return; + + var instances = drawableInfo.Select(d => d.CreateInstance()) + .OfType() + .ToArray(); + + foreach (var i in instances) + placeComponent(i); + + SelectedComponents.Clear(); + SelectedComponents.AddRange(instances); + } + protected void Undo() => changeHandler?.RestoreState(-1); protected void Redo() => changeHandler?.RestoreState(1); From bcf2555545b6250432ffa9b51906f2cc6d6d6b13 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 15 Feb 2023 19:34:42 +0900 Subject: [PATCH 2/9] Fix components having incorrect default positions --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 9c5d6677b9..5adbe7273c 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -444,7 +444,7 @@ namespace osu.Game.Overlays.SkinEditor .ToArray(); foreach (var i in instances) - placeComponent(i); + placeComponent(i, false); SelectedComponents.Clear(); SelectedComponents.AddRange(instances); From bc83b0c2642936246e3482ff777474190a3f55f8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 15 Feb 2023 19:35:22 +0900 Subject: [PATCH 3/9] Fix clipboard changes not batching as undo steps --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 5adbe7273c..5d061a5851 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -434,7 +434,9 @@ namespace osu.Game.Overlays.SkinEditor protected void Paste() { - var drawableInfo = JsonConvert.DeserializeObject(Clipboard.Content.Value); + changeHandler?.BeginChange(); + + var drawableInfo = JsonConvert.DeserializeObject(clipboard.Content.Value); if (drawableInfo == null) return; @@ -448,6 +450,8 @@ namespace osu.Game.Overlays.SkinEditor SelectedComponents.Clear(); SelectedComponents.AddRange(instances); + + changeHandler?.EndChange(); } protected void Undo() => changeHandler?.RestoreState(-1); @@ -491,8 +495,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 From 925deb7ca548b7ed3476126af11740b5cb1cc2b8 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 15 Feb 2023 19:35:37 +0900 Subject: [PATCH 4/9] Make skin editor clipboard shared between screens and skins to allow moving elements over --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 10 +++++----- osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 5d061a5851..ad07099d48 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -49,9 +49,6 @@ namespace osu.Game.Overlays.SkinEditor private Bindable currentSkin = null!; - [Cached] - public readonly EditorClipboard Clipboard = new EditorClipboard(); - [Resolved] private OsuGame? game { get; set; } @@ -64,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; } @@ -232,7 +232,7 @@ namespace osu.Game.Overlays.SkinEditor canCopy.Value = canCut.Value = SelectedComponents.Any(); }, true); - Clipboard.Content.BindValueChanged(content => canPaste.Value = !string.IsNullOrEmpty(content.NewValue), true); + clipboard.Content.BindValueChanged(content => canPaste.Value = !string.IsNullOrEmpty(content.NewValue), true); Show(); @@ -414,7 +414,7 @@ namespace osu.Game.Overlays.SkinEditor protected void Copy() { - Clipboard.Content.Value = JsonConvert.SerializeObject(SelectedComponents.Cast().Select(s => s.CreateSerialisedInfo()).ToArray()); + clipboard.Content.Value = JsonConvert.SerializeObject(SelectedComponents.Cast().Select(s => s.CreateSerialisedInfo()).ToArray()); } protected void Clone() 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!; From 2fbaf88a3c8edd6f629b18a81df33acc96138cc4 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 16 Feb 2023 15:24:37 +0900 Subject: [PATCH 5/9] Add clipboard dependency to `SkinEditor` specific tests This is usually provided by the `SkinEditorOverlay`, which is not always present in tests. --- osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs | 4 ++++ .../Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs | 4 ++++ 2 files changed, 8 insertions(+) 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() { From a9c7edd08787a442e8390f453043ccbed0350d43 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 20 Feb 2023 19:57:16 +0900 Subject: [PATCH 6/9] Remove copy pasted comment --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index ad07099d48..ae831f4d66 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -423,11 +423,6 @@ namespace osu.Game.Overlays.SkinEditor if (!canCopy.Value) return; - // This is an initial implementation just to get an idea of how people used this function. - // There are a couple of differences from osu!stable's implementation which will require more work to match: - // - The "clipboard" is not populated during the duplication process. - // - The duplicated hitobjects are inserted after the original pattern (add one beat_length and then quantize using beat snap). - // - The duplicated hitobjects are selected (but this is also applied for all paste operations so should be changed there). Copy(); Paste(); } From b68562b03359e5e8832df72f4cc6d3a4e3731486 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 20 Feb 2023 20:00:12 +0900 Subject: [PATCH 7/9] Make `placeComponent` resilient to missing dependencies --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index ae831f4d66..8177c31058 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -353,12 +353,18 @@ namespace osu.Game.Overlays.SkinEditor placeComponent(component); } - private void placeComponent(ISerialisableDrawable component, bool applyDefaults = true) + /// + /// Attempt to place a given component in the current target. + /// + /// 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; @@ -370,10 +376,19 @@ 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() From 43d33d45caa852707a1b5064f8cb23549ddc45de Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 20 Feb 2023 20:02:43 +0900 Subject: [PATCH 8/9] Only add valid placed components to selected collection on paste --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 8177c31058..944a64d131 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -455,11 +455,13 @@ namespace osu.Game.Overlays.SkinEditor .OfType() .ToArray(); - foreach (var i in instances) - placeComponent(i, false); - SelectedComponents.Clear(); - SelectedComponents.AddRange(instances); + + foreach (var i in instances) + { + if (placeComponent(i, false)) + SelectedComponents.Add(i); + } changeHandler?.EndChange(); } From af062e7a68c7b21ce9c8250c7f13bace75c19e2d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 21 Feb 2023 14:07:47 +0900 Subject: [PATCH 9/9] Change `placeComponent` to only add to selection, not clear an existing selection --- osu.Game/Overlays/SkinEditor/SkinEditor.cs | 26 +++++++++------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 944a64d131..d6521e759e 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -318,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); + } }; } } @@ -345,16 +352,8 @@ 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); - } - /// - /// Attempt to place a given component in the current target. + /// 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. @@ -386,7 +385,6 @@ namespace osu.Game.Overlays.SkinEditor return false; } - SelectedComponents.Clear(); SelectedComponents.Add(component); return true; } @@ -458,10 +456,7 @@ namespace osu.Game.Overlays.SkinEditor SelectedComponents.Clear(); foreach (var i in instances) - { - if (placeComponent(i, false)) - SelectedComponents.Add(i); - } + placeComponent(i, false); changeHandler?.EndChange(); } @@ -549,6 +544,7 @@ namespace osu.Game.Overlays.SkinEditor Position = skinnableTarget.ToLocalSpace(GetContainingInputManager().CurrentState.Mouse.Position), }; + SelectedComponents.Clear(); placeComponent(sprite, false); SkinSelectionHandler.ApplyClosestAnchor(sprite);