Selection History for Unity's Editor

This small plug-in for Unity's editor makes it possible to easily navigate backward or forward through recently selected objects and view their properties in the editor's Inspector window. If you've ever had to edit the properties of several related objects, this plug-in can save quite a bit of time spent searching for something that was just selected.

This plug-in does not require a UI, so it uses the EditorSingleton class that I discussed in a previous article to create a singleton instance whose fields are persisted across script recompilations (domain reloads). See the download section below to get it and other utilities.

SelectionHistory.cs
// Copyright 2021 by Hextant Studios. https://HextantStudios.com
// This work is licensed under CC BY 4.0. http://creativecommons.org/licenses/by/4.0/
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

namespace Hextant.Editor
{
// Maintains a small history of recently selected objects that can be moved
// backward or forward through similar to a web browser.
// F1: Move back through history.
// Shift + F1: Move forward through history.
public sealed class SelectionHistory : EditorSingleton<SelectionHistory>
{
// Initialize the singleton on editor load.
[InitializeOnLoadMethod]
static void OnLoad() => Initialize();

[MenuItem( "Edit/Selection/History Back _F1" )] // F1
static void OnBack() => instance.Back();

[MenuItem( "Edit/Selection/History Forward #F1" )] // Shift + F1
static void OnForward() => instance.Forward();

// Move backwards one entry in the history.
void Back()
{
// Return if there are no previous entries.
if( _currentIndex <= 0 ) return;

// Move backwards and remove any entries that are now null or the same.
var selected = _current;
while( _currentIndex > 0 &&
IsNullOrEqual( _history[ --_currentIndex ], selected ) )
_history.RemoveAt( _currentIndex );

// Select the current entry if it is valid.
if( !IsNull( _current ) )
Selection.objects = _current;
}

// Move forwards one entry in the history.
void Forward()
{
// Return if there are no newer entries.
if( _currentIndex == _history.Count - 1 ) return;

// Move forwards and remove any entries that are now null or the same.
var selected = _current;
while( _currentIndex < _history.Count - 1 &&
IsNullOrEqual( _history[ ++_currentIndex ], selected ) )
_history.RemoveAt( _currentIndex-- );

// Select the current entry if it is valid.
if( !IsNull( _current ) )
Selection.objects = _current;
}

// Adds the specified objects entry to the history.
void Add( Object[] objects )
{
// Return if the new entry is null or is the same as the current entry.
if( IsNullOrEqual( objects, _current ) ) return;

// Remove oldest entry if full.
if( _history.Count == _maxHistory )
{
_history.RemoveAt( 0 );
--_currentIndex;
}

// Remove "newer" entries.
++_currentIndex;
_history.RemoveRange( _currentIndex, _history.Count - _currentIndex );

// Add the new entry to the history.
_history.Add( objects );
}

// Called initially or when the selection changes to update the history.
void UpdateSelection() => Add( Selection.objects );

// Called when the instance is created or after a domain reload.
void OnEnable()
{
UpdateSelection();
Selection.selectionChanged += UpdateSelection;
}

// Called when the instance is destroyed or before a domain reload.
void OnDisable()
{
Selection.selectionChanged -= UpdateSelection;
}

// True if the Object array 'a' is null or "equal" to 'b'.
static bool IsNullOrEqual( Object[] a, Object[] b ) =>
IsNull( a ) || AreEqual( a, b );

// True if the Object array is null or if all entries are null.
static bool IsNull( Object[] objects )
{
if( objects != null )
foreach( var obj in objects )
if( obj != null ) return false;
return true;
}

// True if there are the same number of non-null entries that are equal.
static bool AreEqual( Object[] a, Object[] b )
{
if( a == b ) return true;
var aLength = a != null ? a.Length : 0;
var bLength = b != null ? b.Length : 0;
for( int ia = 0, ib = 0; ia < aLength || ib < bLength; )
{
if( ia < aLength && a[ ia ] == null ) ++ia;
else if( ib < bLength && b[ ib ] == null ) ++ib;
else if( ia >= aLength || ib >= bLength || a[ ia++ ] != b[ ib++ ] )
return false;
}
return true;
}

// The currently selected objects in the history.
Objects _current => _currentIndex >= 0 ? _history[ _currentIndex ] : default;

// The history of selected objects.
List<Objects> _history = new List<Objects>( _maxHistory );

// The index of the current history entry.
int _currentIndex = -1;

// The maximum number of items in the history.
const int _maxHistory = 15;

// A serializable wrapper for Object[]'s for use inside a List<>.
[System.Serializable]
struct Objects
{
// Convert from Object[] to Objects.
public static implicit operator Objects( Object[] objects ) =>
new Objects { _objects = objects };

// Convert from Objects to Object[].
public static implicit operator Object[]( Objects objects ) =>
objects._objects;

Object[] _objects;
}
}
}

The plug-in starts by initializing the singleton when the editor starts using the [InitializeOnLoadMethod] attribute. This will result in the OnEnable() handler being called which registers a callback for selectionChanged notifications. Whenever the selection changes in the editor, the UpdateSelection() method is called which calls the Add() method to add the currently selected objects (if they differ) to the history. This method also removes any "newer" items in the history list should the user have moved back in the history.

Next, the plug-in uses the [MenuItem] attribute to register two menu items with shortcut keys that move backward and forward in the history. The default key is F1 to move backward and Shift + F1 to move forward, but these can be changed in the code or in the Edit / Shortcuts dialog if need be. The Back() and Forward() methods are similar and simply move the _currentIndex field backward or forward while handling any necessary cleanup in case an object has been deleted from the project.

Supporting a history that contains entries with multiple objects is a little more complicated mainly because Unity's serialization does not support lists or arrays of lists or arrays. One simple solution is to create a [Serializable] wrapper for the array such as the Objects struct above, and the _history field can now be a List<Objects> which will properly serialize between domain reloads.

Finally, the two helper methods IsNull() and AreEqual() are used to properly check for entries that may contain references to deleted objects. To understand why this is needed, suppose the history contained two entries: [{Object1}, {Object1,Object3}]. If Object3 was later deleted, it would leave two adjacent entries that essentially contain just Object1 which would require pressing Back twice to move past.

Well, that's pretty much the extent of it! Thanks to the EditorSingleton base class which derives from ScriptableObject, the _history and _currentIndex field values will be preserved across a domain reload without any special handling. If you happen to run into any issues or have a question, please leave a comment below.

Download