Last active
April 28, 2022 22:08
-
-
Save kraj0t/cd79b7fbad917f6f1897b760ee98b78c to your computer and use it in GitHub Desktop.
DisplayKeyboardShortcuts - a Unity editor tool that shows the keys and shortcuts that the user is pressing
This file contains hidden or 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
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Reflection; | |
using System.Text; | |
using UnityEditor; | |
using UnityEditor.ShortcutManagement; | |
using UnityEngine; | |
[InitializeOnLoad] | |
internal class DisplayKeyboardShortcuts | |
{ | |
private const string SETTINGS_PATH = "_AURELIO/Display Shortcuts"; | |
private const string KEY_ENABLED = "DisplayKeyboardShortcuts_enabled"; | |
private const string KEY_ONLYSHOWKEYS = "DisplayKeyboardShortcuts_onlyShowKeys"; | |
private const string KEY_SHOWFULLSHORTCUTPATHS = "DisplayKeyboardShortcuts_showFullShortcutPaths"; | |
private const string KEY_IGNOREKEYPRESSESWITHOUTSHORTCUTS = | |
"DisplayKeyboardShortcuts_ignoreKeypressesWithoutShortcut"; | |
private const int NOTIFICATION_MAX_CHARS_PER_LINE = 20; | |
private static readonly GUIContent _notificationGuiContent = new GUIContent(); | |
private static readonly StringBuilder _sb = new StringBuilder(); | |
private static bool _onlyShowKeys = false; | |
private static bool _showFullShortcutPaths = false; | |
private static bool _ignoreKeypressesWithoutShortcut = false; | |
private static KeyCombination _pressedKeyCombination; | |
// Cache the reflection stuff to skip as much of it as possible on every call. | |
private static MethodInfo _findShortcutsInternalMethodInfo; | |
private static PropertyInfo _shortcutControllerPropertyInfo; | |
private static IEnumerable<object> _listInternalShortcutEntries; | |
private static bool Enabled => EditorPrefs.GetBool(KEY_ENABLED, true); | |
static DisplayKeyboardShortcuts() | |
{ | |
if (Enabled) | |
{ | |
Enable(); | |
} | |
} | |
[SettingsProvider] | |
private static SettingsProvider CreateMyCustomSettingsProvider() | |
{ | |
var provider = new SettingsProvider(SETTINGS_PATH, SettingsScope.User) | |
{ | |
guiHandler = (searchContext) => | |
{ | |
// Get current values. | |
var enabled = Enabled; | |
_onlyShowKeys = EditorPrefs.GetBool(KEY_ONLYSHOWKEYS, _onlyShowKeys); | |
_showFullShortcutPaths = EditorPrefs.GetBool(KEY_SHOWFULLSHORTCUTPATHS, _showFullShortcutPaths); | |
_ignoreKeypressesWithoutShortcut = EditorPrefs.GetBool(KEY_IGNOREKEYPRESSESWITHOUTSHORTCUTS, | |
_ignoreKeypressesWithoutShortcut); | |
// Ask the user for new values. | |
EditorGUI.BeginChangeCheck(); | |
EditorGUIUtility.labelWidth += 120; | |
enabled = EditorGUILayout.Toggle("Enabled", enabled); | |
using var enabledScope = new EditorGUI.DisabledScope(!enabled); | |
bool newOnlyShowKeys = EditorGUILayout.Toggle("Only show keys", _onlyShowKeys); | |
using var onlyShowKeysScope = new EditorGUI.DisabledScope(_onlyShowKeys); | |
bool newShowFullShortcutPaths = | |
EditorGUILayout.Toggle("Show full shortcut paths", _showFullShortcutPaths); | |
bool newIgnoreKeypressesWithoutShortcut = EditorGUILayout.Toggle( | |
"Ignore keypresses with no defined shortcut", _ignoreKeypressesWithoutShortcut); | |
EditorGUIUtility.labelWidth -= 120; | |
// Store the values only if some change was detected. And also, enable or disable accordingly. | |
if (EditorGUI.EndChangeCheck()) | |
{ | |
EditorPrefs.SetBool(KEY_ENABLED, enabled); | |
if (enabled) | |
{ | |
Enable(); | |
} | |
else | |
{ | |
Disable(); | |
} | |
EditorPrefs.SetBool(KEY_ONLYSHOWKEYS, newOnlyShowKeys); | |
EditorPrefs.SetBool(KEY_SHOWFULLSHORTCUTPATHS, newShowFullShortcutPaths); | |
EditorPrefs.SetBool(KEY_IGNOREKEYPRESSESWITHOUTSHORTCUTS, newIgnoreKeypressesWithoutShortcut); | |
} | |
}, | |
keywords = new HashSet<string>(new[] { "Keyboard", "Shortcut", "Keypress", "Keyboard" }) | |
}; | |
return provider; | |
} | |
private static void Enable() | |
{ | |
EditorPrefs.SetBool(KEY_ENABLED, true); | |
_onlyShowKeys = EditorPrefs.GetBool(KEY_ONLYSHOWKEYS, _onlyShowKeys); | |
_showFullShortcutPaths = EditorPrefs.GetBool(KEY_SHOWFULLSHORTCUTPATHS, _showFullShortcutPaths); | |
_ignoreKeypressesWithoutShortcut = | |
EditorPrefs.GetBool(KEY_IGNOREKEYPRESSESWITHOUTSHORTCUTS, _ignoreKeypressesWithoutShortcut); | |
_notificationGuiContent.image ??= Resources.Load<Texture2D>("Icons/ICO_Keyboard"); | |
// Use reflection to add our method to Unity's internal input event delegate. | |
// Why is this thing still not publicly exposed after so may years one can only wonder. | |
// The only documentation for it is the comment next to its declaration: | |
// "Global key up/down event that was not handled by anyone" | |
var globalEventHandlerFieldInfo = typeof(EditorApplication).GetField("globalEventHandler", | |
BindingFlags.Static | BindingFlags.NonPublic); | |
var globalEventHandler = (EditorApplication.CallbackFunction)globalEventHandlerFieldInfo.GetValue(null); | |
globalEventHandler += DisplayCurrentPressedKeyCombination; | |
globalEventHandlerFieldInfo.SetValue(null, globalEventHandler); | |
} | |
private static void Disable() | |
{ | |
EditorPrefs.SetBool(KEY_ENABLED, false); | |
var globalEventHandlerFieldInfo = typeof(EditorApplication).GetField("globalEventHandler", | |
BindingFlags.Static | BindingFlags.NonPublic); | |
var globalEventHandler = (EditorApplication.CallbackFunction)globalEventHandlerFieldInfo.GetValue(null); | |
globalEventHandler += DisplayCurrentPressedKeyCombination; | |
globalEventHandlerFieldInfo.SetValue(null, globalEventHandler); | |
} | |
private static void DisplayCurrentPressedKeyCombination() | |
{ | |
if (EditorWindow.focusedWindow == null) | |
return; | |
// Make sure the event is a key press. | |
var e = Event.current; | |
if (e == null || !e.isKey || e.keyCode == KeyCode.None) | |
return; | |
// We only want to display the pressed keys if the user is pressing a non-modifier key. | |
var k = e.keyCode; | |
if (k == KeyCode.LeftControl || k == KeyCode.RightControl || | |
k == KeyCode.LeftShift || k == KeyCode.RightShift || | |
k == KeyCode.LeftAlt || k == KeyCode.RightAlt || | |
k == KeyCode.LeftCommand || k == KeyCode.RightCommand) | |
{ | |
return; | |
} | |
// Create a KeyCombination with the keys pressed in the event. | |
var isActionKeyPressed = Application.platform == RuntimePlatform.OSXEditor ? e.command : e.control; | |
var shortcutModifiers = (isActionKeyPressed ? ShortcutModifiers.Action : 0) | | |
(e.shift ? ShortcutModifiers.Shift : 0) | | |
(e.alt ? ShortcutModifiers.Alt : 0); | |
_pressedKeyCombination = new KeyCombination(e.keyCode, shortcutModifiers); | |
// This generates a string such as "Shift+Alt+A" | |
_notificationGuiContent.text = _pressedKeyCombination.ToString(); | |
// Let's check the pressed combination against the shortcuts that are defined in the settings. | |
var shortcutId = FindShortcutForActiveContext(in _pressedKeyCombination); | |
// If there is no shortcut defined for the pressed keys, then show just the keypress. | |
if (string.IsNullOrEmpty(shortcutId)) | |
{ | |
if (!_ignoreKeypressesWithoutShortcut) | |
{ | |
EditorWindow.focusedWindow.ShowNotification(_notificationGuiContent, 0); | |
} | |
} | |
else | |
{ | |
if (_onlyShowKeys) | |
{ | |
EditorWindow.focusedWindow.ShowNotification(_notificationGuiContent, 0); | |
} | |
else | |
{ | |
var shownPath = shortcutId; | |
if (!_showFullShortcutPaths) | |
{ | |
var splits = shortcutId.Split('/'); | |
shownPath = splits[splits.Length - 1]; | |
} | |
// Split the path (word wrap) if it's too wide for the notification box. | |
var wrappedPath = Wrap(shownPath, NOTIFICATION_MAX_CHARS_PER_LINE, new[] { '/', ' ' }, false); | |
_notificationGuiContent.text += $"\n({wrappedPath})"; | |
EditorWindow.focusedWindow.ShowNotification(_notificationGuiContent, 0); | |
} | |
} | |
} | |
// ReSharper disable PossibleNullReferenceException | |
private static string FindShortcutForActiveContext(in KeyCombination keyCombination) | |
{ | |
// Let's put this whole method in a try-catch block. | |
// Why do this? Because this method uses heavy reflection. So, it is very possible that something goes wrong, | |
// for example if Unity's internal code changes between versions. | |
try | |
{ | |
// In this method, we will use reflection to finally invoke this method: | |
// ShortcutIntegration.instance.trigger.m_Directory.FindShortcutEntries() | |
// First, let's find the method: ShortcutManagement.IDirectory.FindShortcutEntries() | |
// We will do this only once. | |
// The class has three method overloads with that name, and we are interested in the one that expects an | |
// IContextManager as its second parameter. | |
// The signature of the method is: | |
// public void FindShortcutEntries(List<KeyCombination> combinationSequence, IContextManager contextManager, List<ShortcutEntry> outputShortcuts) | |
if (_findShortcutsInternalMethodInfo == null) | |
{ | |
var contextManagerType = | |
typeof(ShortcutManager).Assembly.GetType("UnityEditor.ShortcutManagement.IContextManager"); | |
_findShortcutsInternalMethodInfo = typeof(ShortcutManager).Assembly | |
.GetType("UnityEditor.ShortcutManagement.IDirectory") | |
.GetMethods() | |
.First(m => m.Name.Equals("FindShortcutEntries") && | |
m.GetParameters()[1].ParameterType == contextManagerType); | |
} | |
// Find the static instance of ShortcutController: ShortcutIntegration.instance | |
_shortcutControllerPropertyInfo ??= typeof(ShortcutManager).Assembly | |
.GetType("UnityEditor.ShortcutManagement.ShortcutIntegration") | |
.GetProperty("instance", BindingFlags.Public | BindingFlags.Static); | |
var shortcutController = _shortcutControllerPropertyInfo.GetValue(null); | |
// Get the IContextManager instance from: ShortcutIntegration.instance.contextManager | |
// This is the second value passed as parameter to the method. | |
var contextManager = | |
shortcutController.GetType().GetProperty("contextManager").GetValue(shortcutController); | |
// Find the instance of IDirectory for which the method will be invoked: ShortcutIntegration.instance.trigger.m_Directory | |
var trigger = shortcutController.GetType() | |
.GetProperty("trigger") | |
.GetValue(shortcutController); | |
var directory = trigger.GetType() | |
.GetField("m_Directory", BindingFlags.NonPublic | BindingFlags.Instance) | |
.GetValue(trigger); | |
// The third parameter is of type List<ShortcutEntry>. | |
// Since we cannot access that class, we need to instantiate the list using reflection. | |
// We will create it only once. | |
if (_listInternalShortcutEntries == null) | |
{ | |
var shortcutEntryType = typeof(ShortcutManager).Assembly | |
.GetType("UnityEditor.ShortcutManagement.ShortcutEntry"); | |
var listShortcutEntryType = typeof(List<>).MakeGenericType(shortcutEntryType); | |
_listInternalShortcutEntries = (IEnumerable<object>)Activator.CreateInstance(listShortcutEntryType); | |
} | |
// Invoke the method. | |
var keyCombinationList = new List<KeyCombination>(1); | |
keyCombinationList.Add(keyCombination); | |
var parameters = new object[] { keyCombinationList, contextManager, _listInternalShortcutEntries }; | |
_findShortcutsInternalMethodInfo.Invoke(directory, parameters); | |
// Get the first element of the list. | |
var first = (_listInternalShortcutEntries as IEnumerable<object>)?.First(); | |
if (first != null) | |
{ | |
return (string)first.GetType().GetProperty("displayName").GetValue(first); | |
} | |
} | |
catch | |
{ | |
return null; | |
} | |
return null; | |
} | |
private static string Wrap(string s, int maxCharsPerLine, char[] breakChars, bool removeBreakChars = false) | |
{ | |
if (s.Length <= maxCharsPerLine) | |
return s; | |
var sb = new StringBuilder(); | |
int offset = (removeBreakChars ? 0 : 1); | |
// First loop setup | |
int currentBreakIndex = s.IndexOfAny(breakChars); | |
int previousBreakIndex = 0; | |
int lastLineBreak = 0; | |
// Loop until we find no more break chars | |
while (currentBreakIndex > -1) | |
{ | |
if (currentBreakIndex - lastLineBreak >= maxCharsPerLine) | |
{ | |
sb.AppendLine(s.Substring(lastLineBreak, offset + previousBreakIndex - lastLineBreak)); | |
lastLineBreak = 1 + previousBreakIndex; | |
} | |
previousBreakIndex = currentBreakIndex; | |
currentBreakIndex = s.IndexOfAny(breakChars, currentBreakIndex + 1); | |
} | |
// Add the last bit, checking for one last possible line break. | |
currentBreakIndex = s.Length; | |
if (currentBreakIndex - lastLineBreak >= maxCharsPerLine) | |
{ | |
sb.AppendLine(s.Substring(lastLineBreak, offset + previousBreakIndex - lastLineBreak)); | |
lastLineBreak = 1 + previousBreakIndex; | |
} | |
sb.Append(s.Substring(lastLineBreak, s.Length - lastLineBreak)); | |
return sb.ToString(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment