Instantly share code, notes, and snippets.
Created
December 14, 2023 03:19
-
Star
(0)
0
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save fnuecke/aea9c23b5d3a03595c7e4514eaefe936 to your computer and use it in GitHub Desktop.
[Unity] Selection History Navigation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#if UNITY_EDITOR | |
using System; | |
using System.Collections.Concurrent; | |
using System.Collections.Generic; | |
using System.Runtime.InteropServices; | |
using System.Threading; | |
using UnityEditor; | |
using UnityEngine; | |
/// <summary> | |
/// Utility that listens to mouse forward/backward mous button input in the Unity editor to navigate | |
/// through a selection history. This massively improves UX when jumping between different | |
/// GameObjects or assets, for example. It can also be very useful when accidentally selecting | |
/// something else and quickly wanting to go back to what was selected before. | |
/// <para> | |
/// History is manually tracked using the selection changed event. Since we want to grab the | |
/// input in regular edit mode, this is not using Unity's built-in input systems. | |
/// </para> | |
/// <para> | |
/// Currently only Windows is supported. | |
/// </para> | |
/// </summary> | |
internal sealed class SelectionNavigation : ScriptableObject | |
{ | |
[InitializeOnLoadMethod] | |
private static void InitializeSelectionNavigation() | |
{ | |
if (_instance != null) | |
{ | |
return; | |
} | |
var instances = Resources.FindObjectsOfTypeAll<SelectionNavigation>(); | |
if (instances != null && instances.Length > 0) | |
{ | |
return; | |
} | |
CreateInstance<SelectionNavigation>(); | |
} | |
private enum Command | |
{ | |
Forward, | |
Backward | |
} | |
[Serializable] | |
private struct SelectionHistoryEntry | |
{ | |
public int[] value; | |
} | |
private const int MaxHistorySize = 128; | |
private static SelectionNavigation _instance; | |
private readonly ConcurrentQueue<Command> _queue = new ConcurrentQueue<Command>(); | |
[SerializeField] private List<SelectionHistoryEntry> selectionHistory; | |
[SerializeField] private int selectionIndex; | |
private void OnEnable() | |
{ | |
if (_instance != null && _instance != this) | |
{ | |
if (EditorApplication.isPlaying) | |
{ | |
Destroy(this); | |
} | |
else | |
{ | |
DestroyImmediate(this); | |
} | |
return; | |
} | |
_instance = this; | |
hideFlags = HideFlags.DontUnloadUnusedAsset | HideFlags.DontSaveInEditor | HideFlags.DontSaveInBuild; | |
DontDestroyOnLoad(this); | |
if (!RegisterHook()) | |
{ | |
// No support for this platform, no need to track selection history. | |
if (EditorApplication.isPlaying) | |
{ | |
Destroy(this); | |
} | |
else | |
{ | |
DestroyImmediate(this); | |
} | |
return; | |
} | |
AssemblyReloadEvents.beforeAssemblyReload += UnregisterHook; | |
EditorApplication.update += ProcessQueue; | |
Selection.selectionChanged += HandleSelectionChanged; | |
} | |
private void OnDisable() | |
{ | |
UnregisterHook(); | |
while (_queue.TryDequeue(out _)) | |
{ | |
// Remove everything from queue. There's no clear on ConcurrentQueue due to implementation details. | |
} | |
// ReSharper disable DelegateSubtraction | |
EditorApplication.update -= ProcessQueue; | |
Selection.selectionChanged -= HandleSelectionChanged; | |
// ReSharper restore DelegateSubtraction | |
} | |
private void HandleSelectionChanged() | |
{ | |
ValidateState(); | |
var activeSelection = Selection.instanceIDs; | |
var historySelection = selectionHistory[selectionIndex]; | |
if (activeSelection.Length == historySelection.value.Length) | |
{ | |
var matches = true; | |
for (var i = 0; i < historySelection.value.Length; i++) | |
{ | |
if (activeSelection[i] != historySelection.value[i]) | |
{ | |
matches = false; | |
break; | |
} | |
} | |
// No actual change, ignore. This is at least the case when we changed the selection | |
// to navigate the selection history. | |
if (matches) | |
{ | |
return; | |
} | |
} | |
// Drop old future. | |
if (selectionIndex < selectionHistory.Count - 1) | |
{ | |
selectionHistory.RemoveRange(selectionIndex + 1, selectionHistory.Count - 1 - selectionIndex); | |
} | |
// Push state to history. | |
selectionHistory.Add(new SelectionHistoryEntry {value = Selection.instanceIDs}); | |
selectionIndex = selectionHistory.Count - 1; | |
// Trim history to avoid exceeding max length. | |
if (selectionHistory.Count > MaxHistorySize) | |
{ | |
selectionHistory.RemoveRange(0, selectionHistory.Count - MaxHistorySize); | |
} | |
} | |
private void ProcessQueue() | |
{ | |
ValidateState(); | |
while (_queue.TryDequeue(out var direction)) | |
{ | |
switch (direction) | |
{ | |
case Command.Forward: | |
if (selectionIndex < selectionHistory.Count - 1) | |
{ | |
selectionIndex++; | |
Selection.instanceIDs = selectionHistory[selectionIndex].value; | |
} | |
break; | |
case Command.Backward: | |
if (selectionIndex > 0) | |
{ | |
selectionIndex--; | |
Selection.instanceIDs = selectionHistory[selectionIndex].value; | |
} | |
break; | |
default: | |
throw new ArgumentOutOfRangeException(); | |
} | |
} | |
} | |
private void ValidateState() | |
{ | |
if (selectionHistory == null) | |
{ | |
selectionHistory = new List<SelectionHistoryEntry>(); | |
} | |
if (selectionHistory.Count == 0) | |
{ | |
selectionHistory.Add(new SelectionHistoryEntry {value = Selection.instanceIDs}); | |
} | |
// Collapse empty entries -- new ones might be created when ids become invalid. | |
var lastWasEmpty = false; | |
for (var i = selectionHistory.Count - 1; i >= 0; i--) | |
{ | |
var entry = selectionHistory[i]; | |
var isEmpty = true; | |
for (var j = 0; j < entry.value.Length; j++) | |
{ | |
if (EditorUtility.InstanceIDToObject(entry.value[j]) != null) | |
{ | |
isEmpty = false; | |
break; | |
} | |
} | |
if (isEmpty) | |
{ | |
if (lastWasEmpty) | |
{ | |
selectionHistory.RemoveAt(i); | |
} | |
else | |
{ | |
lastWasEmpty = true; | |
} | |
} | |
else | |
{ | |
lastWasEmpty = false; | |
} | |
} | |
if (selectionIndex >= selectionHistory.Count) | |
{ | |
selectionIndex = selectionHistory.Count - 1; | |
} | |
} | |
#if UNITY_EDITOR_WIN | |
private static readonly Mutex HookMutex = new Mutex(false); | |
private int _hookId; | |
private bool RegisterHook() | |
{ | |
if (_hookId == 0) | |
{ | |
#pragma warning disable 618 | |
_hookId = SetWindowsHookEx(WH_MOUSE, HandleMouseInput, IntPtr.Zero, AppDomain.GetCurrentThreadId()); | |
#pragma warning restore 618 | |
} | |
return true; | |
} | |
private void UnregisterHook() | |
{ | |
if (_hookId != 0) | |
{ | |
HookMutex.WaitOne(); | |
UnhookWindowsHookEx(_hookId); | |
HookMutex.ReleaseMutex(); | |
_hookId = 0; | |
} | |
} | |
#region Windows API Fun Land | |
// ReSharper disable InconsistentNaming | |
// ReSharper disable IdentifierTypo | |
// ReSharper disable MemberCanBePrivate.Local | |
// ReSharper disable FieldCanBeMadeReadOnly.Local | |
private const int WH_MOUSE = 7; | |
private const int WM_XBUTTONDOWN = 0x020B; | |
private const int XBUTTON1 = 0x0001; | |
private const int XBUTTON2 = 0x0002; | |
private const int HC_ACTION = 0; | |
[StructLayout(LayoutKind.Sequential)] | |
private struct POINT | |
{ | |
public int X; | |
public int Y; | |
} | |
[StructLayout(LayoutKind.Sequential)] | |
private struct MOUSEHOOKSTRUCT | |
{ | |
public POINT pt; | |
public IntPtr hwnd; | |
public uint wHitTestCode; | |
public IntPtr dwExtraInfo; | |
} | |
[StructLayout(LayoutKind.Sequential)] | |
private struct MOUSEHOOKSTRUCTEX | |
{ | |
public MOUSEHOOKSTRUCT mouseHookStruct; | |
public int mouseData; | |
} | |
// ReSharper restore InconsistentNaming | |
// ReSharper restore IdentifierTypo | |
// ReSharper restore MemberCanBePrivate.Local | |
// ReSharper restore FieldCanBeMadeReadOnly.Local | |
private delegate int LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam); | |
[DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)] | |
private static extern int SetWindowsHookEx(int idHook, LowLevelMouseProc lpFn, IntPtr hInstance, int threadId); | |
[DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)] | |
private static extern bool UnhookWindowsHookEx(int idHook); | |
[DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.StdCall)] | |
private static extern int CallNextHookEx(int idHook, int nCode, IntPtr wParam, IntPtr lParam); | |
private static int HandleMouseInput(int nCode, IntPtr wParam, IntPtr lParam) | |
{ | |
HookMutex.WaitOne(); | |
try | |
{ | |
try | |
{ | |
if (nCode != HC_ACTION) | |
{ | |
return CallNextHookEx(_instance._hookId, nCode, wParam, lParam); | |
} | |
if (wParam.ToInt32() == WM_XBUTTONDOWN) | |
{ | |
var data = (MOUSEHOOKSTRUCTEX) Marshal.PtrToStructure(lParam, typeof(MOUSEHOOKSTRUCTEX)); | |
switch (data.mouseData >> 16) | |
{ | |
case XBUTTON1: | |
_instance._queue.Enqueue(Command.Backward); | |
return 1; | |
case XBUTTON2: | |
_instance._queue.Enqueue(Command.Forward); | |
return 1; | |
} | |
} | |
} | |
catch | |
{ | |
// Never throw exceptions out of hooks called from Windows. | |
} | |
return CallNextHookEx(_instance._hookId, nCode, wParam, lParam); | |
} | |
finally | |
{ | |
HookMutex.ReleaseMutex(); | |
} | |
} | |
#endregion | |
#else | |
private static bool RegisterHook() | |
{ | |
// Unsupported platform. | |
return false; | |
} | |
private static void UnregisterHook() | |
{ | |
// Unsupported platform. | |
} | |
#endif | |
} | |
#endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment