A game about forced loneliness, made by TACStudios
1#if UNITY_EDITOR 2using System; 3using System.Collections.Generic; 4using System.ComponentModel; 5using System.Linq; 6using System.Reflection; 7using System.Text; 8using UnityEditor; 9using UnityEditor.IMGUI.Controls; 10using UnityEngine.InputSystem.Layouts; 11using UnityEngine.InputSystem.Utilities; 12 13// The action tree view illustrates one of the weaknesses of Unity's editing model. While operating directly 14// on serialized data does have a number of advantages (the built-in undo system being one of them), making the 15// persistence model equivalent to the edit model doesn't work well. Serialized data will be laid out for persistence, 16// not for the convenience of editing operations. This means that editing operations have to constantly jump through 17// hoops to map themselves onto the persistence model of the data. 18 19////TODO: With many actions and bindings the list becomes really hard to grok; make things more visually distinctive 20 21////TODO: add context menu items for reordering action and binging entries (like "Move Up" and "Move Down") 22 23////FIXME: context menu cannot be brought up when there's no items in the tree 24 25////FIXME: RMB context menu for actions displays composites that aren't applicable to the action 26 27namespace UnityEngine.InputSystem.Editor 28{ 29 /// <summary> 30 /// A tree view showing action maps, actions, and bindings. This is the core piece around which the various 31 /// pieces of action editing functionality revolve. 32 /// </summary> 33 /// <remarks> 34 /// The tree view can be flexibly used to contain only parts of a specific action setup. For example, 35 /// by only adding items for action maps (<see cref="ActionMapTreeItem"/> to the tree, the tree view 36 /// will become a flat list of action maps. Or by adding only items for actions (<see cref="ActionTreeItem"/> 37 /// and items for their bindings (<see cref="BindingTreeItem"/>) to the tree, it will become an action-only 38 /// tree view. 39 /// 40 /// This is used by the action asset editor to separate action maps and their actions into two separate 41 /// tree views (the leftmost and the middle column of the editor). 42 /// 43 /// Each action tree comes with copy-paste and context menu support. 44 /// </remarks> 45 internal class InputActionTreeView : TreeView 46 { 47 #region Creation 48 49 public InputActionTreeView(SerializedObject serializedObject, TreeViewState state = null) 50 : base(state ?? new TreeViewState()) 51 { 52 Debug.Assert(serializedObject != null, "Must have serialized object"); 53 this.serializedObject = serializedObject; 54 UpdateSerializedObjectDirtyCount(); 55 foldoutOverride = DrawFoldout; 56 drawHeader = true; 57 drawPlusButton = true; 58 drawMinusButton = true; 59 m_ForceAcceptRename = false; 60 m_Title = new GUIContent(""); 61 } 62 63 /// <summary> 64 /// Build an action tree that shows only the bindings for the given action. 65 /// </summary> 66 public static TreeViewItem BuildWithJustBindingsFromAction(SerializedProperty actionProperty, SerializedProperty actionMapProperty = null) 67 { 68 Debug.Assert(actionProperty != null, "Action property cannot be null"); 69 var root = new ActionTreeItem(actionMapProperty, actionProperty); 70 root.depth = -1; 71 root.AddBindingsTo(root); 72 return root; 73 } 74 75 /// <summary> 76 /// Build an action tree that shows only the actions and bindings for the given action map. 77 /// </summary> 78 public static TreeViewItem BuildWithJustActionsAndBindingsFromMap(SerializedProperty actionMapProperty) 79 { 80 Debug.Assert(actionMapProperty != null, "Action map property cannot be null"); 81 var root = new ActionMapTreeItem(actionMapProperty); 82 root.depth = -1; 83 root.AddActionsAndBindingsTo(root); 84 return root; 85 } 86 87 /// <summary> 88 /// Build an action tree that contains only the action maps from the given .inputactions asset. 89 /// </summary> 90 public static TreeViewItem BuildWithJustActionMapsFromAsset(SerializedObject assetObject) 91 { 92 Debug.Assert(assetObject != null, "Asset object cannot be null"); 93 var root = new ActionMapListItem {id = 0, depth = -1}; 94 ActionMapTreeItem.AddActionMapsFromAssetTo(root, assetObject); 95 return root; 96 } 97 98 public static TreeViewItem BuildFullTree(SerializedObject assetObject) 99 { 100 Debug.Assert(assetObject != null, "Asset object cannot be null"); 101 var root = new TreeViewItem {id = 0, depth = -1}; 102 ActionMapTreeItem.AddActionMapsFromAssetTo(root, assetObject); 103 if (root.hasChildren) 104 foreach (var child in root.children) 105 ((ActionMapTreeItem)child).AddActionsAndBindingsTo(child); 106 return root; 107 } 108 109 protected override TreeViewItem BuildRoot() 110 { 111 var root = onBuildTree?.Invoke() ?? new TreeViewItem(0, -1); 112 113 // If we have a filter, remove unwanted items from the tree. 114 // NOTE: We use this method rather than TreeView's built-in search functionality as we want 115 // to keep the tree structure fully intact whereas searchString switches the tree into 116 // a different view mode. 117 if (m_ItemFilterCriteria.LengthSafe() > 0 && root.hasChildren) 118 { 119 foreach (var child in root.children.OfType<ActionTreeItemBase>().ToArray()) 120 PruneTreeByItemSearchFilter(child); 121 } 122 123 // Root node is required to have `children` not be null. Add empty list, 124 // if necessary. Can happen, for example, if we have a singleton action at the 125 // root but no bindings on it. 126 if (root.children == null) 127 root.children = new List<TreeViewItem>(); 128 129 return root; 130 } 131 132 #endregion 133 134 #region Filtering 135 136 internal bool hasFilter => m_ItemFilterCriteria != null; 137 138 public void ClearItemSearchFilterAndReload() 139 { 140 if (m_ItemFilterCriteria == null) 141 return; 142 m_ItemFilterCriteria = null; 143 Reload(); 144 } 145 146 public void SetItemSearchFilterAndReload(string criteria) 147 { 148 SetItemSearchFilterAndReload(FilterCriterion.FromString(criteria)); 149 } 150 151 public void SetItemSearchFilterAndReload(IEnumerable<FilterCriterion> criteria) 152 { 153 m_ItemFilterCriteria = criteria.ToArray(); 154 Reload(); 155 } 156 157 private void PruneTreeByItemSearchFilter(ActionTreeItemBase item) 158 { 159 // Prune subtree if item is forced out by any of our criteria. 160 if (m_ItemFilterCriteria.Any(x => x.Matches(item) == FilterCriterion.Match.Failure)) 161 { 162 item.parent.children.Remove(item); 163 164 // Add to list of hidden children. 165 if (item.parent is ActionTreeItemBase parent) 166 { 167 if (parent.m_HiddenChildren == null) 168 parent.m_HiddenChildren = new List<ActionTreeItemBase>(); 169 parent.m_HiddenChildren.Add(item); 170 } 171 172 return; 173 } 174 175 ////REVIEW: should we *always* do this? (regardless of whether a control scheme is selected) 176 // When filtering by binding group, we tag bindings that are not in any binding group as "{GLOBAL}". 177 // This helps when having a specific control scheme selected, to also see the bindings that are active 178 // in that control scheme by virtue of not being associated with *any* specific control scheme. 179 if (item is BindingTreeItem bindingItem && 180 !(item is CompositeBindingTreeItem) && 181 string.IsNullOrEmpty(bindingItem.groups) && 182 m_ItemFilterCriteria.Any(x => x.type == FilterCriterion.Type.ByBindingGroup)) 183 { 184 item.displayName += " {GLOBAL}"; 185 } 186 187 // Prune children. 188 if (item.hasChildren) 189 { 190 foreach (var child in item.children.OfType<ActionTreeItemBase>().ToArray()) // We're modifying the child list so copy. 191 PruneTreeByItemSearchFilter(child); 192 } 193 } 194 195 #endregion 196 197 #region Finding Items 198 199 public ActionTreeItemBase FindItemByPath(string path) 200 { 201 var components = path.Split('/'); 202 var current = rootItem; 203 foreach (var component in components) 204 { 205 if (current.hasChildren) 206 { 207 var found = false; 208 foreach (var child in current.children) 209 { 210 if (child.displayName.Equals(component, StringComparison.InvariantCultureIgnoreCase)) 211 { 212 current = child; 213 found = true; 214 break; 215 } 216 } 217 218 if (found) 219 continue; 220 } 221 222 return null; 223 } 224 return (ActionTreeItemBase)current; 225 } 226 227 public ActionTreeItemBase FindItemByPropertyPath(string propertyPath) 228 { 229 return FindFirstItem<ActionTreeItemBase>(x => x.property.propertyPath == propertyPath); 230 } 231 232 public ActionTreeItemBase FindItemFor(SerializedProperty element) 233 { 234 // We may be looking at a SerializedProperty that refers to the same element but is 235 // its own instance different from the one we're using in the tree. Compare properties 236 // by path, not by object instance. 237 return FindFirstItem<ActionTreeItemBase>(x => x.property.propertyPath == element.propertyPath); 238 } 239 240 public TItem FindFirstItem<TItem>(Func<TItem, bool> predicate) 241 where TItem : ActionTreeItemBase 242 { 243 return FindFirstItemRecursive(rootItem, predicate); 244 } 245 246 private static TItem FindFirstItemRecursive<TItem>(TreeViewItem current, Func<TItem, bool> predicate) 247 where TItem : ActionTreeItemBase 248 { 249 if (current is TItem itemOfType && predicate(itemOfType)) 250 return itemOfType; 251 252 if (current.hasChildren) 253 foreach (var child in current.children) 254 { 255 var item = FindFirstItemRecursive(child, predicate); 256 if (item != null) 257 return item; 258 } 259 260 return null; 261 } 262 263 #endregion 264 265 #region Selection 266 267 public void ClearSelection() 268 { 269 SetSelection(new int[0], TreeViewSelectionOptions.FireSelectionChanged); 270 } 271 272 public void SelectItems(IEnumerable<ActionTreeItemBase> items) 273 { 274 SetSelection(items.Select(x => x.id).ToList(), TreeViewSelectionOptions.FireSelectionChanged); 275 } 276 277 public void SelectItem(SerializedProperty element, bool additive = false) 278 { 279 var item = FindItemFor(element); 280 if (item == null) 281 throw new ArgumentException($"Cannot find item for property path '{element.propertyPath}'", nameof(element)); 282 283 SelectItem(item, additive); 284 } 285 286 public void SelectItem(string path, bool additive = false) 287 { 288 if (!TrySelectItem(path, additive)) 289 throw new ArgumentException($"Cannot find item with path 'path'", nameof(path)); 290 } 291 292 public bool TrySelectItem(string path, bool additive = false) 293 { 294 var item = FindItemByPath(path); 295 if (item == null) 296 return false; 297 298 SelectItem(item, additive); 299 return true; 300 } 301 302 public void SelectItem(ActionTreeItemBase item, bool additive = false) 303 { 304 if (additive) 305 { 306 var selection = new List<int>(); 307 selection.AddRange(GetSelection()); 308 selection.Add(item.id); 309 SetSelection(selection, TreeViewSelectionOptions.FireSelectionChanged); 310 } 311 else 312 { 313 SetSelection(new[] { item.id }, TreeViewSelectionOptions.FireSelectionChanged); 314 } 315 } 316 317 public IEnumerable<ActionTreeItemBase> GetSelectedItems() 318 { 319 foreach (var id in GetSelection()) 320 { 321 if (FindItem(id, rootItem) is ActionTreeItemBase item) 322 yield return item; 323 } 324 } 325 326 /// <summary> 327 /// Same as <see cref="GetSelectedItems"/> but with items that are selected but are children of other items 328 /// that also selected being filtered out. 329 /// </summary> 330 /// <remarks> 331 /// This is useful for operations such as copy-paste where copy a parent will implicitly copy the child. 332 /// </remarks> 333 public IEnumerable<ActionTreeItemBase> GetSelectedItemsWithChildrenFilteredOut() 334 { 335 var selectedItems = GetSelectedItems().ToArray(); 336 foreach (var item in selectedItems) 337 { 338 if (selectedItems.Any(x => x.IsParentOf(item))) 339 continue; 340 yield return item; 341 } 342 } 343 344 public IEnumerable<TItem> GetSelectedItemsOrParentsOfType<TItem>() 345 where TItem : ActionTreeItemBase 346 { 347 // If there is no selection and the root item has the type we're looking for, 348 // consider it selected. This allows adding items at the toplevel. 349 if (!HasSelection() && rootItem is TItem root) 350 { 351 yield return root; 352 } 353 else 354 { 355 foreach (var id in GetSelection()) 356 { 357 var item = FindItem(id, rootItem); 358 while (item != null) 359 { 360 if (item is TItem itemOfType) 361 yield return itemOfType; 362 item = item.parent; 363 } 364 } 365 } 366 } 367 368 public void SelectFirstToplevelItem() 369 { 370 if (rootItem.children.Any()) 371 SetSelection(new[] {rootItem.children[0].id}, TreeViewSelectionOptions.FireSelectionChanged); 372 } 373 374 protected override void SelectionChanged(IList<int> selectedIds) 375 { 376 onSelectionChanged?.Invoke(); 377 } 378 379 #endregion 380 381 #region Renaming 382 383 public new void BeginRename(TreeViewItem item) 384 { 385 // If a rename is already in progress, force it to end first. 386 EndRename(); 387 onBeginRename?.Invoke((ActionTreeItemBase)item); 388 base.BeginRename(item); 389 } 390 391 protected override bool CanRename(TreeViewItem item) 392 { 393 return item is ActionTreeItemBase actionTreeItem && actionTreeItem.canRename; 394 } 395 396 protected override void RenameEnded(RenameEndedArgs args) 397 { 398 if (!(FindItem(args.itemID, rootItem) is ActionTreeItemBase actionItem)) 399 return; 400 401 if (!(args.acceptedRename || m_ForceAcceptRename) || args.originalName == args.newName) 402 return; 403 404 Debug.Assert(actionItem.canRename, "Cannot rename " + actionItem); 405 406 actionItem.Rename(args.newName); 407 OnSerializedObjectModified(); 408 } 409 410 public void EndRename(bool forceAccept) 411 { 412 m_ForceAcceptRename = forceAccept; 413 EndRename(); 414 m_ForceAcceptRename = false; 415 } 416 417 protected override void DoubleClickedItem(int id) 418 { 419 if (!(FindItem(id, rootItem) is ActionTreeItemBase item)) 420 return; 421 422 // If we have a double-click handler, give it control over what happens. 423 if (onDoubleClick != null) 424 { 425 onDoubleClick(item); 426 } 427 else if (item.canRename) 428 { 429 // Otherwise, perform a rename by default. 430 BeginRename(item); 431 } 432 } 433 434 #endregion 435 436 #region Drag&Drop 437 438 protected override bool CanStartDrag(CanStartDragArgs args) 439 { 440 return true; 441 } 442 443 protected override void SetupDragAndDrop(SetupDragAndDropArgs args) 444 { 445 DragAndDrop.PrepareStartDrag(); 446 DragAndDrop.SetGenericData("itemIDs", args.draggedItemIDs.ToArray()); 447 DragAndDrop.SetGenericData("tree", this); 448 DragAndDrop.StartDrag(string.Join(",", args.draggedItemIDs.Select(id => FindItem(id, rootItem).displayName))); 449 } 450 451 protected override DragAndDropVisualMode HandleDragAndDrop(DragAndDropArgs args) 452 { 453 var sourceTree = DragAndDrop.GetGenericData("tree") as InputActionTreeView; 454 if (sourceTree == null) 455 return DragAndDropVisualMode.Rejected; 456 457 var itemIds = (int[])DragAndDrop.GetGenericData("itemIDs"); 458 var altKeyIsDown = Event.current.alt; 459 460 // Reject the drag if the parent item does not accept the drop. 461 if (args.parentItem is ActionTreeItemBase parentItem) 462 { 463 if (itemIds.Any(id => 464 !parentItem.AcceptsDrop((ActionTreeItemBase)sourceTree.FindItem(id, sourceTree.rootItem)))) 465 return DragAndDropVisualMode.Rejected; 466 } 467 else 468 { 469 // If the root item isn't an ActionTreeItemBase, we're looking at a tree that starts 470 // all the way up at the InputActionAsset. Require the drop to be all action maps. 471 if (itemIds.Any(id => !(sourceTree.FindItem(id, sourceTree.rootItem) is ActionMapTreeItem))) 472 return DragAndDropVisualMode.Rejected; 473 } 474 475 // Handle drop using copy-paste. This allows handling all the various operations 476 // using a single code path. 477 var isMove = !altKeyIsDown; 478 if (args.performDrop) 479 { 480 // Copy item data. 481 var copyBuffer = new StringBuilder(); 482 var items = itemIds.Select(id => (ActionTreeItemBase)sourceTree.FindItem(id, sourceTree.rootItem)); 483 CopyItems(items, copyBuffer); 484 485 // If we're moving items within the same tree, no need to generate new IDs. 486 var assignNewIDs = !(isMove && sourceTree == this); 487 488 // Determine where we are moving/copying the data. 489 var target = args.parentItem ?? rootItem; 490 int? childIndex = null; 491 if (args.dragAndDropPosition == DragAndDropPosition.BetweenItems) 492 childIndex = args.insertAtIndex; 493 494 // If alt isn't down (i.e. we're not duplicating), delete old items. 495 // Do this *before* pasting so that assigning new names will not cause names to 496 // change when just moving items around. 497 if (isMove) 498 { 499 // Don't use DeleteDataOfSelectedItems() as that will record as a separate operation. 500 foreach (var item in items) 501 { 502 // If we're dropping *between* items on the same parent as the current item and the 503 // index we're dropping at (in the parent, NOT in the array) is coming *after* this item, 504 // then deleting the item will shift the target index down by one. 505 if (item.parent == target && childIndex != null && childIndex > target.children.IndexOf(item)) 506 --childIndex; 507 508 item.DeleteData(); 509 } 510 } 511 512 // Paste items onto target. 513 var oldBindingGroupForNewBindings = bindingGroupForNewBindings; 514 try 515 { 516 // With drag&drop, preserve binding groups. 517 bindingGroupForNewBindings = null; 518 519 PasteItems(copyBuffer.ToString(), 520 new[] { new InsertLocation { item = target, childIndex = childIndex } }, 521 assignNewIDs: assignNewIDs); 522 } 523 finally 524 { 525 bindingGroupForNewBindings = oldBindingGroupForNewBindings; 526 } 527 528 DragAndDrop.AcceptDrag(); 529 } 530 531 return isMove ? DragAndDropVisualMode.Move : DragAndDropVisualMode.Copy; 532 } 533 534 #endregion 535 536 #region Copy&Paste 537 538 // These need to correspond to what the editor is sending from the "Edit" menu. 539 public const string k_CopyCommand = "Copy"; 540 public const string k_PasteCommand = "Paste"; 541 public const string k_DuplicateCommand = "Duplicate"; 542 public const string k_CutCommand = "Cut"; 543 public const string k_DeleteCommand = "Delete"; 544 public const string k_SoftDeleteCommand = "SoftDelete"; 545 546 public void HandleCopyPasteCommandEvent(Event uiEvent) 547 { 548 if (uiEvent.type == EventType.ValidateCommand) 549 { 550 switch (uiEvent.commandName) 551 { 552 case k_CopyCommand: 553 case k_CutCommand: 554 case k_DuplicateCommand: 555 case k_DeleteCommand: 556 case k_SoftDeleteCommand: 557 if (HasSelection()) 558 uiEvent.Use(); 559 break; 560 561 case k_PasteCommand: 562 var systemCopyBuffer = EditorHelpers.GetSystemCopyBufferContents(); 563 if (systemCopyBuffer != null && systemCopyBuffer.StartsWith(k_CopyPasteMarker)) 564 uiEvent.Use(); 565 break; 566 } 567 } 568 else if (uiEvent.type == EventType.ExecuteCommand) 569 { 570 switch (uiEvent.commandName) 571 { 572 case k_CopyCommand: 573 CopySelectedItemsToClipboard(); 574 break; 575 case k_PasteCommand: 576 PasteDataFromClipboard(); 577 break; 578 case k_CutCommand: 579 CopySelectedItemsToClipboard(); 580 DeleteDataOfSelectedItems(); 581 break; 582 case k_DuplicateCommand: 583 DuplicateSelection(); 584 break; 585 case k_DeleteCommand: 586 case k_SoftDeleteCommand: 587 DeleteDataOfSelectedItems(); 588 break; 589 default: 590 return; 591 } 592 uiEvent.Use(); 593 } 594 } 595 596 private void DuplicateSelection() 597 { 598 var buffer = new StringBuilder(); 599 600 // If we have a multi-selection, we want to perform the duplication as if each item 601 // was duplicated individually. Meaning we paste each duplicate right after the item 602 // it was duplicated from. So if, say, an action is selected at the beginning of the 603 // tree and one is selected from the end of it, we still paste the copies into the 604 // two separate locations correctly. 605 // 606 // Technically, if both parents and children are selected, we're order dependent here 607 // but not sure we really need to care. 608 609 var selection = GetSelection(); 610 ClearSelection(); 611 612 // Copy-paste each selected item in turn. 613 var newItemIds = new List<int>(); 614 foreach (var id in selection) 615 { 616 SetSelection(new[] { id }); 617 618 buffer.Length = 0; 619 CopySelectedItemsTo(buffer); 620 PasteDataFrom(buffer.ToString()); 621 622 newItemIds.AddRange(GetSelection()); 623 } 624 625 SetSelection(newItemIds); 626 } 627 628 internal const string k_CopyPasteMarker = "INPUTASSET "; 629 private const string k_StartOfText = "\u0002"; 630 private const string k_StartOfHeading = "\u0001"; 631 private const string k_EndOfTransmission = "\u0004"; 632 private const string k_EndOfTransmissionBlock = "\u0017"; 633 634 /// <summary> 635 /// Copy the currently selected items to the clipboard. 636 /// </summary> 637 /// <seealso cref="EditorGUIUtility.systemCopyBuffer"/> 638 public void CopySelectedItemsToClipboard() 639 { 640 var copyBuffer = new StringBuilder(); 641 CopySelectedItemsTo(copyBuffer); 642 EditorHelpers.SetSystemCopyBufferContents(copyBuffer.ToString()); 643 } 644 645 public void CopySelectedItemsTo(StringBuilder buffer) 646 { 647 CopyItems(GetSelectedItemsWithChildrenFilteredOut(), buffer); 648 } 649 650 public static void CopyItems(IEnumerable<ActionTreeItemBase> items, StringBuilder buffer) 651 { 652 buffer.Append(k_CopyPasteMarker); 653 foreach (var item in items) 654 { 655 CopyItemData(item, buffer); 656 buffer.Append(k_EndOfTransmission); 657 } 658 } 659 660 private static void CopyItemData(ActionTreeItemBase item, StringBuilder buffer) 661 { 662 buffer.Append(k_StartOfHeading); 663 buffer.Append(item.GetType().Name); 664 buffer.Append(k_StartOfText); 665 // InputActionMaps have back-references to InputActionAssets. Make sure we ignore those. 666 buffer.Append(item.property.CopyToJson(ignoreObjectReferences: true)); 667 buffer.Append(k_EndOfTransmissionBlock); 668 669 if (!item.serializedDataIncludesChildren && item.hasChildrenIncludingHidden) 670 foreach (var child in item.childrenIncludingHidden) 671 CopyItemData(child, buffer); 672 } 673 674 /// <summary> 675 /// Remove the data from the currently selected items from the <see cref="SerializedObject"/> 676 /// referenced by the tree's data. 677 /// </summary> 678 public void DeleteDataOfSelectedItems() 679 { 680 // When deleting data, indices will shift around. However, we do not delete elements by array indices 681 // directly but rather by GUIDs which are used to look up array indices dynamically. This means that 682 // we can safely delete the items without worrying about one deletion affecting the next. 683 // 684 // NOTE: It is important that we first fetch *all* of the selection filtered for parent/child duplicates. 685 // If we don't do so up front, the deletions happening later may start interacting with our 686 // parent/child test. 687 var selection = GetSelectedItemsWithChildrenFilteredOut().ToArray(); 688 689 // Clear our current selection. If we don't do this first, TreeView will implicitly 690 // clear the selection as items disappear but we will not see a selection change notification 691 // being triggered. 692 ClearSelection(); 693 694 DeleteItems(selection); 695 } 696 697 public void DeleteItems(IEnumerable<ActionTreeItemBase> items) 698 { 699 foreach (var item in items) 700 item.DeleteData(); 701 702 OnSerializedObjectModified(); 703 } 704 705 public bool HavePastableClipboardData() 706 { 707 var clipboard = EditorHelpers.GetSystemCopyBufferContents(); 708 return clipboard.StartsWith(k_CopyPasteMarker); 709 } 710 711 public void PasteDataFromClipboard() 712 { 713 PasteDataFrom(EditorHelpers.GetSystemCopyBufferContents()); 714 } 715 716 public void PasteDataFrom(string copyBufferString) 717 { 718 if (!copyBufferString.StartsWith(k_CopyPasteMarker)) 719 return; 720 721 var locations = GetSelectedItemsWithChildrenFilteredOut().Select(x => new InsertLocation { item = x }).ToList(); 722 if (locations.Count == 0) 723 locations.Add(new InsertLocation { item = rootItem }); 724 725 ////REVIEW: filtering out children may remove the very item we need to get the right match for a copy block? 726 PasteItems(copyBufferString, locations); 727 } 728 729 public struct InsertLocation 730 { 731 public TreeViewItem item; 732 public int? childIndex; 733 } 734 735 public void PasteItems(string copyBufferString, IEnumerable<InsertLocation> locations, bool assignNewIDs = true, bool selectNewItems = true) 736 { 737 var newItemPropertyPaths = new List<string>(); 738 739 // Split buffer into transmissions and then into transmission blocks. Each transmission is an item subtree 740 // meant to be pasted as a whole and each transmission block is a single chunk of serialized data. 741 foreach (var transmission in copyBufferString.Substring(k_CopyPasteMarker.Length) 742 .Split(new[] {k_EndOfTransmission}, StringSplitOptions.RemoveEmptyEntries)) 743 { 744 foreach (var location in locations) 745 PasteBlocks(transmission, location, assignNewIDs, newItemPropertyPaths); 746 } 747 748 OnSerializedObjectModified(); 749 750 // If instructed to do so, go and select all newly added items. 751 if (selectNewItems && newItemPropertyPaths.Count > 0) 752 { 753 // We may have pasted into a different tree view. Only select the items if we can find them in 754 // our current tree view. 755 var newItems = newItemPropertyPaths.Select(FindItemByPropertyPath).Where(x => x != null); 756 if (newItems.Any()) 757 SelectItems(newItems); 758 } 759 } 760 761 private const string k_ActionMapTag = k_StartOfHeading + "ActionMapTreeItem" + k_StartOfText; 762 private const string k_ActionTag = k_StartOfHeading + "ActionTreeItem" + k_StartOfText; 763 private const string k_BindingTag = k_StartOfHeading + "BindingTreeItem" + k_StartOfText; 764 private const string k_CompositeBindingTag = k_StartOfHeading + "CompositeBindingTreeItem" + k_StartOfText; 765 private const string k_PartOfCompositeBindingTag = k_StartOfHeading + "PartOfCompositeBindingTreeItem" + k_StartOfText; 766 767 private void PasteBlocks(string transmission, InsertLocation location, bool assignNewIDs, List<string> newItemPropertyPaths) 768 { 769 Debug.Assert(location.item != null, "Should have drop target"); 770 771 var blocks = transmission.Split(new[] {k_EndOfTransmissionBlock}, 772 StringSplitOptions.RemoveEmptyEntries); 773 if (blocks.Length < 1) 774 return; 775 776 Type CopyTagToType(string tagName) 777 { 778 switch (tagName) 779 { 780 case k_ActionMapTag: return typeof(ActionMapTreeItem); 781 case k_ActionTag: return typeof(ActionTreeItem); 782 case k_BindingTag: return typeof(BindingTreeItem); 783 case k_CompositeBindingTag: return typeof(CompositeBindingTreeItem); 784 case k_PartOfCompositeBindingTag: return typeof(PartOfCompositeBindingTreeItem); 785 default: 786 throw new Exception($"Unrecognized copy block tag '{tagName}'"); 787 } 788 } 789 790 SplitTagAndData(blocks[0], out var tag, out var data); 791 792 // Determine where to drop the item. 793 SerializedProperty array = null; 794 var arrayIndex = -1; 795 var itemType = CopyTagToType(tag); 796 if (location.item is ActionTreeItemBase dropTarget) 797 { 798 if (!dropTarget.GetDropLocation(itemType, location.childIndex, ref array, ref arrayIndex)) 799 return; 800 } 801 else if (tag == k_ActionMapTag) 802 { 803 // Paste into InputActionAsset. 804 array = serializedObject.FindProperty("m_ActionMaps"); 805 arrayIndex = location.childIndex ?? array.arraySize; 806 } 807 else 808 { 809 throw new InvalidOperationException($"Cannot paste {tag} into {location.item.displayName}"); 810 } 811 812 // If not given a specific index, we paste onto the end of the array. 813 if (arrayIndex == -1 || arrayIndex > array.arraySize) 814 arrayIndex = array.arraySize; 815 816 // Determine action to assign to pasted bindings. 817 string actionForNewBindings = null; 818 if (location.item is ActionTreeItem actionItem) 819 actionForNewBindings = actionItem.name; 820 else if (location.item is BindingTreeItem bindingItem) 821 actionForNewBindings = bindingItem.action; 822 823 // Paste new element. 824 var newElement = PasteBlock(tag, data, array, arrayIndex, assignNewIDs, actionForNewBindings); 825 newItemPropertyPaths.Add(newElement.propertyPath); 826 827 // If the element can have children, read whatever blocks are following the current one (if any). 828 if ((tag == k_ActionTag || tag == k_CompositeBindingTag) && blocks.Length > 1) 829 { 830 var bindingArray = array; 831 832 if (tag == k_ActionTag) 833 { 834 // We don't support pasting actions separately into action maps in the same paste operations so 835 // there must be an ActionMapTreeItem in the hierarchy we pasted into. 836 var actionMapItem = location.item.TryFindItemInHierarchy<ActionMapTreeItem>(); 837 Debug.Assert(actionMapItem != null, "Cannot find ActionMapTreeItem in hierarchy of pasted action"); 838 bindingArray = actionMapItem.bindingsArrayProperty; 839 actionForNewBindings = InputActionSerializationHelpers.GetName(newElement); 840 } 841 842 for (var i = 1; i < blocks.Length; ++i) 843 { 844 SplitTagAndData(blocks[i], out var blockTag, out var blockData); 845 846 PasteBlock(blockTag, blockData, bindingArray, 847 tag == k_CompositeBindingTag ? arrayIndex + i : -1, 848 assignNewIDs, 849 actionForNewBindings); 850 } 851 } 852 } 853 854 private static void SplitTagAndData(string block, out string tag, out string data) 855 { 856 var indexOfStartOfTextChar = block.IndexOf(k_StartOfText); 857 if (indexOfStartOfTextChar == -1) 858 throw new ArgumentException($"Incorrect copy data format: Expecting '{k_StartOfText}' in '{block}'", 859 nameof(block)); 860 861 tag = block.Substring(0, indexOfStartOfTextChar + 1); 862 data = block.Substring(indexOfStartOfTextChar + 1); 863 } 864 865 public static SerializedProperty AddElement(SerializedProperty arrayProperty, string name, int index = -1) 866 { 867 var uniqueName = InputActionSerializationHelpers.FindUniqueName(arrayProperty, name); 868 if (index < 0) 869 index = arrayProperty.arraySize; 870 871 arrayProperty.InsertArrayElementAtIndex(index); 872 var elementProperty = arrayProperty.GetArrayElementAtIndex(index); 873 elementProperty.ResetValuesToDefault(); 874 875 elementProperty.FindPropertyRelative("m_Name").stringValue = uniqueName; 876 elementProperty.FindPropertyRelative("m_Id").stringValue = Guid.NewGuid().ToString(); 877 878 return elementProperty; 879 } 880 881 private SerializedProperty PasteBlock(string tag, string data, SerializedProperty array, int arrayIndex, 882 bool assignNewIDs, string actionForNewBindings = null) 883 { 884 // Add an element to the array. Then read the serialized properties stored in the copy data 885 // back into the element. 886 var property = AddElement(array, "tempName", arrayIndex); 887 property.RestoreFromJson(data); 888 if (tag == k_ActionTag || tag == k_ActionMapTag) 889 InputActionSerializationHelpers.EnsureUniqueName(property); 890 if (assignNewIDs) 891 { 892 // Assign new IDs to the element as well as to any elements it contains. This means 893 // that for action maps, we will also assign new IDs to every action and binding in the map. 894 InputActionSerializationHelpers.AssignUniqueIDs(property); 895 } 896 897 // If the element is a binding, update its action target and binding group, if necessary. 898 if (tag == k_BindingTag || tag == k_CompositeBindingTag || tag == k_PartOfCompositeBindingTag) 899 { 900 ////TODO: use {id} rather than plain name 901 // Update action to refer to given action. 902 InputActionSerializationHelpers.ChangeBinding(property, action: actionForNewBindings); 903 904 // If we have a binding group to set for new bindings, overwrite the binding's 905 // group with it. 906 if (!string.IsNullOrEmpty(bindingGroupForNewBindings) && tag != k_CompositeBindingTag) 907 { 908 InputActionSerializationHelpers.ChangeBinding(property, 909 groups: bindingGroupForNewBindings); 910 } 911 912 onBindingAdded?.Invoke(property); 913 } 914 915 return property; 916 } 917 918 #endregion 919 920 #region Context Menus 921 922 public void BuildContextMenuFor(Type itemType, GenericMenu menu, bool multiSelect, ActionTreeItem actionItem = null, bool noSelection = false) 923 { 924 var canRename = false; 925 if (itemType == typeof(ActionMapTreeItem)) 926 { 927 menu.AddItem(s_AddActionLabel, false, AddNewAction); 928 } 929 else if (itemType == typeof(ActionTreeItem)) 930 { 931 canRename = true; 932 BuildMenuToAddBindings(menu, actionItem); 933 } 934 else if (itemType == typeof(CompositeBindingTreeItem)) 935 { 936 canRename = true; 937 } 938 else if (itemType == typeof(ActionMapListItem)) 939 { 940 menu.AddItem(s_AddActionMapLabel, false, AddNewActionMap); 941 } 942 943 // Common menu entries shared by all types of items. 944 menu.AddSeparator(""); 945 if (noSelection) 946 { 947 menu.AddDisabledItem(s_CutLabel); 948 menu.AddDisabledItem(s_CopyLabel); 949 } 950 else 951 { 952 menu.AddItem(s_CutLabel, false, () => 953 { 954 CopySelectedItemsToClipboard(); 955 DeleteDataOfSelectedItems(); 956 }); 957 menu.AddItem(s_CopyLabel, false, CopySelectedItemsToClipboard); 958 } 959 if (HavePastableClipboardData()) 960 menu.AddItem(s_PasteLabel, false, PasteDataFromClipboard); 961 else 962 menu.AddDisabledItem(s_PasteLabel); 963 menu.AddSeparator(""); 964 if (!noSelection && canRename && !multiSelect) 965 menu.AddItem(s_RenameLabel, false, () => BeginRename(GetSelectedItems().First())); 966 else if (canRename) 967 menu.AddDisabledItem(s_RenameLabel); 968 if (noSelection) 969 { 970 menu.AddDisabledItem(s_DuplicateLabel); 971 menu.AddDisabledItem(s_DeleteLabel); 972 } 973 else 974 { 975 menu.AddItem(s_DuplicateLabel, false, DuplicateSelection); 976 menu.AddItem(s_DeleteLabel, false, DeleteDataOfSelectedItems); 977 } 978 979 if (itemType != typeof(ActionMapTreeItem)) 980 { 981 menu.AddSeparator(""); 982 menu.AddItem(s_ExpandAllLabel, false, ExpandAll); 983 menu.AddItem(s_CollapseAllLabel, false, CollapseAll); 984 } 985 } 986 987 public void BuildMenuToAddBindings(GenericMenu menu, ActionTreeItem actionItem = null) 988 { 989 // Add entry to add "normal" bindings. 990 menu.AddItem(s_AddBindingLabel, false, 991 () => 992 { 993 if (actionItem != null) 994 AddNewBinding(actionItem.property, actionItem.actionMapProperty); 995 else 996 AddNewBinding(); 997 }); 998 999 // Add one entry for each registered type of composite binding. 1000 var expectedControlLayout = new InternedString(actionItem?.expectedControlLayout); 1001 foreach (var compositeName in InputBindingComposite.s_Composites.internedNames.Where(x => 1002 !InputBindingComposite.s_Composites.aliases.Contains(x)).OrderBy(x => x)) 1003 { 1004 // Skip composites we should hide from the UI. 1005 var compositeType = InputBindingComposite.s_Composites.LookupTypeRegistration(compositeName); 1006 var designTimeVisible = compositeType.GetCustomAttribute<DesignTimeVisibleAttribute>(); 1007 if (designTimeVisible != null && !designTimeVisible.Visible) 1008 continue; 1009 1010 // If the action is expected a specific control layout, check 1011 // whether the value type use by the composite matches that of 1012 // the layout. 1013 if (!expectedControlLayout.IsEmpty()) 1014 { 1015 var valueType = InputBindingComposite.GetValueType(compositeName); 1016 if (valueType != null && 1017 !InputControlLayout.s_Layouts.ValueTypeIsAssignableFrom(expectedControlLayout, valueType)) 1018 continue; 1019 } 1020 1021 var displayName = compositeType.GetCustomAttribute<DisplayNameAttribute>(); 1022 var niceName = displayName != null ? displayName.DisplayName.Replace('/', '\\') : ObjectNames.NicifyVariableName(compositeName) + " Composite"; 1023 menu.AddItem(new GUIContent($"Add {niceName}"), false, 1024 () => 1025 { 1026 if (actionItem != null) 1027 AddNewComposite(actionItem.property, actionItem.actionMapProperty, compositeName); 1028 else 1029 AddNewComposite(compositeName); 1030 }); 1031 } 1032 } 1033 1034 private void PopUpContextMenu() 1035 { 1036 // See if we have a selection of mixed types. 1037 var selected = GetSelectedItems().ToList(); 1038 var mixedSelection = selected.Select(x => x.GetType()).Distinct().Count() > 1; 1039 var noSelection = selected.Count == 0; 1040 1041 // Create and pop up context menu. 1042 var menu = new GenericMenu(); 1043 if (noSelection) 1044 { 1045 BuildContextMenuFor(rootItem.GetType(), menu, true, noSelection: noSelection); 1046 } 1047 else if (mixedSelection) 1048 { 1049 BuildContextMenuFor(typeof(ActionTreeItemBase), menu, true, noSelection: noSelection); 1050 } 1051 else 1052 { 1053 var item = selected.First(); 1054 BuildContextMenuFor(item.GetType(), menu, GetSelection().Count > 1, actionItem: item as ActionTreeItem); 1055 } 1056 menu.ShowAsContext(); 1057 } 1058 1059 protected override void ContextClickedItem(int id) 1060 { 1061 // When right-clicking an unselected item, TreeView does change the selection to the 1062 // clicked item but the visual feedback only comes in the *next* repaint. This means that 1063 // if we pop up a context menu right away here, the user does not correctly see which item 1064 // is affected. 1065 // 1066 // So, instead we force a repaint and open the context menu on the next OnGUI() call. Note 1067 // that we can't use something like EditorApplication.delayCall here as ShowAsContext() 1068 // can only be called from UI callbacks (otherwise it will simply be ignored). 1069 1070 m_InitiateContextMenuOnNextRepaint = true; 1071 Repaint(); 1072 1073 Event.current.Use(); 1074 } 1075 1076 protected override void ContextClicked() 1077 { 1078 ClearSelection(); 1079 m_InitiateContextMenuOnNextRepaint = true; 1080 Repaint(); 1081 1082 Event.current.Use(); 1083 } 1084 1085 #endregion 1086 1087 #region Add New Items 1088 1089 /// <summary> 1090 /// Add a new action map to the toplevel <see cref="InputActionAsset"/>. 1091 /// </summary> 1092 public void AddNewActionMap() 1093 { 1094 var actionMapProperty = InputActionSerializationHelpers.AddActionMap(serializedObject); 1095 var actionProperty = InputActionSerializationHelpers.AddAction(actionMapProperty); 1096 InputActionSerializationHelpers.AddBinding(actionProperty, actionMapProperty, groups: bindingGroupForNewBindings); 1097 OnNewItemAdded(actionMapProperty); 1098 } 1099 1100 /// <summary> 1101 /// Add new action to the currently active action map(s). 1102 /// </summary> 1103 public void AddNewAction() 1104 { 1105 foreach (var actionMapItem in GetSelectedItemsOrParentsOfType<ActionMapTreeItem>()) 1106 AddNewAction(actionMapItem.property); 1107 } 1108 1109 public void AddNewAction(SerializedProperty actionMapProperty) 1110 { 1111 if (onHandleAddNewAction != null) 1112 onHandleAddNewAction(actionMapProperty); 1113 else 1114 { 1115 var actionProperty = InputActionSerializationHelpers.AddAction(actionMapProperty); 1116 InputActionSerializationHelpers.AddBinding(actionProperty, actionMapProperty, groups: bindingGroupForNewBindings); 1117 OnNewItemAdded(actionProperty); 1118 } 1119 } 1120 1121 public void AddNewBinding() 1122 { 1123 foreach (var actionItem in GetSelectedItemsOrParentsOfType<ActionTreeItem>()) 1124 AddNewBinding(actionItem.property, actionItem.actionMapProperty); 1125 } 1126 1127 public void AddNewBinding(SerializedProperty actionProperty, SerializedProperty actionMapProperty) 1128 { 1129 var bindingProperty = InputActionSerializationHelpers.AddBinding(actionProperty, actionMapProperty, 1130 groups: bindingGroupForNewBindings); 1131 onBindingAdded?.Invoke(bindingProperty); 1132 OnNewItemAdded(bindingProperty); 1133 } 1134 1135 public void AddNewComposite(string compositeType) 1136 { 1137 foreach (var actionItem in GetSelectedItemsOrParentsOfType<ActionTreeItem>()) 1138 AddNewComposite(actionItem.property, actionItem.actionMapProperty, compositeType); 1139 } 1140 1141 public void AddNewComposite(SerializedProperty actionProperty, SerializedProperty actionMapProperty, string compositeName) 1142 { 1143 var compositeType = InputBindingComposite.s_Composites.LookupTypeRegistration(compositeName); 1144 if (compositeType == null) 1145 throw new ArgumentException($"Cannot find composite registration for {compositeName}", 1146 nameof(compositeName)); 1147 var compositeProperty = InputActionSerializationHelpers.AddCompositeBinding(actionProperty, 1148 actionMapProperty, compositeName, compositeType, groups: bindingGroupForNewBindings); 1149 onBindingAdded?.Invoke(compositeProperty); 1150 OnNewItemAdded(compositeProperty); 1151 } 1152 1153 private void OnNewItemAdded(SerializedProperty property) 1154 { 1155 OnSerializedObjectModified(); 1156 SelectItemAndBeginRename(property); 1157 } 1158 1159 private void SelectItemAndBeginRename(SerializedProperty property) 1160 { 1161 var item = FindItemFor(property); 1162 if (item == null) 1163 { 1164 // if we could not find the item, try clearing search filters. 1165 ClearItemSearchFilterAndReload(); 1166 item = FindItemFor(property); 1167 } 1168 Debug.Assert(item != null, $"Cannot find newly created item for {property.propertyPath}"); 1169 SetExpandedRecursive(item.id, true); 1170 SelectItem(item); 1171 SetFocus(); 1172 FrameItem(item.id); 1173 if (item.canRename) 1174 BeginRename(item); 1175 } 1176 1177 #endregion 1178 1179 #region Drawing 1180 1181 public override void OnGUI(Rect rect) 1182 { 1183 if (m_InitiateContextMenuOnNextRepaint) 1184 { 1185 m_InitiateContextMenuOnNextRepaint = false; 1186 PopUpContextMenu(); 1187 } 1188 1189 if (ReloadIfSerializedObjectHasBeenChanged()) 1190 return; 1191 1192 // Draw border rect. 1193 EditorGUI.LabelField(rect, GUIContent.none, Styles.backgroundWithBorder); 1194 rect.x += 1; 1195 rect.y += 1; 1196 rect.height -= 1; 1197 rect.width -= 2; 1198 1199 if (drawHeader) 1200 DrawHeader(ref rect); 1201 1202 base.OnGUI(rect); 1203 1204 HandleCopyPasteCommandEvent(Event.current); 1205 } 1206 1207 private void DrawHeader(ref Rect rect) 1208 { 1209 var headerRect = rect; 1210 headerRect.height = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; 1211 1212 rect.y += headerRect.height; 1213 rect.height -= headerRect.height; 1214 1215 // Draw label. 1216 EditorGUI.LabelField(headerRect, m_Title, Styles.columnHeaderLabel); 1217 1218 // Draw minus button. 1219 var buttonRect = headerRect; 1220 buttonRect.width = EditorGUIUtility.singleLineHeight; 1221 buttonRect.x += rect.width - buttonRect.width - EditorGUIUtility.standardVerticalSpacing; 1222 if (drawMinusButton) 1223 { 1224 var minusButtonDisabled = !HasSelection(); 1225 using (new EditorGUI.DisabledScope(minusButtonDisabled)) 1226 { 1227 if (GUI.Button(buttonRect, minusIcon, GUIStyle.none)) 1228 DeleteDataOfSelectedItems(); 1229 } 1230 1231 buttonRect.x -= buttonRect.width + EditorGUIUtility.standardVerticalSpacing; 1232 } 1233 1234 // Draw plus button. 1235 if (drawPlusButton) 1236 { 1237 var plusIconDisabled = onBuildTree == null; 1238 using (new EditorGUI.DisabledScope(plusIconDisabled)) 1239 { 1240 if (GUI.Button(buttonRect, plusIcon, GUIStyle.none)) 1241 { 1242 if (rootItem is ActionMapTreeItem mapItem) 1243 { 1244 AddNewAction(mapItem.property); 1245 } 1246 else if (rootItem is ActionTreeItem actionItem) 1247 { 1248 // Adding a composite has multiple options. Pop up a menu. 1249 var menu = new GenericMenu(); 1250 BuildMenuToAddBindings(menu, actionItem); 1251 menu.ShowAsContext(); 1252 } 1253 else 1254 { 1255 AddNewActionMap(); 1256 } 1257 } 1258 1259 buttonRect.x -= buttonRect.width + EditorGUIUtility.standardVerticalSpacing; 1260 } 1261 } 1262 1263 // Draw action properties button. 1264 if (drawActionPropertiesButton && rootItem is ActionTreeItem item) 1265 { 1266 if (GUI.Button(buttonRect, s_ActionPropertiesIcon, GUIStyle.none)) 1267 onDoubleClick?.Invoke(item); 1268 } 1269 } 1270 1271 // For each item, we draw 1272 // 1) color tag 1273 // 2) foldout 1274 // 3) display name 1275 // 4) Line underneath item 1276 1277 private const int kColorTagWidth = 6; 1278 private const int kFoldoutWidth = 15; 1279 1280 ////FIXME: foldout hover region is way too large; partly overlaps the text of items 1281 private bool DrawFoldout(Rect position, bool expandedState, GUIStyle style) 1282 { 1283 // We don't get the depth of the item we're drawing the foldout for but we can 1284 // infer it by the amount that the given rectangle was indented. 1285 var indent = (int)(position.x / kFoldoutWidth); 1286 var indentLevel = EditorGUI.indentLevel; 1287 1288 // When drawing input actions in the input actions editor, we don't want to offset the foldout 1289 // icon any further than the position that's passed in to this function, so take advantage of 1290 // the fact that indentLevel is always zero in that editor. 1291 position.x = EditorGUI.IndentedRect(position).x * Mathf.Clamp01(indentLevel) + kColorTagWidth + 2 + indent * kColorTagWidth; 1292 1293 position.width = kFoldoutWidth; 1294 1295 var hierarchyMode = EditorGUIUtility.hierarchyMode; 1296 1297 // We remove the editor indent level and set hierarchy mode to false when drawing the foldout 1298 // arrow so that in the inspector we don't get additional padding on the arrow for the inspector 1299 // gutter, and so that the indent level doesn't apply because we've done that ourselves. 1300 EditorGUI.indentLevel = 0; 1301 EditorGUIUtility.hierarchyMode = false; 1302 1303 var foldoutExpanded = EditorGUI.Foldout(position, expandedState, GUIContent.none, true, style); 1304 1305 EditorGUI.indentLevel = indentLevel; 1306 EditorGUIUtility.hierarchyMode = hierarchyMode; 1307 1308 return foldoutExpanded; 1309 } 1310 1311 protected override void RowGUI(RowGUIArgs args) 1312 { 1313 var item = (ActionTreeItemBase)args.item; 1314 var isRepaint = Event.current.type == EventType.Repaint; 1315 1316 // Color tag at beginning of line. 1317 var colorTagRect = EditorGUI.IndentedRect(args.rowRect); 1318 colorTagRect.x += item.depth * kColorTagWidth; 1319 colorTagRect.width = kColorTagWidth; 1320 if (isRepaint) 1321 item.colorTagStyle.Draw(colorTagRect, GUIContent.none, false, false, false, false); 1322 1323 // Text. 1324 // NOTE: When renaming, the renaming overlay gets drawn outside of our control so don't draw the label in that case 1325 // as otherwise it will peak out from underneath the overlay. 1326 if (!args.isRenaming && isRepaint) 1327 { 1328 var text = item.displayName; 1329 var textRect = GetTextRect(args.rowRect, item); 1330 1331 var style = args.selected ? Styles.selectedText : Styles.text; 1332 1333 if (item.showWarningIcon) 1334 { 1335 var content = new GUIContent(text, EditorGUIUtility.FindTexture("console.warnicon.sml")); 1336 style.Draw(textRect, content, false, false, args.selected, args.focused); 1337 } 1338 else 1339 style.Draw(textRect, text, false, false, args.selected, args.focused); 1340 } 1341 1342 // Bottom line. 1343 var lineRect = EditorGUI.IndentedRect(args.rowRect); 1344 lineRect.y += lineRect.height - 1; 1345 lineRect.height = 1; 1346 if (isRepaint) 1347 Styles.border.Draw(lineRect, GUIContent.none, false, false, false, false); 1348 1349 // For action items, add a dropdown menu to add bindings. 1350 if (item is ActionTreeItem actionItem) 1351 { 1352 var buttonRect = args.rowRect; 1353 buttonRect.x = buttonRect.width - (EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing); 1354 buttonRect.width = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; 1355 if (GUI.Button(buttonRect, s_PlusBindingIcon, GUIStyle.none)) 1356 { 1357 var menu = new GenericMenu(); 1358 BuildMenuToAddBindings(menu, actionItem); 1359 menu.ShowAsContext(); 1360 } 1361 } 1362 } 1363 1364 protected override float GetCustomRowHeight(int row, TreeViewItem item) 1365 { 1366 return 18; 1367 } 1368 1369 protected override Rect GetRenameRect(Rect rowRect, int row, TreeViewItem item) 1370 { 1371 var textRect = GetTextRect(rowRect, item, false); 1372 textRect.x += 2; 1373 textRect.height -= 2; 1374 return textRect; 1375 } 1376 1377 private Rect GetTextRect(Rect rowRect, TreeViewItem item, bool applyIndent = true) 1378 { 1379 var indent = (item.depth + 1) * kColorTagWidth + kFoldoutWidth; 1380 var textRect = applyIndent ? EditorGUI.IndentedRect(rowRect) : rowRect; 1381 textRect.x += indent; 1382 return textRect; 1383 } 1384 1385 #endregion 1386 1387 // Undo is a problem. When an undo or redo is performed, the SerializedObject may change behind 1388 // our backs which means that the information shown in the tree may be outdated now. 1389 // 1390 // We do have the Undo.undoRedoPerformed global callback but because PropertyDrawers 1391 // have no observable life cycle, we cannot always easily hook into the callback and force a reload 1392 // of the tree. Also, while returning false from PropertyDrawer.CanCacheInspectorGUI() might one make suspect that 1393 // a PropertyDrawer would automatically be thrown away and recreated if the SerializedObject 1394 // is modified by undo, that does not happen in practice. 1395 // 1396 // We could just Reload() the tree all the time but TreeView.Reload() itself forces a repaint and 1397 // this will thus easily lead to infinite repaints. 1398 // 1399 // So, what we do is make use of the built-in dirty count we can get for Unity objects. If the count 1400 // changes and it wasn't caused by us, we reload the tree. Means we still reload unnecessarily if 1401 // some other property on a component changes but at least we don't reload all the time. 1402 // 1403 // A positive side-effect is that we will catch *any* change to the SerializedObject, not just 1404 // undo/redo and we can do so without having to hook into Undo.undoRedoPerformed anywhere. 1405 1406 private void OnSerializedObjectModified() 1407 { 1408 serializedObject.ApplyModifiedProperties(); 1409 UpdateSerializedObjectDirtyCount(); 1410 Reload(); 1411 onSerializedObjectModified?.Invoke(); 1412 } 1413 1414 public void UpdateSerializedObjectDirtyCount() 1415 { 1416 m_SerializedObjectDirtyCount = serializedObject != null ? EditorUtility.GetDirtyCount(serializedObject.targetObject) : 0; 1417 } 1418 1419 private bool ReloadIfSerializedObjectHasBeenChanged() 1420 { 1421 var oldCount = m_SerializedObjectDirtyCount; 1422 UpdateSerializedObjectDirtyCount(); 1423 if (oldCount != m_SerializedObjectDirtyCount) 1424 { 1425 Reload(); 1426 onSerializedObjectModified?.Invoke(); 1427 return true; 1428 } 1429 return false; 1430 } 1431 1432 public SerializedObject serializedObject { get; } 1433 public string bindingGroupForNewBindings { get; set; } 1434 public new TreeViewItem rootItem => base.rootItem; 1435 1436 public Action onSerializedObjectModified { get; set; } 1437 public Action onSelectionChanged { get; set; } 1438 public Action<ActionTreeItemBase> onDoubleClick { get; set; } 1439 public Action<ActionTreeItemBase> onBeginRename { get; set; } 1440 public Func<TreeViewItem> onBuildTree { get; set; } 1441 public Action<SerializedProperty> onBindingAdded { get; set; } 1442 1443 public bool drawHeader { get; set; } 1444 public bool drawPlusButton { get; set; } 1445 public bool drawMinusButton { get; set; } 1446 public bool drawActionPropertiesButton { get; set; } 1447 1448 public Action<SerializedProperty> onHandleAddNewAction { get; set; } 1449 1450 public (string, string) title 1451 { 1452 get => (m_Title?.text, m_Title?.tooltip); 1453 set => m_Title = new GUIContent(value.Item1, value.Item2); 1454 } 1455 1456 public new float totalHeight 1457 { 1458 get 1459 { 1460 var height = base.totalHeight; 1461 if (drawHeader) 1462 height += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; 1463 height += 1; // Border. 1464 return height; 1465 } 1466 } 1467 1468 public ActionTreeItemBase this[string path] 1469 { 1470 get 1471 { 1472 var item = FindItemByPath(path); 1473 if (item == null) 1474 throw new KeyNotFoundException(path); 1475 return item; 1476 } 1477 } 1478 1479 private GUIContent plusIcon 1480 { 1481 get 1482 { 1483 if (rootItem is ActionMapTreeItem) 1484 return s_PlusActionIcon; 1485 if (rootItem is ActionTreeItem) 1486 return s_PlusBindingIcon; 1487 return s_PlusActionMapIcon; 1488 } 1489 } 1490 1491 private GUIContent minusIcon => s_DeleteSectionIcon; 1492 1493 private FilterCriterion[] m_ItemFilterCriteria; 1494 private GUIContent m_Title; 1495 private bool m_InitiateContextMenuOnNextRepaint; 1496 private bool m_ForceAcceptRename; 1497 private int m_SerializedObjectDirtyCount; 1498 1499 private static readonly GUIContent s_AddBindingLabel = EditorGUIUtility.TrTextContent("Add Binding"); 1500 private static readonly GUIContent s_AddActionLabel = EditorGUIUtility.TrTextContent("Add Action"); 1501 private static readonly GUIContent s_AddActionMapLabel = EditorGUIUtility.TrTextContent("Add Action Map"); 1502 private static readonly GUIContent s_PlusBindingIcon = EditorGUIUtility.TrIconContent("Toolbar Plus More", "Add Binding"); 1503 private static readonly GUIContent s_PlusActionIcon = EditorGUIUtility.TrIconContent("Toolbar Plus", "Add Action"); 1504 private static readonly GUIContent s_PlusActionMapIcon = EditorGUIUtility.TrIconContent("Toolbar Plus", "Add Action Map"); 1505 private static readonly GUIContent s_DeleteSectionIcon = EditorGUIUtility.TrIconContent("Toolbar Minus", "Delete Selection"); 1506 private static readonly GUIContent s_ActionPropertiesIcon = EditorGUIUtility.TrIconContent("Settings", "Action Properties"); 1507 1508 private static readonly GUIContent s_CutLabel = EditorGUIUtility.TrTextContent("Cut"); 1509 private static readonly GUIContent s_CopyLabel = EditorGUIUtility.TrTextContent("Copy"); 1510 private static readonly GUIContent s_PasteLabel = EditorGUIUtility.TrTextContent("Paste"); 1511 private static readonly GUIContent s_DeleteLabel = EditorGUIUtility.TrTextContent("Delete"); 1512 private static readonly GUIContent s_DuplicateLabel = EditorGUIUtility.TrTextContent("Duplicate"); 1513 private static readonly GUIContent s_RenameLabel = EditorGUIUtility.TrTextContent("Rename"); 1514 private static readonly GUIContent s_ExpandAllLabel = EditorGUIUtility.TrTextContent("Expand All"); 1515 private static readonly GUIContent s_CollapseAllLabel = EditorGUIUtility.TrTextContent("Collapse All"); 1516 1517 public static string SharedResourcesPath = "Packages/com.unity.inputsystem/InputSystem/Editor/AssetEditor/PackageResources/"; 1518 public static string ResourcesPath 1519 { 1520 get 1521 { 1522 if (EditorGUIUtility.isProSkin) 1523 return SharedResourcesPath + "pro/"; 1524 return SharedResourcesPath + "personal/"; 1525 } 1526 } 1527 1528 public struct FilterCriterion 1529 { 1530 public enum Type 1531 { 1532 ByName, 1533 ByBindingGroup, 1534 ByDeviceLayout, 1535 } 1536 1537 public enum Match 1538 { 1539 Success, 1540 Failure, 1541 None, 1542 } 1543 1544 public string text; 1545 public Type type; 1546 1547 public static string k_BindingGroupTag = "g:"; 1548 public static string k_DeviceLayoutTag = "d:"; 1549 1550 public Match Matches(ActionTreeItemBase item) 1551 { 1552 Debug.Assert(item != null, "Item cannot be null"); 1553 1554 switch (type) 1555 { 1556 case Type.ByName: 1557 { 1558 // NOTE: Composite items have names (and part bindings in a way, too) but we don't filter on them. 1559 if (item is ActionMapTreeItem || item is ActionTreeItem) 1560 { 1561 var matchesSelf = item.displayName.Contains(text, StringComparison.InvariantCultureIgnoreCase); 1562 1563 // Name filters behave recursively. I.e. if any item in the subtree is matched by the name filter, 1564 // the item is included. 1565 if (!matchesSelf && CheckChildrenFor(Match.Success, item)) 1566 return Match.Success; 1567 1568 return matchesSelf ? Match.Success : Match.Failure; 1569 } 1570 break; 1571 } 1572 1573 case Type.ByBindingGroup: 1574 { 1575 if (item is BindingTreeItem bindingItem) 1576 { 1577 // For composites, succeed the match if any children match. 1578 if (item is CompositeBindingTreeItem) 1579 return CheckChildrenFor(Match.Success, item) ? Match.Success : Match.Failure; 1580 1581 // Items that are in no binding group match any binding group. 1582 if (string.IsNullOrEmpty(bindingItem.groups)) 1583 return Match.Success; 1584 1585 var groups = bindingItem.groups.Split(InputBinding.Separator); 1586 var bindingGroup = text; 1587 return groups.Any(x => x.Equals(bindingGroup, StringComparison.InvariantCultureIgnoreCase)) 1588 ? Match.Success 1589 : Match.Failure; 1590 } 1591 break; 1592 } 1593 1594 case Type.ByDeviceLayout: 1595 { 1596 if (item is BindingTreeItem bindingItem) 1597 { 1598 // For composites, succeed the match if any children match. 1599 if (item is CompositeBindingTreeItem) 1600 return CheckChildrenFor(Match.Success, item) ? Match.Success : Match.Failure; 1601 1602 var deviceLayout = InputControlPath.TryGetDeviceLayout(bindingItem.path); 1603 return string.Equals(deviceLayout, text, StringComparison.InvariantCultureIgnoreCase) 1604 || InputControlLayout.s_Layouts.IsBasedOn(new InternedString(deviceLayout), new InternedString(text)) 1605 ? Match.Success 1606 : Match.Failure; 1607 } 1608 break; 1609 } 1610 } 1611 1612 return Match.None; 1613 } 1614 1615 private bool CheckChildrenFor(Match match, ActionTreeItemBase item) 1616 { 1617 if (!item.hasChildren) 1618 return false; 1619 1620 foreach (var child in item.children.OfType<ActionTreeItemBase>()) 1621 if (Matches(child) == match) 1622 return true; 1623 1624 return false; 1625 } 1626 1627 public static FilterCriterion ByName(string name) 1628 { 1629 return new FilterCriterion {text = name, type = Type.ByName}; 1630 } 1631 1632 public static FilterCriterion ByBindingGroup(string group) 1633 { 1634 return new FilterCriterion {text = group, type = Type.ByBindingGroup}; 1635 } 1636 1637 public static FilterCriterion ByDeviceLayout(string layout) 1638 { 1639 return new FilterCriterion {text = layout, type = Type.ByDeviceLayout}; 1640 } 1641 1642 public static List<FilterCriterion> FromString(string criteria) 1643 { 1644 if (string.IsNullOrEmpty(criteria)) 1645 return null; 1646 1647 var list = new List<FilterCriterion>(); 1648 foreach (var substring in criteria.Tokenize()) 1649 { 1650 if (substring.StartsWith(k_DeviceLayoutTag)) 1651 list.Add(ByDeviceLayout(substring.Substr(2).Unescape())); 1652 else if (substring.StartsWith(k_BindingGroupTag)) 1653 list.Add(ByBindingGroup(substring.Substr(2).Unescape())); 1654 else 1655 list.Add(ByName(substring.ToString().Unescape())); 1656 } 1657 1658 return list; 1659 } 1660 1661 public static string ToString(IEnumerable<FilterCriterion> criteria) 1662 { 1663 var builder = new StringBuilder(); 1664 foreach (var criterion in criteria) 1665 { 1666 if (builder.Length > 0) 1667 builder.Append(' '); 1668 1669 if (criterion.type == Type.ByBindingGroup) 1670 builder.Append(k_BindingGroupTag); 1671 else if (criterion.type == Type.ByDeviceLayout) 1672 builder.Append(k_DeviceLayoutTag); 1673 1674 builder.Append(criterion.text); 1675 } 1676 return builder.ToString(); 1677 } 1678 } 1679 1680 public static class Styles 1681 { 1682 public static readonly GUIStyle text = new GUIStyle("Label").WithAlignment(TextAnchor.MiddleLeft); 1683 public static readonly GUIStyle selectedText = new GUIStyle("Label").WithAlignment(TextAnchor.MiddleLeft).WithNormalTextColor(Color.white); 1684 public static readonly GUIStyle backgroundWithoutBorder = new GUIStyle("Label") 1685 .WithNormalBackground(AssetDatabase.LoadAssetAtPath<Texture2D>(ResourcesPath + "actionTreeBackgroundWithoutBorder.png")); 1686 public static readonly GUIStyle border = new GUIStyle("Label") 1687 .WithNormalBackground(AssetDatabase.LoadAssetAtPath<Texture2D>(ResourcesPath + "actionTreeBackground.png")) 1688 .WithBorder(new RectOffset(0, 0, 0, 1)); 1689 public static readonly GUIStyle backgroundWithBorder = new GUIStyle("Label") 1690 .WithNormalBackground(AssetDatabase.LoadAssetAtPath<Texture2D>(ResourcesPath + "actionTreeBackground.png")) 1691 .WithBorder(new RectOffset(3, 3, 3, 3)) 1692 .WithMargin(new RectOffset(4, 4, 4, 4)); 1693 public static readonly GUIStyle columnHeaderLabel = new GUIStyle(EditorStyles.toolbar) 1694 .WithAlignment(TextAnchor.MiddleLeft) 1695 .WithFontStyle(FontStyle.Bold) 1696 .WithPadding(new RectOffset(10, 6, 0, 0)); 1697 } 1698 1699 // Just so that we can tell apart TreeViews containing only maps. 1700 internal class ActionMapListItem : TreeViewItem 1701 { 1702 } 1703 } 1704} 1705#endif // UNITY_EDITOR