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