1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 11:37:28 +08:00

Merge pull request #22506 from peppy/skin-editor-undo-support

Add very basic skin editor undo / redo support
This commit is contained in:
Bartłomiej Dach 2023-02-08 19:19:25 +01:00 committed by GitHub
commit 2873905cc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 342 additions and 62 deletions

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using NUnit.Framework; using NUnit.Framework;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Beatmaps;
@ -12,7 +10,7 @@ using osu.Game.Screens.Edit;
namespace osu.Game.Tests.Editing namespace osu.Game.Tests.Editing
{ {
[TestFixture] [TestFixture]
public class EditorChangeHandlerTest public class BeatmapEditorChangeHandlerTest
{ {
private int stateChangedFired; private int stateChangedFired;
@ -23,18 +21,23 @@ namespace osu.Game.Tests.Editing
} }
[Test] [Test]
public void TestSaveRestoreState() public void TestSaveRestoreStateUsingTransaction()
{ {
var (handler, beatmap) = createChangeHandler(); var (handler, beatmap) = createChangeHandler();
Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False);
addArbitraryChange(beatmap); handler.BeginChange();
handler.SaveState();
// Initial state will be saved on BeginChange
Assert.That(stateChangedFired, Is.EqualTo(1)); Assert.That(stateChangedFired, Is.EqualTo(1));
addArbitraryChange(beatmap);
handler.EndChange();
Assert.That(stateChangedFired, Is.EqualTo(2));
Assert.That(handler.CanUndo.Value, Is.True); Assert.That(handler.CanUndo.Value, Is.True);
Assert.That(handler.CanRedo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False);
@ -43,7 +46,35 @@ namespace osu.Game.Tests.Editing
Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.True); Assert.That(handler.CanRedo.Value, Is.True);
Assert.That(stateChangedFired, Is.EqualTo(3));
}
[Test]
public void TestSaveRestoreState()
{
var (handler, beatmap) = createChangeHandler();
Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.False);
// Save initial state
handler.SaveState();
Assert.That(stateChangedFired, Is.EqualTo(1));
addArbitraryChange(beatmap);
handler.SaveState();
Assert.That(stateChangedFired, Is.EqualTo(2)); Assert.That(stateChangedFired, Is.EqualTo(2));
Assert.That(handler.CanUndo.Value, Is.True);
Assert.That(handler.CanRedo.Value, Is.False);
handler.RestoreState(-1);
Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.True);
Assert.That(stateChangedFired, Is.EqualTo(3));
} }
[Test] [Test]
@ -54,6 +85,10 @@ namespace osu.Game.Tests.Editing
Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False);
// Save initial state
handler.SaveState();
Assert.That(stateChangedFired, Is.EqualTo(1));
string originalHash = handler.CurrentStateHash; string originalHash = handler.CurrentStateHash;
addArbitraryChange(beatmap); addArbitraryChange(beatmap);
@ -61,7 +96,7 @@ namespace osu.Game.Tests.Editing
Assert.That(handler.CanUndo.Value, Is.True); Assert.That(handler.CanUndo.Value, Is.True);
Assert.That(handler.CanRedo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False);
Assert.That(stateChangedFired, Is.EqualTo(1)); Assert.That(stateChangedFired, Is.EqualTo(2));
string hash = handler.CurrentStateHash; string hash = handler.CurrentStateHash;
@ -69,7 +104,7 @@ namespace osu.Game.Tests.Editing
handler.RestoreState(-1); handler.RestoreState(-1);
Assert.That(originalHash, Is.EqualTo(handler.CurrentStateHash)); Assert.That(originalHash, Is.EqualTo(handler.CurrentStateHash));
Assert.That(stateChangedFired, Is.EqualTo(2)); Assert.That(stateChangedFired, Is.EqualTo(3));
addArbitraryChange(beatmap); addArbitraryChange(beatmap);
handler.SaveState(); handler.SaveState();
@ -84,12 +119,16 @@ namespace osu.Game.Tests.Editing
Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False);
// Save initial state
handler.SaveState();
Assert.That(stateChangedFired, Is.EqualTo(1));
addArbitraryChange(beatmap); addArbitraryChange(beatmap);
handler.SaveState(); handler.SaveState();
Assert.That(handler.CanUndo.Value, Is.True); Assert.That(handler.CanUndo.Value, Is.True);
Assert.That(handler.CanRedo.Value, Is.False); Assert.That(handler.CanRedo.Value, Is.False);
Assert.That(stateChangedFired, Is.EqualTo(1)); Assert.That(stateChangedFired, Is.EqualTo(2));
string hash = handler.CurrentStateHash; string hash = handler.CurrentStateHash;
@ -97,7 +136,7 @@ namespace osu.Game.Tests.Editing
handler.SaveState(); handler.SaveState();
Assert.That(hash, Is.EqualTo(handler.CurrentStateHash)); Assert.That(hash, Is.EqualTo(handler.CurrentStateHash));
Assert.That(stateChangedFired, Is.EqualTo(1)); Assert.That(stateChangedFired, Is.EqualTo(2));
handler.RestoreState(-1); handler.RestoreState(-1);
@ -106,7 +145,7 @@ namespace osu.Game.Tests.Editing
// we should only be able to restore once even though we saved twice. // we should only be able to restore once even though we saved twice.
Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanUndo.Value, Is.False);
Assert.That(handler.CanRedo.Value, Is.True); Assert.That(handler.CanRedo.Value, Is.True);
Assert.That(stateChangedFired, Is.EqualTo(2)); Assert.That(stateChangedFired, Is.EqualTo(3));
} }
[Test] [Test]
@ -114,11 +153,15 @@ namespace osu.Game.Tests.Editing
{ {
var (handler, beatmap) = createChangeHandler(); var (handler, beatmap) = createChangeHandler();
// Save initial state
handler.SaveState();
Assert.That(stateChangedFired, Is.EqualTo(1));
Assert.That(handler.CanUndo.Value, Is.False); Assert.That(handler.CanUndo.Value, Is.False);
for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++) for (int i = 0; i < EditorChangeHandler.MAX_SAVED_STATES; i++)
{ {
Assert.That(stateChangedFired, Is.EqualTo(i)); Assert.That(stateChangedFired, Is.EqualTo(i + 1));
addArbitraryChange(beatmap); addArbitraryChange(beatmap);
handler.SaveState(); handler.SaveState();
@ -169,7 +212,7 @@ namespace osu.Game.Tests.Editing
}, },
}); });
var changeHandler = new EditorChangeHandler(beatmap); var changeHandler = new BeatmapEditorChangeHandler(beatmap);
changeHandler.OnStateChange += () => stateChangedFired++; changeHandler.OnStateChange += () => stateChangedFired++;
return (changeHandler, beatmap); return (changeHandler, beatmap);

View File

@ -66,10 +66,16 @@ namespace osu.Game.Extensions
foreach (var (_, property) in component.GetSettingsSourceProperties()) foreach (var (_, property) in component.GetSettingsSourceProperties())
{ {
if (!info.Settings.TryGetValue(property.Name.ToSnakeCase(), out object? settingValue)) var bindable = ((IBindable)property.GetValue(component)!);
continue;
skinnable.CopyAdjustedSetting(((IBindable)property.GetValue(component)!), settingValue); if (!info.Settings.TryGetValue(property.Name.ToSnakeCase(), out object? settingValue))
{
// TODO: We probably want to restore default if not included in serialisation information.
// This is not simple to do as SetDefault() is only found in the typed Bindable<T> interface right now.
continue;
}
skinnable.CopyAdjustedSetting(bindable, settingValue);
} }
} }

View File

@ -24,6 +24,7 @@ using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Overlays.OSD; using osu.Game.Overlays.OSD;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -31,7 +32,7 @@ using osu.Game.Skinning;
namespace osu.Game.Overlays.SkinEditor namespace osu.Game.Overlays.SkinEditor
{ {
[Cached(typeof(SkinEditor))] [Cached(typeof(SkinEditor))]
public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler<PlatformAction> public partial class SkinEditor : VisibilityContainer, ICanAcceptFiles, IKeyBindingHandler<PlatformAction>, IEditorChangeHandler
{ {
public const double TRANSITION_DURATION = 300; public const double TRANSITION_DURATION = 300;
@ -72,6 +73,11 @@ namespace osu.Game.Overlays.SkinEditor
private EditorSidebar componentsSidebar = null!; private EditorSidebar componentsSidebar = null!;
private EditorSidebar settingsSidebar = null!; private EditorSidebar settingsSidebar = null!;
private SkinEditorChangeHandler? changeHandler;
private EditorMenuItem undoMenuItem = null!;
private EditorMenuItem redoMenuItem = null!;
[Resolved] [Resolved]
private OnScreenDisplay? onScreenDisplay { get; set; } private OnScreenDisplay? onScreenDisplay { get; set; }
@ -131,6 +137,14 @@ namespace osu.Game.Overlays.SkinEditor
new EditorMenuItem(CommonStrings.Exit, MenuItemType.Standard, () => skinEditorOverlay?.Hide()), 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 headerText = new OsuTextFlowContainer
@ -210,6 +224,14 @@ namespace osu.Game.Overlays.SkinEditor
{ {
switch (e.Action) switch (e.Action)
{ {
case PlatformAction.Undo:
Undo();
return true;
case PlatformAction.Redo:
Redo();
return true;
case PlatformAction.Save: case PlatformAction.Save:
if (e.Repeat) if (e.Repeat)
return false; return false;
@ -229,6 +251,8 @@ namespace osu.Game.Overlays.SkinEditor
{ {
this.targetScreen = targetScreen; this.targetScreen = targetScreen;
changeHandler?.Dispose();
SelectedComponents.Clear(); SelectedComponents.Clear();
// Immediately clear the previous blueprint container to ensure it doesn't try to interact with the old target. // 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); 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); content.Child = new SkinBlueprintContainer(targetScreen);
componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable) componentsSidebar.Child = new SkinComponentToolbox(getFirstTarget() as CompositeDrawable)
@ -333,6 +361,10 @@ namespace osu.Game.Overlays.SkinEditor
} }
} }
protected void Undo() => changeHandler?.RestoreState(-1);
protected void Redo() => changeHandler?.RestoreState(1);
public void Save(bool userTriggered = true) public void Save(bool userTriggered = true)
{ {
if (!hasBegunMutating) if (!hasBegunMutating)
@ -436,5 +468,27 @@ namespace osu.Game.Overlays.SkinEditor
{ {
} }
} }
#region Delegation of IEditorChangeHandler
public event Action? OnStateChange
{
add => throw new NotImplementedException();
remove => throw new NotImplementedException();
}
private IEditorChangeHandler? beginChangeHandler;
public void BeginChange()
{
// Change handler may change between begin and end, which can cause unbalanced operations.
// Let's track the one that was used when beginning the change so we can call EndChange on it specifically.
(beginChangeHandler = changeHandler)?.BeginChange();
}
public void EndChange() => beginChangeHandler?.EndChange();
public void SaveState() => changeHandler?.SaveState();
#endregion
} }
} }

View File

@ -0,0 +1,78 @@
// 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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using osu.Framework.Bindables;
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 ISkinnableTarget? firstTarget;
// ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable
private readonly BindableList<ISkinnableDrawable>? components;
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.
firstTarget = targetScreen.ChildrenOfType<ISkinnableTarget>().FirstOrDefault();
if (firstTarget == null)
return;
components = new BindableList<ISkinnableDrawable> { BindTarget = firstTarget.Components };
components.BindCollectionChanged((_, _) => 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<IEnumerable<SkinnableInfo>>(Encoding.UTF8.GetString(newState));
if (deserializedContent == null)
return;
SkinnableInfo[] skinnableInfo = deserializedContent.ToArray();
Drawable[] targetComponents = firstTarget.Components.OfType<Drawable>().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++]);
}
}
}
}

View File

@ -241,6 +241,8 @@ namespace osu.Game.Overlays.SkinEditor
private void applyOrigins(Anchor origin) private void applyOrigins(Anchor origin)
{ {
OnOperationBegan();
foreach (var item in SelectedItems) foreach (var item in SelectedItems)
{ {
var drawable = (Drawable)item; var drawable = (Drawable)item;
@ -255,6 +257,8 @@ namespace osu.Game.Overlays.SkinEditor
ApplyClosestAnchor(drawable); ApplyClosestAnchor(drawable);
} }
OnOperationEnded();
} }
/// <summary> /// <summary>
@ -266,6 +270,8 @@ namespace osu.Game.Overlays.SkinEditor
private void applyFixedAnchors(Anchor anchor) private void applyFixedAnchors(Anchor anchor)
{ {
OnOperationBegan();
foreach (var item in SelectedItems) foreach (var item in SelectedItems)
{ {
var drawable = (Drawable)item; var drawable = (Drawable)item;
@ -273,15 +279,21 @@ namespace osu.Game.Overlays.SkinEditor
item.UsesFixedAnchor = true; item.UsesFixedAnchor = true;
applyAnchor(drawable, anchor); applyAnchor(drawable, anchor);
} }
OnOperationEnded();
} }
private void applyClosestAnchors() private void applyClosestAnchors()
{ {
OnOperationBegan();
foreach (var item in SelectedItems) foreach (var item in SelectedItems)
{ {
item.UsesFixedAnchor = false; item.UsesFixedAnchor = false;
ApplyClosestAnchor((Drawable)item); ApplyClosestAnchor((Drawable)item);
} }
OnOperationEnded();
} }
private static Anchor getClosestAnchor(Drawable drawable) private static Anchor getClosestAnchor(Drawable drawable)

View File

@ -2,10 +2,13 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Localisation; using osu.Game.Localisation;
using osu.Game.Overlays.Settings;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components; using osu.Game.Screens.Edit.Components;
using osuTK; using osuTK;
@ -13,19 +16,41 @@ namespace osu.Game.Overlays.SkinEditor
{ {
internal partial class SkinSettingsToolbox : EditorSidebarSection internal partial class SkinSettingsToolbox : EditorSidebarSection
{ {
[Resolved]
private IEditorChangeHandler? changeHandler { get; set; }
protected override Container<Drawable> Content { get; } protected override Container<Drawable> Content { get; }
private readonly Drawable component;
public SkinSettingsToolbox(Drawable component) public SkinSettingsToolbox(Drawable component)
: base(SkinEditorStrings.Settings(component.GetType().Name)) : base(SkinEditorStrings.Settings(component.GetType().Name))
{ {
this.component = component;
base.Content.Add(Content = new FillFlowContainer base.Content.Add(Content = new FillFlowContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical, Direction = FillDirection.Vertical,
Spacing = new Vector2(10), Spacing = new Vector2(10),
Children = component.CreateSettingsControls().ToArray()
}); });
} }
[BackgroundDependencyLoader]
private void load()
{
var controls = component.CreateSettingsControls().ToArray();
Content.AddRange(controls);
// track any changes to update undo states.
foreach (var c in controls.OfType<ISettingsItem>())
{
// TODO: SettingChanged is called too often for cases like SettingsTextBox and SettingsSlider.
// We will want to expose a SettingCommitted or similar to make this work better.
c.SettingChanged += () => changeHandler?.SaveState();
}
}
} }
} }

View File

@ -0,0 +1,40 @@
// 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.IO;
using System.Text;
using osu.Game.Beatmaps.Formats;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit
{
public partial class BeatmapEditorChangeHandler : EditorChangeHandler
{
private readonly LegacyEditorBeatmapPatcher patcher;
private readonly EditorBeatmap editorBeatmap;
/// <summary>
/// Creates a new <see cref="EditorChangeHandler"/>.
/// </summary>
/// <param name="editorBeatmap">The <see cref="EditorBeatmap"/> to track the <see cref="HitObject"/>s of.</param>
public BeatmapEditorChangeHandler(EditorBeatmap editorBeatmap)
{
this.editorBeatmap = editorBeatmap;
editorBeatmap.TransactionBegan += BeginChange;
editorBeatmap.TransactionEnded += EndChange;
editorBeatmap.SaveStateTriggered += SaveState;
patcher = new LegacyEditorBeatmapPatcher(editorBeatmap);
}
protected override void WriteCurrentStateToStream(MemoryStream stream)
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(editorBeatmap, editorBeatmap.BeatmapSkin).Encode(sw);
}
protected override void ApplyStateChange(byte[] previousState, byte[] newState) =>
patcher.Patch(previousState, newState);
}
}

View File

@ -240,7 +240,7 @@ namespace osu.Game.Screens.Edit
if (canSave) if (canSave)
{ {
changeHandler = new EditorChangeHandler(editorBeatmap); changeHandler = new BeatmapEditorChangeHandler(editorBeatmap);
dependencies.CacheAs<IEditorChangeHandler>(changeHandler); dependencies.CacheAs<IEditorChangeHandler>(changeHandler);
} }

View File

@ -1,31 +1,25 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Game.Beatmaps.Formats;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit namespace osu.Game.Screens.Edit
{ {
/// <summary> /// <summary>
/// Tracks changes to the <see cref="Editor"/>. /// Tracks changes to the <see cref="Editor"/>.
/// </summary> /// </summary>
public partial class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler public abstract partial class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler
{ {
public readonly Bindable<bool> CanUndo = new Bindable<bool>(); public readonly Bindable<bool> CanUndo = new Bindable<bool>();
public readonly Bindable<bool> CanRedo = new Bindable<bool>(); public readonly Bindable<bool> CanRedo = new Bindable<bool>();
public event Action OnStateChange; public event Action? OnStateChange;
private readonly LegacyEditorBeatmapPatcher patcher;
private readonly List<byte[]> savedStates = new List<byte[]>(); private readonly List<byte[]> savedStates = new List<byte[]>();
private int currentState = -1; private int currentState = -1;
@ -37,32 +31,28 @@ namespace osu.Game.Screens.Edit
{ {
get get
{ {
ensureStateSaved();
using (var stream = new MemoryStream(savedStates[currentState])) using (var stream = new MemoryStream(savedStates[currentState]))
return stream.ComputeSHA2Hash(); return stream.ComputeSHA2Hash();
} }
} }
private readonly EditorBeatmap editorBeatmap;
private bool isRestoring; private bool isRestoring;
public const int MAX_SAVED_STATES = 50; public const int MAX_SAVED_STATES = 50;
/// <summary> public override void BeginChange()
/// Creates a new <see cref="EditorChangeHandler"/>.
/// </summary>
/// <param name="editorBeatmap">The <see cref="EditorBeatmap"/> to track the <see cref="HitObject"/>s of.</param>
public EditorChangeHandler(EditorBeatmap editorBeatmap)
{ {
this.editorBeatmap = editorBeatmap; ensureStateSaved();
editorBeatmap.TransactionBegan += BeginChange; base.BeginChange();
editorBeatmap.TransactionEnded += EndChange; }
editorBeatmap.SaveStateTriggered += SaveState;
patcher = new LegacyEditorBeatmapPatcher(editorBeatmap); private void ensureStateSaved()
{
// Initial state. if (savedStates.Count == 0)
SaveState(); SaveState();
} }
protected override void UpdateState() protected override void UpdateState()
@ -72,9 +62,7 @@ namespace osu.Game.Screens.Edit
using (var stream = new MemoryStream()) using (var stream = new MemoryStream())
{ {
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) WriteCurrentStateToStream(stream);
new LegacyBeatmapEncoder(editorBeatmap, editorBeatmap.BeatmapSkin).Encode(sw);
byte[] newState = stream.ToArray(); byte[] newState = stream.ToArray();
// if the previous state is binary equal we don't need to push a new one, unless this is the initial state. // if the previous state is binary equal we don't need to push a new one, unless this is the initial state.
@ -113,7 +101,8 @@ namespace osu.Game.Screens.Edit
isRestoring = true; isRestoring = true;
patcher.Patch(savedStates[currentState], savedStates[newState]); ApplyStateChange(savedStates[currentState], savedStates[newState]);
currentState = newState; currentState = newState;
isRestoring = false; isRestoring = false;
@ -122,6 +111,20 @@ namespace osu.Game.Screens.Edit
updateBindables(); updateBindables();
} }
/// <summary>
/// Write a serialised copy of the currently tracked state to the provided stream.
/// This will be stored as a state which can be restored in the future.
/// </summary>
/// <param name="stream">The stream which the state should be written to.</param>
protected abstract void WriteCurrentStateToStream(MemoryStream stream);
/// <summary>
/// Given a previous and new state, apply any changes required to bring the current state in line with the new state.
/// </summary>
/// <param name="previousState">The previous (current before this call) serialised state.</param>
/// <param name="newState">The new state to be applied.</param>
protected abstract void ApplyStateChange(byte[] previousState, byte[] newState);
private void updateBindables() private void updateBindables()
{ {
CanUndo.Value = savedStates.Count > 0 && currentState > 0; CanUndo.Value = savedStates.Count > 0 && currentState > 0;

View File

@ -1,9 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using osu.Framework.Allocation;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
namespace osu.Game.Screens.Edit namespace osu.Game.Screens.Edit
@ -11,12 +10,13 @@ namespace osu.Game.Screens.Edit
/// <summary> /// <summary>
/// Interface for a component that manages changes in the <see cref="Editor"/>. /// Interface for a component that manages changes in the <see cref="Editor"/>.
/// </summary> /// </summary>
[Cached]
public interface IEditorChangeHandler public interface IEditorChangeHandler
{ {
/// <summary> /// <summary>
/// Fired whenever a state change occurs. /// Fired whenever a state change occurs.
/// </summary> /// </summary>
event Action OnStateChange; event Action? OnStateChange;
/// <summary> /// <summary>
/// Begins a bulk state change event. <see cref="EndChange"/> should be invoked soon after. /// Begins a bulk state change event. <see cref="EndChange"/> should be invoked soon after.

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // 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. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -16,17 +14,17 @@ namespace osu.Game.Screens.Edit
/// <summary> /// <summary>
/// Fires whenever a transaction begins. Will not fire on nested transactions. /// Fires whenever a transaction begins. Will not fire on nested transactions.
/// </summary> /// </summary>
public event Action TransactionBegan; public event Action? TransactionBegan;
/// <summary> /// <summary>
/// Fires when the last transaction completes. /// Fires when the last transaction completes.
/// </summary> /// </summary>
public event Action TransactionEnded; public event Action? TransactionEnded;
/// <summary> /// <summary>
/// Fires when <see cref="SaveState"/> is called and results in a non-transactional state save. /// Fires when <see cref="SaveState"/> is called and results in a non-transactional state save.
/// </summary> /// </summary>
public event Action SaveStateTriggered; public event Action? SaveStateTriggered;
public bool TransactionActive => bulkChangesStarted > 0; public bool TransactionActive => bulkChangesStarted > 0;
@ -35,7 +33,7 @@ namespace osu.Game.Screens.Edit
/// <summary> /// <summary>
/// Signal the beginning of a change. /// Signal the beginning of a change.
/// </summary> /// </summary>
public void BeginChange() public virtual void BeginChange()
{ {
if (bulkChangesStarted++ == 0) if (bulkChangesStarted++ == 0)
TransactionBegan?.Invoke(); TransactionBegan?.Invoke();

View File

@ -69,8 +69,7 @@ namespace osu.Game.Screens.Play.HUD
{ {
var bindable = (IBindable)property.GetValue(component)!; var bindable = (IBindable)property.GetValue(component)!;
if (!bindable.IsDefault) Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue());
Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue());
} }
if (component is Container<Drawable> container) if (component is Container<Drawable> container)

View File

@ -36,6 +36,11 @@ namespace osu.Game.Skinning
/// </summary> /// </summary>
void Reload(); void Reload();
/// <summary>
/// Reload this target from the provided skinnable information.
/// </summary>
void Reload(SkinnableInfo[] skinnableInfo);
/// <summary> /// <summary>
/// Add a new skinnable component to this target. /// Add a new skinnable component to this target.
/// </summary> /// </summary>
@ -46,6 +51,6 @@ namespace osu.Game.Skinning
/// Remove an existing skinnable component from this target. /// Remove an existing skinnable component from this target.
/// </summary> /// </summary>
/// <param name="component">The component to remove.</param> /// <param name="component">The component to remove.</param>
public void Remove(ISkinnableDrawable component); void Remove(ISkinnableDrawable component);
} }
} }

View File

@ -2,10 +2,12 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Screens.Play.HUD;
namespace osu.Game.Skinning namespace osu.Game.Skinning
{ {
@ -30,16 +32,31 @@ namespace osu.Game.Skinning
Target = target; Target = target;
} }
/// <summary> public void Reload(SkinnableInfo[] skinnableInfo)
/// Reload all components in this container from the current skin. {
/// </summary> var drawables = new List<Drawable>();
public void Reload()
foreach (var i in skinnableInfo)
drawables.Add(i.CreateInstance());
Reload(new SkinnableTargetComponentsContainer
{
Children = drawables,
});
}
public void Reload() => Reload(CurrentSkin.GetDrawableComponent(new GlobalSkinComponentLookup(Target)) as SkinnableTargetComponentsContainer);
public void Reload(SkinnableTargetComponentsContainer? componentsContainer)
{ {
ClearInternal(); ClearInternal();
components.Clear(); components.Clear();
ComponentsLoaded = false; ComponentsLoaded = false;
content = CurrentSkin.GetDrawableComponent(new GlobalSkinComponentLookup(Target)) as SkinnableTargetComponentsContainer; if (componentsContainer == null)
return;
content = componentsContainer;
cancellationSource?.Cancel(); cancellationSource?.Cancel();
cancellationSource = null; cancellationSource = null;