using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Numerics; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using GlitchyEngine.Core; using GlitchyEngine.Extensions; using GlitchyEngine.Math; using ImGuiNET; namespace GlitchyEngine.Editor; class DidNotChange { } internal class EntityEditor { public static readonly DidNotChange DidNotChange = new DidNotChange(); private delegate object? ShowCustomEditorMethod(object? reference, Type fieldType, string fieldName); private static Dictionary _customEditors = new(); static EntityEditor() { foreach (Type type in TypeExtension.EnumerateAllTypes()) { if (type.TryGetCustomAttribute(out var attribute)) { MethodInfo? showEditorMethod = type.GetMethod("ShowEditor", BindingFlags.Static | BindingFlags.Public, null, new []{ typeof(object), typeof(Type), typeof(string) }, null); if (showEditorMethod == null) { Log.Error($"No ShowEditor-method found for type {type}"); } else { ShowCustomEditorMethod method = showEditorMethod.GetDelegate(); _customEditors.Add(attribute.Type, method); } } } } private static bool TryShowCustomEditor(object? reference, Type fieldType, string fieldName, out object? editedValue) { editedValue = DidNotChange; try { // Try to match the concrete type first (e.g. Foo -> Foo and Foo -> List) // Note: Foo wont match a serializer for Foo<> if (_customEditors.TryGetValue(fieldType, out ShowCustomEditorMethod customEditorMethod)) { editedValue = customEditorMethod(reference, fieldType, fieldName); return true; } if (fieldType.IsGenericType && _customEditors.TryGetValue(fieldType.GetGenericTypeDefinition(), out customEditorMethod)) { editedValue = customEditorMethod(reference, fieldType, fieldName); return true; } } catch (Exception e) { Log.Exception(e); } return false; } public static bool ShowFieldInEditor(FieldInfo fieldInfo) { if (fieldInfo.IsPublic) { // Public fields are visible by default (No HideInEditorAttribute) if (fieldInfo.HasCustomAttribute()) return false; return true; } // Private, protected and internal fields are hidden by default (No ShowInEditorAttribute) if (fieldInfo.HasCustomAttribute()) return true; return false; } public static void ShowDefaultEntityEditor(UUID entityId, Type entityType) { // TODO: Call custom editors here! ScriptGlue.Entity_GetScriptInstance(entityId, out object? instance); if (instance != null) ShowEditor(entityType, instance); } private static T? GetAttribute(IEnumerable? attributes) where T : Attribute { return (T?)attributes?.FirstOrDefault(a => a is T); } private static object ShowDecimalEditor(object? reference, Type fieldType, string fieldName, IEnumerable? attributes) { if (reference is not decimal value) return DidNotChange; string stringValue = value.ToString(CultureInfo.InvariantCulture); byte[] bytes = new byte[30 * 4]; Encoding.UTF8.GetBytes(stringValue, 0, stringValue.Length, bytes, 0); ImGui.PushID("Decimal"); if (ImGui.InputText(fieldName, bytes, (uint)bytes.Length, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CharsDecimal)) { string text = Encoding.UTF8.GetString(bytes); if (decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal newValue)) { ImGui.PopID(); return newValue; } } ImGui.PopID(); return DidNotChange; } private static object? ShowPrimitiveEditor(object? reference, Type fieldType, string fieldName, IEnumerable? attributes) { object newValue = DidNotChange; unsafe void DragScalar(ImGuiDataType dataType) where T : unmanaged { T ChangeTypeSafe(object value) { try { return (T)Convert.ChangeType(value, typeof(T)); } catch (OverflowException e) { // Log.Error($"Could not apply value: {e.Message}"); ImGui.TextColored(new Vector4(1, 0, 0, 1), $"Value {value} can not be converted to type {typeof(T)}: {e.Message}."); return default; } } RangeAttribute? range = GetAttribute(attributes); // Get Min and Max Values from Type T T min = ReadStaticField("MinValue"); T max = ReadStaticField("MaxValue"); float dragSpeed = 1.0f; // RangeAttribute takes precedence over Minimum- and Maximum-Attribute if (range != null) { min = ChangeTypeSafe(range.Min); max = ChangeTypeSafe(range.Max); dragSpeed = range.Speed; } else { MinimumAttribute? minimum = GetAttribute(attributes); MaximumAttribute? maximum = GetAttribute(attributes); // Don't use range, if it is invalid if (minimum != null && maximum != null && minimum.Min > maximum.Max) { //Log.Error($"Minimum value ({minimum.Min}) specified for field {fieldName} must not be greater than the maximum value ({maximum.Max})! Ignoring range..."); ImGui.TextColored(new Vector4(1, 0, 0, 1), $"Minimum value ({minimum.Min}) specified for field {fieldName} must not be greater than the maximum value ({maximum.Max})! Ignoring range..."); } else { if (minimum != null) min = ChangeTypeSafe(minimum.Min); if (maximum != null) max = ChangeTypeSafe(maximum.Max); } } var value = (T)reference; if (range?.Slider == true) { if (ImGui.SliderScalar(fieldName, dataType, (IntPtr)(&value), (IntPtr)(&min), (IntPtr)(&max))) { newValue = value; } } else { if (ImGui.DragScalar(fieldName, dataType, (IntPtr)(&value), dragSpeed, (IntPtr)(&min), (IntPtr)(&max))) { newValue = value; } } } if (reference != null) { if (fieldType == typeof(bool)) { bool value = (bool)reference; if (ImGui.Checkbox(fieldName, ref value)) newValue = value; } else if (fieldType == typeof(char)) { unsafe { // TODO: Allow escaped unicode chars \u XXXX // TODO: Add text input validation, so that it isn't possible to type invalid chars char* value = stackalloc char[4]; switch((char)reference) { case '\a': value[0] = '\\'; value[1] = 'a'; break; case '\b': value[0] = '\\'; value[1] = 'b'; break; case '\f': value[0] = '\\'; value[1] = 'f'; break; case '\n': value[0] = '\\'; value[1] = 'n'; break; case '\r': value[0] = '\\'; value[1] = 'r'; break; case '\t': value[0] = '\\'; value[1] = 't'; break; case '\v': value[0] = '\\'; value[1] = 'v'; break; default: value[0] = (char)reference; value[1] = '\0'; break; }; // Enough space to store two code points byte* buffer = stackalloc byte[8]; int encodedBytes = Encoding.UTF8.GetBytes(value, 2, buffer, 8); // Place string delimiter after encoded UTF8 sequence. buffer[encodedBytes] = (byte)'\0'; if (ImGui.InputText(fieldName, (IntPtr)buffer, 8)) { if (buffer[0] == '\\' && buffer[1] != '\0') { newValue = (char)buffer[1] switch { 'a' => newValue = '\a', 'b' => newValue = '\b', 'f' => newValue = '\f', 'n' => newValue = '\n', 'r' => newValue = '\r', 't' => newValue = '\t', 'v' => newValue = '\v', _ => newValue = '\\' }; } else { // Only decode first codepoint Encoding.UTF8.GetChars(buffer, 4, value, 4); newValue = value[0]; } } } } else if (fieldType == typeof(byte)) DragScalar(ImGuiDataType.U8); else if (fieldType == typeof(sbyte)) DragScalar(ImGuiDataType.S8); else if (fieldType == typeof(short)) DragScalar(ImGuiDataType.S16); else if (fieldType == typeof(ushort)) DragScalar(ImGuiDataType.U16); else if (fieldType == typeof(int)) DragScalar(ImGuiDataType.S32); else if (fieldType == typeof(uint)) DragScalar(ImGuiDataType.U32); else if (fieldType == typeof(long)) DragScalar(ImGuiDataType.S64); else if (fieldType == typeof(ulong)) DragScalar(ImGuiDataType.U64); else if (fieldType == typeof(float)) DragScalar(ImGuiDataType.Float); else if (fieldType == typeof(double)) DragScalar(ImGuiDataType.Double); else { ImGui.TextColored(new Vector4(1, 0, 0, 1), $"{fieldName}: Type {fieldType} is not implemented."); } } else { return ActivatorExtension.CreateInstanceSafe(fieldType); } return newValue; } private static object ShowEnumEditor(object? reference, Type fieldType, string fieldName) { object newValue = DidNotChange; if (ImGui.BeginCombo(fieldName, reference?.ToString())) { foreach (object enumValue in Enum.GetValues(fieldType)) { if (ImGui.Selectable(enumValue.ToString(), enumValue == reference)) { newValue = enumValue; } } ImGui.EndCombo(); } return newValue; } public static object? ShowFieldEditor(object? reference, Type fieldType, string fieldName, IEnumerable? attributes = null) { ReadonlyAttribute? readonlyAttribute = GetAttribute(attributes); ImGui.BeginDisabled(readonlyAttribute != null); object? newValue; if (TryShowCustomEditor(reference, fieldType, fieldName, out newValue)) { } else if (fieldType.IsGenericType) { if (fieldType.GetGenericTypeDefinition() == typeof(List<>)) { newValue = ShowListEditor(fieldType, reference, fieldName); } else { // TODO: Try to use a custom editor ImGui.TextColored(new Vector4(1, 0, 0, 1), $"{fieldName}: Type {fieldType} is not implemented."); } } else if (fieldType.IsArray) { newValue = ShowArrayEditor(fieldType, reference, fieldName); } else if (fieldType.IsEnum) { newValue = ShowEnumEditor(reference, fieldType, fieldName); } else if (fieldType.IsPrimitive) { newValue = ShowPrimitiveEditor(reference, fieldType, fieldName, attributes); } else if (fieldType.IsValueType) { if (fieldType == typeof(decimal)) { newValue = ShowDecimalEditor(reference, fieldType, fieldName, attributes); } else if (fieldType == typeof(bool2)) { bool2 value = (bool2)reference!; ImGui.TextUnformatted(fieldName); ImGui.SameLine(); if (ImGui.Checkbox($"##{fieldName}X", ref value.X)) newValue = value; ImGui.SameLine(); if (ImGui.Checkbox($"##{fieldName}Y", ref value.Y)) newValue = value; } else { ImGui.EndDisabled(); // Struct if (ImGui.TreeNode(fieldName)) { ImGui.BeginDisabled(readonlyAttribute != null); ShowEditor(fieldType, reference); ImGui.TreePop(); newValue = reference; } else { ImGui.BeginDisabled(readonlyAttribute != null); } } } else if (fieldType.IsSubclassOf(typeof(Entity)) || fieldType == typeof(Entity)) { newValue = ShowEntityDropTarget(fieldName, fieldType, reference); } else if (fieldType.IsSubclassOf(typeof(Component))) { newValue = ShowComponentDropTarget(fieldName, fieldType, reference); } else if (fieldType == typeof(string)) { newValue = ShowStringEditor(reference, fieldType, fieldName, attributes); } else { ImGui.EndDisabled(); if (ImGui.TreeNode(fieldName)) { ImGui.BeginDisabled(readonlyAttribute != null); if (reference == null) { ImGui.SameLine(ImGui.GetWindowContentRegionMax().X - ImGui.CalcTextSize("Create").X - 2 * ImGui.GetStyle().FramePadding.X); if (ImGui.BeginCombo("Create", "Create", ImGuiComboFlags.HeightSmall | ImGuiComboFlags.NoArrowButton)) { foreach (Type t in TypeExtension.FindDerivedTypes(fieldType)) { if (ImGui.Selectable(t.Name)) { newValue = ActivatorExtension.CreateInstanceSafe(t); } } ImGui.EndCombo(); } } else { ImGui.SameLine(ImGui.GetWindowContentRegionMax().X - ImGui.CalcTextSize("Remove").X - 2 * ImGui.GetStyle().FramePadding.X); if (ImGui.SmallButton("Remove")) { newValue = null; } } if (reference == null) ImGui.Text("(NULL)"); else ShowEditor(reference.GetType(), reference); ImGui.TreePop(); } else { ImGui.BeginDisabled(readonlyAttribute != null); } } ImGui.EndDisabled(); return newValue; } private static object? ShowComponentDropTarget(string fieldName, Type fieldType, object? currentValue) { object? newValue = DidNotChange; ImGui.Text($"{fieldName}: "); ImGui.SameLine(); Component? component = currentValue as Component; string entityName = "None"; if (component != null) { entityName = component.Entity.Name; } Type? actualType = component?.GetType(); entityName += $" ({(actualType ?? fieldType).Name})"; if (ImGui.Button(entityName)) { if (component?.Entity != null) { // TODO: Hightlight Entity in hierarchy // TODO: Highlight Component in editor } } if (ImGui.BeginDragDropTarget()) { ImGuiPayloadPtr peekPayload = ImGui.AcceptDragDropPayload("ENTITY", ImGuiDragDropFlags.AcceptPeekOnly); bool allowDrop = false; unsafe { if (peekPayload.NativePtr != null) { UUID draggedId = *(UUID*)peekPayload.Data; Entity draggedEntity = new Entity(draggedId); allowDrop = draggedEntity.HasComponent(fieldType); } if (allowDrop) { ImGuiPayloadPtr payload = ImGui.AcceptDragDropPayload("ENTITY"); if (payload.NativePtr != null) { UUID droppedId = *(UUID*)payload.Data; Entity draggedEntity = new Entity(droppedId); try { newValue = draggedEntity.GetComponent(fieldType); } catch (Exception e) { Log.Exception(e); } } } } ImGui.EndDragDropTarget(); } return newValue; } private static object? ShowEntityDropTarget(string fieldName, Type fieldType, object? currentValue) { object? newValue = DidNotChange; ImGui.Text($"{fieldName}: "); ImGui.SameLine(); string entityName = "None"; Entity? entity = currentValue as Entity; if (entity != null) { entityName = entity.Name; } Type? actualType = entity?.GetType(); entityName += $" ({(actualType ?? fieldType).Name})"; if (ImGui.Button(entityName)) { if (entity != null) { // TODO: Hightlight Entity in hierarchy } } if (ImGui.BeginDragDropTarget()) { ImGuiPayloadPtr peekPayload = ImGui.AcceptDragDropPayload("ENTITY", ImGuiDragDropFlags.AcceptPeekOnly); bool allowDrop = false; unsafe { if (peekPayload.NativePtr != null) { UUID draggedId = *(UUID*)peekPayload.Data; Entity draggedEntity = new Entity(draggedId); // TODO: Check if entity is valid? if (fieldType == typeof(Entity)) { allowDrop = true; } else { Entity? scriptInstance = Entity.GetScriptReference(draggedId, typeof(Entity)); allowDrop = fieldType.IsInstanceOfType(scriptInstance); } } if (allowDrop) { ImGuiPayloadPtr payload = ImGui.AcceptDragDropPayload("ENTITY"); if (payload.NativePtr != null) { UUID droppedId = *(UUID*)payload.Data; if (fieldType == typeof(Entity)) { newValue = new Entity(droppedId); } else { newValue = Entity.GetScriptReference(droppedId, typeof(Entity)); } } } } ImGui.EndDragDropTarget(); } ImGui.SameLine(); if (ImGui.Button("...")) { // TODO: Show entity selector } ImGuiExtension.AttachTooltip("Search entity..."); return newValue; } private static object ShowStringEditor(object? reference, Type fieldType, string fieldName, IEnumerable? attributes) { object newValue = DidNotChange; TextFieldAttribute? textField = GetAttribute(attributes); string value = reference as string ?? $"{float.MinValue}"; if (textField?.Multiline == true) { ImGui.Text(fieldName); if (ImGui.InputTextMultiline($"##{fieldName}", ref value, 1000, new Vector2(-1.0f, ImGui.GetTextLineHeight() * textField.TextFieldLines))) newValue = value; } else { if (ImGui.InputText(fieldName, ref value, 1000)) newValue = value; } return newValue; } public static void ShowEditor(Type type, object? reference) { int i = 0; // Iterate all fields foreach (FieldInfo field in type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { i++; ImGui.PushID(i); if (ShowFieldInEditor(field)) { object value = field.GetValue(reference); IEnumerable attributes = field.GetCustomAttributes(); object? newValue = ShowFieldEditor(value, value?.GetType() ?? field.FieldType, field.Name, attributes); if (newValue != DidNotChange) { // Reference was changed inside Field Editor -> write back to field (only necessary for value types) field.SetValue(reference, newValue); } } ImGui.PopID(); } ShowButtons(type, reference); } /// /// Shows all buttons in the UI. /// private static void ShowButtons(Type type, object? reference) { ImGui.PushID("Buttons"); int i = 0; // Iterate all methods foreach (MethodInfo method in type.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)) { var showButton = method.GetCustomAttribute(); if (showButton == null) continue; if (Application.IsInEditMode && !showButton.Visibility.HasFlag(ButtonVisibility.InEditMode)) continue; if (Application.IsInPlayMode && !showButton.Visibility.HasFlag(ButtonVisibility.InPlayMode)) continue; i++; ImGui.PushID(i); if (method.GetParameters().Length > 0) { Log.Error($"Method {method.Name} cannot be executed from editor, because it expects arguments."); } else if (ImGui.Button(showButton.ButtonText)) { try { method.Invoke(reference, null); } catch (TargetInvocationException ex) { Log.Exception(ex.InnerException ?? ex); } catch (Exception ex) { Log.Exception(ex); } } ImGui.PopID(); } ImGui.PopID(); } struct ListPayload { public IList List; public int Element; } private static object? ShowListEditor(Type fieldType, object? fieldValue, string fieldName) { Type elementType = fieldType.GenericTypeArguments[0]; void AddElement(IList? list, out object? newList) { newList = DidNotChange; if (list == null) { newList = Activator.CreateInstance(fieldType); list = newList as IList; Debug.Assert(list != null); } try { // Reference values can be null object? newElement = elementType.IsByRef ? null : ActivatorExtension.CreateInstanceSafe(elementType); list?.Add(newElement); } catch (Exception ex) { Log.Error($"Failed to add new element to list: {ex}"); } } void RemoveElement(IList? list, out object? newList) { newList = DidNotChange; if (list == null) return; if (list.Count == 0) { newList = null; } else { list.RemoveAt(list.Count - 1); } } return ShowGenericListEditor(fieldType, elementType, (IEnumerable?)fieldValue, fieldName, AddElement, RemoveElement); } private static object? ShowArrayEditor(Type fieldType, object? fieldValue, string fieldName) { Debug.Assert(fieldType.IsArray); Type? elementType = fieldType.GetElementType(); Debug.Assert(elementType != null); Array? myArray = fieldValue as Array; if (myArray?.Rank > 1) { throw new NotImplementedException("Multidimensional arrays are not yet supported."); } void AddElement(IList? list, out object? newList) { newList = DidNotChange; int newIndex = 0; if (list == null) { newList = Array.CreateInstance(elementType, 1); } else { newIndex = list.Count; Array newArray = Array.CreateInstance(elementType, list.Count + 1); Array.Copy((Array)list, newArray, list.Count); newList = newArray; } object newElement = Activator.CreateInstance(elementType); ((IList)newList)[newIndex] = newElement; } void RemoveElement(IList? list, out object? newList) { newList = DidNotChange; if (list == null) return; if (list.Count == 0) { newList = null; } else { Array newArray = Array.CreateInstance(elementType, list.Count - 1); Array.Copy((Array)list, newArray, newArray.Length); newList = newArray; } } return ShowGenericListEditor(fieldType, elementType!, (IEnumerable?)fieldValue, fieldName, AddElement, RemoveElement); } public delegate void ModifyList(IList? currentList, out object? oldList); private static object? ShowGenericListEditor(Type listType, Type elementType, IEnumerable? list, string fieldName, ModifyList addElement, ModifyList removeElement) { object? newList = DidNotChange; IList? myList = list as IList; // The buttons should always be visible -> save whether the node is open // If we have no instance, the user shouldn't be able to open the list bool listOpen = ImGui.TreeNodeEx(fieldName, ImGuiTreeNodeFlags.AllowOverlap | ImGuiTreeNodeFlags.SpanFullWidth | (myList == null ? ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen : ImGuiTreeNodeFlags.Framed)); if (myList == null) listOpen = false; var addButtonWidth = ImGui.CalcTextSize("+").X + 2 * ImGui.GetStyle().FramePadding.X; var removeButtonWidth = ImGui.CalcTextSize("-").X + 2 * ImGui.GetStyle().FramePadding.X; ImGui.SameLine(ImGui.GetWindowContentRegionMax().X - addButtonWidth - ImGui.GetStyle().FramePadding.X - removeButtonWidth); if (ImGui.SmallButton("+")) { addElement(myList, out newList); } ImGuiExtension.AttachTooltip("Add a new Element at the end of the list."); ImGui.SameLine(ImGui.GetWindowContentRegionMax().X - removeButtonWidth); ImGui.BeginDisabled(myList == null); if (ImGui.SmallButton("-")) { removeElement(myList, out newList); } ImGuiExtension.AttachTooltip("Remove the last Element from the list."); ImGui.EndDisabled(); if (listOpen) { Debug.Assert(myList != null, "Opened tree node even though it should have been a leaf!"); // Returns true if something was dropped into the droptarget bool DropTarget(int insertIndex, string tooltip) { bool dropped = false; if (ImGui.BeginDragDropTarget()) { ImGui.SetTooltip(tooltip); var payloadPtr = ImGui.AcceptDragDropPayload("SCRIPT_EDITOR_LIST_ELEMENT"); unsafe { if (payloadPtr.NativePtr != null) { ref ListPayload payload = ref Unsafe.AsRef((void*)payloadPtr.Data); // Make sure the index is still correct if (payload.Element < myList!.Count) { object item = myList[payload.Element]; myList.RemoveAt(payload.Element); if (payload.Element >= insertIndex) myList.Insert(insertIndex, item); else // If the original index is before the insert index, the insert index will be moved after removing the element // thus we have to subtract one. myList.Insert(insertIndex - 1, item); dropped = true; } } } ImGui.EndDragDropTarget(); } return dropped; } // Drop at index 0 ImGui.Separator(); DropTarget(0, "Drop before Element 0."); bool orderChanged = false; for (int i = 0, id = 0; i < myList!.Count; i++, id++) { ImGui.PushID(id); object element = myList[i]; // A Bullet point to grab the element ImGui.Bullet(); if (ImGui.BeginDragDropSource(ImGuiDragDropFlags.SourceAllowNullID)) { unsafe { ListPayload payload = new() { List = myList, Element = i }; ImGui.SetDragDropPayload("SCRIPT_EDITOR_LIST_ELEMENT", (IntPtr)Unsafe.AsPointer(ref payload), (uint)Unsafe.SizeOf()); } ImGui.SetTooltip($"Move Element {i}"); ImGui.EndDragDropSource(); } orderChanged |= DropTarget(i, $"Drop before Element {i}."); if (ImGui.BeginPopupContextWindow($"ListElementContext{i}")) { if (ImGui.MenuItem($"Remove Element {i}")) { myList.RemoveAt(i); i--; ImGui.EndPopup(); ImGui.PopID(); continue; } ImGui.EndPopup(); } ImGui.SameLine(); object? newValue = ShowFieldEditor(element, element?.GetType() ?? elementType, $"Element {i}"); // Don't apply changes, when the order changed if (newValue != DidNotChange && !orderChanged) { myList[i] = newValue; } // Drop after current element ImGui.Separator(); orderChanged |= DropTarget(i + 1, $"Drop after Element {i}."); ImGui.PopID(); } ImGui.TreePop(); } return newList; } private static T ReadStaticField(string name) { FieldInfo? field = typeof(T).GetField(name, BindingFlags.Public | BindingFlags.Static); if (field == null) { throw new InvalidOperationException($"Type {typeof(T).Name} has no static field \"{name}\""); } return (T)field.GetValue(null); } }