// 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); } } }