Files

1455 lines
54 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.Locale;
namespace UVC.UIToolkit
{
/// <summary>
/// 메뉴의 정렬 방향을 나타냅니다.
/// </summary>
public enum UTKMenuOrientation
{
/// <summary>가로 정렬 (기본값)</summary>
Horizontal,
/// <summary>세로 정렬</summary>
Vertical
}
/// <summary>
/// UIToolkit 기반 Top Menu의 View 레이어입니다.
/// 메뉴 아이템의 시각적 표현과 사용자 상호작용을 처리합니다.
///
/// <para><strong>주요 기능:</strong></para>
/// <list type="bullet">
/// <item>텍스트/이미지 메뉴 아이템 지원 (Material Icons 포함)</item>
/// <item>무제한 깊이의 서브메뉴 (Lazy Loading)</item>
/// <item>다국어 지원 (LocalizationManager 연동)</item>
/// <item>테마 변경 지원 (UTKThemeManager 연동)</item>
/// <item>외부 클릭으로 서브메뉴 자동 닫기</item>
/// <item>서브메뉴 위치 조정 (SubMenuOffsetX/Y)</item>
/// <item>메뉴 아이템 간격 조절 (ItemSpacing)</item>
/// <item>가로/세로 정렬 전환 (Orientation)</item>
/// <item>ItemId로 Command 실행 (ExecuteCommand)</item>
/// <item>ItemId로 메뉴 데이터 조회 (TryGetMenuItemData)</item>
/// <item>성능 최적화 (리소스 캐싱, Dictionary 추적)</item>
/// </list>
/// </summary>
/// <example>
/// <code>
/// // === 기본 사용법 ===
///
/// // 1. View 생성 및 UIDocument에 추가
/// var menuView = new UTKTopMenu();
/// uiDocument.rootVisualElement.Add(menuView);
///
/// // 2. 메뉴 데이터 생성
/// var menuModel = new UTKTopMenuModel();
///
/// // 3. 파일 메뉴 추가 (텍스트 메뉴)
/// var fileMenu = new UTKMenuItemData("file", "파일");
/// fileMenu.AddSubMenuItem(new UTKMenuItemData(
/// "file_new",
/// "새 파일",
/// new DebugLogCommand("새 파일 생성"),
/// shortcut: "Ctrl+N"
/// ));
/// fileMenu.AddSubMenuItem(UTKMenuItemData.CreateSeparator());
/// fileMenu.AddSubMenuItem(new UTKMenuItemData(
/// "file_save",
/// "저장",
/// new DebugLogCommand("저장"),
/// shortcut: "Ctrl+S"
/// ));
/// menuModel.AddMenuItem(fileMenu);
///
/// // 4. 이미지 메뉴 추가 (Material Icon)
/// var settingsMenu = new UTKMenuImageItemData(
/// "settings",
/// UTKMaterialIcons.Settings, // Material Icon
/// useMaterialIcon: true,
/// imageSize: 24f
/// );
/// settingsMenu.AddSubMenuItem(new UTKMenuItemData(
/// "settings_preferences",
/// "환경설정",
/// new DebugLogCommand("환경설정")
/// ));
/// menuModel.AddMenuItem(settingsMenu);
///
/// // 5. View에 메뉴 생성
/// if (menuView.MenuContainer != null)
/// {
/// menuView.CreateMenuItems(menuModel.MenuItems, menuView.MenuContainer);
/// }
///
/// // 6. 클릭 이벤트 구독
/// menuView.OnMenuItemClicked += HandleMenuItemClicked;
///
/// // 7. 서브메뉴 위치 조정 (선택적)
/// menuView.SubMenuOffsetX = 10; // 오른쪽으로 10px 이동
/// menuView.SubMenuOffsetY = 5; // 아래쪽으로 5px 이동
///
/// // 8. 메뉴 아이템 간격 조절 (선택적)
/// menuView.ItemSpacing = 8; // 아이템 간 8px 간격
///
/// // 9. 세로 정렬 (선택적)
/// menuView.Orientation = UTKMenuOrientation.Vertical; // 세로 메뉴로 전환
///
/// // 10. ItemId로 Command 직접 실행 (선택적)
/// bool executed = menuView.ExecuteCommand("file_save"); // true: 실행됨, false: 비활성화/Command 없음
///
/// // 11. ItemId로 메뉴 데이터 조회 (선택적)
/// if (menuView.TryGetMenuItemData("file_save", out var data))
/// {
/// Debug.Log($"Enabled: {data?.IsEnabled}, Shortcut: {data?.Shortcut}");
/// }
///
///
/// // === 이벤트 처리 ===
///
/// private void HandleMenuItemClicked(UTKMenuItemData itemData)
/// {
/// if (itemData == null) return;
///
/// Debug.Log($"메뉴 클릭: {itemData.ItemId} - {itemData.DisplayName}");
///
/// // Command 실행
/// if (itemData.Command != null)
/// {
/// itemData.Command.Execute(itemData.CommandParameter);
/// }
/// }
///
///
/// // === 다중 깊이 메뉴 구성 (4 depth 예제) ===
///
/// // Depth 1
/// var depth1Item = new UTKMenuItemData("depth1", "레벨 1 메뉴");
///
/// // Depth 2
/// var depth2Item = new UTKMenuItemData("depth2", "레벨 2 메뉴");
///
/// // Depth 3
/// var depth3Item = new UTKMenuItemData("depth3", "레벨 3 메뉴");
///
/// // Depth 4 (최종 액션)
/// var depth4Action = new UTKMenuItemData(
/// "depth4_action",
/// "최종 액션",
/// new DebugLogCommand("4 Depth 실행")
/// );
///
/// // 계층 구성
/// depth3Item.AddSubMenuItem(depth4Action);
/// depth2Item.AddSubMenuItem(depth3Item);
/// depth1Item.AddSubMenuItem(depth2Item);
/// fileMenu.AddSubMenuItem(depth1Item);
///
///
/// // === CommandParameter 사용 예시 (임의 데이터 연결) ===
///
/// // 메뉴 아이템에 CommandParameter로 임의 데이터 연결
/// var exportItem = new UTKMenuItemData(
/// "export_fbx",
/// "FBX로 내보내기",
/// commandParameter: new ExportOptions { Format = "FBX", Quality = 100 }
/// );
///
/// // 클릭 이벤트에서 CommandParameter 활용
/// menuView.OnMenuItemClicked += (itemData) =>
/// {
/// if (itemData?.CommandParameter is ExportOptions opts)
/// {
/// Debug.Log($"내보내기: {opts.Format}, 품질: {opts.Quality}");
/// }
/// };
///
///
/// // === 정리 (OnDestroy에서) ===
///
/// void OnDestroy()
/// {
/// if (menuView != null)
/// {
/// menuView.OnMenuItemClicked -= HandleMenuItemClicked;
/// menuView.Dispose();
/// }
///
/// menuModel?.Dispose();
/// }
///
///
/// // === UXML 사용 예시 ===
///
/// /*
/// <!-- 기본 사용 -->
/// <ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:utk="UVC.UIToolkit">
/// <utk:UTKTopMenu name="top-menu" />
/// </ui:UXML>
///
/// <!-- 아이템 간격 및 정렬 방향 지정 -->
/// <utk:UTKTopMenu name="top-menu" item-spacing="8" orientation="Horizontal" />
///
/// <!-- 세로 정렬 메뉴 -->
/// <utk:UTKTopMenu name="side-menu" item-spacing="4" orientation="Vertical" />
/// */
/// </code>
/// </example>
/// <remarks>
/// <para><strong>성능 최적화:</strong></para>
/// <list type="bullet">
/// <item><strong>Lazy Loading:</strong> 서브메뉴는 첫 클릭 시에만 생성되어 초기 메모리 사용량 감소</item>
/// <item><strong>리소스 캐싱:</strong> UXML/USS 리소스를 클래스 레벨에서 캐싱하여 반복 로드 방지</item>
/// <item><strong>열린 메뉴 추적:</strong> HashSet으로 열린 서브메뉴만 추적하여 외부 클릭 감지 성능 향상 (O(n) → O(열린 메뉴 수))</item>
/// <item><strong>DisplayStyle 토글:</strong> 서브메뉴 생성/파괴 대신 숨김/표시로 레이아웃 재계산 최소화</item>
/// </list>
///
/// <para><strong>메모리 관리:</strong></para>
/// <list type="bullet">
/// <item>모든 이벤트 핸들러는 RegisterCallback/UnregisterCallback으로 대칭 관리</item>
/// <item>Dictionary 캐싱 (_menuItemElements, _subMenuContainers, _menuItemDataMap)</item>
/// <item>Dispose 시 모든 리소스 및 참조 정리</item>
/// </list>
///
/// <para><strong>레이아웃 설정:</strong></para>
/// <list type="bullet">
/// <item><strong>ItemSpacing:</strong> 최상위 메뉴 아이템 간 간격 (픽셀). 기본값 0</item>
/// <item><strong>Orientation:</strong> 가로(Horizontal, 기본값) 또는 세로(Vertical) 정렬 전환</item>
/// <item>세로 정렬 시 서브메뉴는 아이템 오른쪽에 표시됨</item>
/// <item>UXML에서 item-spacing, orientation 어트리뷰트로 설정 가능</item>
/// </list>
///
/// <para><strong>주의사항:</strong></para>
/// <list type="bullet">
/// <item>CreateMenuItems 호출 전에 MenuContainer가 null이 아닌지 확인</item>
/// <item>서브메뉴가 많을 경우 첫 클릭 시 약간의 지연이 발생할 수 있음 (Lazy Loading)</item>
/// <item>외부 클릭으로 모든 서브메뉴가 닫히므로 필요 시 OnMenuItemClicked에서 재처리</item>
/// </list>
/// </remarks>
[UxmlElement]
public partial class UTKTopMenu : VisualElement, IDisposable
{
#region Constants
private const string UXML_PATH = "UIToolkit/Menu/UTKTopMenu";
private const string USS_PATH = "UIToolkit/Menu/UTKTopMenuUss";
private const string SUBMENU_ITEM_UXML = "UIToolkit/Menu/UTKSubMenuItem";
#endregion
#region Fields
private bool _disposed;
private readonly StyleSheet? _loadedUss;
// UI 요소 캐싱
private VisualElement? _menuContainer;
private VisualElement? _subMenuParentContainer; // SubMenu를 담는 최상위 Container (panel.rootVisualElement 자식)
private VisualElement? _subMenuBlocker; // 메뉴 외부 클릭 감지용 투명 블로커
private VisualElement? _menuContainerOriginalParent; // _menuContainer 원래 부모 (ElevateMenuContainer 복원용)
private int _menuContainerOriginalIndex = -1; // _menuContainer 원래 인덱스 (ElevateMenuContainer 복원용)
private Rect _menuContainerCachedBounds; // _menuContainer의 worldBound 최초 캡처값 (좌표 누적 방지)
// 메뉴 아이템 관리
private readonly Dictionary<string, VisualElement> _menuItemElements = new Dictionary<string, VisualElement>(StringComparer.Ordinal);
private readonly Dictionary<string, VisualElement> _subMenuContainers = new Dictionary<string, VisualElement>(StringComparer.Ordinal);
private readonly Dictionary<string, UTKMenuItemData> _menuItemDataMap = new Dictionary<string, UTKMenuItemData>(StringComparer.Ordinal);
private readonly HashSet<string> _openSubMenuIds = new HashSet<string>(StringComparer.Ordinal); // 열린 서브메뉴 추적
// 상태 관리
private bool _isAnySubMenuOpen;
private LocalizationManager? _locManager;
// 서브메뉴 위치 offset
private float _subMenuOffsetX = 0f;
private float _subMenuOffsetY = 0f;
// 메뉴 아이템 간격 및 정렬 방향
private float _itemSpacing = 0f;
private UTKMenuOrientation _orientation = UTKMenuOrientation.Horizontal;
// 리소스 캐싱 (성능 개선)
private VisualTreeAsset? _cachedSubMenuItemAsset;
private StyleSheet? _cachedSubMenuItemUss;
#endregion
#region Properties
/// <summary>메뉴 아이템이 배치될 컨테이너</summary>
public VisualElement? MenuContainer => _menuContainer;
/// <summary>
/// 최상위 메뉴에서 서브메뉴가 표시될 때 X축 offset (픽셀 단위).
/// 양수는 오른쪽, 음수는 왼쪽으로 이동합니다.
/// </summary>
public float SubMenuOffsetX
{
get => _subMenuOffsetX;
set => _subMenuOffsetX = value;
}
/// <summary>
/// 최상위 메뉴에서 서브메뉴가 표시될 때 Y축 offset (픽셀 단위).
/// 양수는 아래쪽, 음수는 위쪽으로 이동합니다.
/// </summary>
public float SubMenuOffsetY
{
get => _subMenuOffsetY;
set => _subMenuOffsetY = value;
}
/// <summary>
/// 최상위 메뉴 아이템 간 간격 (픽셀 단위).
/// 0 이상의 값을 지정하면 각 아이템 사이에 margin이 적용됩니다.
/// </summary>
[UxmlAttribute("item-spacing")]
public float ItemSpacing
{
get => _itemSpacing;
set
{
_itemSpacing = value;
ApplyItemSpacing();
}
}
/// <summary>
/// 최상위 메뉴의 정렬 방향입니다.
/// Horizontal(기본값)은 가로 정렬, Vertical은 세로 정렬입니다.
/// </summary>
[UxmlAttribute("orientation")]
public UTKMenuOrientation Orientation
{
get => _orientation;
set
{
_orientation = value;
ApplyOrientation();
}
}
#endregion
#region Events
/// <summary>메뉴 아이템 클릭 이벤트</summary>
public event Action<UTKMenuItemData>? OnMenuItemClicked;
#endregion
#region Constructor
/// <summary>
/// UTKTopMenu의 새 인스턴스를 초기화합니다.
/// </summary>
public UTKTopMenu() : base()
{
// 1. 테마 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 2. USS 로드
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
{
styleSheets.Add(uss);
_loadedUss = uss;
}
// 3. UI 생성
CreateUI();
// 4. 테마 변경 구독
SubscribeToThemeChanges();
// 5. LocalizationManager 가져오기
_locManager = LocalizationManager.Instance;
}
#endregion
#region Setup
/// <summary>
/// UI를 생성합니다.
/// </summary>
private void CreateUI()
{
AddToClassList("utk-top-menu-view");
var asset = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (asset != null)
{
CreateUIFromUxml(asset);
}
else
{
CreateUIFallback();
}
// _menuContainer의 레이아웃이 확정되는 시점에 worldBound를 1회 캡처합니다.
// 이후 ElevateMenuContainer()에서 이 값을 재사용하므로 좌표 누적이 발생하지 않습니다.
_menuContainer?.RegisterCallback<GeometryChangedEvent>(OnMenuContainerGeometryChanged);
}
private void OnMenuContainerGeometryChanged(GeometryChangedEvent evt)
{
// _menuContainer가 원래 부모에 있고 absolute 스타일이 없는 정상 상태일 때만 캡처
if (_menuContainer == null) return;
if (_menuContainer.parent == _subMenuParentContainer) return; // 이미 올라간 상태는 무시
if (_menuContainer.style.position == Position.Absolute) return; // absolute 상태는 무시
_menuContainerCachedBounds = _menuContainer.worldBound;
}
/// <summary>
/// UXML에서 UI를 생성합니다.
/// </summary>
/// <param name="asset">UXML 에셋</param>
private void CreateUIFromUxml(VisualTreeAsset asset)
{
var root = asset.Instantiate();
// USS를 root에 추가
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
{
root.styleSheets.Add(uss);
}
// UI 요소 참조 가져오기 (쿼리 캐싱)
_menuContainer = root.Q<VisualElement>("menu-container");
Add(root);
}
/// <summary>
/// Fallback UI를 생성합니다.
/// </summary>
private void CreateUIFallback()
{
var container = new VisualElement();
container.name = "top-menu-container";
container.AddToClassList("top-menu");
_menuContainer = new VisualElement();
_menuContainer.name = "menu-container";
_menuContainer.AddToClassList("top-menu__items");
container.Add(_menuContainer);
Add(container);
}
/// <summary>
/// SubMenu들을 담을 부모 Container를 설정합니다.
/// panel.rootVisualElement에 추가하여 화면 전체에 절대 위치로 표시합니다.
/// </summary>
private void SetupSubMenuParentContainer()
{
// panel.visualTree를 rootVisualElement로 사용합니다.
// visualTree는 항상 (0,0) origin을 가지므로 worldBound 좌표와 일치합니다.
if (panel == null) return;
VisualElement root = panel.visualTree;
// SubMenuParentContainer가 없으면 생성
if (_subMenuParentContainer == null)
{
_subMenuParentContainer = new VisualElement();
_subMenuParentContainer.name = "submenu-parent-container";
_subMenuParentContainer.style.position = Position.Absolute;
_subMenuParentContainer.style.left = 0;
_subMenuParentContainer.style.top = 0;
_subMenuParentContainer.style.width = Length.Percent(100);
_subMenuParentContainer.style.height = Length.Percent(100);
_subMenuParentContainer.pickingMode = PickingMode.Position; // 자식 이벤트 정상 수신
// 가장 바닥에 전체 화면 투명 blocker 배치 (메뉴 외부 클릭 감지용)
_subMenuBlocker = new VisualElement();
_subMenuBlocker.name = "submenu-blocker";
_subMenuBlocker.style.position = Position.Absolute;
_subMenuBlocker.style.left = 0;
_subMenuBlocker.style.top = 0;
_subMenuBlocker.style.width = Length.Percent(100);
_subMenuBlocker.style.height = Length.Percent(100);
_subMenuBlocker.style.backgroundColor = new StyleColor(new Color(0, 0, 0, 0)); // 투명
_subMenuBlocker.RegisterCallback<PointerDownEvent>(OnBlockerPointerDown);
_subMenuParentContainer.Add(_subMenuBlocker);
}
// panel.visualTree에 추가 (아직 추가되지 않았으면)
if (_subMenuParentContainer.parent != root)
{
_subMenuParentContainer.RemoveFromHierarchy();
root.Add(_subMenuParentContainer);
UTKThemeManager.Instance.ApplyThemeToElement(_subMenuParentContainer);
if (_loadedUss != null)
{
_subMenuParentContainer.styleSheets.Add(_loadedUss);
}
}
_subMenuParentContainer.style.display = DisplayStyle.Flex;
}
/// <summary>
/// SubMenuParentContainer를 정리합니다.
/// </summary>
private void CleanupSubMenuParentContainer()
{
if (_subMenuParentContainer != null)
{
_subMenuParentContainer.style.display = DisplayStyle.None;
_subMenuParentContainer.RemoveFromHierarchy();
}
}
/// <summary>
/// 투명 blocker 클릭 시 호출됩니다 (메뉴 외부 클릭 감지).
/// </summary>
private void OnBlockerPointerDown(PointerDownEvent evt)
{
CloseAllOpenSubMenus();
}
/// <summary>
/// 1depth 메뉴가 열렸을 때 _menuContainer를 _subMenuParentContainer의 index 1에 삽입합니다.
/// z-order: blocker(0) → _menuContainer(1) → subMenuContainer(2+)
/// _menuContainer는 absolute 위치로 고정되어 원래 보이던 위치에 그대로 표시됩니다.
/// </summary>
private void ElevateMenuContainer()
{
if (_menuContainer == null || _subMenuParentContainer == null) return;
if (_menuContainer.parent == _subMenuParentContainer) return; // 이미 올라간 상태
// 원래 부모/인덱스 저장
_menuContainerOriginalParent = _menuContainer.parent;
_menuContainerOriginalIndex = _menuContainerOriginalParent?.IndexOf(_menuContainer) ?? -1;
// worldBound를 캐시된 값이 없을 때만 캡처합니다.
// RestoreMenuContainer() 직후 바로 재호출되는 경우
// (다른 1depth 메뉴 클릭 시: Restore → 즉시 Elevate),
// 레이아웃 재계산 전이라 worldBound가 아직 absolute 기준이므로 좌표가 누적됩니다.
// 캐시된 값을 재사용하면 항상 원래 위치의 좌표를 사용합니다.
if (_menuContainerCachedBounds == default)
{
_menuContainerCachedBounds = _menuContainer.worldBound;
}
// absolute 위치 고정 후 _subMenuParentContainer에 index 1로 삽입
// (blocker 위, 서브메뉴 아래 z-order 보장)
_menuContainer.style.position = Position.Absolute;
_menuContainer.style.left = _menuContainerCachedBounds.x;
_menuContainer.style.top = _menuContainerCachedBounds.y;
_menuContainer.style.width = _menuContainerCachedBounds.width;
_menuContainer.style.height = _menuContainerCachedBounds.height;
_menuContainer.RemoveFromHierarchy();
_subMenuParentContainer.Insert(1, _menuContainer);
}
/// <summary>
/// 1depth 메뉴가 닫혔을 때 _menuContainer를 원래 부모/인덱스로 복원합니다.
/// </summary>
private void RestoreMenuContainer()
{
if (_menuContainer == null) return;
if (_menuContainer.parent != _subMenuParentContainer) return; // 이미 복원된 상태
// absolute 스타일 초기화
_menuContainer.style.position = StyleKeyword.Null;
_menuContainer.style.left = StyleKeyword.Null;
_menuContainer.style.top = StyleKeyword.Null;
_menuContainer.style.width = StyleKeyword.Null;
_menuContainer.style.height = StyleKeyword.Null;
// 원래 부모/인덱스로 복원
_menuContainer.RemoveFromHierarchy();
if (_menuContainerOriginalParent != null && _menuContainerOriginalIndex >= 0)
{
_menuContainerOriginalParent.Insert(_menuContainerOriginalIndex, _menuContainer);
}
else
{
_menuContainerOriginalParent?.Add(_menuContainer);
}
_menuContainerOriginalParent = null;
_menuContainerOriginalIndex = -1;
// 캐시는 여기서 초기화하지 않습니다.
// 다른 1depth 메뉴 클릭 시 Restore → 즉시 Elevate 흐름에서
// 레이아웃 재계산 전에 worldBound를 읽으면 좌표가 누적되므로
// 캐시된 원래 위치 값을 재사용합니다.
// 캐시 초기화는 완전히 닫힐 때(CleanupSubMenuParentContainer) 수행합니다.
}
/// <summary>
/// 테마 및 언어 변경 이벤트를 구독합니다.
/// </summary>
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
if (_locManager != null)
{
_locManager.OnLanguageChanged += OnLanguageChanged;
}
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 언어 변경 이벤트 재구독
if (_locManager != null)
{
_locManager.OnLanguageChanged -= OnLanguageChanged;
_locManager.OnLanguageChanged += OnLanguageChanged;
}
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
// 언어 변경 이벤트 해제
if (_locManager != null)
{
_locManager.OnLanguageChanged -= OnLanguageChanged;
}
// _menuContainer가 올라가 있으면 원래 위치로 복원
RestoreMenuContainer();
// SubMenuParentContainer 정리
CleanupSubMenuParentContainer();
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
// SubMenuParentContainer에도 테마 적용
if (_subMenuParentContainer != null)
{
UTKThemeManager.Instance.ApplyThemeToElement(_subMenuParentContainer);
}
// 모든 SubMenuContainer에도 테마 적용
foreach (var subMenuContainer in _subMenuContainers.Values)
{
UTKThemeManager.Instance.ApplyThemeToElement(subMenuContainer);
}
}
/// <summary>
/// 언어 변경 시 호출됩니다.
/// </summary>
/// <param name="newLanguageCode">새로운 언어 코드</param>
private void OnLanguageChanged(string newLanguageCode)
{
// 모든 서브메뉴 아이템의 label 텍스트 업데이트
foreach (var kvp in _menuItemElements)
{
string itemId = kvp.Key;
VisualElement menuItemElement = kvp.Value;
if (!_menuItemDataMap.TryGetValue(itemId, out var itemData))
continue;
var label = menuItemElement.Q<UTKLabel>("label");
if (label != null)
{
label.Text = _locManager?.GetString(itemData.DisplayName) ?? itemData.DisplayName;
}
}
}
#endregion
#region Public Methods
/// <summary>
/// 메뉴 아이템들을 생성하고 배치합니다.
/// </summary>
/// <param name="items">메뉴 아이템 데이터 리스트</param>
/// <param name="parentContainer">부모 컨테이너</param>
/// <param name="depth">메뉴 깊이 (0: 최상위)</param>
/// <exception cref="ArgumentNullException">items 또는 parentContainer가 null인 경우</exception>
public virtual void CreateMenuItems(List<UTKMenuItemData> items, VisualElement? parentContainer = null, int depth = 0)
{
if (items == null)
throw new ArgumentNullException(nameof(items), "메뉴 아이템 리스트가 null입니다.");
if (parentContainer == null) parentContainer = _menuContainer;
if (parentContainer == null)
throw new ArgumentNullException(nameof(parentContainer), "부모 컨테이너가 null입니다.");
foreach (var itemData in items)
{
_menuItemDataMap[itemData.ItemId] = itemData;
if (itemData.IsSeparator)
{
// 구분선 생성
CreateSeparator(itemData, parentContainer);
}
else
{
// 일반 메뉴 아이템 생성
if (depth == 0)
{
CreateTopMenuItem(itemData, parentContainer, depth);
}
else
{
CreateSubMenuItem(itemData, parentContainer, depth);
}
}
}
// 최상위 메뉴인 경우 간격 적용
if (depth == 0)
{
ApplyItemSpacing();
}
}
/// <summary>
/// 모든 메뉴 아이템을 제거합니다.
/// </summary>
public virtual void ClearMenuItems()
{
CloseAllOpenSubMenus();
// 모든 메뉴 아이템 제거
foreach (var element in _menuItemElements.Values)
{
if (element is IDisposable disposable)
{
disposable.Dispose();
}
element.RemoveFromHierarchy();
}
_menuItemElements.Clear();
// 모든 하위 메뉴 컨테이너 제거
foreach (var container in _subMenuContainers.Values)
{
container.RemoveFromHierarchy();
}
_subMenuContainers.Clear();
_menuItemDataMap.Clear();
// 열린 서브메뉴 추적 초기화 (성능 개선)
_openSubMenuIds.Clear();
// 메뉴 컨테이너 정리
_menuContainer?.Clear();
}
/// <summary>
/// 모든 메뉴 텍스트를 업데이트합니다 (언어 변경 시).
/// </summary>
/// <param name="items">메뉴 아이템 리스트</param>
public virtual void UpdateAllMenuTexts(List<UTKMenuItemData> items)
{
if (_locManager == null)
{
Debug.LogWarning("LocalizationManager가 없어 메뉴 텍스트를 업데이트할 수 없습니다.");
return;
}
UpdateMenuTextsRecursive(items);
}
/// <summary>
/// 특정 메뉴 아이템의 단축키를 업데이트합니다.
/// </summary>
/// <param name="itemId">메뉴 아이템 ID</param>
/// <param name="shortcut">단축키 문자열</param>
public virtual void UpdateShortcutText(string itemId, string? shortcut)
{
if (!_menuItemElements.TryGetValue(itemId, out var element))
return;
var shortcutLabel = element.Q<Label>("shortcut");
if (shortcutLabel != null)
{
shortcutLabel.text = shortcut ?? string.Empty;
}
}
/// <summary>
/// 모든 메뉴 아이템의 단축키를 업데이트합니다.
/// </summary>
/// <param name="items">메뉴 아이템 리스트</param>
public virtual void UpdateAllShortcuts(List<UTKMenuItemData> items)
{
UpdateShortcutsRecursive(items);
}
/// <summary>
/// 메뉴 아이템 VisualElement를 가져옵니다.
/// </summary>
/// <param name="itemId">메뉴 아이템 ID</param>
/// <param name="menuItemElement">찾은 VisualElement</param>
/// <returns>찾았으면 true, 그렇지 않으면 false</returns>
public bool TryGetMenuItemElement(string itemId, out VisualElement? menuItemElement)
{
return _menuItemElements.TryGetValue(itemId, out menuItemElement);
}
/// <summary>
/// ItemId로 메뉴 아이템 데이터를 가져옵니다.
/// </summary>
/// <param name="itemId">메뉴 아이템 ID</param>
/// <param name="itemData">찾은 메뉴 아이템 데이터</param>
/// <returns>찾았으면 true, 그렇지 않으면 false</returns>
public bool TryGetMenuItemData(string itemId, out UTKMenuItemData? itemData)
{
return _menuItemDataMap.TryGetValue(itemId, out itemData);
}
/// <summary>
/// ItemId로 메뉴 아이템의 Command를 실행합니다.
/// Command가 없거나 비활성화된 아이템이면 false를 반환합니다.
/// </summary>
/// <param name="itemId">실행할 메뉴 아이템 ID</param>
/// <returns>Command가 실행되었으면 true, 그렇지 않으면 false</returns>
public bool ExecuteCommand(string itemId)
{
if (!_menuItemDataMap.TryGetValue(itemId, out var itemData))
return false;
if (!itemData.IsEnabled || itemData.Command == null)
return false;
itemData.Command.Execute(itemData.CommandParameter);
OnMenuItemClicked?.Invoke(itemData);
CloseAllOpenSubMenus();
return true;
}
/// <summary>
/// 모든 열린 하위 메뉴를 닫습니다.
/// </summary>
public virtual void CloseAllOpenSubMenus()
{
bool anyActuallyClosed = false;
// 성능 개선: 열린 서브메뉴만 닫기 (전체 순회 대신 추적 사용)
foreach (var menuId in _openSubMenuIds)
{
if (_subMenuContainers.TryGetValue(menuId, out var subMenuContainer))
{
subMenuContainer.style.display = DisplayStyle.None;
anyActuallyClosed = true;
}
}
// 열린 서브메뉴 추적 초기화
_openSubMenuIds.Clear();
// 1depth 메뉴가 올라가 있었으면 원래 위치로 복원
RestoreMenuContainer();
if (anyActuallyClosed || _isAnySubMenuOpen)
{
_isAnySubMenuOpen = false;
// 모든 서브메뉴가 닫혔으므로 SubMenuParentContainer도 정리
CleanupSubMenuParentContainer();
}
}
#endregion
#region Private Methods -
/// <summary>
/// 메뉴 아이템 간 간격을 적용합니다.
/// </summary>
private void ApplyItemSpacing()
{
if (_menuContainer == null) return;
for (int i = 0; i < _menuContainer.childCount; i++)
{
var child = _menuContainer[i];
if (_orientation == UTKMenuOrientation.Horizontal)
{
child.style.marginLeft = i > 0 ? _itemSpacing : 0;
child.style.marginTop = 0;
}
else
{
child.style.marginTop = i > 0 ? _itemSpacing : 0;
child.style.marginLeft = 0;
}
}
}
/// <summary>
/// 메뉴 컨테이너의 정렬 방향을 적용합니다.
/// </summary>
private void ApplyOrientation()
{
if (_menuContainer == null) return;
if (_orientation == UTKMenuOrientation.Horizontal)
{
_menuContainer.style.flexDirection = FlexDirection.Row;
_menuContainer.RemoveFromClassList("top-menu__items--vertical");
_menuContainer.AddToClassList("top-menu__items");
}
else
{
_menuContainer.style.flexDirection = FlexDirection.Column;
_menuContainer.RemoveFromClassList("top-menu__items");
_menuContainer.AddToClassList("top-menu__items--vertical");
}
// 간격도 방향에 맞게 재적용
ApplyItemSpacing();
// 최상위 메뉴 아이템의 화살표 표시 갱신
ApplyTopMenuArrows();
}
/// <summary>
/// 최상위 메뉴 아이템의 화살표 표시를 정렬 방향에 맞게 갱신합니다.
/// </summary>
private void ApplyTopMenuArrows()
{
if (_menuContainer == null) return;
foreach (var kvp in _menuItemDataMap)
{
var itemData = kvp.Value;
// 최상위 메뉴만 대상 (Depth == 0)
if (itemData.Depth != 0) continue;
if (_menuItemElements.TryGetValue(kvp.Key, out var element) && element is UTKMenuItemBase menuItem)
{
bool showArrow = _orientation == UTKMenuOrientation.Vertical
&& itemData.SubMenuItems != null
&& itemData.SubMenuItems.Count > 0;
menuItem.ShowArrow(showArrow);
}
}
}
#endregion
#region Private Methods -
/// <summary>
/// 최상위 메뉴 아이템을 생성합니다.
/// </summary>
private void CreateTopMenuItem(UTKMenuItemData itemData, VisualElement parentContainer, int depth)
{
// 타입에 따라 적절한 메뉴 아이템 생성
UTKMenuItemBase menuItem;
if (itemData is UTKMenuImageItemData)
{
menuItem = new UTKTopMenuImageItem();
}
else
{
menuItem = new UTKTopMenuItem();
}
menuItem.SetData(itemData);
// 세로 정렬 시 서브메뉴가 있으면 화살표 표시
bool showArrow = _orientation == UTKMenuOrientation.Vertical
&& itemData.SubMenuItems != null
&& itemData.SubMenuItems.Count > 0;
menuItem.ShowArrow(showArrow);
// 클릭 이벤트 등록
menuItem.OnClicked += (data) =>
{
OnMenuItemClicked?.Invoke(data);
if (data.SubMenuItems != null && data.SubMenuItems.Count > 0)
{
ToggleSubMenuDisplay(data, menuItem, depth);
}
else
{
CloseAllOpenSubMenus();
}
};
parentContainer.Add(menuItem);
_menuItemElements[itemData.ItemId] = menuItem;
// Lazy Loading: 하위 메뉴는 클릭 시 생성 (메모리 최적화)
// 하위 메뉴 생성 코드 제거 - ToggleSubMenuDisplay에서 지연 생성
}
/// <summary>
/// 하위 메뉴 아이템을 생성합니다.
/// </summary>
private void CreateSubMenuItem(UTKMenuItemData itemData, VisualElement parentContainer, int depth)
{
// 리소스 캐싱 (성능 개선: 첫 호출 시에만 로드)
if (_cachedSubMenuItemAsset == null)
{
_cachedSubMenuItemAsset = Resources.Load<VisualTreeAsset>(SUBMENU_ITEM_UXML);
_cachedSubMenuItemUss = Resources.Load<StyleSheet>("UIToolkit/Menu/UTKSubMenuItemUss");
}
VisualElement menuItemElement;
if (_cachedSubMenuItemAsset != null)
{
menuItemElement = _cachedSubMenuItemAsset.Instantiate();
// USS를 하위 메뉴 아이템에 추가
if (_cachedSubMenuItemUss != null)
{
menuItemElement.styleSheets.Add(_cachedSubMenuItemUss);
}
}
else
{
menuItemElement = CreateSubMenuItemFallback();
}
// UI 요소 설정
var button = menuItemElement.Q<Button>("submenu-button");
var label = menuItemElement.Q<UTKLabel>("label");
var shortcut = menuItemElement.Q<UTKLabel>("shortcut");
var arrow = menuItemElement.Q<VisualElement>("arrow");
if (label != null)
{
label.Text = _locManager?.GetString(itemData.DisplayName) ?? itemData.DisplayName;
}
if (shortcut != null)
{
shortcut.Text = itemData.Shortcut ?? string.Empty;
}
if (arrow != null)
{
arrow.style.display = (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
? DisplayStyle.Flex
: DisplayStyle.None;
}
if (button != null)
{
button.SetEnabled(itemData.IsEnabled);
button.RegisterCallback<ClickEvent>(evt =>
{
if (itemData.IsEnabled)
{
OnMenuItemClicked?.Invoke(itemData);
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
ToggleSubMenuDisplay(itemData, menuItemElement, depth);
}
else
{
CloseAllOpenSubMenus();
}
}
});
}
parentContainer.Add(menuItemElement);
_menuItemElements[itemData.ItemId] = menuItemElement;
// Lazy Loading: 하위 메뉴는 클릭 시 생성 (메모리 최적화)
// 하위 메뉴 생성 코드 제거 - ToggleSubMenuDisplay에서 지연 생성
}
/// <summary>
/// Fallback 하위 메뉴 아이템을 생성합니다.
/// </summary>
private VisualElement CreateSubMenuItemFallback()
{
var button = new Button();
button.name = "submenu-button";
button.AddToClassList("submenu-item");
var label = new UTKLabel();
label.name = "label";
label.AddToClassList("submenu-item__label");
var spacer = new VisualElement();
spacer.AddToClassList("submenu-item__spacer");
var shortcut = new UTKLabel();
shortcut.name = "shortcut";
shortcut.AddToClassList("submenu-item__shortcut");
var arrow = new VisualElement();
arrow.name = "arrow";
arrow.AddToClassList("submenu-item__arrow");
button.Add(label);
button.Add(spacer);
button.Add(shortcut);
button.Add(arrow);
return button;
}
/// <summary>
/// 구분선을 생성합니다.
/// </summary>
private void CreateSeparator(UTKMenuItemData itemData, VisualElement parentContainer)
{
var separator = new VisualElement();
separator.name = $"Separator_{itemData.ItemId}";
separator.AddToClassList("submenu-separator");
parentContainer.Add(separator);
_menuItemElements[itemData.ItemId] = separator;
}
/// <summary>
/// 하위 메뉴 컨테이너를 생성합니다.
/// </summary>
private void CreateSubMenuContainer(UTKMenuItemData itemData, VisualElement menuItemElement, int depth)
{
var subMenuContainer = new VisualElement();
subMenuContainer.name = $"SubMenuContainer_{itemData.ItemId}";
subMenuContainer.AddToClassList("submenu-container");
subMenuContainer.style.display = DisplayStyle.None;
subMenuContainer.style.position = Position.Absolute;
subMenuContainer.pickingMode = PickingMode.Ignore; // 자식 요소만 이벤트 수신
// 하위 메뉴 아이템 생성
CreateMenuItems(itemData.SubMenuItems!, subMenuContainer, depth + 1);
// SubMenuParentContainer에 추가
if (_subMenuParentContainer != null)
{
subMenuContainer.RemoveFromHierarchy();
_subMenuParentContainer.Add(subMenuContainer);
// 테마 및 스타일 적용
UTKThemeManager.Instance.ApplyThemeToElement(subMenuContainer);
// 리소스 캐싱 사용 (성능 개선)
if (_cachedSubMenuItemUss == null)
{
_cachedSubMenuItemUss = Resources.Load<StyleSheet>("UIToolkit/Menu/UTKSubMenuItemUss");
}
if (_cachedSubMenuItemUss != null)
{
subMenuContainer.styleSheets.Add(_cachedSubMenuItemUss);
}
}
_subMenuContainers[itemData.ItemId] = subMenuContainer;
}
#endregion
#region Private Methods -
/// <summary>
/// 하위 메뉴 표시를 토글합니다.
/// </summary>
private void ToggleSubMenuDisplay(UTKMenuItemData itemData, VisualElement menuItemElement, int depth)
{
// SubMenuParentContainer 먼저 초기화 (서브메뉴 생성 전에 필요, panel 연결 후에만 가능)
if (panel == null) return;
if (_subMenuParentContainer == null || _subMenuParentContainer.panel == null)
{
SetupSubMenuParentContainer();
}
// Lazy Loading: 서브메뉴가 없으면 이때 생성 (메모리 최적화)
if (!_subMenuContainers.TryGetValue(itemData.ItemId, out var subMenuContainer))
{
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
// 서브메뉴 지연 생성
CreateSubMenuContainer(itemData, menuItemElement, depth);
if (!_subMenuContainers.TryGetValue(itemData.ItemId, out subMenuContainer))
{
Debug.LogWarning($"SubMenuContainer 생성 실패: {itemData.ItemId}");
return;
}
}
else
{
Debug.LogWarning($"SubMenuContainer를 찾을 수 없습니다: {itemData.ItemId}");
return;
}
}
bool isCurrentlyVisible = subMenuContainer.style.display == DisplayStyle.Flex;
bool willBeVisible = !isCurrentlyVisible;
if (willBeVisible)
{
// 다른 하위 메뉴 먼저 닫기 + SubMenuParentContainer 재설정
if (depth == 0)
{
CloseAllOpenSubMenus();
// CloseAllOpenSubMenus에서 _subMenuParentContainer가 계층에서 제거되므로 재설정
SetupSubMenuParentContainer();
}
// SubMenuContainer를 SubMenuParentContainer에 추가 (아직 추가되지 않았으면)
if (subMenuContainer.parent != _subMenuParentContainer)
{
subMenuContainer.RemoveFromHierarchy();
_subMenuParentContainer?.Add(subMenuContainer);
}
// 위치 설정
PositionSubMenu(subMenuContainer, menuItemElement, depth);
// 표시
subMenuContainer.style.display = DisplayStyle.Flex;
_isAnySubMenuOpen = true;
// 열린 서브메뉴 추적 (성능 개선)
_openSubMenuIds.Add(itemData.ItemId);
// 1depth 메뉴가 열렸을 때 _menuContainer를 blocker와 subMenuContainer 사이에 삽입
// 순서: blocker(0) → _menuContainer(1) → subMenuContainer(2...)
if (depth == 0)
{
ElevateMenuContainer();
}
}
else
{
// 숨김
subMenuContainer.style.display = DisplayStyle.None;
// 열린 서브메뉴 추적 제거 (성능 개선)
_openSubMenuIds.Remove(itemData.ItemId);
CloseSubMenuAndDescendants(itemData);
CheckIfAnySubMenuRemainsOpen();
// 토글로 1depth 메뉴 닫을 때도 _menuContainer 복원
if (depth == 0)
{
RestoreMenuContainer();
CleanupSubMenuParentContainer();
}
}
}
/// <summary>
/// 하위 메뉴 위치를 설정합니다.
/// </summary>
private void PositionSubMenu(VisualElement subMenuContainer, VisualElement menuItemElement, int depth)
{
subMenuContainer.style.position = Position.Absolute;
if (depth == 0)
{
// depth==0은 ElevateMenuContainer() 호출 전후로 _menuContainer의 레이아웃이
// 재계산 중일 수 있으므로, worldBound 대신 캐시된 좌표 + 로컬 layout을 사용
// (_menuContainerCachedBounds: _menuContainer가 정상 위치일 때 캡처된 절대 좌표)
var localLayout = menuItemElement.layout;
var containerOrigin = _menuContainerCachedBounds;
float absX = containerOrigin.x + localLayout.x;
float absY = containerOrigin.y + localLayout.y;
float absYMax = containerOrigin.y + localLayout.yMax;
float absXMax = containerOrigin.x + localLayout.xMax;
if (_orientation == UTKMenuOrientation.Horizontal)
{
// 가로 정렬: 아래쪽에 표시 (offset 적용)
subMenuContainer.style.left = absX + _subMenuOffsetX;
subMenuContainer.style.top = absYMax + _subMenuOffsetY;
}
else
{
// 세로 정렬: 오른쪽에 표시 (offset 적용)
subMenuContainer.style.left = absXMax + _subMenuOffsetX;
subMenuContainer.style.top = absY + _subMenuOffsetY;
}
}
else
{
// 2차 이상: worldBound 사용 (부모가 이미 absolute로 안정적으로 배치됨)
var menuItemRect = menuItemElement.worldBound;
subMenuContainer.style.left = menuItemRect.xMax;
subMenuContainer.style.top = menuItemRect.y;
}
}
/// <summary>
/// 하위 메뉴와 그 자손들을 재귀적으로 닫습니다.
/// </summary>
private void CloseSubMenuAndDescendants(UTKMenuItemData itemData)
{
if (itemData.SubMenuItems == null)
return;
foreach (var subItem in itemData.SubMenuItems)
{
if (_subMenuContainers.TryGetValue(subItem.ItemId, out var subContainer))
{
if (subContainer.style.display == DisplayStyle.Flex)
{
subContainer.style.display = DisplayStyle.None;
// 열린 서브메뉴 추적 제거 (성능 개선)
_openSubMenuIds.Remove(subItem.ItemId);
CloseSubMenuAndDescendants(subItem);
}
}
}
}
/// <summary>
/// 열린 하위 메뉴가 남아있는지 확인합니다.
/// </summary>
private void CheckIfAnySubMenuRemainsOpen()
{
_isAnySubMenuOpen = false;
foreach (var subMenuContainer in _subMenuContainers.Values)
{
if (subMenuContainer.style.display == DisplayStyle.Flex)
{
_isAnySubMenuOpen = true;
break;
}
}
}
#endregion
#region Private Methods -
/// <summary>
/// 메뉴 텍스트를 재귀적으로 업데이트합니다.
/// </summary>
private void UpdateMenuTextsRecursive(List<UTKMenuItemData> items)
{
if (items == null || _locManager == null)
return;
foreach (var itemData in items)
{
if (itemData.IsSeparator)
continue;
if (_menuItemElements.TryGetValue(itemData.ItemId, out var element))
{
var label = element.Q<Label>("label");
if (label != null)
{
label.text = _locManager.GetString(itemData.DisplayName);
}
}
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
UpdateMenuTextsRecursive(itemData.SubMenuItems);
}
}
}
/// <summary>
/// 단축키를 재귀적으로 업데이트합니다.
/// </summary>
private void UpdateShortcutsRecursive(List<UTKMenuItemData> items)
{
if (items == null)
return;
foreach (var itemData in items)
{
if (itemData.IsSeparator)
continue;
UpdateShortcutText(itemData.ItemId, itemData.Shortcut);
if (itemData.SubMenuItems != null && itemData.SubMenuItems.Count > 0)
{
UpdateShortcutsRecursive(itemData.SubMenuItems);
}
}
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 이벤트 구독 해제
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
if (_locManager != null)
{
_locManager.OnLanguageChanged -= OnLanguageChanged;
}
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
// 메뉴 아이템 정리
foreach (var element in _menuItemElements.Values)
{
if (element is IDisposable disposable)
{
disposable.Dispose();
}
}
_menuItemElements.Clear();
_subMenuContainers.Clear();
_menuItemDataMap.Clear();
_openSubMenuIds.Clear();
// _menuContainer가 올라가 있으면 원래 위치로 복원
RestoreMenuContainer();
// SubMenuParentContainer 정리
_subMenuBlocker?.UnregisterCallback<PointerDownEvent>(OnBlockerPointerDown);
_subMenuBlocker = null;
CleanupSubMenuParentContainer();
// 참조 정리
OnMenuItemClicked = null;
_menuContainer?.UnregisterCallback<GeometryChangedEvent>(OnMenuContainerGeometryChanged);
_menuContainer = null;
_subMenuParentContainer = null;
_locManager = null;
_cachedSubMenuItemAsset = null;
_cachedSubMenuItemUss = null;
}
#endregion
}
}