// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers
{
#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.Styles;
using WallstopStudios.UnityHelpers.Utils;
///
/// Data model for configuring the dropdown popup window.
///
public sealed class WDropDownPopupData
{
///
/// Display labels shown to the user.
///
public string[] DisplayLabels { get; set; } = Array.Empty();
///
/// Optional tooltips for each option.
///
public string[] Tooltips { get; set; }
///
/// The currently selected index, or -1 if none.
///
public int SelectedIndex { get; set; } = -1;
///
/// Maximum items per page (from settings).
///
public int PageSize { get; set; } = 10;
///
/// Callback invoked when a selection is made. Receives the selected index.
///
public Action OnSelectionChanged { get; set; }
///
/// Optional title for the popup window.
///
public string Title { get; set; } = string.Empty;
}
///
/// A UI Toolkit-based dropdown popup window that supports search, pagination, and keyboard navigation.
/// Uses for positioning relative to the triggering button.
///
public sealed class WDropDownPopupWindow : EditorWindow
{
private const float PopupWidth = 360f;
private const float SearchRowHeight = 26f;
private const float PaginationRowHeight = 26f;
private const float OptionRowHeight = 24f;
private const float SuggestionRowHeight = 18f;
private const float NoResultsHeight = 40f;
private const float VerticalPadding = 8f;
private const float ButtonWidth = 28f;
private const float PageLabelWidth = 90f;
private const string NoResultsMessage = "No results match the current search.";
private const string ClearButtonActiveClass = "w-dropdown-clear-button--active";
private static readonly Dictionary TabCompleteTextCache = new(
StringComparer.Ordinal
);
///
/// Gets a cached pagination label. Delegates to .
///
private static string GetPaginationLabel(int page, int totalPages)
{
return EditorCacheHelper.GetPaginationLabel(page, totalPages);
}
private WDropDownPopupData _data;
private VisualElement _root;
private TextField _searchField;
private Button _clearButton;
private VisualElement _paginationContainer;
private Button _previousButton;
private Label _pageLabel;
private Button _nextButton;
private ScrollView _optionsContainer;
private Label _noResultsLabel;
private Label _suggestionLabel;
private List _filteredIndices;
private PooledResource> _filteredIndicesLease;
private string _searchText = string.Empty;
private string _suggestion = string.Empty;
private int _suggestionIndex = -1;
private int _pageIndex;
private int _focusedOptionIndex = -1;
private bool _closing;
///
/// Shows the dropdown popup window positioned relative to the given button rect.
///
/// The screen-space rect of the triggering button.
/// Configuration data for the popup.
public static void Show(Rect buttonRect, WDropDownPopupData data)
{
if (data == null || data.DisplayLabels == null || data.DisplayLabels.Length == 0)
{
return;
}
WDropDownPopupWindow window = CreateInstance();
window._data = data;
window.titleContent = new GUIContent(
string.IsNullOrEmpty(data.Title) ? "Select" : data.Title
);
Vector2 windowSize = window.CalculateInitialWindowSize(
data.DisplayLabels.Length,
data.PageSize
);
window.ShowAsDropDown(buttonRect, windowSize);
}
///
/// Shows the dropdown popup for a StringInList property.
///
/// The GUI rect of the button that triggered the popup (in GUI space).
/// The serialized property being edited.
/// Available options.
/// Display labels for options (can be same as options).
/// Optional tooltips for each option.
/// Maximum items per page.
public static void ShowForStringInList(
Rect buttonRect,
SerializedProperty property,
string[] options,
string[] displayLabels,
string[] tooltips,
int pageSize
)
{
if (property == null || options == null || options.Length == 0)
{
return;
}
int currentIndex = ResolveCurrentIndex(property, options);
SerializedObject serializedObject = property.serializedObject;
string propertyPath = property.propertyPath;
bool isStringProperty = property.propertyType == SerializedPropertyType.String;
bool isIntegerProperty = property.propertyType == SerializedPropertyType.Integer;
WDropDownPopupData data = new()
{
DisplayLabels = displayLabels ?? options,
Tooltips = tooltips,
SelectedIndex = property.hasMultipleDifferentValues ? -1 : currentIndex,
PageSize = pageSize,
OnSelectionChanged = (selectedIndex) =>
{
if (selectedIndex < 0 || selectedIndex >= options.Length)
{
return;
}
serializedObject.Update();
SerializedProperty prop = serializedObject.FindProperty(propertyPath);
if (prop == null)
{
return;
}
Undo.RecordObjects(
serializedObject.targetObjects,
"Change StringInList Selection"
);
if (isStringProperty)
{
prop.stringValue = options[selectedIndex] ?? string.Empty;
}
else if (isIntegerProperty)
{
prop.intValue = selectedIndex;
}
serializedObject.ApplyModifiedProperties();
},
};
Rect screenRect = GUIUtility.GUIToScreenRect(buttonRect);
Show(screenRect, data);
}
///
/// Shows the dropdown popup for a WValueDropDown property.
///
/// The value type.
/// The GUI rect of the button that triggered the popup (in GUI space).
/// The serialized property being edited.
/// Available values.
/// Display labels for values.
/// Optional tooltips for each value.
/// Maximum items per page.
/// Function to apply the selected value to the property.
/// The currently selected value index.
public static void ShowForValueDropDown(
Rect buttonRect,
SerializedProperty property,
T[] values,
string[] displayLabels,
string[] tooltips,
int pageSize,
Action applyValue,
int currentValueIndex
)
{
if (property == null || values == null || values.Length == 0 || applyValue == null)
{
return;
}
SerializedObject serializedObject = property.serializedObject;
string propertyPath = property.propertyPath;
WDropDownPopupData data = new()
{
DisplayLabels =
displayLabels ?? Array.ConvertAll(values, v => v?.ToString() ?? string.Empty),
Tooltips = tooltips,
SelectedIndex = currentValueIndex,
PageSize = pageSize,
OnSelectionChanged = (selectedIndex) =>
{
if (selectedIndex < 0 || selectedIndex >= values.Length)
{
return;
}
serializedObject.Update();
SerializedProperty prop = serializedObject.FindProperty(propertyPath);
if (prop == null)
{
return;
}
Undo.RecordObjects(
serializedObject.targetObjects,
"Change ValueDropDown Selection"
);
applyValue(prop, values[selectedIndex]);
serializedObject.ApplyModifiedProperties();
},
};
Rect screenRect = GUIUtility.GUIToScreenRect(buttonRect);
Show(screenRect, data);
}
///
/// Shows the dropdown popup for an IntDropDown property.
///
/// The GUI rect of the button that triggered the popup (in GUI space).
/// The serialized property being edited.
/// Available integer options.
/// Display labels for options.
/// Maximum items per page.
public static void ShowForIntDropDown(
Rect buttonRect,
SerializedProperty property,
int[] options,
string[] displayLabels,
int pageSize
)
{
if (property == null || options == null || options.Length == 0)
{
return;
}
int currentValue = property.intValue;
int currentIndex = Array.IndexOf(options, currentValue);
SerializedObject serializedObject = property.serializedObject;
string propertyPath = property.propertyPath;
WDropDownPopupData data = new()
{
DisplayLabels = displayLabels,
Tooltips = null,
SelectedIndex = property.hasMultipleDifferentValues ? -1 : currentIndex,
PageSize = pageSize,
OnSelectionChanged = (selectedIndex) =>
{
if (selectedIndex < 0 || selectedIndex >= options.Length)
{
return;
}
serializedObject.Update();
SerializedProperty prop = serializedObject.FindProperty(propertyPath);
if (prop == null)
{
return;
}
Undo.RecordObjects(
serializedObject.targetObjects,
"Change IntDropDown Selection"
);
prop.intValue = options[selectedIndex];
serializedObject.ApplyModifiedProperties();
},
};
Rect screenRect = GUIUtility.GUIToScreenRect(buttonRect);
Show(screenRect, data);
}
private static int ResolveCurrentIndex(SerializedProperty property, string[] options)
{
if (property.propertyType == SerializedPropertyType.String)
{
string selected = property.stringValue ?? string.Empty;
return Array.IndexOf(options, selected);
}
if (property.propertyType == SerializedPropertyType.Integer)
{
int index = property.intValue;
if (index >= 0 && index < options.Length)
{
return index;
}
}
return -1;
}
private Vector2 CalculateInitialWindowSize(int totalOptions, int pageSize)
{
int visibleCount = Mathf.Min(totalOptions, pageSize);
bool hasPagination = totalOptions > pageSize;
float height = VerticalPadding * 2;
height += SearchRowHeight + 4f;
if (hasPagination)
{
height += PaginationRowHeight + 4f;
}
height += visibleCount * OptionRowHeight;
height += 8f;
return new Vector2(PopupWidth, Mathf.Max(height, 100f));
}
private Vector2 CalculateWindowSize()
{
if (_data == null)
{
return new Vector2(PopupWidth, 100f);
}
int pageSize = _data.PageSize;
int filteredCount = _filteredIndices?.Count ?? CalculateFilteredCount();
bool hasPagination = filteredCount > pageSize;
int rowsOnPage = CalculateRowsOnPage(filteredCount, pageSize, _pageIndex);
float height = VerticalPadding * 2;
height += SearchRowHeight + 4f;
if (hasPagination)
{
height += PaginationRowHeight + 4f;
}
if (filteredCount == 0)
{
height += NoResultsHeight;
}
else
{
height += rowsOnPage * OptionRowHeight;
}
if (!string.IsNullOrEmpty(_suggestion))
{
height += SuggestionRowHeight;
}
height += 8f;
return new Vector2(PopupWidth, Mathf.Max(height, 100f));
}
private void OnEnable()
{
_filteredIndicesLease = Buffers.List.Get(out _filteredIndices);
}
private void OnDisable()
{
_filteredIndicesLease.Dispose();
_filteredIndices = null;
}
private void OnLostFocus()
{
if (!_closing)
{
_closing = true;
Close();
}
}
private void CreateGUI()
{
_root = rootVisualElement;
WDropDownStyleLoader.ApplyStyles(_root);
_root.AddToClassList(WDropDownStyleLoader.ClassNames.Popup);
_root.style.paddingTop = VerticalPadding;
_root.style.paddingBottom = VerticalPadding;
_root.style.paddingLeft = 8f;
_root.style.paddingRight = 8f;
BuildSearchRow();
BuildSuggestionLabel();
BuildPaginationRow();
BuildOptionsContainer();
BuildNoResultsLabel();
_root.RegisterCallback(OnKeyDown);
RefreshDisplay();
EditorApplication.delayCall += () => _searchField?.Focus();
}
private void BuildSearchRow()
{
VisualElement searchRow = new()
{
style =
{
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
marginBottom = 4f,
height = SearchRowHeight,
overflow = Overflow.Hidden,
flexShrink = 0f,
},
};
searchRow.AddToClassList(WDropDownStyleLoader.ClassNames.SearchContainer);
Label searchLabel = new("Search") { style = { width = 50f, marginRight = 4f } };
_searchField = new TextField
{
style =
{
flexGrow = 1f,
flexShrink = 1f,
marginLeft = 0f,
marginRight = 0f,
overflow = Overflow.Hidden,
},
};
_searchField.AddToClassList(WDropDownStyleLoader.ClassNames.Search);
_searchField.RegisterValueChangedCallback(OnSearchChanged);
_clearButton = new Button(OnClearClicked)
{
text = "Clear",
style = { marginLeft = 4f, width = 50f },
};
_clearButton.AddToClassList(WDropDownStyleLoader.ClassNames.ClearButton);
_clearButton.SetEnabled(false);
searchRow.Add(searchLabel);
searchRow.Add(_searchField);
searchRow.Add(_clearButton);
_root.Add(searchRow);
}
private void BuildSuggestionLabel()
{
_suggestionLabel = new Label
{
style =
{
display = DisplayStyle.None,
marginLeft = 54f,
marginBottom = 2f,
color = new Color(0.7f, 0.85f, 1f, 0.75f),
unityFontStyleAndWeight = FontStyle.Italic,
fontSize = 11f,
height = SuggestionRowHeight,
},
pickingMode = PickingMode.Ignore,
};
_suggestionLabel.AddToClassList(WDropDownStyleLoader.ClassNames.Suggestion);
_root.Add(_suggestionLabel);
}
private void BuildPaginationRow()
{
_paginationContainer = new VisualElement
{
style =
{
flexDirection = FlexDirection.Row,
alignItems = Align.Center,
justifyContent = Justify.Center,
marginBottom = 4f,
height = PaginationRowHeight,
display = DisplayStyle.None,
},
};
_paginationContainer.AddToClassList(WDropDownStyleLoader.ClassNames.Pagination);
_previousButton = new Button(OnPreviousPage)
{
text = "‹",
style = { width = ButtonWidth, height = 20f },
};
_previousButton.AddToClassList(WDropDownStyleLoader.ClassNames.PaginationButton);
_pageLabel = new Label
{
style = { width = PageLabelWidth, unityTextAlign = TextAnchor.MiddleCenter },
};
_pageLabel.AddToClassList(WDropDownStyleLoader.ClassNames.PaginationLabel);
_nextButton = new Button(OnNextPage)
{
text = "›",
style = { width = ButtonWidth, height = 20f },
};
_nextButton.AddToClassList(WDropDownStyleLoader.ClassNames.PaginationButton);
_paginationContainer.Add(_previousButton);
_paginationContainer.Add(_pageLabel);
_paginationContainer.Add(_nextButton);
_root.Add(_paginationContainer);
}
private void BuildOptionsContainer()
{
_optionsContainer = new ScrollView(ScrollViewMode.Vertical)
{
style = { flexGrow = 1f, marginBottom = 4f },
};
_optionsContainer.AddToClassList(WDropDownStyleLoader.ClassNames.OptionsContainer);
_root.Add(_optionsContainer);
}
private void BuildNoResultsLabel()
{
_noResultsLabel = new Label(NoResultsMessage)
{
style =
{
display = DisplayStyle.None,
unityTextAlign = TextAnchor.MiddleCenter,
paddingTop = 8f,
paddingBottom = 8f,
color = new Color(0.7f, 0.7f, 0.7f, 1f),
},
};
_noResultsLabel.AddToClassList(WDropDownStyleLoader.ClassNames.NoResults);
_root.Add(_noResultsLabel);
}
private void OnSearchChanged(ChangeEvent evt)
{
_searchText = evt.newValue ?? string.Empty;
_pageIndex = 0;
_focusedOptionIndex = -1;
RefreshDisplay();
ResizeWindow();
}
private void OnClearClicked()
{
_searchText = string.Empty;
_searchField.SetValueWithoutNotify(string.Empty);
_pageIndex = 0;
_focusedOptionIndex = -1;
RefreshDisplay();
ResizeWindow();
_searchField.Focus();
}
private void OnPreviousPage()
{
if (_pageIndex > 0)
{
_pageIndex--;
_focusedOptionIndex = -1;
RefreshDisplay();
}
}
private void OnNextPage()
{
int pageCount = CalculatePageCount();
if (_pageIndex < pageCount - 1)
{
_pageIndex++;
_focusedOptionIndex = -1;
RefreshDisplay();
}
}
private void OnKeyDown(KeyDownEvent evt)
{
switch (evt.keyCode)
{
case KeyCode.Escape:
evt.StopPropagation();
Close();
break;
case KeyCode.DownArrow:
evt.StopPropagation();
MoveFocus(1);
break;
case KeyCode.UpArrow:
evt.StopPropagation();
MoveFocus(-1);
break;
case KeyCode.Return:
case KeyCode.KeypadEnter:
evt.StopPropagation();
if (_focusedOptionIndex >= 0)
{
SelectFocusedOption();
}
else if (_suggestionIndex >= 0)
{
AcceptSuggestion();
}
break;
case KeyCode.Tab:
if (!string.IsNullOrEmpty(_suggestion) && _suggestionIndex >= 0)
{
evt.StopPropagation();
evt.PreventDefault();
AcceptSuggestion();
}
break;
case KeyCode.PageDown:
evt.StopPropagation();
OnNextPage();
break;
case KeyCode.PageUp:
evt.StopPropagation();
OnPreviousPage();
break;
}
}
private void MoveFocus(int delta)
{
int optionCount = _optionsContainer.childCount;
if (optionCount == 0)
{
return;
}
_focusedOptionIndex += delta;
if (_focusedOptionIndex < 0)
{
_focusedOptionIndex = optionCount - 1;
}
else if (_focusedOptionIndex >= optionCount)
{
_focusedOptionIndex = 0;
}
UpdateOptionFocus();
}
private void UpdateOptionFocus()
{
for (int i = 0; i < _optionsContainer.childCount; i++)
{
VisualElement child = _optionsContainer[i];
if (i == _focusedOptionIndex)
{
child.AddToClassList(WDropDownStyleLoader.ClassNames.OptionFocused);
}
else
{
child.RemoveFromClassList(WDropDownStyleLoader.ClassNames.OptionFocused);
}
}
}
private void SelectFocusedOption()
{
if (_focusedOptionIndex < 0 || _focusedOptionIndex >= _optionsContainer.childCount)
{
return;
}
VisualElement optionElement = _optionsContainer[_focusedOptionIndex];
if (optionElement.userData is int optionIndex)
{
SelectOption(optionIndex);
}
}
private void AcceptSuggestion()
{
if (_suggestionIndex < 0)
{
return;
}
SelectOption(_suggestionIndex);
}
private void SelectOption(int optionIndex)
{
_data?.OnSelectionChanged?.Invoke(optionIndex);
Close();
}
private void RefreshDisplay()
{
if (_data == null)
{
return;
}
UpdateFilteredIndices();
UpdateClearButton();
UpdateSuggestion();
UpdatePagination();
UpdateOptions();
UpdateNoResults();
}
private string GetNormalizedLabel(int index)
{
string label = _data.DisplayLabels[index];
if (string.IsNullOrEmpty(label))
{
return DropDownShared.GetFallbackOptionLabel(index);
}
return label;
}
private void UpdateFilteredIndices()
{
_filteredIndices.Clear();
if (string.IsNullOrEmpty(_searchText))
{
for (int i = 0; i < _data.DisplayLabels.Length; i++)
{
_filteredIndices.Add(i);
}
}
else
{
for (int i = 0; i < _data.DisplayLabels.Length; i++)
{
string label = GetNormalizedLabel(i);
if (label.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0)
{
_filteredIndices.Add(i);
}
}
}
}
private void UpdateClearButton()
{
bool hasSearch = !string.IsNullOrEmpty(_searchText);
_clearButton.SetEnabled(hasSearch);
if (hasSearch)
{
_clearButton.AddToClassList(ClearButtonActiveClass);
}
else
{
_clearButton.RemoveFromClassList(ClearButtonActiveClass);
}
}
private void UpdateSuggestion()
{
_suggestion = string.Empty;
_suggestionIndex = -1;
if (string.IsNullOrEmpty(_searchText) || _filteredIndices.Count == 0)
{
_suggestionLabel.style.display = DisplayStyle.None;
return;
}
for (int i = 0; i < _filteredIndices.Count; i++)
{
int index = _filteredIndices[i];
string label = GetNormalizedLabel(index);
if (label.StartsWith(_searchText, StringComparison.OrdinalIgnoreCase))
{
_suggestion = label;
_suggestionIndex = index;
break;
}
}
if (!string.IsNullOrEmpty(_suggestion))
{
if (!TabCompleteTextCache.TryGetValue(_suggestion, out string tabCompleteText))
{
tabCompleteText = "Tab to complete: " + _suggestion;
TabCompleteTextCache[_suggestion] = tabCompleteText;
}
_suggestionLabel.text = tabCompleteText;
_suggestionLabel.style.display = DisplayStyle.Flex;
}
else
{
_suggestionLabel.style.display = DisplayStyle.None;
}
}
private void UpdatePagination()
{
int filteredCount = _filteredIndices.Count;
int pageSize = _data.PageSize;
int pageCount = CalculatePageCount();
_pageIndex = Mathf.Clamp(_pageIndex, 0, Mathf.Max(0, pageCount - 1));
bool showPagination = pageCount > 1;
_paginationContainer.style.display = showPagination
? DisplayStyle.Flex
: DisplayStyle.None;
if (showPagination)
{
_previousButton.SetEnabled(_pageIndex > 0);
_nextButton.SetEnabled(_pageIndex < pageCount - 1);
_pageLabel.text = GetPaginationLabel(_pageIndex + 1, pageCount);
}
}
private void UpdateOptions()
{
_optionsContainer.Clear();
_focusedOptionIndex = -1;
if (_filteredIndices.Count == 0)
{
_optionsContainer.style.display = DisplayStyle.None;
return;
}
_optionsContainer.style.display = DisplayStyle.Flex;
int pageSize = _data.PageSize;
int startIndex = _pageIndex * pageSize;
int endIndex = Mathf.Min(startIndex + pageSize, _filteredIndices.Count);
for (int i = startIndex; i < endIndex; i++)
{
int optionIndex = _filteredIndices[i];
VisualElement optionRow = CreateOptionRow(optionIndex);
_optionsContainer.Add(optionRow);
}
}
private VisualElement CreateOptionRow(int optionIndex)
{
string label = GetNormalizedLabel(optionIndex);
string tooltip =
_data.Tooltips != null && optionIndex < _data.Tooltips.Length
? _data.Tooltips[optionIndex] ?? string.Empty
: string.Empty;
bool isSelected = optionIndex == _data.SelectedIndex;
Button optionButton = new()
{
text = label,
tooltip = tooltip,
userData = optionIndex,
style =
{
height = OptionRowHeight,
marginBottom = 2f,
unityTextAlign = TextAnchor.MiddleLeft,
paddingLeft = 8f,
paddingRight = 8f,
},
};
optionButton.AddToClassList(WDropDownStyleLoader.ClassNames.Option);
if (isSelected)
{
optionButton.AddToClassList(WDropDownStyleLoader.ClassNames.OptionSelected);
}
optionButton.RegisterCallback(OnOptionButtonClick);
optionButton.RegisterCallback(
OnOptionMouseEnter,
optionButton
);
optionButton.RegisterCallback(
OnOptionMouseLeave,
optionButton
);
return optionButton;
}
private void OnOptionButtonClick(ClickEvent evt)
{
if (evt.target is Button button && button.userData is int optionIndex)
{
SelectOption(optionIndex);
}
}
private static void OnOptionMouseEnter(MouseEnterEvent evt, Button button)
{
button.AddToClassList(WDropDownStyleLoader.ClassNames.OptionHover);
}
private static void OnOptionMouseLeave(MouseLeaveEvent evt, Button button)
{
button.RemoveFromClassList(WDropDownStyleLoader.ClassNames.OptionHover);
}
private void UpdateNoResults()
{
bool showNoResults = _filteredIndices.Count == 0 && !string.IsNullOrEmpty(_searchText);
_noResultsLabel.style.display = showNoResults ? DisplayStyle.Flex : DisplayStyle.None;
}
private void ResizeWindow()
{
if (_data == null)
{
return;
}
Vector2 newSize = CalculateWindowSize();
Rect pos = position;
pos.size = newSize;
position = pos;
}
private int CalculateFilteredCount()
{
if (_data == null || _data.DisplayLabels == null)
{
return 0;
}
if (string.IsNullOrEmpty(_searchText))
{
return _data.DisplayLabels.Length;
}
int count = 0;
for (int i = 0; i < _data.DisplayLabels.Length; i++)
{
string label = GetNormalizedLabel(i);
if (label.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0)
{
count++;
}
}
return count;
}
private int CalculatePageCount()
{
if (_data == null)
{
return 1;
}
int filteredCount = _filteredIndices?.Count ?? CalculateFilteredCount();
int pageSize = Mathf.Max(1, _data.PageSize);
if (filteredCount <= 0)
{
return 1;
}
return (filteredCount + pageSize - 1) / pageSize;
}
private int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage)
{
if (filteredCount <= 0 || pageSize <= 0)
{
return 0;
}
int pageCount = (filteredCount + pageSize - 1) / pageSize;
int clampedPage = Mathf.Clamp(currentPage, 0, Mathf.Max(0, pageCount - 1));
int startIndex = clampedPage * pageSize;
int remaining = filteredCount - startIndex;
return Mathf.Clamp(remaining, 0, pageSize);
}
}
#endif
}