// Copyright (c) 2012-2023 Wojciech Figat. All rights reserved. using System; using System.Collections; using System.Linq; using FlaxEditor.CustomEditors.Elements; using FlaxEditor.CustomEditors.GUI; using FlaxEditor.GUI; using FlaxEditor.GUI.ContextMenu; using FlaxEditor.GUI.Input; using FlaxEditor.Scripting; using FlaxEngine; using FlaxEngine.GUI; using FlaxEngine.Json; using FlaxEngine.Utilities; namespace FlaxEditor.CustomEditors.Editors { /// /// Default implementation of the inspector used to edit key-value dictionaries. /// public class DictionaryEditor : CustomEditor { /// /// The custom implementation of the dictionary items labels that can be used to remove items or edit keys. /// /// private class DictionaryItemLabel : PropertyNameLabel { private DictionaryEditor _editor; private object _key; /// /// Initializes a new instance of the class. /// /// The editor. /// The key. public DictionaryItemLabel(DictionaryEditor editor, object key) : base(key?.ToString() ?? "") { _editor = editor; _key = key; SetupContextMenu += OnSetupContextMenu; } private void OnSetupContextMenu(PropertyNameLabel label, ContextMenu menu, CustomEditor linkedEditor) { if (menu.Items.Any()) menu.AddSeparator(); menu.AddButton("Remove", OnRemoveClicked).Enabled = !_editor._readOnly; menu.AddButton("Edit", OnEditClicked).Enabled = _editor._canEditKeys; } private void OnRemoveClicked(ContextMenuButton button) { _editor.Remove(_key); } private void OnEditClicked(ContextMenuButton button) { var keyType = _editor.Values.Type.GetGenericArguments()[0]; if (keyType == typeof(string) || keyType.IsPrimitive) { // Edit as text var popup = RenamePopup.Show(Parent, Rectangle.Margin(Bounds, Margin), Text, false); popup.Validate += (renamePopup, value) => { object newKey; if (keyType.IsPrimitive) newKey = JsonSerializer.Deserialize(value, keyType); else newKey = value; return !((IDictionary)_editor.Values[0]).Contains(newKey); }; popup.Renamed += renamePopup => { object newKey; if (keyType.IsPrimitive) newKey = JsonSerializer.Deserialize(renamePopup.Text, keyType); else newKey = renamePopup.Text; _editor.ChangeKey(_key, newKey); _key = newKey; Text = _key.ToString(); }; } else if (keyType.IsEnum) { // Edit via enum picker var popup = RenamePopup.Show(Parent, Rectangle.Margin(Bounds, Margin), Text, false); var picker = new EnumComboBox(keyType) { AnchorPreset = AnchorPresets.StretchAll, Offsets = Margin.Zero, Parent = popup, EnumTypeValue = _key, }; picker.ValueChanged += () => { popup.Hide(); object newKey = picker.EnumTypeValue; if (!((IDictionary)_editor.Values[0]).Contains(newKey)) { _editor.ChangeKey(_key, newKey); _key = newKey; Text = _key.ToString(); } }; } else { // Generic editor var popup = ContextMenuBase.ShowEmptyMenu(Parent, Rectangle.Margin(Bounds, Margin)); var presenter = new CustomEditorPresenter(null); presenter.Panel.AnchorPreset = AnchorPresets.StretchAll; presenter.Panel.IsScrollable = false; presenter.Panel.Parent = popup; presenter.Select(_key); presenter.Modified += () => { popup.Hide(); object newKey = presenter.Selection[0]; _editor.ChangeKey(_key, newKey); _key = newKey; Text = _key?.ToString(); }; } } /// public override bool OnMouseDoubleClick(Float2 location, MouseButton button) { if (button == MouseButton.Left) { OnEditClicked(null); return true; } return base.OnMouseDoubleClick(location, button); } /// public override void OnDestroy() { _editor = null; _key = null; base.OnDestroy(); } } private IntValueBox _sizeBox; private Color _background; private int _elementsCount; private bool _readOnly; private bool _notNullItems; private bool _canEditKeys; private bool _keyEdited; private CollectionAttribute.DisplayType _displayType; /// /// Gets the length of the collection. /// public int Count => (Values[0] as IDictionary)?.Count ?? 0; /// public override void Initialize(LayoutElementsContainer layout) { // No support for different collections for now if (HasDifferentValues || HasDifferentTypes) return; var type = Values.Type; var size = Count; var argTypes = type.GetGenericArguments(); var keyType = argTypes[0]; var valueType = argTypes[1]; _canEditKeys = keyType == typeof(string) || keyType.IsPrimitive || keyType.IsEnum || keyType.IsValueType; _background = FlaxEngine.GUI.Style.Current.CollectionBackgroundColor; _readOnly = false; _notNullItems = false; _displayType = CollectionAttribute.DisplayType.Header; // Try get CollectionAttribute for collection editor meta var attributes = Values.GetAttributes(); Type overrideEditorType = null; float spacing = 0.0f; var collection = (CollectionAttribute)attributes?.FirstOrDefault(x => x is CollectionAttribute); if (collection != null) { _readOnly = collection.ReadOnly; _notNullItems = collection.NotNullItems; if (collection.BackgroundColor.HasValue) _background = collection.BackgroundColor.Value; overrideEditorType = TypeUtils.GetType(collection.OverrideEditorTypeName).Type; spacing = collection.Spacing; _displayType = collection.Display; } // 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 = _notNullItems ? size : 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 || !_canEditKeys) { _sizeBox.IsReadOnly = true; _sizeBox.Enabled = false; } else { _sizeBox.EditEnd += OnSizeChanged; } } // Elements if (size > 0) { var panel = layout.VerticalPanel(); panel.Panel.BackgroundColor = _background; var keysEnumerable = ((IDictionary)Values[0]).Keys.OfType(); var keys = keysEnumerable as object[] ?? keysEnumerable.ToArray(); var valuesType = new ScriptType(valueType); bool single = valuesType.IsPrimitive || valuesType.Equals(new ScriptType(typeof(string))) || valuesType.IsEnum || (valuesType.GetFields().Length == 1 && valuesType.GetProperties().Length == 0) || (valuesType.GetProperties().Length == 1 && valuesType.GetFields().Length == 0) || valuesType.Equals(new ScriptType(typeof(JsonAsset))) || valuesType.Equals(new ScriptType(typeof(SettingsBase))); // Use separate layout cells for each collection items to improve layout updates for them in separation var useSharedLayout = valueType.IsPrimitive || valueType.IsEnum; for (int i = 0; i < size; i++) { if (i > 0 && i < size && spacing > 0) { panel.Space(spacing); } var key = keys.ElementAt(i); var overrideEditor = overrideEditorType != null ? (CustomEditor)Activator.CreateInstance(overrideEditorType) : null; var property = panel.AddPropertyItem(new DictionaryItemLabel(this, key)); var itemLayout = useSharedLayout ? (LayoutElementsContainer)property : property.VerticalPanel(); itemLayout.Object(new DictionaryValueContainer(valuesType, key, Values), overrideEditor); } } _elementsCount = size; // Add/Remove buttons if (!_readOnly && _canEditKeys) { var area = layout.Space(20); var addButton = new Button(area.ContainerControl.Width - (16 + 16 + 2 + 2), 2, 16, 16) { Text = "+", TooltipText = "Add new item", AnchorPreset = AnchorPresets.TopRight, Parent = area.ContainerControl, Enabled = !_notNullItems, }; addButton.Clicked += () => { if (IsSetBlocked) return; Resize(Count + 1); }; var removeButton = new Button(addButton.Right + 2, addButton.Y, 16, 16) { Text = "-", TooltipText = "Remove last item", AnchorPreset = AnchorPresets.TopRight, Parent = area.ContainerControl, Enabled = size > 0, }; removeButton.Clicked += () => { if (IsSetBlocked) return; Resize(Count - 1); }; } } /// /// 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); } /// /// Removes the item of the specified key. It supports undo. /// /// The key of the item to remove. private void Remove(object key) { if (IsSetBlocked) return; // Allocate new collection var dictionary = Values[0] as IDictionary; var type = Values.Type; var newValues = (IDictionary)type.CreateInstance(); // Copy all keys/values except the specified one if (dictionary != null) { foreach (var e in dictionary.Keys) { if (Equals(e, key)) continue; newValues[e] = dictionary[e]; } } SetValue(newValues); } /// /// Changes the key of the item. /// /// The old key value. /// The new key value. protected void ChangeKey(object oldKey, object newKey) { var dictionary = (IDictionary)Values[0]; var newValues = (IDictionary)Values.Type.CreateInstance(); foreach (var e in dictionary.Keys) { if (Equals(e, oldKey)) newValues[newKey] = dictionary[e]; else newValues[e] = dictionary[e]; } SetValue(newValues); _keyEdited = true; // TODO: use custom UndoAction to rebuild UI after key modification } /// /// Resizes collection to the specified new size. /// /// The new size. protected void Resize(int newSize) { var dictionary = Values[0] as IDictionary; var oldSize = dictionary?.Count ?? 0; if (oldSize == newSize) return; // Allocate new collection var type = Values.Type; var argTypes = type.GetGenericArguments(); var keyType = argTypes[0]; var valueType = argTypes[1]; var newValues = (IDictionary)type.CreateInstance(); // Copy all keys/values int itemsLeft = newSize; if (dictionary != null) { foreach (var e in dictionary.Keys) { if (itemsLeft == 0) break; newValues[e] = dictionary[e]; itemsLeft--; } } // Insert new items (find unique keys) int newItemsLeft = newSize - oldSize; while (newItemsLeft-- > 0) { object newKey = null; if (keyType.IsPrimitive) { long uniqueKey = 0; bool isUnique; do { isUnique = true; foreach (var e in newValues.Keys) { var asLong = Convert.ToInt64(e); if (asLong.Equals(uniqueKey)) { uniqueKey++; isUnique = false; break; } } } while (!isUnique); newKey = Convert.ChangeType(uniqueKey, keyType); } else if (keyType.IsEnum) { var enumValues = Enum.GetValues(keyType); int uniqueKeyIndex = 0; bool isUnique; do { isUnique = true; foreach (var e in newValues.Keys) { if (Equals(e, enumValues.GetValue(uniqueKeyIndex))) { uniqueKeyIndex++; isUnique = false; break; } } } while (!isUnique && uniqueKeyIndex < enumValues.Length); newKey = enumValues.GetValue(uniqueKeyIndex); } else if (keyType == typeof(string)) { string uniqueKey = "Key"; bool isUnique; do { isUnique = true; foreach (var e in newValues.Keys) { if (string.Equals((string)e, uniqueKey, StringComparison.InvariantCulture)) { uniqueKey += "*"; isUnique = false; break; } } } while (!isUnique); newKey = uniqueKey; } else { newKey = TypeUtils.GetDefaultValue(new ScriptType(keyType)); } newValues[newKey] = TypeUtils.GetDefaultValue(new ScriptType(valueType)); } SetValue(newValues); } /// public override void Refresh() { if (_keyEdited) { _keyEdited = false; RebuildLayout(); RebuildParentCollection(); } base.Refresh(); // No support for different collections for now if (HasDifferentValues || HasDifferentTypes) return; // Check if collection has been resized (by UI or from external source) if (Count != _elementsCount) { RebuildLayout(); RebuildParentCollection(); } } } }