diff --git a/osu.Game/Overlays/SkinEditor/SkinEditor.cs b/osu.Game/Overlays/SkinEditor/SkinEditor.cs index 275cbd18d7..51cca96f4a 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditor.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditor.cs @@ -24,6 +24,7 @@ using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Overlays.OSD; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Skinning; @@ -31,7 +32,7 @@ using osu.Game.Skinning; namespace osu.Game.Overlays.SkinEditor { [Cached(typeof(SkinEditor))] - public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler + public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler, IEditorChangeHandler { public const double TRANSITION_DURATION = 300; @@ -72,6 +73,11 @@ namespace osu.Game.Overlays.SkinEditor private EditorSidebar componentsSidebar = null!; private EditorSidebar settingsSidebar = null!; + private SkinEditorChangeHandler? changeHandler; + + private EditorMenuItem undoMenuItem = null!; + private EditorMenuItem redoMenuItem = null!; + [Resolved] private OnScreenDisplay? onScreenDisplay { get; set; } @@ -131,6 +137,14 @@ namespace osu.Game.Overlays.SkinEditor new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, () => skinEditorOverlay?.Hide()), }, }, + new MenuItem(CommonStrings.MenuBarEdit) + { + Items = new[] + { + undoMenuItem = new EditorMenuItem(CommonStrings.Undo, MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem(CommonStrings.Redo, MenuItemType.Standard, Redo), + } + }, } }, headerText = new OsuTextFlowContainer @@ -210,6 +224,14 @@ namespace osu.Game.Overlays.SkinEditor { switch (e.Action) { + case PlatformAction.Undo: + Undo(); + return true; + + case PlatformAction.Redo: + Redo(); + return true; + case PlatformAction.Save: if (e.Repeat) return false; @@ -229,6 +251,8 @@ namespace osu.Game.Overlays.SkinEditor { this.targetScreen = targetScreen; + changeHandler?.Dispose(); + SelectedComponents.Clear(); // Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target. @@ -241,6 +265,10 @@ namespace osu.Game.Overlays.SkinEditor { Debug.Assert(content != null); + changeHandler = new SkinEditorChangeHandler(targetScreen); + changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); + changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); + content.Child = new SkinBlueprintContainer(targetScreen); componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable) @@ -301,6 +329,8 @@ namespace osu.Game.Overlays.SkinEditor SelectedComponents.Clear(); SelectedComponents.Add(component); + + changeHandler?.SaveState(); } private void populateSettings() @@ -333,6 +363,10 @@ namespace osu.Game.Overlays.SkinEditor } } + protected void Undo() => changeHandler?.RestoreState(-1); + + protected void Redo() => changeHandler?.RestoreState(1); + public void Save() { if (!hasBegunMutating) @@ -371,6 +405,8 @@ namespace osu.Game.Overlays.SkinEditor { foreach (var item in items) availableTargets.FirstOrDefault(t => t.Components.Contains(item))?.Remove(item); + + changeHandler?.SaveState(); } #region Drag & drop import handling @@ -435,5 +471,19 @@ namespace osu.Game.Overlays.SkinEditor { } } + + #region Delegation of IEditorChangeHandler + + public event Action? OnStateChange + { + add => changeHandler!.OnStateChange += value; + remove => changeHandler!.OnStateChange -= value; + } + + public void BeginChange() => changeHandler?.BeginChange(); + public void EndChange() => changeHandler?.EndChange(); + public void SaveState() => changeHandler?.SaveState(); + + #endregion } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs new file mode 100644 index 0000000000..95b24bbd6f --- /dev/null +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs @@ -0,0 +1,73 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Newtonsoft.Json; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Extensions; +using osu.Game.Screens.Edit; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; + +namespace osu.Game.Overlays.SkinEditor +{ + public partial class SkinEditorChangeHandler : EditorChangeHandler + { + private readonly Drawable targetScreen; + + private ISkinnableTarget? firstTarget => targetScreen.ChildrenOfType().FirstOrDefault(); + + public SkinEditorChangeHandler(Drawable targetScreen) + { + // To keep things simple, we are currently only handling the current target screen for undo / redo. + // In the future we'll want this to cover all changes, even to skin's `InstantiationInfo`. + // We'll also need to consider cases where multiple targets are on screen at the same time. + + this.targetScreen = targetScreen; + + // Save initial state. + SaveState(); + } + + protected override void WriteCurrentStateToStream(MemoryStream stream) + { + if (firstTarget == null) + return; + + var skinnableInfos = firstTarget.CreateSkinnableInfo().ToArray(); + string json = JsonConvert.SerializeObject(skinnableInfos, new JsonSerializerSettings { Formatting = Formatting.Indented }); + stream.Write(Encoding.UTF8.GetBytes(json)); + } + + protected override void ApplyStateChange(byte[] previousState, byte[] newState) + { + if (firstTarget == null) + return; + + var deserializedContent = JsonConvert.DeserializeObject>(Encoding.UTF8.GetString(newState)); + + if (deserializedContent == null) + return; + + SkinnableInfo[] skinnableInfo = deserializedContent.ToArray(); + Drawable[] targetComponents = firstTarget.Components.OfType().ToArray(); + + if (!skinnableInfo.Select(s => s.Type).SequenceEqual(targetComponents.Select(d => d.GetType()))) + { + // Perform a naive full reload for now. + firstTarget.Reload(skinnableInfo); + } + else + { + int i = 0; + + foreach (var drawable in targetComponents) + drawable.ApplySkinnableInfo(skinnableInfo[i++]); + } + } + } +} diff --git a/osu.Game/Screens/Edit/IEditorChangeHandler.cs b/osu.Game/Screens/Edit/IEditorChangeHandler.cs index e7abc1c43d..9fe40ba1b1 100644 --- a/osu.Game/Screens/Edit/IEditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/IEditorChangeHandler.cs @@ -1,9 +1,8 @@ // 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.Game.Rulesets.Objects; namespace osu.Game.Screens.Edit @@ -11,12 +10,13 @@ namespace osu.Game.Screens.Edit /// /// Interface for a component that manages changes in the . /// + [Cached] public interface IEditorChangeHandler { /// /// Fired whenever a state change occurs. /// - event Action OnStateChange; + event Action? OnStateChange; /// /// Begins a bulk state change event. should be invoked soon after.