#if UNITY_EDITOR using System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Reflection; using System.Text; using UnityEditor; using UnityEditor.IMGUI.Controls; using UnityEngine.InputSystem.Layouts; using UnityEngine.InputSystem.Utilities; // The action tree view illustrates one of the weaknesses of Unity's editing model. While operating directly // on serialized data does have a number of advantages (the built-in undo system being one of them), making the // persistence model equivalent to the edit model doesn't work well. Serialized data will be laid out for persistence, // not for the convenience of editing operations. This means that editing operations have to constantly jump through // hoops to map themselves onto the persistence model of the data. ////TODO: With many actions and bindings the list becomes really hard to grok; make things more visually distinctive ////TODO: add context menu items for reordering action and binging entries (like "Move Up" and "Move Down") ////FIXME: context menu cannot be brought up when there's no items in the tree ////FIXME: RMB context menu for actions displays composites that aren't applicable to the action namespace UnityEngine.InputSystem.Editor { /// /// A tree view showing action maps, actions, and bindings. This is the core piece around which the various /// pieces of action editing functionality revolve. /// /// /// The tree view can be flexibly used to contain only parts of a specific action setup. For example, /// by only adding items for action maps ( to the tree, the tree view /// will become a flat list of action maps. Or by adding only items for actions ( /// and items for their bindings () to the tree, it will become an action-only /// tree view. /// /// This is used by the action asset editor to separate action maps and their actions into two separate /// tree views (the leftmost and the middle column of the editor). /// /// Each action tree comes with copy-paste and context menu support. /// internal class InputActionTreeView : TreeView { #region Creation public InputActionTreeView(SerializedObject serializedObject, TreeViewState state = null) : base(state ?? new TreeViewState()) { Debug.Assert(serializedObject != null, "Must have serialized object"); this.serializedObject = serializedObject; UpdateSerializedObjectDirtyCount(); foldoutOverride = DrawFoldout; drawHeader = true; drawPlusButton = true; drawMinusButton = true; m_ForceAcceptRename = false; m_Title = new GUIContent(""); } /// /// Build an action tree that shows only the bindings for the given action. /// public static TreeViewItem BuildWithJustBindingsFromAction(SerializedProperty actionProperty, SerializedProperty actionMapProperty = null) { Debug.Assert(actionProperty != null, "Action property cannot be null"); var root = new ActionTreeItem(actionMapProperty, actionProperty); root.depth = -1; root.AddBindingsTo(root); return root; } /// /// Build an action tree that shows only the actions and bindings for the given action map. /// public static TreeViewItem BuildWithJustActionsAndBindingsFromMap(SerializedProperty actionMapProperty) { Debug.Assert(actionMapProperty != null, "Action map property cannot be null"); var root = new ActionMapTreeItem(actionMapProperty); root.depth = -1; root.AddActionsAndBindingsTo(root); return root; } /// /// Build an action tree that contains only the action maps from the given .inputactions asset. /// public static TreeViewItem BuildWithJustActionMapsFromAsset(SerializedObject assetObject) { Debug.Assert(assetObject != null, "Asset object cannot be null"); var root = new ActionMapListItem {id = 0, depth = -1}; ActionMapTreeItem.AddActionMapsFromAssetTo(root, assetObject); return root; } public static TreeViewItem BuildFullTree(SerializedObject assetObject) { Debug.Assert(assetObject != null, "Asset object cannot be null"); var root = new TreeViewItem {id = 0, depth = -1}; ActionMapTreeItem.AddActionMapsFromAssetTo(root, assetObject); if (root.hasChildren) foreach (var child in root.children) ((ActionMapTreeItem)child).AddActionsAndBindingsTo(child); return root; } protected override TreeViewItem BuildRoot() { var root = onBuildTree?.Invoke() ?? new TreeViewItem(0, -1); // If we have a filter, remove unwanted items from the tree. // NOTE: We use this method rather than TreeView's built-in search functionality as we want // to keep the tree structure fully intact whereas searchString switches the tree into // a different view mode. if (m_ItemFilterCriteria.LengthSafe() > 0 && root.hasChildren) { foreach (var child in root.children.OfType().ToArray()) PruneTreeByItemSearchFilter(child); } // Root node is required to have `children` not be null. Add empty list, // if necessary. Can happen, for example, if we have a singleton action at the // root but no bindings on it. if (root.children == null) root.children = new List(); return root; } #endregion #region Filtering internal bool hasFilter => m_ItemFilterCriteria != null; public void ClearItemSearchFilterAndReload() { if (m_ItemFilterCriteria == null) return; m_ItemFilterCriteria = null; Reload(); } public void SetItemSearchFilterAndReload(string criteria) { SetItemSearchFilterAndReload(FilterCriterion.FromString(criteria)); } public void SetItemSearchFilterAndReload(IEnumerable criteria) { m_ItemFilterCriteria = criteria.ToArray(); Reload(); } private void PruneTreeByItemSearchFilter(ActionTreeItemBase item) { // Prune subtree if item is forced out by any of our criteria. if (m_ItemFilterCriteria.Any(x => x.Matches(item) == FilterCriterion.Match.Failure)) { item.parent.children.Remove(item); // Add to list of hidden children. if (item.parent is ActionTreeItemBase parent) { if (parent.m_HiddenChildren == null) parent.m_HiddenChildren = new List(); parent.m_HiddenChildren.Add(item); } return; } ////REVIEW: should we *always* do this? (regardless of whether a control scheme is selected) // When filtering by binding group, we tag bindings that are not in any binding group as "{GLOBAL}". // This helps when having a specific control scheme selected, to also see the bindings that are active // in that control scheme by virtue of not being associated with *any* specific control scheme. if (item is BindingTreeItem bindingItem && !(item is CompositeBindingTreeItem) && string.IsNullOrEmpty(bindingItem.groups) && m_ItemFilterCriteria.Any(x => x.type == FilterCriterion.Type.ByBindingGroup)) { item.displayName += " {GLOBAL}"; } // Prune children. if (item.hasChildren) { foreach (var child in item.children.OfType().ToArray()) // We're modifying the child list so copy. PruneTreeByItemSearchFilter(child); } } #endregion #region Finding Items public ActionTreeItemBase FindItemByPath(string path) { var components = path.Split('/'); var current = rootItem; foreach (var component in components) { if (current.hasChildren) { var found = false; foreach (var child in current.children) { if (child.displayName.Equals(component, StringComparison.InvariantCultureIgnoreCase)) { current = child; found = true; break; } } if (found) continue; } return null; } return (ActionTreeItemBase)current; } public ActionTreeItemBase FindItemByPropertyPath(string propertyPath) { return FindFirstItem(x => x.property.propertyPath == propertyPath); } public ActionTreeItemBase FindItemFor(SerializedProperty element) { // We may be looking at a SerializedProperty that refers to the same element but is // its own instance different from the one we're using in the tree. Compare properties // by path, not by object instance. return FindFirstItem(x => x.property.propertyPath == element.propertyPath); } public TItem FindFirstItem(Func predicate) where TItem : ActionTreeItemBase { return FindFirstItemRecursive(rootItem, predicate); } private static TItem FindFirstItemRecursive(TreeViewItem current, Func predicate) where TItem : ActionTreeItemBase { if (current is TItem itemOfType && predicate(itemOfType)) return itemOfType; if (current.hasChildren) foreach (var child in current.children) { var item = FindFirstItemRecursive(child, predicate); if (item != null) return item; } return null; } #endregion #region Selection public void ClearSelection() { SetSelection(new int[0], TreeViewSelectionOptions.FireSelectionChanged); } public void SelectItems(IEnumerable items) { SetSelection(items.Select(x => x.id).ToList(), TreeViewSelectionOptions.FireSelectionChanged); } public void SelectItem(SerializedProperty element, bool additive = false) { var item = FindItemFor(element); if (item == null) throw new ArgumentException($"Cannot find item for property path '{element.propertyPath}'", nameof(element)); SelectItem(item, additive); } public void SelectItem(string path, bool additive = false) { if (!TrySelectItem(path, additive)) throw new ArgumentException($"Cannot find item with path 'path'", nameof(path)); } public bool TrySelectItem(string path, bool additive = false) { var item = FindItemByPath(path); if (item == null) return false; SelectItem(item, additive); return true; } public void SelectItem(ActionTreeItemBase item, bool additive = false) { if (additive) { var selection = new List(); selection.AddRange(GetSelection()); selection.Add(item.id); SetSelection(selection, TreeViewSelectionOptions.FireSelectionChanged); } else { SetSelection(new[] { item.id }, TreeViewSelectionOptions.FireSelectionChanged); } } public IEnumerable GetSelectedItems() { foreach (var id in GetSelection()) { if (FindItem(id, rootItem) is ActionTreeItemBase item) yield return item; } } /// /// Same as but with items that are selected but are children of other items /// that also selected being filtered out. /// /// /// This is useful for operations such as copy-paste where copy a parent will implicitly copy the child. /// public IEnumerable GetSelectedItemsWithChildrenFilteredOut() { var selectedItems = GetSelectedItems().ToArray(); foreach (var item in selectedItems) { if (selectedItems.Any(x => x.IsParentOf(item))) continue; yield return item; } } public IEnumerable GetSelectedItemsOrParentsOfType() where TItem : ActionTreeItemBase { // If there is no selection and the root item has the type we're looking for, // consider it selected. This allows adding items at the toplevel. if (!HasSelection() && rootItem is TItem root) { yield return root; } else { foreach (var id in GetSelection()) { var item = FindItem(id, rootItem); while (item != null) { if (item is TItem itemOfType) yield return itemOfType; item = item.parent; } } } } public void SelectFirstToplevelItem() { if (rootItem.children.Any()) SetSelection(new[] {rootItem.children[0].id}, TreeViewSelectionOptions.FireSelectionChanged); } protected override void SelectionChanged(IList selectedIds) { onSelectionChanged?.Invoke(); } #endregion #region Renaming public new void BeginRename(TreeViewItem item) { // If a rename is already in progress, force it to end first. EndRename(); onBeginRename?.Invoke((ActionTreeItemBase)item); base.BeginRename(item); } protected override bool CanRename(TreeViewItem item) { return item is ActionTreeItemBase actionTreeItem && actionTreeItem.canRename; } protected override void RenameEnded(RenameEndedArgs args) { if (!(FindItem(args.itemID, rootItem) is ActionTreeItemBase actionItem)) return; if (!(args.acceptedRename || m_ForceAcceptRename) || args.originalName == args.newName) return; Debug.Assert(actionItem.canRename, "Cannot rename " + actionItem); actionItem.Rename(args.newName); OnSerializedObjectModified(); } public void EndRename(bool forceAccept) { m_ForceAcceptRename = forceAccept; EndRename(); m_ForceAcceptRename = false; } protected override void DoubleClickedItem(int id) { if (!(FindItem(id, rootItem) is ActionTreeItemBase item)) return; // If we have a double-click handler, give it control over what happens. if (onDoubleClick != null) { onDoubleClick(item); } else if (item.canRename) { // Otherwise, perform a rename by default. BeginRename(item); } } #endregion #region Drag&Drop protected override bool CanStartDrag(CanStartDragArgs args) { return true; } protected override void SetupDragAndDrop(SetupDragAndDropArgs args) { DragAndDrop.PrepareStartDrag(); DragAndDrop.SetGenericData("itemIDs", args.draggedItemIDs.ToArray()); DragAndDrop.SetGenericData("tree", this); DragAndDrop.StartDrag(string.Join(",", args.draggedItemIDs.Select(id => FindItem(id, rootItem).displayName))); } protected override DragAndDropVisualMode HandleDragAndDrop(DragAndDropArgs args) { var sourceTree = DragAndDrop.GetGenericData("tree") as InputActionTreeView; if (sourceTree == null) return DragAndDropVisualMode.Rejected; var itemIds = (int[])DragAndDrop.GetGenericData("itemIDs"); var altKeyIsDown = Event.current.alt; // Reject the drag if the parent item does not accept the drop. if (args.parentItem is ActionTreeItemBase parentItem) { if (itemIds.Any(id => !parentItem.AcceptsDrop((ActionTreeItemBase)sourceTree.FindItem(id, sourceTree.rootItem)))) return DragAndDropVisualMode.Rejected; } else { // If the root item isn't an ActionTreeItemBase, we're looking at a tree that starts // all the way up at the InputActionAsset. Require the drop to be all action maps. if (itemIds.Any(id => !(sourceTree.FindItem(id, sourceTree.rootItem) is ActionMapTreeItem))) return DragAndDropVisualMode.Rejected; } // Handle drop using copy-paste. This allows handling all the various operations // using a single code path. var isMove = !altKeyIsDown; if (args.performDrop) { // Copy item data. var copyBuffer = new StringBuilder(); var items = itemIds.Select(id => (ActionTreeItemBase)sourceTree.FindItem(id, sourceTree.rootItem)); CopyItems(items, copyBuffer); // If we're moving items within the same tree, no need to generate new IDs. var assignNewIDs = !(isMove && sourceTree == this); // Determine where we are moving/copying the data. var target = args.parentItem ?? rootItem; int? childIndex = null; if (args.dragAndDropPosition == DragAndDropPosition.BetweenItems) childIndex = args.insertAtIndex; // If alt isn't down (i.e. we're not duplicating), delete old items. // Do this *before* pasting so that assigning new names will not cause names to // change when just moving items around. if (isMove) { // Don't use DeleteDataOfSelectedItems() as that will record as a separate operation. foreach (var item in items) { // If we're dropping *between* items on the same parent as the current item and the // index we're dropping at (in the parent, NOT in the array) is coming *after* this item, // then deleting the item will shift the target index down by one. if (item.parent == target && childIndex != null && childIndex > target.children.IndexOf(item)) --childIndex; item.DeleteData(); } } // Paste items onto target. var oldBindingGroupForNewBindings = bindingGroupForNewBindings; try { // With drag&drop, preserve binding groups. bindingGroupForNewBindings = null; PasteItems(copyBuffer.ToString(), new[] { new InsertLocation { item = target, childIndex = childIndex } }, assignNewIDs: assignNewIDs); } finally { bindingGroupForNewBindings = oldBindingGroupForNewBindings; } DragAndDrop.AcceptDrag(); } return isMove ? DragAndDropVisualMode.Move : DragAndDropVisualMode.Copy; } #endregion #region Copy&Paste // These need to correspond to what the editor is sending from the "Edit" menu. public const string k_CopyCommand = "Copy"; public const string k_PasteCommand = "Paste"; public const string k_DuplicateCommand = "Duplicate"; public const string k_CutCommand = "Cut"; public const string k_DeleteCommand = "Delete"; public const string k_SoftDeleteCommand = "SoftDelete"; public void HandleCopyPasteCommandEvent(Event uiEvent) { if (uiEvent.type == EventType.ValidateCommand) { switch (uiEvent.commandName) { case k_CopyCommand: case k_CutCommand: case k_DuplicateCommand: case k_DeleteCommand: case k_SoftDeleteCommand: if (HasSelection()) uiEvent.Use(); break; case k_PasteCommand: var systemCopyBuffer = EditorHelpers.GetSystemCopyBufferContents(); if (systemCopyBuffer != null && systemCopyBuffer.StartsWith(k_CopyPasteMarker)) uiEvent.Use(); break; } } else if (uiEvent.type == EventType.ExecuteCommand) { switch (uiEvent.commandName) { case k_CopyCommand: CopySelectedItemsToClipboard(); break; case k_PasteCommand: PasteDataFromClipboard(); break; case k_CutCommand: CopySelectedItemsToClipboard(); DeleteDataOfSelectedItems(); break; case k_DuplicateCommand: DuplicateSelection(); break; case k_DeleteCommand: case k_SoftDeleteCommand: DeleteDataOfSelectedItems(); break; default: return; } uiEvent.Use(); } } private void DuplicateSelection() { var buffer = new StringBuilder(); // If we have a multi-selection, we want to perform the duplication as if each item // was duplicated individually. Meaning we paste each duplicate right after the item // it was duplicated from. So if, say, an action is selected at the beginning of the // tree and one is selected from the end of it, we still paste the copies into the // two separate locations correctly. // // Technically, if both parents and children are selected, we're order dependent here // but not sure we really need to care. var selection = GetSelection(); ClearSelection(); // Copy-paste each selected item in turn. var newItemIds = new List(); foreach (var id in selection) { SetSelection(new[] { id }); buffer.Length = 0; CopySelectedItemsTo(buffer); PasteDataFrom(buffer.ToString()); newItemIds.AddRange(GetSelection()); } SetSelection(newItemIds); } internal const string k_CopyPasteMarker = "INPUTASSET "; private const string k_StartOfText = "\u0002"; private const string k_StartOfHeading = "\u0001"; private const string k_EndOfTransmission = "\u0004"; private const string k_EndOfTransmissionBlock = "\u0017"; /// /// Copy the currently selected items to the clipboard. /// /// public void CopySelectedItemsToClipboard() { var copyBuffer = new StringBuilder(); CopySelectedItemsTo(copyBuffer); EditorHelpers.SetSystemCopyBufferContents(copyBuffer.ToString()); } public void CopySelectedItemsTo(StringBuilder buffer) { CopyItems(GetSelectedItemsWithChildrenFilteredOut(), buffer); } public static void CopyItems(IEnumerable items, StringBuilder buffer) { buffer.Append(k_CopyPasteMarker); foreach (var item in items) { CopyItemData(item, buffer); buffer.Append(k_EndOfTransmission); } } private static void CopyItemData(ActionTreeItemBase item, StringBuilder buffer) { buffer.Append(k_StartOfHeading); buffer.Append(item.GetType().Name); buffer.Append(k_StartOfText); // InputActionMaps have back-references to InputActionAssets. Make sure we ignore those. buffer.Append(item.property.CopyToJson(ignoreObjectReferences: true)); buffer.Append(k_EndOfTransmissionBlock); if (!item.serializedDataIncludesChildren && item.hasChildrenIncludingHidden) foreach (var child in item.childrenIncludingHidden) CopyItemData(child, buffer); } /// /// Remove the data from the currently selected items from the /// referenced by the tree's data. /// public void DeleteDataOfSelectedItems() { // When deleting data, indices will shift around. However, we do not delete elements by array indices // directly but rather by GUIDs which are used to look up array indices dynamically. This means that // we can safely delete the items without worrying about one deletion affecting the next. // // NOTE: It is important that we first fetch *all* of the selection filtered for parent/child duplicates. // If we don't do so up front, the deletions happening later may start interacting with our // parent/child test. var selection = GetSelectedItemsWithChildrenFilteredOut().ToArray(); // Clear our current selection. If we don't do this first, TreeView will implicitly // clear the selection as items disappear but we will not see a selection change notification // being triggered. ClearSelection(); DeleteItems(selection); } public void DeleteItems(IEnumerable items) { foreach (var item in items) item.DeleteData(); OnSerializedObjectModified(); } public bool HavePastableClipboardData() { var clipboard = EditorHelpers.GetSystemCopyBufferContents(); return clipboard.StartsWith(k_CopyPasteMarker); } public void PasteDataFromClipboard() { PasteDataFrom(EditorHelpers.GetSystemCopyBufferContents()); } public void PasteDataFrom(string copyBufferString) { if (!copyBufferString.StartsWith(k_CopyPasteMarker)) return; var locations = GetSelectedItemsWithChildrenFilteredOut().Select(x => new InsertLocation { item = x }).ToList(); if (locations.Count == 0) locations.Add(new InsertLocation { item = rootItem }); ////REVIEW: filtering out children may remove the very item we need to get the right match for a copy block? PasteItems(copyBufferString, locations); } public struct InsertLocation { public TreeViewItem item; public int? childIndex; } public void PasteItems(string copyBufferString, IEnumerable locations, bool assignNewIDs = true, bool selectNewItems = true) { var newItemPropertyPaths = new List(); // Split buffer into transmissions and then into transmission blocks. Each transmission is an item subtree // meant to be pasted as a whole and each transmission block is a single chunk of serialized data. foreach (var transmission in copyBufferString.Substring(k_CopyPasteMarker.Length) .Split(new[] {k_EndOfTransmission}, StringSplitOptions.RemoveEmptyEntries)) { foreach (var location in locations) PasteBlocks(transmission, location, assignNewIDs, newItemPropertyPaths); } OnSerializedObjectModified(); // If instructed to do so, go and select all newly added items. if (selectNewItems && newItemPropertyPaths.Count > 0) { // We may have pasted into a different tree view. Only select the items if we can find them in // our current tree view. var newItems = newItemPropertyPaths.Select(FindItemByPropertyPath).Where(x => x != null); if (newItems.Any()) SelectItems(newItems); } } private const string k_ActionMapTag = k_StartOfHeading + "ActionMapTreeItem" + k_StartOfText; private const string k_ActionTag = k_StartOfHeading + "ActionTreeItem" + k_StartOfText; private const string k_BindingTag = k_StartOfHeading + "BindingTreeItem" + k_StartOfText; private const string k_CompositeBindingTag = k_StartOfHeading + "CompositeBindingTreeItem" + k_StartOfText; private const string k_PartOfCompositeBindingTag = k_StartOfHeading + "PartOfCompositeBindingTreeItem" + k_StartOfText; private void PasteBlocks(string transmission, InsertLocation location, bool assignNewIDs, List newItemPropertyPaths) { Debug.Assert(location.item != null, "Should have drop target"); var blocks = transmission.Split(new[] {k_EndOfTransmissionBlock}, StringSplitOptions.RemoveEmptyEntries); if (blocks.Length < 1) return; Type CopyTagToType(string tagName) { switch (tagName) { case k_ActionMapTag: return typeof(ActionMapTreeItem); case k_ActionTag: return typeof(ActionTreeItem); case k_BindingTag: return typeof(BindingTreeItem); case k_CompositeBindingTag: return typeof(CompositeBindingTreeItem); case k_PartOfCompositeBindingTag: return typeof(PartOfCompositeBindingTreeItem); default: throw new Exception($"Unrecognized copy block tag '{tagName}'"); } } SplitTagAndData(blocks[0], out var tag, out var data); // Determine where to drop the item. SerializedProperty array = null; var arrayIndex = -1; var itemType = CopyTagToType(tag); if (location.item is ActionTreeItemBase dropTarget) { if (!dropTarget.GetDropLocation(itemType, location.childIndex, ref array, ref arrayIndex)) return; } else if (tag == k_ActionMapTag) { // Paste into InputActionAsset. array = serializedObject.FindProperty("m_ActionMaps"); arrayIndex = location.childIndex ?? array.arraySize; } else { throw new InvalidOperationException($"Cannot paste {tag} into {location.item.displayName}"); } // If not given a specific index, we paste onto the end of the array. if (arrayIndex == -1 || arrayIndex > array.arraySize) arrayIndex = array.arraySize; // Determine action to assign to pasted bindings. string actionForNewBindings = null; if (location.item is ActionTreeItem actionItem) actionForNewBindings = actionItem.name; else if (location.item is BindingTreeItem bindingItem) actionForNewBindings = bindingItem.action; // Paste new element. var newElement = PasteBlock(tag, data, array, arrayIndex, assignNewIDs, actionForNewBindings); newItemPropertyPaths.Add(newElement.propertyPath); // If the element can have children, read whatever blocks are following the current one (if any). if ((tag == k_ActionTag || tag == k_CompositeBindingTag) && blocks.Length > 1) { var bindingArray = array; if (tag == k_ActionTag) { // We don't support pasting actions separately into action maps in the same paste operations so // there must be an ActionMapTreeItem in the hierarchy we pasted into. var actionMapItem = location.item.TryFindItemInHierarchy(); Debug.Assert(actionMapItem != null, "Cannot find ActionMapTreeItem in hierarchy of pasted action"); bindingArray = actionMapItem.bindingsArrayProperty; actionForNewBindings = InputActionSerializationHelpers.GetName(newElement); } for (var i = 1; i < blocks.Length; ++i) { SplitTagAndData(blocks[i], out var blockTag, out var blockData); PasteBlock(blockTag, blockData, bindingArray, tag == k_CompositeBindingTag ? arrayIndex + i : -1, assignNewIDs, actionForNewBindings); } } } private static void SplitTagAndData(string block, out string tag, out string data) { var indexOfStartOfTextChar = block.IndexOf(k_StartOfText); if (indexOfStartOfTextChar == -1) throw new ArgumentException($"Incorrect copy data format: Expecting '{k_StartOfText}' in '{block}'", nameof(block)); tag = block.Substring(0, indexOfStartOfTextChar + 1); data = block.Substring(indexOfStartOfTextChar + 1); } public static SerializedProperty AddElement(SerializedProperty arrayProperty, string name, int index = -1) { var uniqueName = InputActionSerializationHelpers.FindUniqueName(arrayProperty, name); if (index < 0) index = arrayProperty.arraySize; arrayProperty.InsertArrayElementAtIndex(index); var elementProperty = arrayProperty.GetArrayElementAtIndex(index); elementProperty.ResetValuesToDefault(); elementProperty.FindPropertyRelative("m_Name").stringValue = uniqueName; elementProperty.FindPropertyRelative("m_Id").stringValue = Guid.NewGuid().ToString(); return elementProperty; } private SerializedProperty PasteBlock(string tag, string data, SerializedProperty array, int arrayIndex, bool assignNewIDs, string actionForNewBindings = null) { // Add an element to the array. Then read the serialized properties stored in the copy data // back into the element. var property = AddElement(array, "tempName", arrayIndex); property.RestoreFromJson(data); if (tag == k_ActionTag || tag == k_ActionMapTag) InputActionSerializationHelpers.EnsureUniqueName(property); if (assignNewIDs) { // Assign new IDs to the element as well as to any elements it contains. This means // that for action maps, we will also assign new IDs to every action and binding in the map. InputActionSerializationHelpers.AssignUniqueIDs(property); } // If the element is a binding, update its action target and binding group, if necessary. if (tag == k_BindingTag || tag == k_CompositeBindingTag || tag == k_PartOfCompositeBindingTag) { ////TODO: use {id} rather than plain name // Update action to refer to given action. InputActionSerializationHelpers.ChangeBinding(property, action: actionForNewBindings); // If we have a binding group to set for new bindings, overwrite the binding's // group with it. if (!string.IsNullOrEmpty(bindingGroupForNewBindings) && tag != k_CompositeBindingTag) { InputActionSerializationHelpers.ChangeBinding(property, groups: bindingGroupForNewBindings); } onBindingAdded?.Invoke(property); } return property; } #endregion #region Context Menus public void BuildContextMenuFor(Type itemType, GenericMenu menu, bool multiSelect, ActionTreeItem actionItem = null, bool noSelection = false) { var canRename = false; if (itemType == typeof(ActionMapTreeItem)) { menu.AddItem(s_AddActionLabel, false, AddNewAction); } else if (itemType == typeof(ActionTreeItem)) { canRename = true; BuildMenuToAddBindings(menu, actionItem); } else if (itemType == typeof(CompositeBindingTreeItem)) { canRename = true; } else if (itemType == typeof(ActionMapListItem)) { menu.AddItem(s_AddActionMapLabel, false, AddNewActionMap); } // Common menu entries shared by all types of items. menu.AddSeparator(""); if (noSelection) { menu.AddDisabledItem(s_CutLabel); menu.AddDisabledItem(s_CopyLabel); } else { menu.AddItem(s_CutLabel, false, () => { CopySelectedItemsToClipboard(); DeleteDataOfSelectedItems(); }); menu.AddItem(s_CopyLabel, false, CopySelectedItemsToClipboard); } if (HavePastableClipboardData()) menu.AddItem(s_PasteLabel, false, PasteDataFromClipboard); else menu.AddDisabledItem(s_PasteLabel); menu.AddSeparator(""); if (!noSelection && canRename && !multiSelect) menu.AddItem(s_RenameLabel, false, () => BeginRename(GetSelectedItems().First())); else if (canRename) menu.AddDisabledItem(s_RenameLabel); if (noSelection) { menu.AddDisabledItem(s_DuplicateLabel); menu.AddDisabledItem(s_DeleteLabel); } else { menu.AddItem(s_DuplicateLabel, false, DuplicateSelection); menu.AddItem(s_DeleteLabel, false, DeleteDataOfSelectedItems); } if (itemType != typeof(ActionMapTreeItem)) { menu.AddSeparator(""); menu.AddItem(s_ExpandAllLabel, false, ExpandAll); menu.AddItem(s_CollapseAllLabel, false, CollapseAll); } } public void BuildMenuToAddBindings(GenericMenu menu, ActionTreeItem actionItem = null) { // Add entry to add "normal" bindings. menu.AddItem(s_AddBindingLabel, false, () => { if (actionItem != null) AddNewBinding(actionItem.property, actionItem.actionMapProperty); else AddNewBinding(); }); // Add one entry for each registered type of composite binding. var expectedControlLayout = new InternedString(actionItem?.expectedControlLayout); foreach (var compositeName in InputBindingComposite.s_Composites.internedNames.Where(x => !InputBindingComposite.s_Composites.aliases.Contains(x)).OrderBy(x => x)) { // Skip composites we should hide from the UI. var compositeType = InputBindingComposite.s_Composites.LookupTypeRegistration(compositeName); var designTimeVisible = compositeType.GetCustomAttribute(); if (designTimeVisible != null && !designTimeVisible.Visible) continue; // If the action is expected a specific control layout, check // whether the value type use by the composite matches that of // the layout. if (!expectedControlLayout.IsEmpty()) { var valueType = InputBindingComposite.GetValueType(compositeName); if (valueType != null && !InputControlLayout.s_Layouts.ValueTypeIsAssignableFrom(expectedControlLayout, valueType)) continue; } var displayName = compositeType.GetCustomAttribute(); var niceName = displayName != null ? displayName.DisplayName.Replace('/', '\\') : ObjectNames.NicifyVariableName(compositeName) + " Composite"; menu.AddItem(new GUIContent($"Add {niceName}"), false, () => { if (actionItem != null) AddNewComposite(actionItem.property, actionItem.actionMapProperty, compositeName); else AddNewComposite(compositeName); }); } } private void PopUpContextMenu() { // See if we have a selection of mixed types. var selected = GetSelectedItems().ToList(); var mixedSelection = selected.Select(x => x.GetType()).Distinct().Count() > 1; var noSelection = selected.Count == 0; // Create and pop up context menu. var menu = new GenericMenu(); if (noSelection) { BuildContextMenuFor(rootItem.GetType(), menu, true, noSelection: noSelection); } else if (mixedSelection) { BuildContextMenuFor(typeof(ActionTreeItemBase), menu, true, noSelection: noSelection); } else { var item = selected.First(); BuildContextMenuFor(item.GetType(), menu, GetSelection().Count > 1, actionItem: item as ActionTreeItem); } menu.ShowAsContext(); } protected override void ContextClickedItem(int id) { // When right-clicking an unselected item, TreeView does change the selection to the // clicked item but the visual feedback only comes in the *next* repaint. This means that // if we pop up a context menu right away here, the user does not correctly see which item // is affected. // // So, instead we force a repaint and open the context menu on the next OnGUI() call. Note // that we can't use something like EditorApplication.delayCall here as ShowAsContext() // can only be called from UI callbacks (otherwise it will simply be ignored). m_InitiateContextMenuOnNextRepaint = true; Repaint(); Event.current.Use(); } protected override void ContextClicked() { ClearSelection(); m_InitiateContextMenuOnNextRepaint = true; Repaint(); Event.current.Use(); } #endregion #region Add New Items /// /// Add a new action map to the toplevel . /// public void AddNewActionMap() { var actionMapProperty = InputActionSerializationHelpers.AddActionMap(serializedObject); var actionProperty = InputActionSerializationHelpers.AddAction(actionMapProperty); InputActionSerializationHelpers.AddBinding(actionProperty, actionMapProperty, groups: bindingGroupForNewBindings); OnNewItemAdded(actionMapProperty); } /// /// Add new action to the currently active action map(s). /// public void AddNewAction() { foreach (var actionMapItem in GetSelectedItemsOrParentsOfType()) AddNewAction(actionMapItem.property); } public void AddNewAction(SerializedProperty actionMapProperty) { if (onHandleAddNewAction != null) onHandleAddNewAction(actionMapProperty); else { var actionProperty = InputActionSerializationHelpers.AddAction(actionMapProperty); InputActionSerializationHelpers.AddBinding(actionProperty, actionMapProperty, groups: bindingGroupForNewBindings); OnNewItemAdded(actionProperty); } } public void AddNewBinding() { foreach (var actionItem in GetSelectedItemsOrParentsOfType()) AddNewBinding(actionItem.property, actionItem.actionMapProperty); } public void AddNewBinding(SerializedProperty actionProperty, SerializedProperty actionMapProperty) { var bindingProperty = InputActionSerializationHelpers.AddBinding(actionProperty, actionMapProperty, groups: bindingGroupForNewBindings); onBindingAdded?.Invoke(bindingProperty); OnNewItemAdded(bindingProperty); } public void AddNewComposite(string compositeType) { foreach (var actionItem in GetSelectedItemsOrParentsOfType()) AddNewComposite(actionItem.property, actionItem.actionMapProperty, compositeType); } public void AddNewComposite(SerializedProperty actionProperty, SerializedProperty actionMapProperty, string compositeName) { var compositeType = InputBindingComposite.s_Composites.LookupTypeRegistration(compositeName); if (compositeType == null) throw new ArgumentException($"Cannot find composite registration for {compositeName}", nameof(compositeName)); var compositeProperty = InputActionSerializationHelpers.AddCompositeBinding(actionProperty, actionMapProperty, compositeName, compositeType, groups: bindingGroupForNewBindings); onBindingAdded?.Invoke(compositeProperty); OnNewItemAdded(compositeProperty); } private void OnNewItemAdded(SerializedProperty property) { OnSerializedObjectModified(); SelectItemAndBeginRename(property); } private void SelectItemAndBeginRename(SerializedProperty property) { var item = FindItemFor(property); if (item == null) { // if we could not find the item, try clearing search filters. ClearItemSearchFilterAndReload(); item = FindItemFor(property); } Debug.Assert(item != null, $"Cannot find newly created item for {property.propertyPath}"); SetExpandedRecursive(item.id, true); SelectItem(item); SetFocus(); FrameItem(item.id); if (item.canRename) BeginRename(item); } #endregion #region Drawing public override void OnGUI(Rect rect) { if (m_InitiateContextMenuOnNextRepaint) { m_InitiateContextMenuOnNextRepaint = false; PopUpContextMenu(); } if (ReloadIfSerializedObjectHasBeenChanged()) return; // Draw border rect. EditorGUI.LabelField(rect, GUIContent.none, Styles.backgroundWithBorder); rect.x += 1; rect.y += 1; rect.height -= 1; rect.width -= 2; if (drawHeader) DrawHeader(ref rect); base.OnGUI(rect); HandleCopyPasteCommandEvent(Event.current); } private void DrawHeader(ref Rect rect) { var headerRect = rect; headerRect.height = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; rect.y += headerRect.height; rect.height -= headerRect.height; // Draw label. EditorGUI.LabelField(headerRect, m_Title, Styles.columnHeaderLabel); // Draw minus button. var buttonRect = headerRect; buttonRect.width = EditorGUIUtility.singleLineHeight; buttonRect.x += rect.width - buttonRect.width - EditorGUIUtility.standardVerticalSpacing; if (drawMinusButton) { var minusButtonDisabled = !HasSelection(); using (new EditorGUI.DisabledScope(minusButtonDisabled)) { if (GUI.Button(buttonRect, minusIcon, GUIStyle.none)) DeleteDataOfSelectedItems(); } buttonRect.x -= buttonRect.width + EditorGUIUtility.standardVerticalSpacing; } // Draw plus button. if (drawPlusButton) { var plusIconDisabled = onBuildTree == null; using (new EditorGUI.DisabledScope(plusIconDisabled)) { if (GUI.Button(buttonRect, plusIcon, GUIStyle.none)) { if (rootItem is ActionMapTreeItem mapItem) { AddNewAction(mapItem.property); } else if (rootItem is ActionTreeItem actionItem) { // Adding a composite has multiple options. Pop up a menu. var menu = new GenericMenu(); BuildMenuToAddBindings(menu, actionItem); menu.ShowAsContext(); } else { AddNewActionMap(); } } buttonRect.x -= buttonRect.width + EditorGUIUtility.standardVerticalSpacing; } } // Draw action properties button. if (drawActionPropertiesButton && rootItem is ActionTreeItem item) { if (GUI.Button(buttonRect, s_ActionPropertiesIcon, GUIStyle.none)) onDoubleClick?.Invoke(item); } } // For each item, we draw // 1) color tag // 2) foldout // 3) display name // 4) Line underneath item private const int kColorTagWidth = 6; private const int kFoldoutWidth = 15; ////FIXME: foldout hover region is way too large; partly overlaps the text of items private bool DrawFoldout(Rect position, bool expandedState, GUIStyle style) { // We don't get the depth of the item we're drawing the foldout for but we can // infer it by the amount that the given rectangle was indented. var indent = (int)(position.x / kFoldoutWidth); var indentLevel = EditorGUI.indentLevel; // When drawing input actions in the input actions editor, we don't want to offset the foldout // icon any further than the position that's passed in to this function, so take advantage of // the fact that indentLevel is always zero in that editor. position.x = EditorGUI.IndentedRect(position).x * Mathf.Clamp01(indentLevel) + kColorTagWidth + 2 + indent * kColorTagWidth; position.width = kFoldoutWidth; var hierarchyMode = EditorGUIUtility.hierarchyMode; // We remove the editor indent level and set hierarchy mode to false when drawing the foldout // arrow so that in the inspector we don't get additional padding on the arrow for the inspector // gutter, and so that the indent level doesn't apply because we've done that ourselves. EditorGUI.indentLevel = 0; EditorGUIUtility.hierarchyMode = false; var foldoutExpanded = EditorGUI.Foldout(position, expandedState, GUIContent.none, true, style); EditorGUI.indentLevel = indentLevel; EditorGUIUtility.hierarchyMode = hierarchyMode; return foldoutExpanded; } protected override void RowGUI(RowGUIArgs args) { var item = (ActionTreeItemBase)args.item; var isRepaint = Event.current.type == EventType.Repaint; // Color tag at beginning of line. var colorTagRect = EditorGUI.IndentedRect(args.rowRect); colorTagRect.x += item.depth * kColorTagWidth; colorTagRect.width = kColorTagWidth; if (isRepaint) item.colorTagStyle.Draw(colorTagRect, GUIContent.none, false, false, false, false); // Text. // NOTE: When renaming, the renaming overlay gets drawn outside of our control so don't draw the label in that case // as otherwise it will peak out from underneath the overlay. if (!args.isRenaming && isRepaint) { var text = item.displayName; var textRect = GetTextRect(args.rowRect, item); var style = args.selected ? Styles.selectedText : Styles.text; if (item.showWarningIcon) { var content = new GUIContent(text, EditorGUIUtility.FindTexture("console.warnicon.sml")); style.Draw(textRect, content, false, false, args.selected, args.focused); } else style.Draw(textRect, text, false, false, args.selected, args.focused); } // Bottom line. var lineRect = EditorGUI.IndentedRect(args.rowRect); lineRect.y += lineRect.height - 1; lineRect.height = 1; if (isRepaint) Styles.border.Draw(lineRect, GUIContent.none, false, false, false, false); // For action items, add a dropdown menu to add bindings. if (item is ActionTreeItem actionItem) { var buttonRect = args.rowRect; buttonRect.x = buttonRect.width - (EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing); buttonRect.width = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; if (GUI.Button(buttonRect, s_PlusBindingIcon, GUIStyle.none)) { var menu = new GenericMenu(); BuildMenuToAddBindings(menu, actionItem); menu.ShowAsContext(); } } } protected override float GetCustomRowHeight(int row, TreeViewItem item) { return 18; } protected override Rect GetRenameRect(Rect rowRect, int row, TreeViewItem item) { var textRect = GetTextRect(rowRect, item, false); textRect.x += 2; textRect.height -= 2; return textRect; } private Rect GetTextRect(Rect rowRect, TreeViewItem item, bool applyIndent = true) { var indent = (item.depth + 1) * kColorTagWidth + kFoldoutWidth; var textRect = applyIndent ? EditorGUI.IndentedRect(rowRect) : rowRect; textRect.x += indent; return textRect; } #endregion // Undo is a problem. When an undo or redo is performed, the SerializedObject may change behind // our backs which means that the information shown in the tree may be outdated now. // // We do have the Undo.undoRedoPerformed global callback but because PropertyDrawers // have no observable life cycle, we cannot always easily hook into the callback and force a reload // of the tree. Also, while returning false from PropertyDrawer.CanCacheInspectorGUI() might one make suspect that // a PropertyDrawer would automatically be thrown away and recreated if the SerializedObject // is modified by undo, that does not happen in practice. // // We could just Reload() the tree all the time but TreeView.Reload() itself forces a repaint and // this will thus easily lead to infinite repaints. // // So, what we do is make use of the built-in dirty count we can get for Unity objects. If the count // changes and it wasn't caused by us, we reload the tree. Means we still reload unnecessarily if // some other property on a component changes but at least we don't reload all the time. // // A positive side-effect is that we will catch *any* change to the SerializedObject, not just // undo/redo and we can do so without having to hook into Undo.undoRedoPerformed anywhere. private void OnSerializedObjectModified() { serializedObject.ApplyModifiedProperties(); UpdateSerializedObjectDirtyCount(); Reload(); onSerializedObjectModified?.Invoke(); } public void UpdateSerializedObjectDirtyCount() { m_SerializedObjectDirtyCount = serializedObject != null ? EditorUtility.GetDirtyCount(serializedObject.targetObject) : 0; } private bool ReloadIfSerializedObjectHasBeenChanged() { var oldCount = m_SerializedObjectDirtyCount; UpdateSerializedObjectDirtyCount(); if (oldCount != m_SerializedObjectDirtyCount) { Reload(); onSerializedObjectModified?.Invoke(); return true; } return false; } public SerializedObject serializedObject { get; } public string bindingGroupForNewBindings { get; set; } public new TreeViewItem rootItem => base.rootItem; public Action onSerializedObjectModified { get; set; } public Action onSelectionChanged { get; set; } public Action onDoubleClick { get; set; } public Action onBeginRename { get; set; } public Func onBuildTree { get; set; } public Action onBindingAdded { get; set; } public bool drawHeader { get; set; } public bool drawPlusButton { get; set; } public bool drawMinusButton { get; set; } public bool drawActionPropertiesButton { get; set; } public Action onHandleAddNewAction { get; set; } public (string, string) title { get => (m_Title?.text, m_Title?.tooltip); set => m_Title = new GUIContent(value.Item1, value.Item2); } public new float totalHeight { get { var height = base.totalHeight; if (drawHeader) height += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; height += 1; // Border. return height; } } public ActionTreeItemBase this[string path] { get { var item = FindItemByPath(path); if (item == null) throw new KeyNotFoundException(path); return item; } } private GUIContent plusIcon { get { if (rootItem is ActionMapTreeItem) return s_PlusActionIcon; if (rootItem is ActionTreeItem) return s_PlusBindingIcon; return s_PlusActionMapIcon; } } private GUIContent minusIcon => s_DeleteSectionIcon; private FilterCriterion[] m_ItemFilterCriteria; private GUIContent m_Title; private bool m_InitiateContextMenuOnNextRepaint; private bool m_ForceAcceptRename; private int m_SerializedObjectDirtyCount; private static readonly GUIContent s_AddBindingLabel = EditorGUIUtility.TrTextContent("Add Binding"); private static readonly GUIContent s_AddActionLabel = EditorGUIUtility.TrTextContent("Add Action"); private static readonly GUIContent s_AddActionMapLabel = EditorGUIUtility.TrTextContent("Add Action Map"); private static readonly GUIContent s_PlusBindingIcon = EditorGUIUtility.TrIconContent("Toolbar Plus More", "Add Binding"); private static readonly GUIContent s_PlusActionIcon = EditorGUIUtility.TrIconContent("Toolbar Plus", "Add Action"); private static readonly GUIContent s_PlusActionMapIcon = EditorGUIUtility.TrIconContent("Toolbar Plus", "Add Action Map"); private static readonly GUIContent s_DeleteSectionIcon = EditorGUIUtility.TrIconContent("Toolbar Minus", "Delete Selection"); private static readonly GUIContent s_ActionPropertiesIcon = EditorGUIUtility.TrIconContent("Settings", "Action Properties"); private static readonly GUIContent s_CutLabel = EditorGUIUtility.TrTextContent("Cut"); private static readonly GUIContent s_CopyLabel = EditorGUIUtility.TrTextContent("Copy"); private static readonly GUIContent s_PasteLabel = EditorGUIUtility.TrTextContent("Paste"); private static readonly GUIContent s_DeleteLabel = EditorGUIUtility.TrTextContent("Delete"); private static readonly GUIContent s_DuplicateLabel = EditorGUIUtility.TrTextContent("Duplicate"); private static readonly GUIContent s_RenameLabel = EditorGUIUtility.TrTextContent("Rename"); private static readonly GUIContent s_ExpandAllLabel = EditorGUIUtility.TrTextContent("Expand All"); private static readonly GUIContent s_CollapseAllLabel = EditorGUIUtility.TrTextContent("Collapse All"); public static string SharedResourcesPath = "Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/PackageResources/"; public static string ResourcesPath { get { if (EditorGUIUtility.isProSkin) return SharedResourcesPath + "pro/"; return SharedResourcesPath + "personal/"; } } public struct FilterCriterion { public enum Type { ByName, ByBindingGroup, ByDeviceLayout, } public enum Match { Success, Failure, None, } public string text; public Type type; public static string k_BindingGroupTag = "g:"; public static string k_DeviceLayoutTag = "d:"; public Match Matches(ActionTreeItemBase item) { Debug.Assert(item != null, "Item cannot be null"); switch (type) { case Type.ByName: { // NOTE: Composite items have names (and part bindings in a way, too) but we don't filter on them. if (item is ActionMapTreeItem || item is ActionTreeItem) { var matchesSelf = item.displayName.Contains(text, StringComparison.InvariantCultureIgnoreCase); // Name filters behave recursively. I.e. if any item in the subtree is matched by the name filter, // the item is included. if (!matchesSelf && CheckChildrenFor(Match.Success, item)) return Match.Success; return matchesSelf ? Match.Success : Match.Failure; } break; } case Type.ByBindingGroup: { if (item is BindingTreeItem bindingItem) { // For composites, succeed the match if any children match. if (item is CompositeBindingTreeItem) return CheckChildrenFor(Match.Success, item) ? Match.Success : Match.Failure; // Items that are in no binding group match any binding group. if (string.IsNullOrEmpty(bindingItem.groups)) return Match.Success; var groups = bindingItem.groups.Split(InputBinding.Separator); var bindingGroup = text; return groups.Any(x => x.Equals(bindingGroup, StringComparison.InvariantCultureIgnoreCase)) ? Match.Success : Match.Failure; } break; } case Type.ByDeviceLayout: { if (item is BindingTreeItem bindingItem) { // For composites, succeed the match if any children match. if (item is CompositeBindingTreeItem) return CheckChildrenFor(Match.Success, item) ? Match.Success : Match.Failure; var deviceLayout = InputControlPath.TryGetDeviceLayout(bindingItem.path); return string.Equals(deviceLayout, text, StringComparison.InvariantCultureIgnoreCase) || InputControlLayout.s_Layouts.IsBasedOn(new InternedString(deviceLayout), new InternedString(text)) ? Match.Success : Match.Failure; } break; } } return Match.None; } private bool CheckChildrenFor(Match match, ActionTreeItemBase item) { if (!item.hasChildren) return false; foreach (var child in item.children.OfType()) if (Matches(child) == match) return true; return false; } public static FilterCriterion ByName(string name) { return new FilterCriterion {text = name, type = Type.ByName}; } public static FilterCriterion ByBindingGroup(string group) { return new FilterCriterion {text = group, type = Type.ByBindingGroup}; } public static FilterCriterion ByDeviceLayout(string layout) { return new FilterCriterion {text = layout, type = Type.ByDeviceLayout}; } public static List FromString(string criteria) { if (string.IsNullOrEmpty(criteria)) return null; var list = new List(); foreach (var substring in criteria.Tokenize()) { if (substring.StartsWith(k_DeviceLayoutTag)) list.Add(ByDeviceLayout(substring.Substr(2).Unescape())); else if (substring.StartsWith(k_BindingGroupTag)) list.Add(ByBindingGroup(substring.Substr(2).Unescape())); else list.Add(ByName(substring.ToString().Unescape())); } return list; } public static string ToString(IEnumerable criteria) { var builder = new StringBuilder(); foreach (var criterion in criteria) { if (builder.Length > 0) builder.Append(' '); if (criterion.type == Type.ByBindingGroup) builder.Append(k_BindingGroupTag); else if (criterion.type == Type.ByDeviceLayout) builder.Append(k_DeviceLayoutTag); builder.Append(criterion.text); } return builder.ToString(); } } public static class Styles { public static readonly GUIStyle text = new GUIStyle("Label").WithAlignment(TextAnchor.MiddleLeft); public static readonly GUIStyle selectedText = new GUIStyle("Label").WithAlignment(TextAnchor.MiddleLeft).WithNormalTextColor(Color.white); public static readonly GUIStyle backgroundWithoutBorder = new GUIStyle("Label") .WithNormalBackground(AssetDatabase.LoadAssetAtPath(ResourcesPath + "actionTreeBackgroundWithoutBorder.png")); public static readonly GUIStyle border = new GUIStyle("Label") .WithNormalBackground(AssetDatabase.LoadAssetAtPath(ResourcesPath + "actionTreeBackground.png")) .WithBorder(new RectOffset(0, 0, 0, 1)); public static readonly GUIStyle backgroundWithBorder = new GUIStyle("Label") .WithNormalBackground(AssetDatabase.LoadAssetAtPath(ResourcesPath + "actionTreeBackground.png")) .WithBorder(new RectOffset(3, 3, 3, 3)) .WithMargin(new RectOffset(4, 4, 4, 4)); public static readonly GUIStyle columnHeaderLabel = new GUIStyle(EditorStyles.toolbar) .WithAlignment(TextAnchor.MiddleLeft) .WithFontStyle(FontStyle.Bold) .WithPadding(new RectOffset(10, 6, 0, 0)); } // Just so that we can tell apart TreeViews containing only maps. internal class ActionMapListItem : TreeViewItem { } } } #endif // UNITY_EDITOR