Search Unity

(Free) DEVBUS: (Developer Extermely Vriendly Better Undo System)

Discussion in 'Assets and Asset Store' started by vexe, Jul 21, 2014.

  1. vexe

    vexe

    Joined:
    May 18, 2013
    Posts:
    644
    Hi all,

    if you've ever worked with editor scripts it won't be long till you notice that Unity's Undo system is not so flexible. A lot of the times you want to provide support for custom operations, using a simple memento-pattern-implemented undo system might not do the trick. A better, more flexible and extensible way to implement an undo system is via the command pattern.

    This is what DEVBUS essentially is, an undo system implemented via the command pattern.
    It gives you the ability to define what it is to be done, and how it is to be undone.

    It has been a feature request for a while so I thought I give it a shot.

    I used it with great success when I supported undo for my uFAction.

    To write an operation, just implement IOperation and write you do/undo logic. In IOperation you also get two delegates, OnPerformed (gets fired when the operation is performed) and OnUndone (gets fired when the operation is undone)

    Code (csharp):
    1.  
    2.    public interface IOperation
    3.    {
    4.      void Perform();
    5.      void Undo();
    6.      Action OnPerformed { get; set; }
    7.      Action OnUndone { get; set; }
    8.    }
    9.  
    The examples include list operations, such as adding, removing, clearing, inserting etc. There's also a SelectionMemorizer example so you could go back and forth between objects you select in the editor.

    The API is not static, to work with DEVBUS you have to instantiate an instance of BetterUndo. Then when you want to perform an operation, you just create one and register it in the BetterUndo instance you created..

    Code (csharp):
    1.  
    2. var undo = new BetterUndo();
    3. var list = new List<int> { 1, 2, 3 };
    4. var clear = new ClearList<int>
    5. {
    6.        GetList = () => list,
    7.        OnPerformed = () => Debug.Log("Cleared"),
    8.        OnUndone = () => Debug.Log("Uncleared")
    9. };
    10. undo.Register(clear);
    11. // somewhere else...
    12. clear.Perform();
    13. // later on....
    14. undo.Undo();
    15.  
    Registering an operation will push it to the undo stack and clears the redo stack.
    Usually one would register an op and perform it immediately, but you could perform it later like you see in the previous example. So we could just write:

    Code (csharp):
    1.  
    2. undo.RegisterThenPerform(clear);
    3.  
    Of course, clearing a list is common so I wrote some helper methods for common ops so we could just write:

    Code (csharp):
    1.  
    2. undo.RecordClearList(() => list, () => Debug.Log("Cleared"), () => Debug.Log("Uncleared"));
    3.  
    The delegates are optional of course, so we can just write:
    Code (csharp):
    1.  
    2. undo.RecordClearList(() => list);
    3.  
    "Record" pretty much wraps the creation of the operation, registers it and performs it.

    You might ask, does this work with Ctrl-Z? Well, no - but it could be setup to work with a custom key binding. Be default you could hold Ctrl+Alt and press 'u' to undo, and Ctrl+Alt+r to redo.
    The keybindings are actually just MenuItems. And as we know, for a MenuItem method to execute it has to be static so we need to somehow make our undo instance ^ globally available... All we have to do is:

    Code (csharp):
    1.  
    2. BetterUndo _undo = new BetterUndo();
    3. BetterUndo undo { get { return BetterUndo.MakeCurrent(ref _undo); } }
    4.  
    Then from here on out we use 'undo' instead of '_undo' because it is now the current static globally available instance so it could be accessed from MenuItem methods. Hope that makes sense.

    EDIT: Actually I forgot I had a public Current, so you could just do:
    Code (csharp):
    1.  
    2. BetterUndo undo { get { return BetterUndo.Current; } }
    3.  
    Please see the SelectionMemorizer and DevbusTestEditor in the package for more usage examples to make it clear.

    NOTES:
    1. DEVBUS could be used in runtime and not just in the editor. So you could use it to handle undo in your games too!
    2. For Editor-only operations, the operation class should be put in an editor folder. If you want to write a RecordXXX helper method (like the above RecordClearList) to easily perform your op, write it as an extension method (See BetterUndoEditorExtensions as an example for the selection operation)
    3. Same thing for runtime operations, if you want to write helper RecordXXX methods for them write then as extension methods.
    4. The undo/redo stacks persist only for one editing session. i.e. if you exit out of Unity, come back and try to perform an undo, there's nothing to undo. Same thing if you enter/exit playmode. The stacks don't survive assembly reloads.

    The reasons for 2 and 3 is because if you add the helper methods by modifying the original source of BetterUndo.cs, there's a chance that your modifications would be lost when you update to a new DEVBUS version. So just to make sure nothing gets lost, write what you want to add as extension methods and keep them somewhere they won't get overwritten.

    I hope you find this useful like I did! - Let me know how it goes.

    I got a refuse from the asset store cause I was using my name as an menu item, they didn't like that. I'm cool with that, but it'd be nice if they apply this rule to 'everybody' - But it seems the rule doesn't apply to hot-shot assets. They also claimed there was no doc... I'm not sure how far they went reading the source files, but every file was pretty much documented. The code is pretty much self-explanatory. Anyway, I attached the .unitypackage file. Will re-up again though. Here's the repository link as well.

    License? Do whatever you want with it, just don't say you wrote it.

    Any questions/feature requests/bug reports you could post 'em here or email me at askvexe@gmail.com

    Thanks for reading!
     

    Attached Files:

    Last edited: Jul 22, 2014
    EliasMasche likes this.
  2. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    Looks sweet;) Have you thought of implementing it in the current system by creating a dummy entry and use the undoRedoPerformed callback? That way we could extend the current system easily. Actually, I'll give it a go now:) I'll post back later when I'm done
     
  3. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    Addition: IF I'll ever get done.
    I have the dummy set up, and now try to recognise in the 'Undo.undoRedoPerformed' callback whether any of my dummys were undone/redone. As complained on some other threads, you can't even differenciate between Undo/Redo, let alone which record.

    It's impossible to compose that out of the information given - it's called after performing undo/redo, so GetCurrentGroup does not return the record which was performed, and you cannot rely on the id (which normally increases which each record by one, but gives unrealiable results sadly:( ).

    I tried to keep track of the current undo stack - though I cannot reach it with reflection, as it's internal completely. Trying to built it manually using the Undo.willFlushUndoRecord callback, which should be called everytime a record is added, doesn't work neither, as it only gets called for custom undo calls, not for built-in ones like Selection changes:(

    Any idea on these problems? Maybe a new approach? Or, maybe, any pattern in the ID stuff?

    EDIT: Maybe keeping track of the stack by calling GetCurrentGroup inside update, and comparing it with previous results, works. I'll try now:)
     
  4. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    OK For anyones interest, I've found out some things:
    For each Event unity fires, theres an undo record for it. Every mouse click, drag, and so on.
    What we see in the undo menu is simply the group name: When we undo, we do not notice the lot of Undo records. But with each action we do in the editor, it's either incrementing the current group ID, if it's a meaningless mouse click, or it creates a new group, like when we change selection.

    All we can receive of infos is the group name ("Selection Change" for example) and it's id. With the update approach I found I proposed above I could not completely differenciate when a new record was fired: The name can be the same (two times "Selection Change") and the id increments each event nevertheless.

    When performing an undo/redo, two records seem to be fired in the same frame: the Undo/redo button click and the actual undo/redo perform. That results in an id increment of two. Even though I step a record back, the id does not point to the initial record.

    That gives me no way to keep track of the records, not even with a horrible hack;)
    Theres also not much to fetch by reflection - other than
    Code (CSharp):
    1. [WrapperlessIcall]
    2. [MethodImpl (MethodImplOptions.InternalCall)]
    3. private static extern void GetRecordsInternal (object undoRecords, object redoRecords);
    But I still have no idea how I would read the record lists out of the objects - might need to search for a built-in function which does that. Nasty:(

    EDIT: Simply switching language to IL (top-right) shows the function as:
    Code (CSharp):
    1. .method assembly hidebysig static
    2.         void GetRecords (
    3.             class [mscorlib]System.Collections.Generic.List`1<string> undoRecords,
    4.             class [mscorlib]System.Collections.Generic.List`1<string> redoRecords
    5.         ) cil managed
    6.     {
    7.         // Method begins at RVA 0x1cec8
    8.         // Code size 8 (0x8)
    9.         .maxstack 8
    10.  
    11.         IL_0000: ldarg.0
    12.         IL_0001: ldarg.1
    13.         IL_0002: call void UnityEditor.Undo::GetRecordsInternal(object, object)
    14.         IL_0007: ret
    15.     } // end of method Undo::GetRecords
    16.  
    They're simply Generic Lists of strings! :)

    EDIT2: Managed to fetch those list of records correctly, but it behaves way different from what I've been experiencing with the previous method... And I only get the names, not the ids. Indices are not the ids... though they are in the correct order atleast:)
     
    Last edited: Oct 8, 2015
  5. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    I got it to work, finally:)
    I ended up not using any of your DEVBUS code, and now it's a simple static script integrating into Unity's default Undo system:) It also provides you with UndoPerformed(string) and RedoPerformed(string) callbacks:)

    Before publishing, I need to find a easy ways to store the static list of custom records though. Currently I managed to pack everything into a single script, don't want to use a gameobject in the scene, an asset in the project nor anything else... Maybe EditorPrefs, but the data needs only to be stored for this session... I'll take a look at that.

    FYI, one single reflection call is needed
    1) when recording undos using actions
    2) when an undo/redo is performed, be it a default undo or a custom one

    It's also a fragile system, as not much information is avaiable, I had to rely on the names of the records. That means that you cannot use default undo names, else the default ones will trigger the custom ones named the same. But inside the system itself, you can have multiple records being called the same. I may find a way to not interfer with records from other Editor extensions outside of this system, as these will call some callback (beforeFlushObjectRecords?).
     
  6. vexe

    vexe

    Joined:
    May 18, 2013
    Posts:
    644
    Great digging there! I liked that you looked up the IL code and found it's a List, yeah never trust decompiled code. Which decompiler you used? Are you just trying to get two different callbacks of when Undo/Redo happens?
     
  7. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    Thanks, actually my goal was to be able to record custom undo/redo actions, which is working now:) There might be some case I didn't met yet that cause unexpected behaviour though. On the way of developing that I needed these callbacks, that's why I implemented them along.
    In the end, that means you can record custom actions for undo/redo in the builtin system and I also provided callbacks for undo/redo which pass the name of the record;) I can post the code later when I finished saving of the custom record list, currently it gets lost when reompiling as it's static:(

    And I simply used Mono's Assembly browser for decompiling;)
     
    Last edited: Oct 22, 2015
  8. vexe

    vexe

    Joined:
    May 18, 2013
    Posts:
    644
    You could save your stuff in a .asset file, scriptable object. I don't know how you're saving the state, but maybe serialize it to string and save it in the .asset file. And I always use ILSpy, very dependable.
     
  9. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    I've done a alot with scriptable objects in the last time, they're great;) But as long as I can't save to the Temp folder :>( or hide it thats no option. Now I want to save to a hidden gameobject in the scene with the hideAndDontSave Hideflags on. Thats the best way to copy the behaviour of the default undo system, it only persists from scene load until unload or editor shutdown.
     
  10. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    Ok that system I made is still too unreliable. My problem is, I have the internal records list from reflection, and when the undoRedo callback is called, I check what has happened (Undo or redo, which record, ...). For that to work, the actual list has to be the one right before the Undo/Redo. Currently I fetch them on specific undo callbacks and records, Update would be horrible for performance. Some undo actions (adding records, for Example) do not have any callbacks though, so there might have happened other modifications to the lists other than the undo/redo. That makes everything way more complicated:( That means it does kinda work but there are cases where the system currently screws it up:(
     
  11. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    I ended up with simply fetching that update every frame, which is not optimal for performance (reflection call every frame!! :( ) but I have no other choice to make it work right not. Maybe an Event will do (OnGUI?). Besides that, another big bummer: I planned to somehow save it so the custom records do not vanish when recompiling. For that I nned to serialize them in any way though, and delegates cannot be serialized :( Nothing I can do there, unfortunately...

    Anyway, here's the code:
    Code (csharp):
    1.  
    2. using UnityEngine;
    3. using UnityEditor;
    4. using System;
    5. using System.Reflection;
    6. using System.Collections.Generic;
    7. using System.Linq;
    8. using System.IO;
    9.  
    10. [InitializeOnLoad]
    11. public static class UndoPro
    12. {
    13.     private static MethodInfo getRecordsInternal;
    14.  
    15.     public static List<string> undoRecords = new List<string> ();
    16.     public static List<string> redoRecords = new List<string> ();
    17.  
    18.     public static UndoProRecord record;
    19.  
    20.     public static Action<string> UndoPerformed;
    21.     public static Action<string> RedoPerformed;
    22.  
    23.     static UndoPro ()
    24.     {
    25.         Undo.undoRedoPerformed += UndoRedoPerformed;
    26.         Undo.willFlushUndoRecord += willFlushRecord;
    27.         EditorApplication.update += Update;
    28.  
    29.         Assembly UnityEditorAsssembly = Assembly.GetAssembly (typeof(UnityEditor.Editor));
    30.         Type undoType = UnityEditorAsssembly.GetType ("UnityEditor.Undo");
    31.         getRecordsInternal = undoType.GetMethod ("GetRecordsInternal", BindingFlags.NonPublic | BindingFlags.Static);
    32.  
    33.         FetchRecords ();
    34.         CheckUndoProRecord ();
    35.     }
    36.  
    37.     private static void CheckUndoProRecord ()
    38.     {
    39.         if (record == null || record.scene != EditorApplication.currentScene)
    40.         {
    41.             record = AssetDatabase.LoadAssetAtPath<UndoProRecord> ("Assets/ProUndoRecord.asset");
    42.             if (record == null)
    43.             {
    44.                 record = new UndoProRecord ();
    45.                 record.scene = EditorApplication.currentScene;
    46.                 record.proUndoRecords = new List<UndoRedoOperation> ();
    47.                 record.proRedoRecords = new List<UndoRedoOperation> ();
    48.  
    49.                 //AssetDatabase.CreateAsset (record, "Assets/ProUndoRecord.asset");
    50.                 //AssetDatabase.Refresh ();
    51.             }
    52.             else if (record.scene != EditorApplication.currentScene)
    53.             {
    54.                 record.scene = EditorApplication.currentScene;
    55.                 record.proRedoRecords = new List<UndoRedoOperation> ();
    56.                 record.proUndoRecords = new List<UndoRedoOperation> ();
    57.             }
    58.         }
    59.     }
    60.  
    61.     #region Custom Undo Recording
    62.  
    63.     public static void RecordOperationAndPerform (Action perform, Action undo, string label)
    64.     {
    65.         RecordOperation (new UndoRedoOperation (perform, undo, label));
    66.         if (perform != null)
    67.             perform.Invoke ();
    68.     }
    69.    
    70.     public static void RecordOperation (Action perform, Action undo, string label)
    71.     {
    72.         RecordOperation (new UndoRedoOperation (perform, undo, label));
    73.     }
    74.    
    75.     public static void RecordOperation (UndoRedoOperation operation)
    76.     {
    77.         CheckUndoProRecord ();
    78.         record.proRedoRecords.Clear ();
    79.  
    80.         Undo.IncrementCurrentGroup ();
    81.        
    82.         record.proUndoRecords.Add (operation);
    83.         // Creating a dummy entry is best done with a simple object creation (which needs to change afterwards to force recording)
    84.         UnityEngine.Object dummyObj = new Material (Shader.Find ("Unlit/Color"));
    85.         Undo.RecordObject (dummyObj, operation.label);
    86.         dummyObj.name = "tempDummyObj";
    87.         Undo.FlushUndoRecordObjects ();
    88.        
    89.         Undo.IncrementCurrentGroup ();
    90.     }
    91.  
    92.     #endregion
    93.  
    94.     #region Catching Undo/Redo events
    95.  
    96.     private static void UndoRedoPerformed ()
    97.     {
    98.         CheckUndoProRecord ();
    99.        
    100.         int prevUndoCnt = undoRecords.Count, prevRedoCnt = redoRecords.Count;
    101.         string prevUndoRecord = prevUndoCnt!=0? undoRecords[prevUndoCnt-1] : "";
    102.         string prevRedoRecord = prevRedoCnt!=0? redoRecords[prevRedoCnt-1] : "";
    103.        
    104.         FetchRecords ();
    105.        
    106.         int newUndoCnt = undoRecords.Count, newRedoCnt = redoRecords.Count;
    107.         string newUndoRecord = newUndoCnt!=0? undoRecords[newUndoCnt-1] : "";
    108.         string newRedoRecord = newRedoCnt!=0? redoRecords[newRedoCnt-1] : "";
    109.        
    110.         // TODO: Still unreliable, alot of things could happen (add record, ...) without taking notice after the last FetchRecords call and thus it's hard to keep track.
    111.        
    112.         if (prevUndoCnt>newUndoCnt && prevRedoCnt<newRedoCnt && prevUndoRecord == newRedoRecord)
    113.         {
    114.             //Debug.Log ("Performed Undo on " + prevUndoRecord);
    115.             UndoRedoOperation UndoRecord = GetCurrentProUndo (prevUndoRecord);
    116.             if (UndoRecord != null)
    117.             {
    118.                 if (UndoRecord.undo != null)
    119.                     UndoRecord.undo.Invoke ();
    120.                 record.proUndoRecords.RemoveAt (record.proUndoRecords.Count-1);
    121.                 record.proRedoRecords.Add (UndoRecord);
    122.             }
    123.             if (UndoPerformed != null)
    124.                 UndoPerformed.Invoke (prevUndoRecord);
    125.            
    126.         }
    127.         else if (prevUndoCnt<newUndoCnt && prevRedoCnt>newRedoCnt && prevRedoRecord == newUndoRecord)
    128.         {
    129.             //Debug.Log ("Performed Redo on " + prevRedoRecord);
    130.             UndoRedoOperation RedoRecord = GetCurrentProRedo (prevRedoRecord);
    131.             if (RedoRecord != null)
    132.             {
    133.                 if (RedoRecord.perform != null)
    134.                     RedoRecord.perform.Invoke ();
    135.                 record.proRedoRecords.RemoveAt (record.proRedoRecords.Count-1);
    136.                 record.proUndoRecords.Add (RedoRecord);
    137.             }
    138.             if (RedoPerformed != null)
    139.                 RedoPerformed.Invoke (prevRedoRecord);
    140.         }
    141.         else
    142.             Debug.LogError ("Could not recognise event! Error!");
    143.     }
    144.  
    145.     #endregion
    146.  
    147.     #region Internal Undo/Redo List Update
    148.  
    149.     private static void FetchRecords ()
    150.     {
    151.         undoRecords = new List<string> ();
    152.         redoRecords = new List<string> ();
    153.         getRecordsInternal.Invoke (null, new object[2] { undoRecords, redoRecords });
    154.     }
    155.    
    156. //    private static bool shouldFetchRecords;
    157.     private static void willFlushRecord ()
    158.     {
    159. //        shouldFetchRecords = true;
    160.     }
    161.     private static void Update ()
    162.     {
    163.         CheckUndoProRecord ();
    164.         FetchRecords ();
    165. //        if (shouldFetchRecords)
    166. //        {
    167. //            FetchRecords ();
    168. //            shouldFetchRecords = false;
    169. //        }
    170.     }
    171.  
    172.     #endregion
    173.  
    174.     #region Custom Undo fetch
    175.  
    176.     public static UndoRedoOperation GetCurrentProUndo (string undoLabel)
    177.     {
    178.         if (record.proUndoRecords.Count == 0)
    179.             return null;
    180.         UndoRedoOperation undoOP = record.proUndoRecords[record.proUndoRecords.Count-1];
    181.         return (undoOP != null && undoOP.label == undoLabel)? undoOP : null;
    182.     }
    183.    
    184.     public static UndoRedoOperation GetCurrentProRedo (string redoLabel)
    185.     {
    186.         if (record.proRedoRecords.Count == 0)
    187.             return null;
    188.         UndoRedoOperation redoOP = record.proRedoRecords[record.proRedoRecords.Count-1];
    189.         return (redoOP != null && redoOP.label == redoLabel)? redoOP : null;
    190.     }
    191.  
    192.     #endregion
    193.  
    194.     [Serializable]
    195.     public class UndoRedoOperation
    196.     {
    197.         public Action perform;
    198.         public Action undo;
    199.         public string label;
    200.  
    201.         public UndoRedoOperation (Action PerformAction, Action UndoAction, string Label)
    202.         {
    203.             perform = PerformAction;
    204.             undo = UndoAction;
    205.             label = Label;
    206.         }
    207.     }
    208. }
    209.  
    210. public class UndoProRecord : UnityEngine.Object
    211. {
    212.     public string scene;
    213.     public List<UndoPro.UndoRedoOperation> proRedoRecords;
    214.     public List<UndoPro.UndoRedoOperation> proUndoRecords;
    215. }
    216.  
    UndoProRecord is an object that holds the data to be saved. Does not work, currently, but if anyone finds a way to serialize it properly, please tell me! ;)
     
  12. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    I found you've got a tool called uFAction that can serialize actions - really cool! ;) Also found a blog about it, might be worth looking into. Basically I have to serialize the action to binary aswell as the targets, too. That also might give me the option to save to other folders than the Assets folder (like the Temp folder), as I do not need to use AssetDatabase:)
     
  13. vexe

    vexe

    Joined:
    May 18, 2013
    Posts:
    644
    uFAction is obsolete. I'm not developing it anymore. Use the delegates stuff in VFW instead.
     
  14. Seneral

    Seneral

    Joined:
    Jun 2, 2014
    Posts:
    1,206
    It looks brilliant, just think that might be too much (iE whole framework my yet one script is dependant on) for just serializing delegates. I may go another way and implement it myself and suited for that use case.

    And, btw, you've got some very sweet stuff published;)
    Especially Fast.Reflection, I am doing alot with Reflection in the editor recently implementing stuff which couldn't be done without hacks (proper in-group GUI scaling hacking into the grouping system, undo pro now, ...), and a faster reflection would ease my life greatly ;)
    My UndoPro attempt would enormously benefit from it, I would be able to call that list update on every update call without getting performance problems :D