// Copyright (c) 2012-2023 Wojciech Figat. All rights reserved. using System; using System.Collections; using System.Linq; using FlaxEditor.CustomEditors.GUI; using FlaxEditor.GUI.Input; using FlaxEditor.Content; using FlaxEditor.GUI.ContextMenu; using FlaxEditor.GUI.Drag; using FlaxEditor.SceneGraph; using FlaxEditor.Scripting; using FlaxEngine; using FlaxEngine.GUI; using FlaxEngine.Utilities; namespace FlaxEditor.CustomEditors.Editors { /// /// Default implementation of the inspector used to edit arrays/list. /// [HideInEditor] public abstract class CollectionEditor : CustomEditor { /// /// The custom implementation of the collection items labels that can be used to reorder items. /// /// private class CollectionItemLabel : PropertyNameLabel { /// /// The editor. /// public CollectionEditor Editor; /// /// The index of the item (zero-based). /// public readonly int Index; /// /// Initializes a new instance of the class. /// /// The editor. /// The index. public CollectionItemLabel(CollectionEditor editor, int index) : base("Element " + index) { Editor = editor; Index = index; SetupContextMenu += OnSetupContextMenu; } private void OnSetupContextMenu(PropertyNameLabel label, ContextMenu menu, CustomEditor linkedEditor) { menu.ItemsContainer.RemoveChildren(); menu.AddButton("Copy", linkedEditor.Copy); var paste = menu.AddButton("Paste", linkedEditor.Paste); paste.Enabled = linkedEditor.CanPaste; menu.AddSeparator(); var moveUpButton = menu.AddButton("Move up", OnMoveUpClicked); moveUpButton.Enabled = Index > 0; var moveDownButton = menu.AddButton("Move down", OnMoveDownClicked); moveDownButton.Enabled = Index + 1 < Editor.Count; menu.AddButton("Remove", OnRemoveClicked); } private void OnMoveUpClicked() { Editor.Move(Index, Index - 1); } private void OnMoveDownClicked() { Editor.Move(Index, Index + 1); } private void OnRemoveClicked() { Editor.Remove(Index); } } private class CollectionDropPanel : DropPanel { /// /// The collection editor. /// public CollectionEditor Editor; /// /// The index of the item (zero-based). /// public int Index { get; private set; } /// /// The linked editor. /// public CustomEditor LinkedEditor; private bool _canReorder = true; public void Setup(CollectionEditor editor, int index, bool canReorder = true) { HeaderHeight = 18; _canReorder = canReorder; EnableDropDownIcon = true; var icons = FlaxEditor.Editor.Instance.Icons; ArrowImageClosed = new SpriteBrush(icons.ArrowRight12); ArrowImageOpened = new SpriteBrush(icons.ArrowDown12); HeaderText = $"Element {index}"; IsClosed = false; Editor = editor; Index = index; Offsets = new Margin(7, 7, 0, 0); MouseButtonRightClicked += OnMouseButtonRightClicked; if (_canReorder) { // TODO: Drag drop } } private void OnMouseButtonRightClicked(DropPanel panel, Float2 location) { if (LinkedEditor == null) return; var linkedEditor = LinkedEditor; var menu = new ContextMenu(); menu.AddButton("Copy", linkedEditor.Copy); var paste = menu.AddButton("Paste", linkedEditor.Paste); paste.Enabled = linkedEditor.CanPaste; if (_canReorder) { menu.AddSeparator(); var moveUpButton = menu.AddButton("Move up", OnMoveUpClicked); moveUpButton.Enabled = Index > 0; var moveDownButton = menu.AddButton("Move down", OnMoveDownClicked); moveDownButton.Enabled = Index + 1 < Editor.Count; } menu.AddButton("Remove", OnRemoveClicked); menu.Show(panel, location); } private void OnMoveUpClicked() { Editor.Move(Index, Index - 1); } private void OnMoveDownClicked() { Editor.Move(Index, Index + 1); } private void OnRemoveClicked() { Editor.Remove(Index); } } /// /// Determines if value of collection can be null. /// protected bool NotNullItems; private IntValueBox _sizeBox; private Color _background; private int _elementsCount; private bool _readOnly; private bool _canReorderItems; private CollectionAttribute.DisplayType _displayType; /// /// Gets the length of the collection. /// public abstract int Count { get; } /// /// Gets the type of the collection elements. /// public ScriptType ElementType { get { var type = Values.Type; return type.IsGenericType ? new ScriptType(type.GetGenericArguments()[0]) : type.GetElementType(); } } /// public override bool RevertValueWithChildren => false; // Always revert value for a whole collection /// public override void Initialize(LayoutElementsContainer layout) { // No support for different collections for now if (HasDifferentTypes) return; var size = Count; _readOnly = false; _canReorderItems = true; _background = FlaxEngine.GUI.Style.Current.CollectionBackgroundColor; _displayType = CollectionAttribute.DisplayType.Header; NotNullItems = false; // Try get CollectionAttribute for collection editor meta var attributes = Values.GetAttributes(); Type overrideEditorType = null; float spacing = 1.0f; var collection = (CollectionAttribute)attributes?.FirstOrDefault(x => x is CollectionAttribute); if (collection != null) { _readOnly = collection.ReadOnly; _canReorderItems = collection.CanReorderItems; NotNullItems = collection.NotNullItems; if (collection.BackgroundColor.HasValue) _background = collection.BackgroundColor.Value; overrideEditorType = TypeUtils.GetType(collection.OverrideEditorTypeName).Type; spacing = collection.Spacing; _displayType = collection.Display; } var dragArea = layout.CustomContainer(); dragArea.CustomControl.Editor = this; dragArea.CustomControl.ElementType = ElementType; // Check for the AssetReferenceAttribute. In JSON assets, it can be used to filter // which scripts can be dragged over and dropped on this collection editor. var assetReference = (AssetReferenceAttribute)attributes?.FirstOrDefault(x => x is AssetReferenceAttribute); if (assetReference != null) { if (string.IsNullOrEmpty(assetReference.TypeName)) { } else if (assetReference.TypeName.Length > 1 && assetReference.TypeName[0] == '.') { dragArea.CustomControl.ElementType = ScriptType.Null; dragArea.CustomControl.FileExtension = assetReference.TypeName; } else { var customType = TypeUtils.GetType(assetReference.TypeName); if (customType != ScriptType.Null) dragArea.CustomControl.ElementType = customType; else if (!Content.Settings.GameSettings.OptionalPlatformSettings.Contains(assetReference.TypeName)) Debug.LogWarning(string.Format("Unknown asset type '{0}' to use for drag and drop filter.", assetReference.TypeName)); else dragArea.CustomControl.ElementType = ScriptType.Void; } } // Size if (layout.ContainerControl is DropPanel dropPanel) { var height = dropPanel.HeaderHeight - dropPanel.HeaderTextMargin.Height; var y = -dropPanel.HeaderHeight + dropPanel.HeaderTextMargin.Top; _sizeBox = new IntValueBox(size) { MinValue = 0, MaxValue = ushort.MaxValue, AnchorPreset = AnchorPresets.TopRight, Bounds = new Rectangle(-40 - dropPanel.ItemsMargin.Right, y, 40, height), Parent = dropPanel, }; var label = new Label { Text = "Size", AnchorPreset = AnchorPresets.TopRight, Bounds = new Rectangle(-_sizeBox.Width - 40 - dropPanel.ItemsMargin.Right - 2, y, 40, height), Parent = dropPanel }; if (_readOnly || (NotNullItems && size == 0)) { _sizeBox.IsReadOnly = true; _sizeBox.Enabled = false; } else { _sizeBox.EditEnd += OnSizeChanged; } } // Elements if (size > 0) { var panel = dragArea.VerticalPanel(); panel.Panel.Offsets = new Margin(7, 7, 0, 0); panel.Panel.BackgroundColor = _background; var elementType = ElementType; bool single = elementType.IsPrimitive || elementType.Equals(new ScriptType(typeof(string))) || elementType.IsEnum || (elementType.GetFields().Length == 1 && elementType.GetProperties().Length == 0) || (elementType.GetProperties().Length == 1 && elementType.GetFields().Length == 0) || elementType.Equals(new ScriptType(typeof(JsonAsset))) || elementType.Equals(new ScriptType(typeof(SettingsBase))); for (int i = 0; i < size; i++) { // Apply spacing if (i > 0 && i < size && spacing > 0 && !single) panel.Space(spacing); var overrideEditor = overrideEditorType != null ? (CustomEditor)Activator.CreateInstance(overrideEditorType) : null; if (_displayType == CollectionAttribute.DisplayType.Inline || (collection == null && single) || (_displayType == CollectionAttribute.DisplayType.Default && single)) { PropertyNameLabel itemLabel; if (_canReorderItems) itemLabel = new CollectionItemLabel(this, i); else itemLabel = new PropertyNameLabel("Element " + i); var property = panel.AddPropertyItem(itemLabel); var itemLayout = (LayoutElementsContainer)property; itemLabel.LinkedEditor = itemLayout.Object(new ListValueContainer(elementType, i, Values, attributes), overrideEditor); } else if (_displayType == CollectionAttribute.DisplayType.Header || (_displayType == CollectionAttribute.DisplayType.Default && !single)) { var cdp = panel.CustomContainer(); cdp.CustomControl.Setup(this, i, _canReorderItems); var itemLayout = cdp.VerticalPanel(); cdp.CustomControl.LinkedEditor = itemLayout.Object(new ListValueContainer(elementType, i, Values, attributes), overrideEditor); } } } _elementsCount = size; // Add/Remove buttons if (!_readOnly) { var panel = dragArea.HorizontalPanel(); panel.Panel.Size = new Float2(0, 20); panel.Panel.Margin = new Margin(2); var removeButton = panel.Button("-", "Remove last item"); removeButton.Button.Size = new Float2(16, 16); removeButton.Button.Enabled = size > 0; removeButton.Button.AnchorPreset = AnchorPresets.TopRight; removeButton.Button.Clicked += () => { if (IsSetBlocked) return; Resize(Count - 1); }; var addButton = panel.Button("+", "Add new item"); addButton.Button.Size = new Float2(16, 16); addButton.Button.Enabled = !NotNullItems || size > 0; addButton.Button.AnchorPreset = AnchorPresets.TopRight; addButton.Button.Clicked += () => { if (IsSetBlocked) return; Resize(Count + 1); }; } } /// protected override void Deinitialize() { _sizeBox = null; base.Deinitialize(); } /// /// Rebuilds the parent layout if its collection. /// public void RebuildParentCollection() { if (ParentEditor is DictionaryEditor dictionaryEditor) { dictionaryEditor.RebuildParentCollection(); dictionaryEditor.RebuildLayout(); return; } if (ParentEditor is CollectionEditor collectionEditor) { collectionEditor.RebuildParentCollection(); collectionEditor.RebuildLayout(); } } private void OnSizeChanged() { if (IsSetBlocked) return; Resize(_sizeBox.Value); } /// /// Moves the specified item at the given index and swaps it with the other item. It supports undo. /// /// Index of the source item. /// Index of the destination item to swap with. private void Move(int srcIndex, int dstIndex) { if (IsSetBlocked) return; var cloned = CloneValues(); var tmp = cloned[dstIndex]; cloned[dstIndex] = cloned[srcIndex]; cloned[srcIndex] = tmp; SetValue(cloned); } /// /// Removes the item at the specified index. It supports undo. /// /// The index of the item to remove. private void Remove(int index) { if (IsSetBlocked) return; var newValues = Allocate(Count - 1); var oldValues = (IList)Values[0]; for (int i = 0; i < index; i++) { newValues[i] = oldValues[i]; } for (int i = index; i < newValues.Count; i++) { newValues[i] = oldValues[i + 1]; } SetValue(newValues); } /// /// Allocates the collection of the specified size. /// /// The size. /// The collection. protected abstract IList Allocate(int size); /// /// Resizes collection to the specified new size. /// /// The new size. protected abstract void Resize(int newSize); /// /// Clones the collection values. /// protected abstract IList CloneValues(); /// public override void Refresh() { base.Refresh(); // No support for different collections for now if (HasDifferentValues || HasDifferentTypes) return; // Update reference/default value indicator if (_sizeBox != null) { var color = Color.Transparent; if (Values.HasReferenceValue && Values.ReferenceValue is IList referenceValue && referenceValue.Count != Count) color = FlaxEngine.GUI.Style.Current.BackgroundSelected; else if (Values.HasDefaultValue && Values.DefaultValue is IList defaultValue && defaultValue.Count != Count) color = Color.Yellow * 0.8f; _sizeBox.BorderColor = color; } // Check if collection has been resized (by UI or from external source) if (Count != _elementsCount) { RebuildLayout(); RebuildParentCollection(); } } /// protected override bool OnDirty(CustomEditor editor, object value, object token = null) { if (NotNullItems) { if (value == null && editor.ParentEditor == this) return false; if (editor == this && value is IList list) { for (int i = 0; i < list.Count; i++) { if (list[i] == null) return false; } } } return base.OnDirty(editor, value, token); } private class DragAreaControl : VerticalPanel { private DragItems _dragItems; private DragActors _dragActors; private DragHandlers _dragHandlers; private AssetPickerValidator _pickerValidator; public ScriptType ElementType { get => _pickerValidator?.AssetType ?? ScriptType.Null; set => _pickerValidator = new AssetPickerValidator(value); } public CollectionEditor Editor { get; set; } public string FileExtension { set => _pickerValidator.FileExtension = value; } /// public override void Draw() { if (_dragHandlers is { HasValidDrag: true }) { var area = new Rectangle(Float2.Zero, Size); Render2D.FillRectangle(area, Color.Orange * 0.5f); Render2D.DrawRectangle(area, Color.Black); } base.Draw(); } public override void OnDestroy() { _pickerValidator.OnDestroy(); } private bool ValidateActors(ActorNode node) { return node.Actor.GetScript(ElementType.Type) || ElementType.Type.IsAssignableTo(typeof(Actor)); } /// public override DragDropEffect OnDragEnter(ref Float2 location, DragData data) { var result = base.OnDragEnter(ref location, data); if (result != DragDropEffect.None) return result; if (_dragHandlers == null) { _dragItems = new DragItems(_pickerValidator.IsValid); _dragActors = new DragActors(ValidateActors); _dragHandlers = new DragHandlers { _dragActors, _dragItems }; } return _dragHandlers.OnDragEnter(data); } /// public override DragDropEffect OnDragMove(ref Float2 location, DragData data) { var result = base.OnDragMove(ref location, data); if (result != DragDropEffect.None) return result; return _dragHandlers.Effect; } /// public override void OnDragLeave() { _dragHandlers.OnDragLeave(); base.OnDragLeave(); } /// public override DragDropEffect OnDragDrop(ref Float2 location, DragData data) { var result = base.OnDragDrop(ref location, data); if (result != DragDropEffect.None) { _dragHandlers.OnDragDrop(null); return result; } if (_dragHandlers.HasValidDrag) { if (_dragItems.HasValidDrag) { var list = Editor.CloneValues(); if (list == null) { if (Editor.Values.Type.IsArray) { list = TypeUtils.CreateArrayInstance(Editor.Values.Type.GetElementType(), 0); } else { list = Editor.Values.Type.CreateInstance() as IList; } } if (list.IsFixedSize) { var oldSize = list.Count; var newSize = list.Count + _dragItems.Objects.Count; var type = Editor.Values.Type.GetElementType(); var array = TypeUtils.CreateArrayInstance(type, newSize); list.CopyTo(array, 0); for (var i = oldSize; i < newSize; i++) { var validator = new AssetPickerValidator { FileExtension = _pickerValidator.FileExtension, AssetType = _pickerValidator.AssetType, SelectedItem = _dragItems.Objects[i - oldSize], }; if (typeof(AssetItem).IsAssignableFrom(ElementType.Type)) array.SetValue(validator.SelectedItem, i); else if (ElementType.Type == typeof(Guid)) array.SetValue(validator.SelectedID, i); else if (ElementType.Type == typeof(SceneReference)) array.SetValue(new SceneReference(validator.SelectedID), i); else if (ElementType.Type == typeof(string)) array.SetValue(validator.SelectedPath, i); else array.SetValue(validator.SelectedAsset, i); validator.OnDestroy(); } Editor.SetValue(array); } else { foreach (var item in _dragItems.Objects) { var validator = new AssetPickerValidator { FileExtension = _pickerValidator.FileExtension, AssetType = _pickerValidator.AssetType, SelectedItem = item, }; if (typeof(AssetItem).IsAssignableFrom(ElementType.Type)) list.Add(validator.SelectedItem); else if (ElementType.Type == typeof(Guid)) list.Add(validator.SelectedID); else if (ElementType.Type == typeof(SceneReference)) list.Add(new SceneReference(validator.SelectedID)); else if (ElementType.Type == typeof(string)) list.Add(validator.SelectedPath); else list.Add(validator.SelectedAsset); validator.OnDestroy(); } Editor.SetValue(list); } } else if (_dragActors.HasValidDrag) { var list = Editor.CloneValues(); if (list == null) { if (Editor.Values.Type.IsArray) { list = TypeUtils.CreateArrayInstance(Editor.Values.Type.GetElementType(), 0); } else { list = Editor.Values.Type.CreateInstance() as IList; } } if (list.IsFixedSize) { var oldSize = list.Count; var newSize = list.Count + _dragActors.Objects.Count; var type = Editor.Values.Type.GetElementType(); var array = TypeUtils.CreateArrayInstance(type, newSize); list.CopyTo(array, 0); for (var i = oldSize; i < newSize; i++) { var actor = _dragActors.Objects[i - oldSize].Actor; if (ElementType.Type.IsAssignableTo(typeof(Actor))) { array.SetValue(actor, i); } else { array.SetValue(actor.GetScript(ElementType.Type), i); } } Editor.SetValue(array); } else { foreach (var actorNode in _dragActors.Objects) { if (ElementType.Type.IsAssignableTo(typeof(Actor))) { list.Add(actorNode.Actor); } else { list.Add(actorNode.Actor.GetScript(ElementType.Type)); } } Editor.SetValue(list); } } _dragHandlers.OnDragDrop(null); } return result; } } } }