Instantly share code, notes, and snippets.
Forked from alexanderameye/CircularMenu.cs
Last active
August 25, 2023 17:34
-
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 Bhushan-Kolhe/5425b4bb67f6bf4d78fa223c4220934f to your computer and use it in GitHub Desktop.
Circular menu for the Unity Editor
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
using System; | |
using System.Collections.Generic; | |
using System.Linq; | |
using System.Reflection; | |
using Ameye.EditorUtilities.CircularMenu; | |
using UnityEditor; | |
using UnityEngine; | |
using UnityEngine.UIElements; | |
namespace Ameye.EditorUtilities.Editor.CircularMenu | |
{ | |
[InitializeOnLoad] | |
public class CircularMenu | |
{ | |
// Scene view. | |
private static SceneView _activeSceneView; | |
private static int _activeSceneViewInstanceID; | |
// VisualElements. | |
private static VisualElement _sceneViewRoot; | |
private static VisualElement _radialMenuRoot; | |
// Radial menu. | |
private const int Radius = 100; | |
private static readonly Vector2 RadialMenuSize = new(100, 100); | |
private const KeyCode ActivationShortcutKey = KeyCode.A; | |
private static int _currentlyHoveredSection = -1; | |
private static readonly CircularMenuView RootCircularMenuView = new("root", "", () => { }, null); | |
private static CircularMenuView _activeCircularMenuView; | |
private static double _timeWhenRadialMenuOpened; | |
private static bool RadialMenuIsVisible => _radialMenuRoot?.style.display == DisplayStyle.Flex; | |
// Mouse info. | |
private static Vector2 _mousePositionWhenRadialMenuOpened; | |
private static Vector2 _currentMousePosition; | |
private static float _currentMouseAngle; | |
// Colors | |
private static readonly Color AnnulusColor = new Color(0.02f, 0.02f, 0.02f, 0.8f); | |
private static readonly Color MouseAngleIndicatorBackgroundColor = new Color(0.01f, 0.01f, 0.01f, 1.0f); | |
private static readonly Color MouseAngleIndicatorForegroundColor = Color.white; | |
public class CircularMenuView | |
{ | |
public CircularMenuView Parent; | |
public readonly string Path; | |
public readonly string Icon; | |
public readonly List<CircularMenuView> Children = new(); | |
public readonly Action OnRadialMenuItemSelected; | |
public CircularMenuView(string path, string icon, Action onRadialMenuItemSelected, CircularMenuView parent) | |
{ | |
Path = path; | |
Icon = icon; | |
OnRadialMenuItemSelected = onRadialMenuItemSelected; | |
Parent = parent; | |
} | |
} | |
static CircularMenu() | |
{ | |
EditorApplication.update -= OnEditorApplicationUpdate; | |
EditorApplication.update += OnEditorApplicationUpdate; | |
SceneView.duringSceneGui -= OnDuringSceneGUI; | |
SceneView.duringSceneGui += OnDuringSceneGUI; | |
} | |
private static void OnEditorApplicationUpdate() | |
{ | |
// Get the currently active scene view. | |
_activeSceneView = SceneView.currentDrawingSceneView ? SceneView.currentDrawingSceneView : SceneView.lastActiveSceneView; | |
// Check if the scene view changed. | |
if (_activeSceneView && _activeSceneView.GetInstanceID() != _activeSceneViewInstanceID) | |
{ | |
_activeSceneViewInstanceID = _activeSceneView.GetInstanceID(); | |
RemovePreviousRadialMenu(); | |
} | |
if (_radialMenuRoot is not null || _sceneViewRoot is not null || _activeSceneView is null) return; | |
_sceneViewRoot = _activeSceneView.rootVisualElement; | |
if (_sceneViewRoot is { }) Initialize(); | |
else Debug.LogError("_activeSceneView.rootVisualElement was null"); | |
} | |
private static void Initialize() | |
{ | |
if (_radialMenuRoot is { }) RemovePreviousRadialMenu(); | |
var methods = TypeCache.GetMethodsWithAttribute<CircularMenuAttribute>(); | |
// Create the root VisualElement that holds the radial menu. | |
_radialMenuRoot = new VisualElement | |
{ | |
style = | |
{ | |
position = Position.Absolute, | |
width = RadialMenuSize.x, | |
height = RadialMenuSize.y, | |
display = DisplayStyle.None, // initially hidden | |
marginBottom = 0.0f, | |
marginTop = 0.0f, | |
marginRight = 0.0f, | |
marginLeft = 0.0f, | |
paddingBottom = 0.0f, | |
paddingTop = 0.0f, | |
paddingRight = 0.0f, | |
paddingLeft = 0.0f, | |
alignItems = Align.Center, | |
alignContent = Align.Center, | |
justifyContent = Justify.Center, | |
} | |
}; | |
// Draw the center mouse angle indicator. | |
_radialMenuRoot.generateVisualContent -= DrawMouseAngleIndicator; | |
_radialMenuRoot.generateVisualContent += DrawMouseAngleIndicator; | |
// Create the radial menu for each method. | |
RootCircularMenuView.Children.Clear(); | |
foreach (var method in methods) | |
{ | |
var attribute = (CircularMenuAttribute) method.GetCustomAttributes(typeof(CircularMenuAttribute), false).First(); | |
CreateRadialMenu(attribute.Path, attribute, method); | |
} | |
_activeCircularMenuView = RootCircularMenuView; | |
// Add the radial menu root to the scene view root. | |
_sceneViewRoot.Add(_radialMenuRoot); | |
} | |
private static void ShowRadialMenu(Vector2 position) | |
{ | |
if (_radialMenuRoot is null) return; | |
_radialMenuRoot.style.display = _radialMenuRoot.style.display == DisplayStyle.None ? DisplayStyle.Flex : DisplayStyle.None; | |
_radialMenuRoot.transform.position = position - new Vector2(RadialMenuSize.x * 0.5f, RadialMenuSize.y * 0.5f); | |
RebuildRadialMenu(); | |
_activeSceneView.Repaint(); | |
} | |
private static void RebuildRadialMenu() | |
{ | |
// Remove all child elements. | |
_radialMenuRoot.Clear(); | |
// If the radial menu item has a parent. | |
if (_activeCircularMenuView.Parent is { }) | |
{ | |
// Add a label showing the current folder. | |
_radialMenuRoot.Add(new Label(_activeCircularMenuView.Path) | |
{ | |
style = | |
{ | |
marginBottom = RadialMenuSize.x * 0.5f + 5.0f, | |
fontSize = 10, | |
unityTextAlign = TextAnchor.MiddleCenter, | |
color = Color.white, | |
textShadow = new TextShadow | |
{ | |
offset = new Vector2(0.2f, 0.2f), | |
blurRadius = 0, | |
color = Color.black | |
} | |
} | |
}); | |
// Add back button. | |
_radialMenuRoot.Add(new CircularMenuButton("Back", "", -1, () => SelectRadialMenuItem(_activeCircularMenuView.Parent))); | |
} | |
// If the menu item does not have a parent, | |
else _radialMenuRoot.Add(new Label("")); // HACKY | |
// Add a button for each child of the radial menu item. | |
var section = 1; | |
foreach (var item in _activeCircularMenuView.Children) | |
{ | |
_radialMenuRoot.Add(new CircularMenuButton( | |
item.Children.Count > 0 ? item.Path + "" : item.Path, | |
item.Icon, | |
_activeCircularMenuView.Children.Count - section, | |
item.OnRadialMenuItemSelected)); | |
section++; | |
} | |
// Move all buttons outwards using an animation. | |
var i = 0; | |
foreach (var item in _radialMenuRoot.Children().Where(c => c is CircularMenuButton)) | |
{ | |
item.transform.position = Vector3.zero; | |
var targetPosition = Vector2.zero + GetCircleOffset(Radius, i, _radialMenuRoot.childCount - 1); | |
item.experimental.animation.Position(targetPosition, 100); | |
i++; | |
} | |
} | |
private static void OnDuringSceneGUI(SceneView view) | |
{ | |
var currentEvent = Event.current; | |
switch (currentEvent.type) | |
{ | |
case EventType.Repaint: | |
_currentMousePosition = currentEvent.mousePosition; | |
break; | |
// Allow clicking the left mouse button to move the location of the radial menu. | |
case EventType.MouseDown when (currentEvent.button == 0 && RadialMenuIsVisible): | |
ShowRadialMenu(_currentMousePosition); | |
break; | |
// Show the radial menu when the activation shortcut is pressed and store the initial mouse position. | |
case EventType.KeyDown when (Event.current.keyCode == ActivationShortcutKey && !RadialMenuIsVisible): | |
_timeWhenRadialMenuOpened = EditorApplication.timeSinceStartup; | |
_mousePositionWhenRadialMenuOpened = _currentMousePosition; | |
ShowRadialMenu(_currentMousePosition); | |
break; | |
// Update the radial menu when moving the mouse. | |
case EventType.MouseMove when RadialMenuIsVisible: | |
{ | |
// Calculate the offset angle. | |
var referenceVector = new Vector2(0.0f, -1.0f); | |
var mouseVector = new Vector2( | |
_currentMousePosition.x - _mousePositionWhenRadialMenuOpened.x, | |
-_currentMousePosition.y + _mousePositionWhenRadialMenuOpened.y).normalized; | |
var angle = (float) (Math.Atan2(referenceVector.y, referenceVector.x) - Math.Atan2(mouseVector.y, mouseVector.x)) * (float) (180 / Math.PI); | |
if (angle < 0) angle += 360.0f; | |
_currentMouseAngle = angle; | |
// Calculate which section is being hovered over. | |
var sectionCount = _activeCircularMenuView.Children.Count; | |
if (_activeCircularMenuView.Parent is { }) sectionCount++; // back button | |
var sectionPartAngle = 360.0f / sectionCount; // the part of a single section | |
int hoveredSection; | |
// HACKY: This code is kind of hacky and was a little bit of trial and error. | |
if (_activeCircularMenuView.Parent is { }) hoveredSection = (Mathf.RoundToInt(angle / sectionPartAngle)) % sectionCount - 1; | |
else hoveredSection = (Mathf.RoundToInt(angle / sectionPartAngle) + sectionCount - 1) % sectionCount; | |
// If we moved the mouse to a new section. | |
if (hoveredSection != _currentlyHoveredSection) | |
{ | |
// Go through all the buttons | |
var buttons = _radialMenuRoot.Children().Where(child => child is CircularMenuButton).ToList().Select(e => e as CircularMenuButton); | |
foreach (var button in buttons) button?.Hover(button.Section == hoveredSection); | |
_currentlyHoveredSection = hoveredSection; | |
} | |
_radialMenuRoot.MarkDirtyRepaint(); | |
break; | |
} | |
// Select a radial menu item when the activation shortcut is released while the radial menu is visible. | |
case EventType.KeyUp when Event.current.keyCode == ActivationShortcutKey && RadialMenuIsVisible: | |
{ | |
if (EditorApplication.timeSinceStartup - _timeWhenRadialMenuOpened > 0.2) | |
{ | |
// Calculate the distance that the mouse has moved. | |
var mouseMoveDistance = Vector3.Distance( | |
EditorGUIUtility.PixelsToPoints(_mousePositionWhenRadialMenuOpened), | |
EditorGUIUtility.PixelsToPoints(_currentMousePosition)); | |
// Require a minimum mouse move distance before a selection is triggered. | |
if (mouseMoveDistance > 15) | |
{ | |
if (_currentlyHoveredSection == -1){ | |
if(_activeCircularMenuView.Parent != null) | |
SelectRadialMenuItem(_activeCircularMenuView.Parent); // back button | |
} | |
else _activeCircularMenuView.Children[_activeCircularMenuView.Children.Count - _currentlyHoveredSection - 1].OnRadialMenuItemSelected(); | |
} | |
else ShowRadialMenu(_currentMousePosition); | |
} | |
break; | |
} | |
} | |
} | |
private static void RemovePreviousRadialMenu() | |
{ | |
if (_radialMenuRoot is null) return; | |
_radialMenuRoot.RemoveFromHierarchy(); | |
_radialMenuRoot = null; | |
} | |
private static Vector2 GetCircleOffset(float radius, float index, float numberOfSections) | |
{ | |
var angle = index / numberOfSections * 360.0f; | |
var offset = new Vector2 | |
{ | |
x = radius * Mathf.Sin(angle * Mathf.Deg2Rad), | |
y = radius * Mathf.Cos(angle * Mathf.Deg2Rad), | |
}; | |
return offset; | |
} | |
private static void DrawMouseAngleIndicator(MeshGenerationContext context) | |
{ | |
var position = new Vector2(RadialMenuSize.x * 0.5f, RadialMenuSize.y * 0.5f); | |
var radius = RadialMenuSize.x * 0.1f; | |
const float indicatorSizeDegrees = 70.0f; | |
var painter = context.painter2D; | |
painter.lineCap = LineCap.Butt; | |
// Draw the annulus. | |
painter.lineWidth = 8.0f; | |
painter.strokeColor = AnnulusColor; | |
painter.BeginPath(); | |
painter.Arc(new Vector2(position.x, position.y), radius, 0.0f, 360.0f); | |
painter.Stroke(); | |
// Draw the mouse angle indicator background. | |
painter.lineWidth = 8.0f; | |
painter.strokeColor = MouseAngleIndicatorBackgroundColor; | |
painter.BeginPath(); | |
painter.Arc(new Vector2(position.x, position.y), radius, _currentMouseAngle + 90.0f - indicatorSizeDegrees * 0.5f, | |
_currentMouseAngle + 90.0f + indicatorSizeDegrees * 0.5f); | |
painter.Stroke(); | |
// Draw the mouse angle indicator. | |
painter.lineWidth = 4.0f; | |
painter.strokeColor = MouseAngleIndicatorForegroundColor; | |
painter.BeginPath(); | |
painter.Arc(new Vector2(position.x, position.y), radius, _currentMouseAngle + 90.0f - indicatorSizeDegrees * 0.5f, | |
_currentMouseAngle + 90.0f + indicatorSizeDegrees * 0.5f); | |
painter.Stroke(); | |
} | |
private static void CreateRadialMenu(string path, CircularMenuAttribute attribute, MethodInfo method) | |
{ | |
var pathSegments = path.Split('/'); | |
// Create the root radial menu view. | |
var rootRadialMenuView = RootCircularMenuView; | |
// Create the branch radial menus views. | |
if (pathSegments.Length > 1) | |
{ | |
for (var i = 0; i < pathSegments.Length - 1; i++) | |
{ | |
var pathSegment = pathSegments[i]; | |
// Look for an existing radial menu view with the same path. | |
var branchRadialMenuView = rootRadialMenuView.Children.Find(x => x.Path == pathSegment); | |
// Create a new one if it does not exist yet. | |
if (branchRadialMenuView is null) | |
{ | |
branchRadialMenuView = new CircularMenuView(pathSegment, "d_Folder Icon", () => SelectRadialMenuItem(branchRadialMenuView), rootRadialMenuView); | |
RootCircularMenuView.Children.Add(branchRadialMenuView); | |
} | |
rootRadialMenuView = branchRadialMenuView; | |
} | |
} | |
// Create the leaf radial menu view. | |
var leafRadialMenuView = new CircularMenuView(pathSegments.Last(), attribute.Icon, () => | |
{ | |
ShowRadialMenu(_mousePositionWhenRadialMenuOpened); | |
// Invoke the method that is linked to this leaf radial menu view. | |
method.Invoke(null, null); | |
}, null); | |
rootRadialMenuView.Children.Add(leafRadialMenuView); | |
} | |
private static void SelectRadialMenuItem(CircularMenuView circularMenuView) | |
{ | |
_activeCircularMenuView = circularMenuView; | |
RebuildRadialMenu(); | |
} | |
} | |
} |
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
using System; | |
namespace Ameye.EditorUtilities.CircularMenu | |
{ | |
[AttributeUsage(AttributeTargets.Method)] | |
public class CircularMenuAttribute : Attribute | |
{ | |
public string Path; | |
public string Icon; | |
public CircularMenuAttribute() { } | |
public CircularMenuAttribute(string path, string icon) | |
{ | |
Path = path; | |
Icon = icon; | |
} | |
} | |
} |
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
using System; | |
using UnityEditor; | |
using UnityEngine; | |
using UnityEngine.UIElements; | |
namespace Ameye.EditorUtilities.Editor.CircularMenu | |
{ | |
public sealed class CircularMenuButton : VisualElement | |
{ | |
public new class UxmlFactory : UxmlFactory<CircularMenuButton, UxmlTraits> | |
{ | |
} | |
public new class UxmlTraits : BindableElement.UxmlTraits | |
{ | |
} | |
public CircularMenuButton() | |
{ | |
} | |
private readonly Button button; | |
public readonly int Section; | |
public CircularMenuButton(string text, string icon, int section, Action clickEvent) | |
{ | |
Section = section; | |
style.position = Position.Absolute; | |
style.alignItems = Align.Center; | |
button = new Button(clickEvent) | |
{ | |
style = | |
{ | |
paddingLeft = 8, | |
paddingRight = 8, | |
paddingTop = 4, | |
paddingBottom = 4, | |
flexDirection = FlexDirection.Row, | |
borderTopLeftRadius = 4.0f, | |
borderBottomLeftRadius = 4.0f, | |
borderBottomRightRadius = 4.0f, | |
borderTopRightRadius = 4.0f, | |
flexGrow = 1, | |
backgroundColor = new Color(0.02f, 0.02f, 0.02f, 0.8f) | |
}, | |
text = "" | |
}; | |
var label = new Label | |
{ | |
style = | |
{ | |
paddingBottom = 0.0f, | |
paddingLeft = 0.0f, | |
paddingRight = 0.0f, | |
paddingTop = 0.0f, | |
marginLeft = 5.0f, | |
marginRight = 5.0f, | |
flexGrow = 1, | |
}, | |
text = text | |
}; | |
if (icon != "") | |
{ | |
var image = new Image | |
{ | |
image = EditorGUIUtility.IconContent(icon).image, | |
style = | |
{ | |
width = 16.0f, | |
height = 16.0f, | |
flexShrink = 0 | |
} | |
}; | |
button.Add(image); | |
} | |
button.Add(label); | |
if (section != -1) | |
{ | |
var index = new Label | |
{ | |
text = section.ToString(), | |
style = | |
{ | |
color = new Color(0.7f, 0.7f, 0.7f, 1.0f), | |
unityFontStyleAndWeight = FontStyle.Italic | |
} | |
}; | |
button.Add(index); | |
} | |
Add(button); | |
} | |
public void Hover(bool active) | |
{ | |
button.style.backgroundColor = active ? new Color(0.2745098f, 0.3764706f, 0.4862745f, 1.0f) : new Color(0.02f, 0.02f, 0.02f, 0.8f); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment