// Copyright (c) 2012-2023 Wojciech Figat. All rights reserved. using System; using System.Collections; using System.Collections.Generic; using System.Linq; using FlaxEditor.CustomEditors.Elements; using FlaxEditor.CustomEditors.GUI; using FlaxEditor.GUI; using FlaxEditor.GUI.ContextMenu; using FlaxEditor.Scripting; using FlaxEngine; using FlaxEngine.GUI; using FlaxEngine.Json; 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) { 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) { 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) { 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 { throw new NotImplementedException("Missing editing for dictionary key type " + keyType); } } /// 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 IntegerValueElement _size; private Color _background; private int _elementsCount; private bool _readOnly; private bool _notNullItems; private bool _canEditKeys; private bool _keyEdited; /// /// 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; _background = FlaxEngine.GUI.Style.Current.CollectionBackgroundColor; _readOnly = false; _notNullItems = false; // 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; } // Size if (_readOnly || !_canEditKeys) { layout.Label("Size", size.ToString()); } else { _size = layout.IntegerValue("Size"); _size.IntValue.MinValue = 0; _size.IntValue.MaxValue = _notNullItems ? size : ushort.MaxValue; _size.IntValue.Value = size; _size.IntValue.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); // 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 && spacing > 0f) { if (panel.Children.Count > 0 && panel.Children[panel.Children.Count - 1] is PropertiesListElement propertiesListElement) { if (propertiesListElement.Labels.Count > 0) { var label = propertiesListElement.Labels[propertiesListElement.Labels.Count - 1]; var margin = label.Margin; margin.Bottom += spacing; label.Margin = margin; } propertiesListElement.Space(spacing); } else { 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(_size.IntValue.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) { 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); newValues[Convert.ChangeType(uniqueKey, keyType)] = TypeUtils.GetDefaultValue(new ScriptType(valueType)); } 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); newValues[enumValues.GetValue(uniqueKeyIndex)] = TypeUtils.GetDefaultValue(new ScriptType(valueType)); } 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); newValues[uniqueKey] = TypeUtils.GetDefaultValue(new ScriptType(valueType)); } else { throw new InvalidOperationException(); } } 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(); } } } }