// Copyright (c) 2024 Synty Studios Limited. All rights reserved.
//
// Use of this software is subject to the terms and conditions of the Synty Studios End User Licence Agreement (EULA)
// available at: https://syntystore.com/pages/end-user-licence-agreement
//
// For additional details, see the LICENSE.MD file bundled with this software.
using System;
using System.Collections.Generic;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace Synty.SidekickCharacters.Synty.SidekickCharacters.Scripts.Editor.UI
{
///
/// Makes a PopupField/DropdownField open a scrollable UIToolkit dropdown menu instead of the native OS context
/// menu, which handles long choice lists poorly (no proper scrollbar).
///
public static class ScrollableDropdown
{
///
/// Replaces the given field's built-in dropdown menu with a scrollable GenericDropdownMenu.
/// Selection still goes through field.value, so existing value-changed callbacks are unaffected.
///
/// The dropdown field to modify.
public static void Apply(PopupField field)
{
Register(field, () => field.choices, () => field.value, newValue => field.value = newValue);
}
///
/// Replaces the given field's built-in dropdown menu with a scrollable GenericDropdownMenu.
/// Selection still goes through field.value, so existing value-changed callbacks are unaffected.
///
/// The dropdown field to modify.
public static void Apply(DropdownField field)
{
Register(field, () => field.choices, () => field.value, newValue => field.value = newValue);
}
///
/// Registers the event interceptors that suppress the field's built-in menu and show the scrollable one.
///
/// The dropdown field element.
/// Getter for the field's current choices.
/// Getter for the field's current value.
/// Setter applying a newly selected value to the field.
private static void Register(VisualElement field, Func> getChoices, Func getValue, Action setValue)
{
field.RegisterCallback(
evt =>
{
SuppressBuiltInMenu(evt);
ShowMenu(field, getChoices, getValue, setValue);
},
TrickleDown.TrickleDown
);
// Block the compatibility mouse event as well so the built-in menu can never open; the menu itself is
// only shown from the pointer event above to avoid opening twice.
field.RegisterCallback(
evt =>
{
SuppressBuiltInMenu(evt);
},
TrickleDown.TrickleDown
);
field.RegisterCallback(
evt =>
{
if (evt.keyCode == KeyCode.Space || evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter)
{
SuppressBuiltInMenu(evt);
ShowMenu(field, getChoices, getValue, setValue);
}
},
TrickleDown.TrickleDown
);
}
///
/// Stops the event from reaching the field's built-in menu handling.
///
/// The event to suppress.
private static void SuppressBuiltInMenu(EventBase evt)
{
evt.StopImmediatePropagation();
#if !UNITY_2023_2_OR_NEWER
// In older Unity versions the built-in menu opens from the event's default action, which is only
// blocked by PreventDefault. In newer versions PreventDefault is obsolete and stopping propagation
// is sufficient.
evt.PreventDefault();
#endif
}
///
/// Shows the scrollable dropdown menu anchored to the given field, built from its current choices.
///
/// The dropdown field element.
/// Getter for the field's current choices.
/// Getter for the field's current value.
/// Setter applying a newly selected value to the field.
private static void ShowMenu(VisualElement field, Func> getChoices, Func getValue, Action setValue)
{
List choices = getChoices();
if (choices == null || choices.Count == 0)
{
return;
}
GenericDropdownMenu menu = new GenericDropdownMenu();
string currentValue = getValue();
// The value can lag behind what the field visibly shows (e.g. Parts tab dropdowns whose choices are
// replaced after creation), so also match against the text displayed on the field itself.
string currentText = field.Q(className: "unity-base-popup-field__text")?.text;
foreach (string choice in choices)
{
string captured = choice;
bool isChecked = Matches(choice, currentValue) || Matches(choice, currentText);
menu.AddItem(choice, isChecked, () => setValue(captured));
}
#if UNITY_6000_3_OR_NEWER
// Fixed matches the old anchored=true behavior: the menu width matches the field's rect.
menu.DropDown(field.worldBound, field, DropdownMenuSizeMode.Fixed);
#else
menu.DropDown(field.worldBound, field, true);
#endif
}
///
/// Compares a menu choice with the field's current value/text, tolerating whitespace and case differences.
///
/// The menu choice to test.
/// The field's current value or displayed text.
/// True if they refer to the same entry; otherwise false.
private static bool Matches(string choice, string current)
{
return !string.IsNullOrEmpty(current)
&& string.Equals(choice?.Trim(), current.Trim(), StringComparison.OrdinalIgnoreCase);
}
}
}