using UnityEngine.UIElements; using System.Collections.Generic; using System; using UnityEditor; using UnityEngine; using UnityEditor.UIElements; using Rive.Utils; namespace Rive.EditorTools { /// /// A visual element that allows the user to select from a list of choices or enter a custom value. /// internal class PopupOrTextField : VisualElement, INotifyValueChanged { private PopupField popupField; private TextField textField; private Button switchModeButton; private bool isCustomValue; private bool isUserEditing; private bool isProgrammaticChange; private SerializedProperty boundProperty; private SerializedObject serializedObject; private UnityEngine.Object targetObject; private string m_Value; public string value { get => m_Value; set { if (m_Value != value) { using (var changeEvent = ChangeEvent.GetPooled(m_Value, value)) { changeEvent.target = this; SetValueWithoutNotify(value); SendEvent(changeEvent); } } } } public List Choices { get => popupField.choices; set { popupField.choices = value; bool valueIsInChoices = value.Contains(m_Value); // Handle transition from Popup to Custom if (!isCustomValue && !valueIsInChoices) { isCustomValue = true; isProgrammaticChange = true; SetValueWithoutNotify(m_Value); isProgrammaticChange = false; } // Handle transition from Custom to Popup else if (valueIsInChoices && isCustomValue) { isCustomValue = false; isProgrammaticChange = true; SetValueWithoutNotify(m_Value); isProgrammaticChange = false; } UpdateVisibility(); } } public string Label { get => popupField.label; set { popupField.label = value; textField.label = value; } } public PopupOrTextField() : this(new List(), "") { } public PopupOrTextField(List choices, string currentValue, string labelText = null) { popupField = new PopupField(choices, 0); textField = new TextField(); switchModeButton = new Button(ToggleMode) { text = "✎", tooltip = "Switch to text input" }; SetupUI(); SetupCallbacks(); SetInitialState(currentValue); if (labelText != null) { Label = labelText; } RegisterCallback(OnSerializedPropertyChange); } private void SetupUI() { var container = new VisualElement(); container.style.flexDirection = FlexDirection.Row; container.style.width = new StyleLength(Length.Percent(100)); container.Add(popupField); container.Add(textField); container.Add(switchModeButton); SetupFieldStyles(popupField); SetupFieldStyles(textField); SetupButtonStyles(switchModeButton); textField.style.display = DisplayStyle.None; textField.visible = false; popupField.style.display = DisplayStyle.Flex; popupField.visible = true; Add(container); } private void SetupFieldStyles(VisualElement field) { // This keeps inspector positioned around the same point as other unity fields. Otherwise the popup fills the whole row, when it should stop in the middle. field.AddToClassList(BaseField.alignedFieldUssClassName); // Same as using "unity-base-field__aligned" in UXML field.style.flexGrow = 1; field.style.marginRight = 20; field.style.paddingBottom = 0; field.style.paddingTop = 0; field.style.paddingLeft = 0; field.style.marginBottom = 0; field.style.marginTop = 0; field.style.marginLeft = 0; } private void SetupButtonStyles(Button button) { button.style.position = Position.Absolute; button.style.right = 0; button.style.width = 20; button.style.height = popupField.style.height; button.style.marginRight = 0; button.style.marginLeft = 0; button.style.marginTop = 0; button.style.marginBottom = 0; button.style.paddingBottom = 0; button.style.paddingTop = 0; button.style.paddingLeft = 0; button.style.paddingRight = 0; } private void SetupCallbacks() { popupField.RegisterValueChangedCallback(evt => { if (isProgrammaticChange) return; // Ignore programmatic changes to prevent recursive calls value = evt.newValue; }); textField.RegisterCallback(evt => isUserEditing = true); textField.RegisterCallback(evt => { isUserEditing = false; UpdateVisualState(); }); textField.RegisterValueChangedCallback(evt => { if (isProgrammaticChange) return; value = evt.newValue; }); } private void SetInitialState(string initialValue) { m_Value = initialValue; isCustomValue = !Choices.Contains(initialValue); if (!isCustomValue) { popupField.SetValueWithoutNotify(initialValue); } else { textField.SetValueWithoutNotify(initialValue); } UpdateVisibility(); } private void ToggleMode() { if (targetObject != null) { Undo.RecordObject(targetObject, "Toggle PopupOrTextField Mode"); } var initialValue = m_Value; isCustomValue = !isCustomValue; if (isCustomValue) { textField.SetValueWithoutNotify(m_Value); } else { if (Choices.Contains(m_Value)) { popupField.SetValueWithoutNotify(m_Value); } else if (Choices.Count > 0) { SetValueWithoutNotify(Choices[0]); } else { popupField.SetValueWithoutNotify(string.Empty); } } UpdateVisibility(); if (targetObject != null) { EditorUtility.SetDirty(targetObject); } if (initialValue != m_Value) { using (var changeEvent = ChangeEvent.GetPooled(initialValue, m_Value)) { changeEvent.target = this; SendEvent(changeEvent); } } } private void UpdateVisualState() { bool valueInChoices = Choices.Contains(m_Value); if (valueInChoices) { if (!isCustomValue) { // We make sure the popupField reflects the current value popupField.SetValueWithoutNotify(m_Value); } if (isCustomValue && !isUserEditing) { isCustomValue = false; UpdateVisibility(); } } else { textField.SetValueWithoutNotify(m_Value); if (!isCustomValue) { isCustomValue = true; UpdateVisibility(); } } } private void UpdateVisibility() { if (isCustomValue) { popupField.style.display = DisplayStyle.None; popupField.visible = false; textField.style.display = DisplayStyle.Flex; textField.visible = true; switchModeButton.text = "▼"; switchModeButton.tooltip = "Switch to dropdown"; } else { textField.style.display = DisplayStyle.None; textField.visible = false; popupField.style.display = DisplayStyle.Flex; popupField.visible = true; switchModeButton.text = "✎"; switchModeButton.tooltip = "Switch to text input"; } this.MarkDirtyRepaint(); } public void SetValueWithoutNotify(string newValue) { if (serializedObject != null) { serializedObject.Update(); } m_Value = newValue; // Force check if value is in choices and update mode accordingly bool valueInChoices = Choices.Contains(newValue); if (valueInChoices && (!isUserEditing || isProgrammaticChange)) { isCustomValue = false; isProgrammaticChange = true; popupField.SetValueWithoutNotify(newValue); isProgrammaticChange = false; } else { if (!valueInChoices) { isCustomValue = true; } isProgrammaticChange = true; textField.SetValueWithoutNotify(newValue); isProgrammaticChange = false; } UpdateVisibility(); if (boundProperty != null) { boundProperty.stringValue = newValue; serializedObject.ApplyModifiedProperties(); } } private void OnSerializedPropertyChange(SerializedPropertyChangeEvent evt) { if (evt.changedProperty == boundProperty) { isProgrammaticChange = true; SetValueWithoutNotify(boundProperty.stringValue); isProgrammaticChange = false; } } public void BindProperty(SerializedProperty property) { UnbindProperty(); if (property != null && property.propertyType == SerializedPropertyType.String) { boundProperty = property; serializedObject = property.serializedObject; targetObject = serializedObject.targetObject; SetInitialState(property.stringValue); EditorApplication.update += UpdateFromSerializedProperty; } else { DebugLogger.Instance.LogError("PopupOrTextField: Attempted to bind to a null or non-string property."); } this.RegisterCallback(OnAttachToPanel); this.RegisterCallback(OnDetachFromPanel); } private void OnAttachToPanel(AttachToPanelEvent evt) { if (serializedObject != null && boundProperty != null) { serializedObject.Update(); SetInitialState(boundProperty.stringValue); } } private void OnDetachFromPanel(DetachFromPanelEvent evt) { UnbindProperty(); } private void UpdateFromSerializedProperty() { try { if (serializedObject == null || boundProperty == null) { UnbindProperty(); return; } // Check if the serializedObject is still valid if (serializedObject.targetObject == null) { UnbindProperty(); return; } serializedObject.Update(); // Double-check everything is still valid after the update if (boundProperty == null || boundProperty.serializedObject == null || boundProperty.serializedObject.targetObject == null) { UnbindProperty(); return; } if (boundProperty.propertyType == SerializedPropertyType.String) { string newValue = boundProperty.stringValue; if (m_Value != newValue && !isUserEditing) { SetValueWithoutNotify(newValue); } } else { DebugLogger.Instance.LogWarning($"PopupOrTextField: Bound property is not a string. Property path: {boundProperty.propertyPath}"); } } catch (Exception) { UnbindProperty(); } } private void UnbindProperty() { boundProperty = null; serializedObject = null; targetObject = null; EditorApplication.update -= UpdateFromSerializedProperty; } } }