// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers.Base { #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using UnityEngine.UIElements; using WallstopStudios.UnityHelpers.Editor.Core.Helper; using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils; using WallstopStudios.UnityHelpers.Editor.Settings; using WallstopStudios.UnityHelpers.Editor.Styles; using WallstopStudios.UnityHelpers.Utils; /// /// Base class for UI Toolkit inline dropdown selectors with search, pagination, and autocomplete. /// Subclasses must implement type-specific methods for display, selection, and matching logic. /// /// The type of the field value (string, int, etc.). public abstract class WDropDownSelectorBase : BaseField { private const float ButtonWidth = 24f; private const float PaginationButtonHeight = 20f; private const float DropDownBottomPadding = 6f; private const float NoResultsVerticalPadding = 6f; private const float NoResultsHorizontalPadding = 6f; private readonly VisualElement _searchRow; private readonly TextField _searchField; private readonly Button _clearButton; private readonly VisualElement _paginationContainer; private readonly Button _previousButton; private readonly Label _pageLabel; private readonly Button _nextButton; private readonly DropdownField _dropdown; private readonly Label _noResultsLabel; private readonly Label _suggestionHintLabel; private List _filteredIndices; private PooledResource> _filteredIndicesLease; private List _pageOptionIndices; private PooledResource> _pageOptionIndicesLease; private List _pageChoices; private PooledResource> _pageChoicesLease; private SerializedObject _boundObject; private string _propertyPath = string.Empty; private string _searchText = string.Empty; private string _suggestion = string.Empty; private int _pageIndex; private int _lastResolvedPageSize; private bool _searchVisible; private int _suggestionOptionIndex; private int _currentFilteredCount; private bool _buffersInitialized; /// /// Gets the total number of options available. /// protected abstract int OptionCount { get; } /// /// Gets the display label for the option at the specified index. /// /// The index of the option. /// The display label string. protected abstract string GetDisplayLabel(int optionIndex); /// /// Gets the normalized display label for the option at the specified index. /// Applies a fallback of "(Option N)" when the raw label is null or empty, /// ensuring consistent behavior across rendering, search, and suggestion logic. /// /// The index of the option. /// The normalized display label, never null or empty. protected string GetNormalizedDisplayLabel(int optionIndex) { string label = GetDisplayLabel(optionIndex); if (string.IsNullOrEmpty(label)) { return DropDownShared.GetFallbackOptionLabel(optionIndex); } return label; } /// /// Gets the tooltip for the option at the specified index. /// Return null or empty string if no tooltip is needed. /// /// The index of the option. /// The tooltip string, or null/empty for no tooltip. protected virtual string GetTooltip(int optionIndex) => string.Empty; /// /// Gets the index of the currently selected option from the property. /// Returns -1 if no valid selection. /// /// The serialized property. /// The selected option index, or -1 if none. protected abstract int GetCurrentSelectionIndex(SerializedProperty property); /// /// Applies the selection at the specified option index to the property. /// /// The serialized property. /// The index of the option to apply. protected abstract void ApplySelectionToProperty( SerializedProperty property, int optionIndex ); /// /// Gets the value to set via SetValueWithoutNotify after selection. /// /// The index of the selected option. /// The value to set on the field. protected abstract TValue GetValueForOption(int optionIndex); /// /// Gets the default value when no selection is available. /// /// The default value. protected abstract TValue GetDefaultValue(); /// /// Checks if the option at the specified index matches the search term. /// Default implementation performs case-insensitive prefix match on the display label. /// /// The index of the option. /// The search term to match. /// True if the option matches the search. protected virtual bool MatchesSearch(int optionIndex, string searchTerm) { string label = GetNormalizedDisplayLabel(optionIndex); return label.StartsWith(searchTerm, StringComparison.OrdinalIgnoreCase); } /// /// Gets the undo action name for selection changes. /// protected virtual string UndoActionName => "Change DropDown Selection"; private static VisualElement CreateInputElement(out VisualElement element) { element = new VisualElement(); return element; } protected WDropDownSelectorBase() : base(string.Empty, CreateInputElement(out VisualElement baseInput)) { EnsureBuffers(); RegisterCallback(OnAttachedToPanel); RegisterCallback(OnDetachedFromPanel); _lastResolvedPageSize = Mathf.Max(1, UnityHelpersSettings.GetStringInListPageLimit()); _suggestionOptionIndex = -1; WDropDownStyleLoader.ApplyStyles(this); AddToClassList("unity-base-field"); AddToClassList("unity-base-field__aligned"); labelElement.AddToClassList("unity-base-field__label"); labelElement.AddToClassList("unity-label"); baseInput.AddToClassList("unity-base-field__input"); baseInput.style.flexGrow = 1f; baseInput.style.marginLeft = 0f; baseInput.style.paddingLeft = 0f; baseInput.style.flexDirection = FlexDirection.Column; _searchRow = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 4f, marginLeft = 0f, paddingLeft = 0f, }, }; _searchRow.AddToClassList(WDropDownStyleLoader.ClassNames.SearchContainer); VisualElement searchWrapper = new() { style = { flexGrow = 1f, position = Position.Relative }, }; searchWrapper.AddToClassList(WDropDownStyleLoader.ClassNames.SearchWrapper); _searchField = new TextField { name = "DropDownSearch", style = { flexGrow = 1f } }; _searchField.AddToClassList(WDropDownStyleLoader.ClassNames.Search); _searchField.RegisterValueChangedCallback(OnSearchChanged); _searchField.RegisterCallback(OnSearchKeyDown); searchWrapper.Add(_searchField); _clearButton = new Button(OnClearClicked) { text = "Clear", style = { marginLeft = 4f }, }; _clearButton.AddToClassList(WDropDownStyleLoader.ClassNames.ClearButton); _clearButton.SetEnabled(false); _paginationContainer = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginLeft = 4f, display = DisplayStyle.None, }, }; _paginationContainer.AddToClassList(WDropDownStyleLoader.ClassNames.Pagination); _previousButton = new Button(OnPreviousPage) { text = "<", style = { marginRight = 0f, minWidth = ButtonWidth, height = PaginationButtonHeight, paddingLeft = 6f, paddingRight = 6f, }, }; _previousButton.AddToClassList("unity-toolbar-button"); _previousButton.AddToClassList(WDropDownStyleLoader.ClassNames.PaginationButton); _pageLabel = new Label { style = { minWidth = 80f, unityTextAlign = TextAnchor.MiddleCenter, marginRight = 0f, paddingLeft = 6f, paddingRight = 6f, minHeight = PaginationButtonHeight, alignSelf = Align.Center, }, }; _pageLabel.AddToClassList(WDropDownStyleLoader.ClassNames.PaginationLabel); _nextButton = new Button(OnNextPage) { text = ">", style = { minWidth = ButtonWidth, height = PaginationButtonHeight, paddingLeft = 6f, paddingRight = 6f, }, }; _nextButton.AddToClassList("unity-toolbar-button"); _nextButton.AddToClassList(WDropDownStyleLoader.ClassNames.PaginationButton); _paginationContainer.Add(_previousButton); _paginationContainer.Add(_pageLabel); _paginationContainer.Add(_nextButton); _searchRow.Add(searchWrapper); _searchRow.Add(_clearButton); _searchRow.Add(_paginationContainer); baseInput.Add(_searchRow); _suggestionHintLabel = new Label { style = { display = DisplayStyle.None, marginLeft = 4f, marginBottom = 2f, color = new Color(0.7f, 0.85f, 1f, 0.75f), unityFontStyleAndWeight = FontStyle.Italic, fontSize = 11f, }, pickingMode = PickingMode.Ignore, }; _suggestionHintLabel.AddToClassList(WDropDownStyleLoader.ClassNames.Suggestion); baseInput.Add(_suggestionHintLabel); _dropdown = new DropdownField { choices = _pageChoices, style = { flexGrow = 1f, marginLeft = 0f, paddingLeft = 0f, marginBottom = DropDownBottomPadding, }, label = string.Empty, }; _dropdown.labelElement.style.display = DisplayStyle.None; _dropdown.RegisterValueChangedCallback(OnDropDownValueChanged); baseInput.Add(_dropdown); _noResultsLabel = new Label("No results match the current search.") { style = { display = DisplayStyle.None, marginLeft = 0f, marginTop = 4f, paddingTop = NoResultsVerticalPadding, paddingBottom = NoResultsVerticalPadding, paddingLeft = NoResultsHorizontalPadding, paddingRight = NoResultsHorizontalPadding, unityTextAlign = TextAnchor.MiddleCenter, }, }; _noResultsLabel.AddToClassList("unity-help-box"); _noResultsLabel.AddToClassList(WDropDownStyleLoader.ClassNames.NoResults); baseInput.Add(_noResultsLabel); // Note: Search visibility is initialized by derived classes calling InitializeSearchVisibility() // after their options are set, since OptionCount is accessed during initialization. RegisterCallback(_ => Undo.undoRedoPerformed += OnUndoRedo); RegisterCallback(_ => Undo.undoRedoPerformed -= OnUndoRedo); } /// /// Initializes search visibility based on option count. Must be called by derived classes /// after their options have been set, since OptionCount is accessed during this call. /// protected void InitializeSearchVisibility() { ApplySearchVisibility(ShouldShowSearch(_lastResolvedPageSize)); } /// /// Binds this selector to a serialized property. /// /// The property to bind. /// The label text to display. public virtual void BindProperty(SerializedProperty property, string labelText) { _boundObject = property.serializedObject; _propertyPath = property.propertyPath; UpdateLabel(labelText, property.tooltip); _pageIndex = 0; _searchText = string.Empty; _suggestion = string.Empty; _searchField.SetValueWithoutNotify(string.Empty); UpdateClearButton(_searchVisible); UpdateSuggestionDisplay(string.Empty, -1, -1); UpdateFromProperty(); } /// /// Unbinds this selector from any property. /// public void UnbindProperty() { _boundObject = null; _propertyPath = string.Empty; UpdateLabel(string.Empty, string.Empty); } private void UpdateLabel(string labelText, string labelTooltip) { bool hasLabel = !string.IsNullOrWhiteSpace(labelText); label = hasLabel ? labelText : string.Empty; labelElement.style.display = hasLabel ? DisplayStyle.Flex : DisplayStyle.None; labelElement.tooltip = labelTooltip; _dropdown.tooltip = labelTooltip; } private void OnUndoRedo() { UpdateFromProperty(); } private void OnSearchChanged(ChangeEvent evt) { if (!_searchVisible) { return; } _searchText = evt.newValue ?? string.Empty; _pageIndex = 0; UpdateClearButton(_searchVisible); UpdateFromProperty(); } private void OnSearchKeyDown(KeyDownEvent evt) { if (!_searchVisible) { return; } if ( ( evt.keyCode == KeyCode.Tab || evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter ) && !string.IsNullOrEmpty(_suggestion) ) { evt.PreventDefault(); evt.StopPropagation(); evt.StopImmediatePropagation(); bool commitSelection = evt.keyCode == KeyCode.Tab && !evt.shiftKey; AcceptSuggestion(commitSelection); } } private void OnClearClicked() { if (!_searchVisible || string.IsNullOrEmpty(_searchText)) { return; } _searchText = string.Empty; _searchField.SetValueWithoutNotify(string.Empty); _pageIndex = 0; UpdateClearButton(_searchVisible); UpdateSuggestionDisplay(string.Empty, -1, -1); UpdateFromProperty(); } private void OnPreviousPage() { if (_pageIndex <= 0) { return; } _pageIndex--; UpdateFromProperty(); } private void OnNextPage() { int pageSize = ResolvePageSize(); int pageCount = CalculatePageCount(pageSize, _currentFilteredCount); if (_pageIndex >= pageCount - 1) { return; } _pageIndex++; UpdateFromProperty(); } private void OnDropDownValueChanged(ChangeEvent evt) { string newValue = evt.newValue; if (string.IsNullOrEmpty(newValue)) { return; } int optionIndex = ResolveOptionIndex(newValue); if (optionIndex < 0) { return; } ApplySelection(optionIndex); } private void UpdateFromProperty() { EnsureBuffers(); if (_boundObject == null || string.IsNullOrEmpty(_propertyPath)) { return; } _boundObject.Update(); SerializedProperty property = _boundObject.FindProperty(_propertyPath); if (property == null) { return; } int pageSize = ResolvePageSize(); bool searchActive = ShouldShowSearch(pageSize); ApplySearchVisibility(searchActive); int selectedOptionIndex = GetCurrentSelectionIndex(property); _filteredIndices.Clear(); string effectiveSearch = searchActive ? (_searchText ?? string.Empty) : string.Empty; bool hasSearch = searchActive && !string.IsNullOrWhiteSpace(effectiveSearch); if (hasSearch) { for (int i = 0; i < OptionCount; i++) { if (MatchesSearch(i, effectiveSearch)) { _filteredIndices.Add(i); } } } int filteredCount = hasSearch ? _filteredIndices.Count : OptionCount; if (filteredCount == 0) { ToggleDropDownVisibility(false); _pageChoices.Clear(); _dropdown.choices = _pageChoices; _dropdown.SetValueWithoutNotify(string.Empty); SetValueWithoutNotify(GetDefaultValue()); _dropdown.SetEnabled(false); _dropdown.tooltip = string.Empty; _noResultsLabel.style.display = DisplayStyle.Flex; UpdatePagination(searchActive, 0, pageSize, 0); UpdateSuggestionDisplay(string.Empty, -1, -1); _currentFilteredCount = 0; return; } _noResultsLabel.style.display = DisplayStyle.None; ToggleDropDownVisibility(true); int pageCount = CalculatePageCount(pageSize, filteredCount); if (selectedOptionIndex >= 0) { int filteredIndex = hasSearch ? _filteredIndices.IndexOf(selectedOptionIndex) : selectedOptionIndex; if (filteredIndex >= 0) { _pageIndex = filteredIndex / pageSize; } else if (_pageIndex >= pageCount) { _pageIndex = 0; } } else if (_pageIndex >= pageCount) { _pageIndex = 0; } UpdatePagination(searchActive, pageCount, pageSize, filteredCount); bool paginate = searchActive && filteredCount > pageSize; _pageOptionIndices.Clear(); _pageChoices.Clear(); int startIndex = paginate ? _pageIndex * pageSize : 0; int endIndex = paginate ? Math.Min(filteredCount, startIndex + pageSize) : filteredCount; for (int i = startIndex; i < endIndex; i++) { int optionIndex = hasSearch ? _filteredIndices[i] : i; string displayLabel = GetNormalizedDisplayLabel(optionIndex); _pageOptionIndices.Add(optionIndex); _pageChoices.Add(displayLabel); } _dropdown.choices = _pageChoices; string dropdownValue = string.Empty; string dropdownTooltip = string.Empty; if (selectedOptionIndex >= 0 && selectedOptionIndex < OptionCount) { dropdownValue = GetNormalizedDisplayLabel(selectedOptionIndex); dropdownTooltip = GetTooltip(selectedOptionIndex); } if (string.IsNullOrEmpty(dropdownValue) && _pageChoices.Count > 0) { dropdownValue = _pageChoices[0]; dropdownTooltip = _pageOptionIndices.Count > 0 ? GetTooltip(_pageOptionIndices[0]) : string.Empty; } _dropdown.SetValueWithoutNotify(dropdownValue); if (selectedOptionIndex >= 0) { SetValueWithoutNotify(GetValueForOption(selectedOptionIndex)); } else { SetValueWithoutNotify(GetDefaultValue()); } _dropdown.SetEnabled(_pageChoices.Count > 0); _dropdown.tooltip = dropdownTooltip ?? string.Empty; _currentFilteredCount = filteredCount; UpdateSuggestion(hasSearch); } private void EnsureBuffers() { if (_buffersInitialized) { return; } _filteredIndicesLease = Buffers.List.Get(out _filteredIndices); _pageOptionIndicesLease = Buffers.List.Get(out _pageOptionIndices); _pageChoicesLease = Buffers.List.Get(out _pageChoices); _buffersInitialized = true; } private void OnAttachedToPanel(AttachToPanelEvent _) { if (!_buffersInitialized) { EnsureBuffers(); } } private void OnDetachedFromPanel(DetachFromPanelEvent _) { ReleaseBuffers(); } private void ReleaseBuffers() { if (!_buffersInitialized) { return; } _filteredIndicesLease.Dispose(); _pageOptionIndicesLease.Dispose(); _pageChoicesLease.Dispose(); _filteredIndices = null; _pageOptionIndices = null; _pageChoices = null; _buffersInitialized = false; } private void UpdatePagination( bool searchActive, int pageCount, int pageSize, int filteredCount ) { if ( _paginationContainer == null || _previousButton == null || _nextButton == null || _pageLabel == null ) { return; } bool showPagination = searchActive && filteredCount > pageSize; _paginationContainer.style.display = showPagination ? DisplayStyle.Flex : DisplayStyle.None; if (!showPagination) { _pageLabel.text = string.Empty; _previousButton.SetEnabled(false); _nextButton.SetEnabled(false); return; } int clampedPageCount = Math.Max(1, pageCount); _pageIndex = Mathf.Clamp(_pageIndex, 0, clampedPageCount - 1); _pageLabel.text = GetPaginationLabel(_pageIndex + 1, clampedPageCount); _previousButton.SetEnabled(_pageIndex > 0); _nextButton.SetEnabled(_pageIndex < clampedPageCount - 1); } private void UpdateClearButton(bool searchActive) { if (_clearButton == null) { return; } _clearButton.SetEnabled(searchActive && !string.IsNullOrEmpty(_searchText)); } private void UpdateSuggestion(bool hasSearch) { if (!hasSearch || _filteredIndices.Count == 0) { UpdateSuggestionDisplay(string.Empty, -1, -1); return; } bool searchVisible = hasSearch && !string.IsNullOrEmpty(_searchText); int optionIndex = _filteredIndices[0]; string optionLabel = GetNormalizedDisplayLabel(optionIndex); bool prefixMatch = searchVisible && optionLabel.StartsWith(_searchText, StringComparison.OrdinalIgnoreCase); UpdateSuggestionDisplay(optionLabel, optionIndex, prefixMatch ? 0 : -1); } private void UpdateSuggestionDisplay( string suggestionValue, int optionIndex, int matchPosition ) { _suggestion = suggestionValue; _suggestionOptionIndex = optionIndex; bool suggestionsVisible = _searchVisible && !string.IsNullOrEmpty(suggestionValue) && optionIndex >= 0 && matchPosition == 0; if (_suggestionHintLabel != null) { _suggestionHintLabel.text = suggestionsVisible ? $"↹ Tab selects: {suggestionValue}" : string.Empty; _suggestionHintLabel.style.display = suggestionsVisible ? DisplayStyle.Flex : DisplayStyle.None; } if (!suggestionsVisible) { _suggestionOptionIndex = -1; } } private void ToggleDropDownVisibility(bool hasResults) { if (_dropdown == null) { return; } _dropdown.style.display = hasResults ? DisplayStyle.Flex : DisplayStyle.None; } private void AcceptSuggestion(bool commitSelection) { if ( !_searchVisible || string.IsNullOrEmpty(_suggestion) || _searchField == null || _suggestionOptionIndex < 0 ) { return; } string previous = _searchText ?? string.Empty; int originalLength = previous.Length; int optionIndexToApply = commitSelection ? _suggestionOptionIndex : -1; _searchText = _suggestion; _searchField.SetValueWithoutNotify(_suggestion); int selectionStart = Mathf.Clamp(originalLength, 0, _suggestion.Length); _searchField.schedule.Execute(() => { _searchField.Focus(); _searchField.SelectRange(selectionStart, _suggestion.Length); }); UpdateClearButton(_searchVisible); UpdateSuggestionDisplay(string.Empty, -1, -1); UpdateFromProperty(); if (commitSelection && optionIndexToApply >= 0) { ApplySelection(optionIndexToApply); } } private int ResolvePageSize() { int resolved = Mathf.Max(1, UnityHelpersSettings.GetStringInListPageLimit()); if (resolved != _lastResolvedPageSize) { _lastResolvedPageSize = resolved; _pageIndex = 0; } return resolved; } private bool ShouldShowSearch(int pageSize) { return OptionCount > pageSize; } private void ApplySearchVisibility(bool searchVisible) { if (_searchRow == null) { return; } _searchVisible = searchVisible; _searchRow.style.display = searchVisible ? DisplayStyle.Flex : DisplayStyle.None; if (_dropdown != null) { _dropdown.style.marginTop = searchVisible ? 2f : 0f; } if (!searchVisible) { if (!string.IsNullOrEmpty(_searchText)) { _searchText = string.Empty; _searchField.SetValueWithoutNotify(string.Empty); } UpdateSuggestionDisplay(string.Empty, -1, -1); _suggestionOptionIndex = -1; if (_paginationContainer != null) { _paginationContainer.style.display = DisplayStyle.None; } if (_previousButton != null) { _previousButton.SetEnabled(false); } if (_nextButton != null) { _nextButton.SetEnabled(false); } } UpdateClearButton(searchVisible); if (!searchVisible && _suggestionHintLabel != null) { _suggestionHintLabel.text = string.Empty; _suggestionHintLabel.style.display = DisplayStyle.None; } } private int ResolveOptionIndex(string optionLabel) { if (string.IsNullOrEmpty(optionLabel)) { return -1; } for (int i = 0; i < _pageChoices.Count && i < _pageOptionIndices.Count; i++) { if (string.Equals(_pageChoices[i], optionLabel, StringComparison.Ordinal)) { return _pageOptionIndices[i]; } } for (int i = 0; i < OptionCount; i++) { string label = GetNormalizedDisplayLabel(i); if (string.Equals(label, optionLabel, StringComparison.Ordinal)) { return i; } } return -1; } internal void ApplySelection(int optionIndex) { if (_boundObject == null || string.IsNullOrEmpty(_propertyPath)) { return; } if (optionIndex < 0 || optionIndex >= OptionCount) { return; } SerializedObject serializedObject = _boundObject; Undo.RecordObjects(serializedObject.targetObjects, UndoActionName); serializedObject.Update(); SerializedProperty property = serializedObject.FindProperty(_propertyPath); if (property == null) { return; } ApplySelectionToProperty(property, optionIndex); SetValueWithoutNotify(GetValueForOption(optionIndex)); serializedObject.ApplyModifiedProperties(); UpdateFromProperty(); } private static int CalculatePageCount(int pageSize, int filteredCount) { if (filteredCount <= 0) { return 1; } return (filteredCount + pageSize - 1) / pageSize; } /// /// Gets a cached pagination label. Delegates to . /// private static string GetPaginationLabel(int page, int totalPages) { return EditorCacheHelper.GetPaginationLabel(page, totalPages); } } #endif }