Last active
July 29, 2017 21:41
-
-
Save akbyrd/c4b9baaf21b7f230a4c9e44750c4b657 to your computer and use it in GitHub Desktop.
An in-progress collection drawer for Unity. Enum specialization is prototype stage, feel free to ignore.
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
namespace PixelDash.Tools.Editor.Presentation | |
{ | |
using System; | |
using PixelDash.Tools.Presentation; | |
using UnityEditor; | |
using UnityEngine; | |
using UnityEngine.Assertions; | |
using UObject = UnityEngine.Object; | |
using Flags = PixelDash.Tools.Presentation.PDCollectionFlags; | |
/* TODO | |
* - Draw other fields in property drawer | |
* - Vertically center foldout arrow | |
* - Profile difference between Unity, custom and Rotorz | |
* - Decide what to do when list items themselves throw exceptions | |
* | |
* - Attempt to encapsulate list struct in a sane way | |
* - target generic base class? | |
* - Non generic dummy base class? | |
* - #if out the wrapper in release? | |
* - inherit from list? | |
* | |
* - Drag elements onto header (UnityEditor.DragAndDrop? Use primary field, disable when resizing is disabled) | |
* | |
* - Ensure entire collection respects indentation | |
* - Handle expanded gracefully (new elements always collapsed? preserve expanded state during reorder) | |
* - Ensure right clicking works as expected everywhere (especially AudioClipSettings parent) | |
* | |
* - Support insert above/below (add button on each element?) | |
* - Support duplicate command/button | |
* - Eat right click on element? (how's this work with property overrides?) | |
* - BUG: Check bolding when adding elements, modifiying child properties | |
* - Ensure nested collections work | |
* - Is it possible to draw textures on top of the text? | |
* | |
* - Decide how keyboard naviation should work (move up/down, remove, duplicate, insert above/below) | |
* - Support multi-selection with buttons | |
* - Support use from Editors (layout), PropertyDrawers (non-layout), procedural calls(?) | |
* - Ensure lists inside scroll views are handled well | |
* - Auto-scroll when dragging at end of Inspector? | |
* - Determine how Element name is chosen. | |
* | |
* - Flags | |
* - DisplayKeyAsParent | |
* - DisablePrefabModification | |
* - DisablePrefabInstanceModification | |
* - Option to ignore indentation | |
* - lock items | |
* (establish usage code before tackling this) | |
* generalize Flags.SyncWithEnum and State.enumNames to be an array of lock values | |
* option to allow other items after the locked ones | |
* separate locking from other flags so other items can use the flags like normal | |
*/ | |
/// <summary> | |
/// Draws serialized collections of items in a nicer container than the | |
/// Unity default. Collections are reorderable by default. | |
/// </summary> | |
/// <remarks> | |
/// Fully supports undo/redo, revert to prefab, item collapse/expand, | |
/// different sized items, synchronizing items with an enum container, and | |
/// a series of customizations through <see cref="Flags"/>. Collections are | |
/// marked with <see cref="PDCollectionAttribute"/> | |
/// </remarks> | |
public static class PDCollection | |
{ | |
public readonly static GUIContent RemoveButtonContent; | |
public readonly static GUIContent AddButtonContent; | |
public readonly static float HeaderHeight; | |
public readonly static float ButtonWidth; | |
public readonly static float DragHandleWidth; | |
public readonly static GUIStyle DragHandle; | |
public readonly static GUIStyle HeaderBackground; | |
public readonly static GUIStyle ContainerBackground; | |
public readonly static GUIStyle ItemBackground; | |
public readonly static GUIStyle DropBackground; | |
public readonly static GUIStyle ButtonOnLight; | |
public readonly static GUIStyle ButtonOnDark; | |
public readonly static GUIStyle Separator; | |
static PDCollection () | |
{ | |
HeaderHeight = 22; | |
ButtonWidth = 26; | |
DragHandleWidth = 26; | |
AddButtonContent = EditorGUIUtility.IconContent("Toolbar Plus"); | |
RemoveButtonContent = EditorGUIUtility.IconContent("Toolbar Minus"); | |
//"Toolbar Plus More" if needed | |
//Resources | |
Color lightGray; | |
Color mediumGray; | |
Color darkGray; | |
Color darkestGray; | |
Color blueGrayTrans; | |
Texture2D headerBackground; | |
Texture2D containerBackground; | |
if ( EditorGUIUtility.isProSkin ) | |
{ | |
lightGray = new Color32(104, 104, 104, 255); | |
mediumGray = new Color32( 79, 79, 79, 255); | |
darkGray = new Color32( 47, 47, 47, 255); | |
darkestGray = new Color32( 10, 10, 10, 255); | |
blueGrayTrans = new Color32( 70, 70, 73, 200); | |
headerBackground = TextureFromString("PDT_HeaderBackground" , "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADtJREFUeNpi/P//P4OKisp/Bii4c+cOIwtIQE9Pj+HLly9gQRCfBcQACbx69QqmmAEseO/ePQZkABBgAD04FXsmmijSAAAAAElFTkSuQmCC"); | |
containerBackground = TextureFromString("PDT_ContainerBackground", "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAD1JREFUeNpi/P//P4OKisp/Bii4c+cOIwtIwMXFheHFixcMEhISYAVMINm3b9+CBUA0CDCiazc0NGQECDAAdH0YelA27kgAAAAASUVORK5CYII="); | |
} | |
else | |
{ | |
lightGray = new Color32(178, 178, 178, 255); | |
mediumGray = new Color32(121, 121, 121, 255); | |
darkGray = new Color32(140, 140, 140, 255); | |
darkestGray = new Color32( 38, 38, 38, 255); | |
blueGrayTrans = new Color32(200, 200, 206, 200); | |
headerBackground = TextureFromString("PDT_HeaderBackground" ,"iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAEFJREFUeNpi/P//P0NxcfF/BgRgZP78+fN/VVVVhpCQEAZjY2OGs2fPNrCApBwdHRkePHgAVwoWnDVrFgMyAAgwAAt4E1dCq1obAAAAAElFTkSuQmCC"); | |
containerBackground = TextureFromString("PDT_ContainerBackground", "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAECAYAAABGM/VAAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADtJREFUeNpi/P//P0NxcfF/Bijo7e1lZCgqKvr/6dOn/5cvXwbTID4TSPb9+/cM8vLyYBoEGLFpBwgwAHGiI8KoD3BZAAAAAElFTkSuQmCC"); | |
} | |
//Textures | |
var pixel = new Color[1]; | |
pixel[0] = lightGray; | |
var lightGrayPixel = TextureFromPixels("PDT_LightGrayPixel", pixel, 1, 1); | |
pixel[0] = mediumGray; | |
var mediumGrayPixel = TextureFromPixels("PDT_MediumGrayPixel", pixel, 1, 1); | |
pixel[0] = darkGray; | |
var darkGrayPixel = TextureFromPixels("PDT_DarkGrayPixel", pixel, 1, 1); | |
Texture2D itemBackground; | |
{ | |
Color shadow = darkestGray; | |
shadow.a = 90f / 255f; | |
var pixels = new[] { | |
shadow, shadow, shadow, | |
darkestGray, darkestGray, darkestGray, | |
darkestGray, blueGrayTrans, darkestGray, | |
darkestGray, darkestGray, darkestGray, | |
shadow, shadow, shadow, | |
}; | |
itemBackground = TextureFromPixels("PDT_ItemBackground", pixels, 3, 5); | |
} | |
Texture2D dropBackground; | |
{ | |
var pixels = new[] { | |
lightGray, lightGray, lightGray, | |
mediumGray, darkGray, mediumGray, | |
lightGray, lightGray, lightGray | |
}; | |
dropBackground = TextureFromPixels("PDT_DropBackground", pixels, 3, 3); | |
} | |
//Styles | |
//Header | |
HeaderBackground = new GUIStyle(); | |
HeaderBackground.normal.background = headerBackground; | |
HeaderBackground.border = new RectOffset(2, 2, 2, 1); | |
HeaderBackground.margin = new RectOffset(0, 0, 0, 10); | |
HeaderBackground.padding = new RectOffset(2, 2, 2, 1); | |
HeaderBackground.name = "PDT_HeaderBackground"; | |
ButtonOnDark = new GUIStyle(); | |
ButtonOnDark.active.background = mediumGrayPixel; | |
ButtonOnDark.alignment = TextAnchor.MiddleCenter; | |
ButtonOnDark.margin = new RectOffset(2, 0, 0, 0); | |
ButtonOnDark.name = "PDT_Button_OnDark"; | |
//Container | |
ContainerBackground = new GUIStyle(); | |
ContainerBackground.normal.background = containerBackground; | |
ContainerBackground.border = new RectOffset(2, 2, 2, 2); | |
ContainerBackground.margin = new RectOffset(5, 5, -1, 10); | |
ContainerBackground.padding = ContainerBackground.border; | |
ContainerBackground.name = "PDT_ContainerBackground"; | |
//Items | |
Separator = new GUIStyle(); | |
Separator.normal.background = lightGrayPixel; | |
Separator.fixedHeight = 1; | |
Separator.stretchWidth = true; | |
Separator.name = "PDT_Separator"; | |
ButtonOnLight = new GUIStyle(ButtonOnDark); | |
ButtonOnLight.active.background = darkGrayPixel; | |
ButtonOnLight.name = "PDT_Button_OnLight"; | |
DragHandle = new GUIStyle("RL DragHandle"); | |
DragHandle.fixedWidth = 10; | |
DragHandle.fixedHeight = DragHandle.normal.background.height; | |
DragHandle.border = new RectOffset(); | |
DragHandle.margin = new RectOffset(0, 2, 0, 0); | |
DragHandle.name = "PDT_DragHandle"; | |
ItemBackground = new GUIStyle(); | |
ItemBackground.normal.background = itemBackground; | |
ItemBackground.border = new RectOffset(1, 1, 2, 2); | |
ItemBackground.margin = new RectOffset(0, 0, 1, 1); | |
ItemBackground.padding = new RectOffset(0, 0, 2, 2); | |
ItemBackground.overflow = new RectOffset( | |
ContainerBackground.padding.left, | |
ContainerBackground.padding.right, | |
1 + ItemBackground.margin.top, | |
1 + ItemBackground.margin.bottom | |
); | |
ItemBackground.name = "PDT_ItemBackground"; | |
DropBackground = new GUIStyle(); | |
DropBackground.normal.background = dropBackground; | |
DropBackground.border = new RectOffset(1, 1, 1, 1); | |
DropBackground.overflow = new RectOffset( | |
ContainerBackground.padding.left - 1, | |
ContainerBackground.padding.right - 1, | |
ItemBackground.margin.top, | |
ItemBackground.margin.bottom | |
); | |
DropBackground.name = "PDT_DropBackground"; | |
} | |
private static Texture2D TextureFromString ( string name, string base64String ) | |
{ | |
byte[] imageData = Convert.FromBase64String(base64String); | |
int offset = 3 + 15; | |
int width = (imageData[offset] << 8) | imageData[offset + 1]; | |
offset = 3 + 15 + 2 + 2; | |
int height = (imageData[offset] << 8) | imageData[offset + 1]; | |
var texture = new Texture2D(width, height, TextureFormat.ARGB32, false, true); | |
texture.LoadImage(imageData); | |
texture.filterMode = FilterMode.Point; | |
texture.wrapMode = TextureWrapMode.Repeat; | |
texture.hideFlags = HideFlags.HideAndDontSave; | |
texture.name = name; | |
return texture; | |
} | |
private static Texture2D TextureFromPixels ( string name, Color[] pixels, int width, int height ) | |
{ | |
//TODO: This doesn't work in DLLs | |
Assert.IsTrue(width*height == pixels.Length, "TextureFromPixels: Provided pixel count and dimensions do not match"); | |
if ( width*height != pixels.Length ) | |
{ | |
//TODO: Allow this assembly to depend on logging? | |
//TODO: Recombine logging and runtime assemblies? | |
//TODO: Does this assembly rely on the runtime assembly? | |
Debug.LogErrorFormat("Pixel Dash Tools Internal Error - TextureFromPixels: Provided pixel count and dimensions do not match." + | |
"\nTexture: '{0}', Dimensions: {1}x{2}, Pixel Count: {3}.", | |
name, width, height, pixels.Length | |
); | |
} | |
var texture = new Texture2D(width, height, TextureFormat.RGBA32, false, false); | |
texture.SetPixels(0, 0, width, height, pixels); | |
texture.Apply(); | |
texture.filterMode = FilterMode.Point; | |
texture.wrapMode = TextureWrapMode.Repeat; | |
texture.hideFlags = HideFlags.HideAndDontSave; | |
texture.name = name; | |
return texture; | |
} | |
public class State | |
{ | |
//TODO: Move some of this to a cache? | |
public Rect position; | |
public string error; | |
public int controlID; | |
public float errorHeight; | |
public float headerHeight; | |
public float collectionHeight; | |
public Flags flags; | |
public string keyFieldName; | |
public string[] fixedKeyValues; | |
public Rect draggedItemBaseRect; | |
public int draggedItemIndex = -1; | |
public float dragInitialMouseY; | |
public int dropIndex = -1; | |
public int keyboardControlAtDragStart; | |
public bool IsDragging { get { return draggedItemIndex != -1; } } | |
public bool HasError { get { return !string.IsNullOrEmpty(error); } } | |
} | |
public static void BeginDrag ( State s, int itemIndex, Rect itemRect, float initialMouseY ) | |
{ | |
/* TODO: Control IDs are order based so changing the draw order | |
* causes focus to jump to an adjacent item. Is there a way to | |
* avoid this? Or is there a way to only clear focus if it's | |
* somewhere in this list? | |
* | |
* If we stop doing this, try using | |
* EditorGUIUtility.editingTextField = false | |
* to remove cursor from text boxes. | |
*/ | |
s.keyboardControlAtDragStart = GUIUtility.keyboardControl; | |
GUIUtility.keyboardControl = s.controlID; | |
GUIUtility.hotControl = s.controlID; | |
//EditorGUI.FocusTextInControl(null)? | |
s.draggedItemBaseRect = itemRect; | |
s.draggedItemIndex = itemIndex; | |
s.dragInitialMouseY = initialMouseY; | |
s.dropIndex = itemIndex; | |
} | |
public static void EndDrag ( State s ) | |
{ | |
if ( s.dropIndex == s.draggedItemIndex ) | |
GUIUtility.keyboardControl = s.keyboardControlAtDragStart; | |
GUIUtility.hotControl = 0; | |
s.draggedItemBaseRect = new Rect(); | |
s.draggedItemIndex = -1; | |
s.dragInitialMouseY = 0; | |
s.dropIndex = -1; | |
} | |
private struct ErrorModule | |
{ | |
public static float GetHeight ( State s, SerializedProperty arrayProp, GUIContent label ) | |
{ | |
if ( !s.HasError ) | |
{ | |
if ( arrayProp == null ) | |
{ | |
s.error = typeof(PDCollection).Name + " could not find the target collection. Ensure the field exists and is serializable."; | |
} | |
else if ( !arrayProp.isArray ) | |
{ | |
s.error = typeof(PDCollection).Name + " cannot be used with non-array fields."; | |
} | |
} | |
if ( s.HasError ) | |
{ | |
s.errorHeight = EditorStyles.helpBox.CalcHeight(PDContent.Temp(s.error), EditorGUIUtility.currentViewWidth); | |
s.errorHeight = Mathf.Max(s.errorHeight, 32); | |
} | |
else | |
{ | |
s.errorHeight = 0; | |
} | |
return s.errorHeight; | |
} | |
public static void OnGUI ( State s, SerializedProperty arrayProp, GUIContent label ) | |
{ | |
if ( !s.HasError ) { return; } | |
Rect line = s.position; | |
line.height = s.errorHeight; | |
s.position.y += line.height; | |
EditorGUI.HelpBox(line, s.error, MessageType.Error); | |
} | |
} | |
public static class HeaderModule | |
{ | |
public static float GetHeight ( State s, SerializedProperty arrayProp, GUIContent label ) | |
{ | |
s.headerHeight = HeaderHeight; | |
return s.headerHeight; | |
} | |
public static void OnGUI ( State s, SerializedProperty arrayProp, GUIContent label ) | |
{ | |
//NOTE: Size field blows up label (it's an internally reused GUIContent). | |
var foldoutContent = PDContent.Temp(label); | |
Rect usable; | |
//Background | |
{ | |
Rect background = s.position; | |
background.height = s.headerHeight; | |
s.position.y += background.height; | |
usable = HeaderBackground.padding.Remove(background); | |
if ( Event.current.type == EventType.Repaint ) | |
HeaderBackground.Draw(background, false, false, false, false); | |
} | |
if ( !s.HasError ) | |
{ | |
if ( arrayProp.isExpanded ) | |
{ | |
//Add button | |
bool disableResize = (s.flags & Flags.DisableResize) != 0; | |
if ( !disableResize ) | |
{ | |
Rect addButton = usable; | |
addButton.xMin = addButton.xMax - ButtonWidth; | |
usable.xMax -= addButton.width; | |
if ( GUI.Button(addButton, AddButtonContent, ButtonOnDark) ) | |
++arrayProp.arraySize; | |
} | |
usable.xMax -= ButtonOnDark.margin.left; | |
//Size | |
bool hideSize = (s.flags & Flags.HideSize) != 0; | |
if ( !hideSize ) | |
{ | |
Rect sizeField = usable; | |
sizeField.xMin = sizeField.xMax - EditorGUIUtility.fieldWidth - ButtonWidth; | |
usable.xMax = sizeField.xMin; | |
Vector2 center = sizeField.center; | |
sizeField.height = 16; | |
sizeField.center = center; | |
using ( new PDEditorGUI.IndentScope(0) ) | |
using ( new PDEditorGUI.LabelWidthScope(ButtonWidth) ) | |
using ( new PDGUI.DisabledScope(disableResize) ) | |
{ | |
arrayProp.arraySize = EditorGUI.DelayedIntField(sizeField, "Size", arrayProp.arraySize); | |
} | |
} | |
} | |
} | |
if ( arrayProp != null ) | |
{ | |
//Foldout | |
using ( new EditorGUI.PropertyScope(usable, foldoutContent, arrayProp) ) | |
{ | |
//Label | |
Rect foldout = usable; | |
usable.size = Vector2.zero; | |
arrayProp.isExpanded = PDEditorGUI.Foldout(foldout, arrayProp.isExpanded, foldoutContent); | |
} | |
} | |
else | |
{ | |
//GUI.Label(usable, foldoutContent); | |
EditorGUI.PrefixLabel(usable, new GUIContent(foldoutContent.ToString())); | |
} | |
} | |
} | |
public static class ArrayModule | |
{ | |
public static float GetHeight ( State s, SerializedProperty arrayProp, GUIContent label ) | |
{ | |
s.collectionHeight = 0; | |
float errorHeight = ErrorModule.GetHeight(s, arrayProp, label); | |
if ( arrayProp == null || arrayProp.isExpanded ) | |
{ | |
s.collectionHeight += ContainerBackground.margin.top; | |
s.collectionHeight += ContainerBackground.padding.top; | |
s.collectionHeight += errorHeight; | |
if ( !s.HasError ) | |
{ | |
float marginSize = Mathf.Max(ItemBackground.margin.top, ItemBackground.margin.bottom); | |
float previousMargin = 0; | |
for ( int i = 0; i < arrayProp.arraySize; ++i ) | |
{ | |
var item = arrayProp.GetArrayElementAtIndex(i); | |
s.collectionHeight += previousMargin; | |
s.collectionHeight += ItemBackground.padding.top; | |
s.collectionHeight += EditorGUI.GetPropertyHeight(item); | |
s.collectionHeight += ItemBackground.padding.bottom; | |
previousMargin = marginSize; | |
} | |
} | |
s.collectionHeight += ContainerBackground.padding.bottom; | |
} | |
//TODO: Is there a cleaner way to do this? No magic values! | |
if ( !s.HasError && arrayProp.arraySize == 0 ) | |
s.collectionHeight += 2; | |
s.collectionHeight += ContainerBackground.margin.bottom; | |
return s.collectionHeight; | |
} | |
public static void OnGUI ( State s, SerializedProperty arrayProp, GUIContent label ) | |
{ | |
if ( arrayProp == null || arrayProp.isExpanded ) | |
{ | |
Event e = Event.current; | |
EventType eType = e.GetTypeForControl(s.controlID); | |
Rect usable = s.position; | |
usable.height = s.collectionHeight; | |
s.position.y += usable.height; | |
//Container background | |
Rect containerRect = usable; | |
containerRect.yMin += ContainerBackground.margin.top; | |
containerRect.yMax -= ContainerBackground.margin.bottom; | |
usable = ContainerBackground.padding.Remove(containerRect); | |
if ( eType == EventType.Repaint ) | |
ContainerBackground.Draw(containerRect, false, false, false, false); | |
//Errors | |
if ( s.HasError ) | |
{ | |
s.position = usable; | |
ErrorModule.OnGUI(s, arrayProp, label); | |
return; | |
} | |
//Handle non-item events | |
//NOTE: We eat all input while dragging | |
if ( s.IsDragging ) | |
{ | |
switch ( eType ) | |
{ | |
//Allowed events | |
case EventType.Layout: | |
case EventType.Repaint: | |
case EventType.ScrollWheel: | |
break; | |
case EventType.MouseDrag: | |
{ | |
//NOTE: The drag needs to be handled by each item | |
e.Use(); | |
break; | |
} | |
case EventType.MouseUp: | |
{ | |
if ( e.button == 0 ) | |
{ | |
//Swap | |
arrayProp.MoveArrayElement(s.draggedItemIndex, s.dropIndex); | |
arrayProp.serializedObject.ApplyModifiedProperties(); | |
arrayProp.serializedObject.Update(); | |
GUI.changed = true; | |
EndDrag(s); | |
} | |
e.Use(); | |
return; | |
} | |
case EventType.KeyDown: | |
{ | |
if ( e.keyCode == KeyCode.Escape ) | |
EndDrag(s); | |
e.Use(); | |
return; | |
} | |
default: | |
{ | |
e.Use(); | |
return; | |
} | |
} | |
} | |
//TODO: Needs to handle structs with an enum 'primary key' | |
//TODO: Don't allow modification of the enum key!! | |
//TODO: Validate the primary key is actually an enum (hard when there are no items!) | |
//TODO: How does this work with multi selection? (Process each item individually?) | |
//TODO: Sorting is going to be tricky on prefab instances. | |
//TODO: Optimize - do only once | |
//TODO: Object pinging doesn't work. Unity bug? | |
//Enum specialization | |
bool synWithEnum = (s.flags & Flags.SyncWithEnum) != 0; | |
if ( synWithEnum ) | |
{ | |
/* TODO: enumNames will log an error if the array type | |
* is not an enum. There appears to be no way to check | |
* this in advance or even handle the error. | |
* | |
* Probably need to do something ugly like use | |
* reflection here or in the attribute. | |
* | |
* Alternatively, could insert one element, get it, | |
* check the type, then update the property without | |
* saving the change. | |
*/ | |
/* NOTE: Unity sorts the enums values by underlying | |
* value, not by the order they appear in source code. | |
*/ | |
//TODO: arrayProp.displayName will be wrong when using a wrapper struct | |
//Remove invalid enum values | |
for ( int i = 0; i < arrayProp.arraySize; ++i ) | |
{ | |
var item = arrayProp.GetArrayElementAtIndex(i); | |
var enumProp = item.FindPropertyRelative(s.keyFieldName); | |
if ( enumProp.enumValueIndex == -1 ) | |
{ | |
UObject target = arrayProp.serializedObject.targetObject; | |
Debug.LogFormat(target, | |
"Removing invalid enum value '{0}'." + | |
"\nObject: '{1}', Field: '{2}'", | |
enumProp.intValue, target.name, arrayProp.displayName | |
); | |
arrayProp.DeleteArrayElementAtIndex(i); | |
GUI.changed = true; | |
} | |
} | |
//Insert and sort valid enum values | |
for ( int i = 0; i < s.fixedKeyValues.Length; ++i ) | |
{ | |
int matchingItemIndex = -1; | |
for ( int j = i; j < arrayProp.arraySize; ++j ) | |
{ | |
var item = arrayProp.GetArrayElementAtIndex(i); | |
var enumProp = item.FindPropertyRelative(s.keyFieldName); | |
if ( enumProp.enumValueIndex == i ) | |
{ | |
matchingItemIndex = j; | |
break; | |
} | |
} | |
if ( matchingItemIndex == -1 ) | |
{ | |
UObject target = arrayProp.serializedObject.targetObject; | |
Debug.LogFormat(target, | |
"Inserting missing enum value '{0}'." + | |
"\nObject: '{1}', Field: '{2}'", | |
s.fixedKeyValues[i], target.name, arrayProp.displayName | |
); | |
//Insert | |
arrayProp.InsertArrayElementAtIndex(i); | |
var newItem = arrayProp.GetArrayElementAtIndex(i); | |
var enumProp = newItem.FindPropertyRelative(s.keyFieldName); | |
enumProp.enumValueIndex = i; | |
GUI.changed = true; | |
} | |
else if ( matchingItemIndex != i ) | |
{ | |
//Sort | |
arrayProp.MoveArrayElement(matchingItemIndex, i); | |
GUI.changed = true; | |
} | |
} | |
} | |
//TODO: Do we need to apply and update when making array changes? We only do it in one place at the moment. | |
//TODO: Ensure changes are always saved if closing the editor | |
//Items | |
float margin = Mathf.Max(ItemBackground.margin.top, ItemBackground.margin.bottom); | |
Rect currentYRect = usable; | |
currentYRect.height = 0; | |
currentYRect.y -= margin; | |
currentYRect.xMin += ItemBackground.margin.left; | |
currentYRect.xMin += ItemBackground.padding.left; | |
currentYRect.xMax -= ItemBackground.padding.right; | |
currentYRect.xMax -= ItemBackground.margin.right; | |
ItemPosition itemP = new ItemPosition(-1, currentYRect); | |
Rect draggedItemFinalRect = s.draggedItemBaseRect; | |
//draggedItemFinalRect.position += new Vector2(-5, 0); | |
float yOffset = e.mousePosition.y - s.dragInitialMouseY; | |
float yMin = usable.yMin; | |
float yMax = usable.yMax - draggedItemFinalRect.height; | |
draggedItemFinalRect.y = Mathf.Clamp(draggedItemFinalRect.y + yOffset, yMin, yMax); | |
//NOTE: Cached because it can change in the middle of the loop | |
int dropIndex = s.dropIndex; | |
Rect dropRect = new Rect(); | |
for ( int i = 0; i < arrayProp.arraySize; ++i ) | |
{ | |
currentYRect.y = itemP.rect.yMax + margin; | |
itemP.actualIndex = i; | |
itemP.apparentIndex += 1; | |
//Draw dragged item last (so it draws on top) | |
if ( s.IsDragging && i >= s.draggedItemIndex ) | |
{ | |
if ( i < arrayProp.arraySize - 1 ) | |
{ | |
itemP.actualIndex = i + 1; | |
} | |
else | |
{ | |
itemP.actualIndex = s.draggedItemIndex; | |
itemP.apparentIndex = dropIndex; | |
} | |
} | |
//Skip drop location | |
if ( itemP.apparentIndex == dropIndex ) | |
{ | |
//TODO: There's probably a more intuitive way to handle this. Maybe combine this check with the one above? | |
if ( itemP.actualIndex != s.draggedItemIndex || itemP.apparentIndex == arrayProp.arraySize-1 ) | |
{ | |
dropRect = currentYRect; | |
dropRect.height = s.draggedItemBaseRect.height; | |
} | |
if ( itemP.actualIndex != s.draggedItemIndex ) | |
{ | |
itemP.apparentIndex += 1; | |
currentYRect.y += dropRect.height + margin; | |
} | |
} | |
//Draw! | |
var item = arrayProp.GetArrayElementAtIndex(itemP.actualIndex); | |
itemP.rect = currentYRect; | |
itemP.rect.height += ItemBackground.padding.top; | |
itemP.rect.height += EditorGUI.GetPropertyHeight(item); | |
itemP.rect.height += ItemBackground.padding.bottom; | |
if ( itemP.actualIndex == s.draggedItemIndex ) | |
{ | |
DrawDraggedItem(s, e, eType, arrayProp, itemP, item, draggedItemFinalRect, dropRect); | |
} | |
else | |
{ | |
DrawItem(s, e, eType, arrayProp, itemP, item, draggedItemFinalRect); | |
} | |
} | |
} | |
} | |
private static void DrawDraggedItem ( State s, Event e, EventType eType, SerializedProperty arrayProp, | |
ItemPosition itemP, SerializedProperty item, | |
Rect draggedItemFinalRect, Rect dropRect ) | |
{ | |
//Always keep the drag cursur up while dragging | |
Resolution res = Screen.currentResolution; | |
Rect dragCursorRect = GUIUtility.ScreenToGUIRect(new Rect(0, 0, res.width, res.height)); | |
EditorGUIUtility.AddCursorRect(dragCursorRect, MouseCursor.Pan); | |
if ( eType == EventType.Repaint ) | |
{ | |
//Drop location background | |
DropBackground.Draw(dropRect, dropRect.Contains(e.mousePosition), false, false, false); | |
//Dragged row background | |
ItemBackground.Draw(draggedItemFinalRect, true, true, false, false); | |
} | |
itemP.rect = draggedItemFinalRect; | |
DrawItem(s, e, eType, arrayProp, itemP, item, draggedItemFinalRect); | |
} | |
private static void DrawItem ( State s, Event e, EventType eType, SerializedProperty arrayProp, | |
ItemPosition itemP, SerializedProperty item, | |
Rect draggedItemFinalRect ) | |
{ | |
bool isDraggedItem = itemP.actualIndex == s.draggedItemIndex; | |
bool disableReordering = (s.flags & Flags.DisableReordering) != 0; | |
Rect usable = itemP.rect; | |
usable.xMin += ItemBackground.padding.left; | |
usable.xMax -= ItemBackground.padding.right; | |
//Drag handle | |
Rect dragHandleRect = new Rect(); | |
if ( !disableReordering ) | |
{ | |
dragHandleRect = usable; | |
dragHandleRect.width = DragHandleWidth; | |
usable.xMin += dragHandleRect.width; | |
EditorGUIUtility.AddCursorRect(dragHandleRect, MouseCursor.Pan); | |
} | |
else | |
{ | |
usable.xMin += DragHandle.margin.right; | |
} | |
//Event handling | |
if ( eType == EventType.MouseDown ) | |
{ | |
Assert.IsTrue(!s.IsDragging); | |
if ( e.button == 0 && dragHandleRect.Contains(e.mousePosition) ) | |
{ | |
BeginDrag(s, itemP.actualIndex, itemP.rect, e.mousePosition.y); | |
e.Use(); | |
} | |
} | |
else if ( eType == EventType.MouseDrag ) | |
{ | |
//Update drop index | |
if ( s.IsDragging && !isDraggedItem ) | |
{ | |
float centerY = itemP.rect.center.y; | |
if ( (draggedItemFinalRect.yMin < centerY && itemP.apparentIndex < s.dropIndex) | |
|| (draggedItemFinalRect.yMax > centerY && itemP.apparentIndex > s.dropIndex) ) | |
{ | |
s.dropIndex = itemP.apparentIndex; | |
} | |
} | |
} | |
else if ( eType == EventType.Repaint ) | |
{ | |
bool isLastItem = itemP.apparentIndex == arrayProp.arraySize - 1; | |
if ( !isDraggedItem && !isLastItem ) | |
{ | |
//Separator | |
Rect separatorRect = itemP.rect; | |
separatorRect.yMin = separatorRect.yMax; | |
separatorRect.height = 1; | |
Separator.Draw(separatorRect, separatorRect.Contains(e.mousePosition), false, false, false); | |
} | |
if ( !disableReordering ) | |
{ | |
//Drag handle texture | |
var dragTextureSize = new Vector2(DragHandle.fixedWidth, DragHandle.fixedHeight); | |
Rect dragTextureRect = new Rect(dragHandleRect.center - .5f*dragTextureSize, dragTextureSize); | |
bool hovered = dragHandleRect.Contains(e.mousePosition) || isDraggedItem; | |
DragHandle.Draw(dragTextureRect, hovered, isDraggedItem, false, false); | |
} | |
} | |
//TODO: These are drawing on top of the dragged item background. Why? | |
//Remove button | |
bool disableResize = (s.flags & Flags.DisableResize) != 0; | |
if ( !disableResize ) | |
{ | |
Rect removeButtonRect = usable; | |
removeButtonRect.xMin = removeButtonRect.xMax - ButtonWidth; | |
usable.xMax -= removeButtonRect.width; | |
//HACK: Temporary hack for the below TODO | |
if ( eType == EventType.MouseDown ) | |
if ( e.button != 0 && removeButtonRect.Contains(e.mousePosition) ) | |
e.Use(); | |
//TODO: This accepts any button :( | |
if ( GUI.Button(removeButtonRect, RemoveButtonContent, ButtonOnLight) ) | |
{ | |
arrayProp.DeleteArrayElementAtIndex(itemP.actualIndex); | |
GUI.changed = true; | |
return; | |
} | |
} | |
usable.xMax -= ButtonOnLight.margin.left; | |
//Element | |
{ | |
Rect elementRect = usable; | |
elementRect.yMin += ItemBackground.padding.top; | |
elementRect.yMax -= ItemBackground.padding.bottom; | |
//NOTE: Undo the built-in offset for the parent foldout | |
if ( item.hasVisibleChildren && EditorGUIUtility.hierarchyMode ) | |
//TODO: This is not robust enough | |
//NOTE: Other property drawers may draw everything on a single line (e.g. the built-in Vector3 drawer). | |
if ( item.propertyType == SerializedPropertyType.Generic ) | |
elementRect.xMin += PDEditorGUI.FoldoutHierarchyModeCorrection; | |
EditorGUI.PropertyField(elementRect, item, true); | |
} | |
} | |
} | |
//TODO: Remove? | |
private struct ItemPosition | |
{ | |
public int actualIndex; | |
public int apparentIndex; | |
public Rect rect; | |
public ItemPosition ( int actualIndex, Rect rect = new Rect() ) | |
: this(actualIndex, actualIndex, rect) { } | |
public ItemPosition ( int actualIndex, int apparentIndex, Rect rect = new Rect() ) | |
{ | |
this.actualIndex = actualIndex; | |
this.apparentIndex = apparentIndex; | |
this.rect = rect; | |
} | |
} | |
} | |
} |
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
namespace PixelDash.Tools.Presentation | |
{ | |
using System; | |
using UnityEngine; | |
using Flags = PDCollectionFlags; | |
/// <summary> | |
/// Flags used to modify the appearance of a PDCollection. | |
/// </summary> | |
[Flags] | |
public enum PDCollectionFlags | |
{ | |
#pragma warning disable 1591 | |
None = 1 << 0, | |
HideSize = 1 << 1, | |
DisableResize = 1 << 2, | |
DisableReordering = 1 << 3, | |
SyncWithEnum = (1 << 4) + HideSize + DisableResize + DisableReordering | |
#pragma warning restore 1591 | |
} | |
//TODO: Can this be applied to the base class? | |
/// <summary> | |
/// Marks a collection to be drawn using PDCollection. | |
/// </summary> | |
[AttributeUsage(AttributeTargets.Field)] | |
public class PDCollectionAttribute : PropertyAttribute | |
{ | |
//TODO: DOn't expose these | |
/// <summary> | |
/// The name of the collection field the PDCollection will target. If | |
/// null, the PDCollection will use the first top-level collection | |
/// field it is able to find. | |
/// </summary> | |
public string collectionName; | |
/// <summary> | |
/// The name of a field that will be considered the 'primary key'. The | |
/// primary key is used to allow drag and drop onto list elements or to | |
/// synchronize the collection with the values of a particular enum. If | |
/// null, the PDCollection will use the first top-level field it is | |
/// able to find. | |
/// </summary> | |
public string keyFieldName; | |
public string[] fixedKeyValues; | |
/// <summary> | |
/// Flags that will be applied to the PDCollection. | |
/// </summary> | |
public Flags flags; | |
/// <inheritdoc /> | |
public PDCollectionAttribute ( Flags flags = Flags.None, string collectionName = null, string keyFieldName = null/*, object[] fixedKeyValues = null*/ ) | |
{ | |
this.collectionName = collectionName; | |
this.keyFieldName = keyFieldName; | |
this.flags = flags; | |
//this.fixedKeyValues = fixedKeyValues; | |
} | |
} | |
[AttributeUsage(AttributeTargets.Field)] | |
public class PDCollectionItemsAttribute : PropertyAttribute { } | |
[AttributeUsage(AttributeTargets.Field)] | |
public class PDCollectionKeyFieldAttribute : ReadOnlyAttribute { } | |
} |
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
namespace PixelDash.Tools.Editor.Presentation | |
{ | |
using System; | |
using System.Reflection; | |
using PixelDash.Tools.Presentation; | |
using UnityEditor; | |
using UnityEngine; | |
using UnityEngine.Assertions; | |
using HeaderModule = PDCollection.HeaderModule; | |
using ArrayModule = PDCollection.ArrayModule; | |
using State = PDCollection.State; | |
using Flags = Tools.Presentation.PDCollectionFlags; | |
/// <summary> | |
/// Draws a collection decorated with <see cref="PDCollectionAttribute"/> | |
/// using <see cref="PDCollection"/>. | |
/// </summary> | |
[CustomPropertyDrawer(typeof(PDCollectionAttribute))] | |
public sealed class PDCollectionDrawer : PropertyDrawer | |
{ | |
//TODO: If objects before the collection change, this miiiiight not work? | |
private int controlID = GUIUtility.GetControlID("PDCollection".GetHashCode(), FocusType.Passive); | |
private bool triedToFindCollection; | |
private bool triedToFindKeyField; | |
/// <summary> | |
/// The usual. | |
/// </summary> | |
public override float GetPropertyHeight ( SerializedProperty property, GUIContent label ) | |
{ | |
var attribute = (PDCollectionAttribute) base.attribute; | |
State state = (State) GUIUtility.GetStateObject(typeof(State), controlID); | |
state.controlID = controlID; | |
state.flags = attribute.flags; | |
//TODO: Probably won't work on nested PDCollections | |
SerializedProperty arrayProp = null; | |
if ( attribute.collectionName == null && !triedToFindCollection ) | |
{ | |
triedToFindCollection = true; | |
attribute.collectionName = GetChildPropertyWithAttribute<PDCollectionItemsAttribute>(property); | |
if ( attribute.collectionName == null ) | |
{ | |
state.error = string.Format("{0} was specified but no {1} was found on a child field.", | |
typeof(PDCollectionAttribute).Name, | |
typeof(PDCollectionItemsAttribute).Name | |
); | |
} | |
else | |
{ | |
arrayProp = property.FindPropertyRelative(attribute.collectionName); | |
if ( arrayProp == null ) | |
{ | |
state.error = string.Format("{0} was specified but the field '{1}' appears not to be serialized.", | |
typeof(PDCollectionItemsAttribute).Name, | |
attribute.collectionName | |
); | |
attribute.collectionName = null; | |
} | |
else if ( !arrayProp.isArray ) | |
{ | |
state.error = string.Format("{0} was specified but the field '{1}' is not a collection.", | |
typeof(PDCollectionItemsAttribute).Name, | |
attribute.collectionName | |
); | |
attribute.collectionName = null; | |
} | |
} | |
} | |
arrayProp = property.FindPropertyRelative(attribute.collectionName); | |
if ( attribute.keyFieldName == null && !triedToFindKeyField ) | |
{ | |
triedToFindKeyField = true; | |
if ( arrayProp != null ) | |
{ | |
attribute.keyFieldName = GetChildPropertyWithAttribute<PDCollectionKeyFieldAttribute>(arrayProp); | |
bool syncWithEnum = (state.flags & Flags.SyncWithEnum) != 0; | |
if ( syncWithEnum ) | |
{ | |
if ( attribute.keyFieldName == null ) | |
{ | |
state.error = string.Format("{0} was specified but no {1} was found on a child field.", | |
Flags.SyncWithEnum, | |
typeof(PDCollectionKeyFieldAttribute).Name | |
); | |
} | |
else | |
{ | |
bool resetProp = false; | |
if ( arrayProp.arraySize == 0 ) | |
{ | |
resetProp = true; | |
//TODO: Does this act differently than Inserting? | |
++arrayProp.arraySize; | |
} | |
var item = arrayProp.GetArrayElementAtIndex(0); | |
var keyFieldProp = item.FindPropertyRelative(attribute.keyFieldName); | |
if ( keyFieldProp == null ) | |
{ | |
state.error = string.Format("{0} was specified but the key field '{1}' appears not to be serialized.", | |
Flags.SyncWithEnum, | |
attribute.keyFieldName | |
); | |
attribute.keyFieldName = null; | |
} | |
else if ( keyFieldProp.propertyType != SerializedPropertyType.Enum ) | |
{ | |
state.error = string.Format("{0} was specified but the key field '{1}' is not an enum.", | |
Flags.SyncWithEnum, | |
attribute.keyFieldName | |
); | |
attribute.keyFieldName = null; | |
} | |
else | |
{ | |
attribute.fixedKeyValues = keyFieldProp.enumNames; | |
} | |
if ( resetProp ) | |
arrayProp.serializedObject.Update(); | |
} | |
} | |
} | |
} | |
state.keyFieldName = attribute.keyFieldName; | |
state.fixedKeyValues = attribute.fixedKeyValues; | |
if ( arrayProp != null ) | |
property.isExpanded = arrayProp.isExpanded; | |
float height = 0; | |
height += HeaderModule.GetHeight(state, arrayProp, label); | |
height += ArrayModule.GetHeight(state, arrayProp, label); | |
return height; | |
} | |
//TODO: Test with namespaces | |
//TODO: This may find types in the wrong assembly | |
private static string GetChildPropertyWithAttribute<T> ( SerializedProperty rootProp ) where T : Attribute | |
{ | |
Type targetType = null; | |
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies(); | |
for ( int i = 0; i < assemblies.Length; ++i ) | |
{ | |
//TODO: this.fieldInfo?! | |
targetType = assemblies[i].GetType(rootProp.type); | |
if ( targetType != null ) | |
break; | |
} | |
if ( targetType == null ) { return null; } | |
FieldInfo[] fields = targetType.GetFields(BindingFlags.Instance|BindingFlags.Public|BindingFlags.NonPublic); | |
for ( int i = 0; i < fields.Length; ++i ) | |
{ | |
T[] attributes = (T[]) fields[i].GetCustomAttributes(typeof(T), false); | |
Assert.IsTrue(attributes.Length <= 1); | |
if ( attributes.Length > 0 ) | |
return fields[i].Name; | |
} | |
return null; | |
} | |
/// <summary> | |
/// The usual. | |
/// </summary> | |
public override void OnGUI ( Rect position, SerializedProperty property, GUIContent label ) | |
{ | |
var attribute = (PDCollectionAttribute) base.attribute; | |
State state = (State) GUIUtility.GetStateObject(typeof(State), controlID); | |
if ( state.controlID != controlID ) | |
{ | |
Debug.LogError(typeof(PDCollectionDrawer) + " - could not find the correct state object. GetPropertyHeight probably wasn't called."); | |
return; | |
} | |
state.position = position; | |
var arrayProp = property.FindPropertyRelative(attribute.collectionName); | |
HeaderModule.OnGUI(state, arrayProp, label); | |
ArrayModule.OnGUI(state, arrayProp, label); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment