Skip to content

Instantly share code, notes, and snippets.

@alexanderameye
Last active October 31, 2024 11:55
Show Gist options
  • Save alexanderameye/1eef648d7d1699a9548bc2de3dde2a45 to your computer and use it in GitHub Desktop.
Save alexanderameye/1eef648d7d1699a9548bc2de3dde2a45 to your computer and use it in GitHub Desktop.
Circular menu for the Unity Editor
/*
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
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 && _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();
}
}
}
/*
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
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;
}
}
}
/*
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
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);
}
}
}
@Bhushan-Kolhe
Copy link

Awesome tool. after writing some tools with it, I encountered this error: NullReferenceException: Object reference not set to an instance of an object Ameye.EditorUtilities.Editor.CircularMenu.CircularMenu.OnDuringSceneGUI (UnityEditor.SceneView view) (at Assets/Editor/CircularMenu/CircularMenu.cs:229) maybe I'll dig in and try to fix it.

I think I've had that issue once as well. Maybe you could add a null check for '_activeCircularMenuView' or '_activeCircularMenuView.Children' but that seems like a bandaid instead of knowing the root of the problem. Will investigate as well 😄

The error is on line 262 in CircularMenu.cs file. Add a null check for _activeCircularMenuView.Parent

@alexanderameye
Copy link
Author

Could you explicitly specify the license, please?☺️

Added MIT license! Apologies for the delayed response.

@alexanderameye
Copy link
Author

Awesome tool. after writing some tools with it, I encountered this error: NullReferenceException: Object reference not set to an instance of an object Ameye.EditorUtilities.Editor.CircularMenu.CircularMenu.OnDuringSceneGUI (UnityEditor.SceneView view) (at Assets/Editor/CircularMenu/CircularMenu.cs:229) maybe I'll dig in and try to fix it.

I think I've had that issue once as well. Maybe you could add a null check for '_activeCircularMenuView' or '_activeCircularMenuView.Children' but that seems like a bandaid instead of knowing the root of the problem. Will investigate as well 😄

The error is on line 262 in CircularMenu.cs file. Add a null check for _activeCircularMenuView.Parent

Changed! thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment