Skip to content

Instantly share code, notes, and snippets.

@Bhushan-Kolhe
Forked from alexanderameye/CircularMenu.cs
Last active August 25, 2023 17:34
Show Gist options
  • Save Bhushan-Kolhe/5425b4bb67f6bf4d78fa223c4220934f to your computer and use it in GitHub Desktop.
Save Bhushan-Kolhe/5425b4bb67f6bf4d78fa223c4220934f to your computer and use it in GitHub Desktop.
Circular menu for the Unity Editor
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();
}
}
}
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;
}
}
}
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