2025-05-09 15:40:34 +08:00

442 lines
17 KiB
C#

// Animancer // Copyright 2018-2024 Kybernetik //
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using Object = UnityEngine.Object;
namespace Animancer.Editor
{
/// <summary>[Editor-Only]
/// A window for managing a copy of some serialized data and applying or reverting it.
/// </summary>
/// <remarks>
/// This system assumes the implementation of <see cref="IEquatable{T}"/>
/// compares the values of all fields in <typeparamref name="TData"/>.
/// </remarks>
/// https://kybernetik.com.au/animancer/api/Animancer.Editor/SerializedDataEditorWindow_2
public abstract class SerializedDataEditorWindow<TObject, TData> : EditorWindow
where TObject : Object
where TData : class, ICopyable<TData>, IEquatable<TData>, new()
{
/************************************************************************************************************************/
[SerializeField]
private TObject _SourceObject;
/// <summary>The object which contains the data this class manages.</summary>
/// <remarks><see cref="SetAndCaptureSource"/> should generally be used instead of setting this property directly.</remarks>
public virtual TObject SourceObject
{
get => _SourceObject;
protected set => _SourceObject = value;
}
/************************************************************************************************************************/
/// <summary>The <see cref="Data"/> field of the <see cref="SourceObject"/>.</summary>
public abstract TData SourceData { get; set; }
/************************************************************************************************************************/
[SerializeField]
private TData _Data;
/// <summary>A copy of the <see cref="SourceData"/> being managed by this window.</summary>
public ref TData Data
=> ref _Data;
/************************************************************************************************************************/
/// <summary>Is the <see cref="Data"/> managed by this window different to the <see cref="SourceData"/>.</summary>
public bool HasDataChanged
{
get
{
try
{
return _Data != null && !_Data.Equals(SourceData);
}
catch (Exception exception)
{
Debug.LogException(exception);
return false;
}
}
}
/************************************************************************************************************************/
/// <summary>Initializes this window.</summary>
protected virtual void OnEnable()
{
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
EditorApplication.wantsToQuit += OnTryCloseEditor;
Undo.undoRedoPerformed += Repaint;
}
/// <summary>Cleans up this window.</summary>
protected virtual void OnDisable()
{
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
EditorApplication.wantsToQuit -= OnTryCloseEditor;
Undo.undoRedoPerformed -= Repaint;
}
/************************************************************************************************************************/
/// <summary>
/// Prompts the user to <see cref="Apply"/> or <see cref="Revert"/>
/// if there are changes in the <see cref="Data"/> when this window is closed.
/// </summary>
protected virtual void OnDestroy()
{
var sourceObject = SourceObject;
if (sourceObject == null ||
!HasDataChanged ||
titleContent == null)
return;
if (EditorUtility.DisplayDialog(
titleContent.text,
$"Apply unsaved changes to '{sourceObject.name}'?",
"Apply",
"Revert"))
{
Apply();
}
}
/************************************************************************************************************************/
/// <summary>Called before closing the Unity Editor to confirm that un-saved data is applied.</summary>
private bool OnTryCloseEditor()
{
var sourceObject = SourceObject;
if (sourceObject == null ||
!HasDataChanged ||
titleContent == null)
return true;
var option = EditorUtility.DisplayDialogComplex(
titleContent.text,
$"Apply unsaved changes to '{sourceObject.name}'?",
"Apply",
"Cancel",
"Revert");
switch (option)
{
case 0:// Apply.
Apply();
return true;
case 2:// Revert.
Revert();
return true;
case 1:// Cancel.
default:
return false;
}
}
/************************************************************************************************************************/
/// <summary>
/// Sets the <see cref="SourceObject"/> and captures the <see cref="Data"/>
/// as a copy of its <see cref="SourceData"/>.
/// </summary>
protected void SetAndCaptureSource(TObject sourceObject)
{
_SourceObject = sourceObject;
CaptureData();
Repaint();
}
/************************************************************************************************************************/
/// <summary>
/// Override this to return <c>true</c> if the <see cref="SourceObject"/> could be part of a prefab
/// to ensure that modifications are serialized properly.
/// </summary>
public virtual bool SourceObjectMightBePrefab
=> false;
/************************************************************************************************************************/
/// <summary>Saves the edited <see cref="Data"/> into the <see cref="SourceObject"/>.</summary>
public virtual void Apply()
{
var sourceObject = SourceObject;
if (sourceObject == null)
return;
using (new ModifySerializedField(sourceObject, name, SourceObjectMightBePrefab))
{
SourceData = _Data.CopyableClone();
if (EditorUtility.IsPersistent(SourceObject))
{
var objects = SetPool.Acquire<Object>();
GatherObjectReferences(sourceObject, objects);
foreach (var obj in objects)
if (!EditorUtility.IsPersistent(obj))
AssetDatabase.AddObjectToAsset(obj, SourceObject);
SetPool.Release(objects);
}
}
Repaint();
AssetDatabase.SaveAssets();
}
/************************************************************************************************************************/
/// <summary>Gathers all objects referenced by the `root`.</summary>
public static void GatherObjectReferences(Object root, HashSet<Object> objects)
{
using var serializedObject = new SerializedObject(root);
var property = serializedObject.GetIterator();
while (property.Next(true))
{
if (property.propertyType == SerializedPropertyType.ObjectReference)
{
var value = property.objectReferenceValue;
if (value != null)
objects.Add(value);
}
}
}
/************************************************************************************************************************/
/// <summary>Restores the <see cref="Data"/> to the original values from the <see cref="SourceData"/>.</summary>
public virtual void Revert()
{
RecordUndo();
CaptureData();
}
/************************************************************************************************************************/
/// <summary>Stores a copy of the <see cref="SourceData"/> in the <see cref="Data"/>.</summary>
protected virtual void CaptureData()
{
_Data = SourceData?.CopyableClone() ?? new();
AnimancerReflection.TryInvoke(_Data, "OnValidate");
}
/************************************************************************************************************************/
/// <summary>Records the current state of this window so it can be undone later.</summary>
public TData RecordUndo()
=> RecordUndo(titleContent.text);
/// <summary>Records the current state of this window so it can be undone later.</summary>
public virtual TData RecordUndo(string name)
{
Undo.RecordObject(this, name);
Repaint();
return _Data;
}
/************************************************************************************************************************/
/// <summary>
/// Opens a new <typeparamref name="TWindow"/> for the `sourceObject`
/// or gives focus to an existing window that was already displaying it.
/// </summary>
public static TWindow Open<TWindow>(
TObject sourceObject,
bool onlyOneWindow = false,
params Type[] desiredDockNextTo)
where TWindow : SerializedDataEditorWindow<TObject, TData>
{
if (!onlyOneWindow)
{
foreach (var window in Resources.FindObjectsOfTypeAll<TWindow>())
{
if (window.SourceObject == sourceObject)
{
window.Show();
window.SetAndCaptureSource(sourceObject);
window.Focus();
return window;
}
}
}
var newWindow = onlyOneWindow
? GetWindow<TWindow>(desiredDockNextTo ?? Type.EmptyTypes)
: CreateInstance<TWindow>();
newWindow.Show();
newWindow.SetAndCaptureSource(sourceObject);
return newWindow;
}
/************************************************************************************************************************/
#region Auto Apply
/************************************************************************************************************************/
/// <summary>The <see cref="EditorPrefs"/> key for <see cref="AutoApply"/>.</summary>
protected virtual string AutoApplyPref
=> $"{titleContent.text}.{nameof(AutoApply)}";
/************************************************************************************************************************/
private bool _HasLoadedAutoApply;
private bool _AutoApply;
private bool _EnabledAutoApplyInPlayMode;
/// <summary>Is the "Auto Apply" toggle currently enabled?</summary>
public bool AutoApply
{
get
{
if (!_HasLoadedAutoApply)
{
_HasLoadedAutoApply = true;
_AutoApply = EditorPrefs.GetBool(AutoApplyPref);
}
return _AutoApply;
}
set
{
_HasLoadedAutoApply = true;
_AutoApply = value;
_EnabledAutoApplyInPlayMode = _AutoApply && EditorApplication.isPlayingOrWillChangePlaymode;
EditorPrefs.SetBool(AutoApplyPref, value);
}
}
/************************************************************************************************************************/
/// <summary>Handles entering and exiting Play Mode.</summary>
protected virtual void OnPlayModeStateChanged(PlayModeStateChange change)
{
switch (change)
{
case PlayModeStateChange.EnteredPlayMode:
if (HasDataChanged && focusedWindow != null)
focusedWindow.ShowNotification(new($"{titleContent.text} window has un-applied changes"));
break;
case PlayModeStateChange.ExitingPlayMode:
if (_EnabledAutoApplyInPlayMode)
AutoApply = false;
break;
}
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
#region GUI
/************************************************************************************************************************/
private static readonly GUIContent
RevertLabel = new(
"Revert",
"Undo all changes made in this window"),
ApplyLabel = new(
"Apply",
"Apply all changes made in this window to the source object"),
AutoApplyLabel = new(
"Auto",
"Immediately apply all changes made in this window to the source object?" +
"\n\nIf enabled in Play Mode, this toggle will be disabled when returning to Edit Mode.");
/************************************************************************************************************************/
/// <summary>
/// Calculates the pixel width required for
/// <see cref="DoApplyRevertGUI(Rect, Rect, Rect, ButtonGroupStyles)"/>.
/// </summary>
public float CalculateApplyRevertWidth(ButtonGroupStyles styles = default)
{
styles.CopyMissingStyles(ButtonGroupStyles.Button);
return
styles.left.CalculateWidth(RevertLabel) +
styles.middle.CalculateWidth(ApplyLabel) +
styles.right.CalculateWidth(AutoApplyLabel);
}
/************************************************************************************************************************/
/// <summary>Draws GUI controls for <see cref="Revert"/>, <see cref="Apply"/>, and <see cref="AutoApply"/>.</summary>
public void DoApplyRevertGUI(ButtonGroupStyles styles = default)
{
styles.CopyMissingStyles(ButtonGroupStyles.Button);
GUILayout.BeginHorizontal();
var leftArea = GUILayoutUtility.GetRect(RevertLabel, styles.left);
var middleArea = GUILayoutUtility.GetRect(ApplyLabel, styles.middle);
var rightArea = GUILayoutUtility.GetRect(AutoApplyLabel, styles.right);
DoApplyRevertGUI(leftArea, middleArea, rightArea, styles);
GUILayout.EndHorizontal();
}
/************************************************************************************************************************/
/// <summary>Draws GUI controls for <see cref="Revert"/>, <see cref="Apply"/>, and <see cref="AutoApply"/>.</summary>
public void DoApplyRevertGUI(Rect area, ButtonGroupStyles styles = default)
{
styles.CopyMissingStyles(ButtonGroupStyles.Button);
var leftArea = AnimancerGUI.StealFromLeft(ref area, styles.left.CalculateWidth(RevertLabel));
var middleArea = AnimancerGUI.StealFromLeft(ref area, styles.middle.CalculateWidth(ApplyLabel));
DoApplyRevertGUI(leftArea, middleArea, area, styles);
}
/************************************************************************************************************************/
/// <summary>Draws GUI controls for <see cref="Revert"/>, <see cref="Apply"/>, and <see cref="AutoApply"/>.</summary>
public void DoApplyRevertGUI(
Rect leftArea,
Rect middleArea,
Rect rightArea,
ButtonGroupStyles styles = default)
{
styles.CopyMissingStyles(ButtonGroupStyles.Button);
var enabled = GUI.enabled;
GUI.enabled = SourceObject != null && HasDataChanged;
// Revert.
if (GUI.Button(leftArea, RevertLabel, styles.left))
Revert();
// Apply.
if (GUI.Button(middleArea, ApplyLabel, styles.middle))
Apply();
// Auto Apply.
var autoApply = AutoApply;
if (autoApply && GUI.enabled)
Apply();
GUI.enabled = enabled;
if (autoApply != GUI.Toggle(rightArea, autoApply, AutoApplyLabel, styles.right))
AutoApply = !autoApply;
}
/************************************************************************************************************************/
#endregion
/************************************************************************************************************************/
}
}
#endif