491 lines
17 KiB
C#
491 lines
17 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using Cysharp.Threading.Tasks;
|
|
using UnityEngine;
|
|
using UnityEngine.UIElements;
|
|
using UVC.Locale;
|
|
|
|
namespace UVC.UIToolkit
|
|
{
|
|
/// <summary>
|
|
/// UIToolkit 기반 툴팁 매니저.
|
|
/// VisualElement에 마우스 오버 시 툴팁을 표시하는 싱글톤 관리자입니다.
|
|
/// panel.visualTree를 사용하여 모든 UI 위에 툴팁을 표시합니다.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Tooltip(툴팁)이란?</b></para>
|
|
/// <para>
|
|
/// Tooltip은 UI 요소에 마우스를 올렸을 때 나타나는 작은 설명 텍스트입니다.
|
|
/// 버튼이나 아이콘의 기능을 설명하거나 추가 정보를 제공할 때 사용합니다.
|
|
/// 일반적으로 잠시 후(500ms) 나타나고, 마우스가 벗어나면 사라집니다.
|
|
/// </para>
|
|
///
|
|
/// <para><b>싱글톤 패턴:</b></para>
|
|
/// <para>
|
|
/// UTKTooltipManager는 싱글톤으로 구현되어 있습니다.
|
|
/// <c>UTKTooltipManager.Instance</c>로 접근하며, 앱 전체에서 하나의 툴팁 UI를 공유합니다.
|
|
/// panel.visualTree를 사용하므로 별도 Initialize 호출이 필요 없습니다.
|
|
/// </para>
|
|
///
|
|
/// <para><b>주요 기능:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item><description>지연 표시 - 마우스 오버 후 일정 시간 뒤에 표시</description></item>
|
|
/// <item><description>마우스 따라가기 - 마우스 이동에 따라 위치 업데이트</description></item>
|
|
/// <item><description>화면 경계 처리 - 화면 밖으로 나가지 않도록 자동 조정</description></item>
|
|
/// <item><description>다국어 지원 - 로컬라이제이션 키 자동 변환</description></item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>주요 메서드:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item><description><c>AttachTooltip(element, text)</c> - 요소에 툴팁 연결</description></item>
|
|
/// <item><description><c>DetachTooltip(element)</c> - 툴팁 제거</description></item>
|
|
/// <item><description><c>Show(text, position)</c> - 즉시 표시</description></item>
|
|
/// <item><description><c>Hide()</c> - 숨기기</description></item>
|
|
/// </list>
|
|
///
|
|
/// <para><b>실제 활용 예시:</b></para>
|
|
/// <list type="bullet">
|
|
/// <item><description>아이콘 버튼 - "저장", "삭제", "설정" 등 기능 설명</description></item>
|
|
/// <item><description>복잡한 옵션 - 설정 항목의 상세 설명</description></item>
|
|
/// <item><description>잘린 텍스트 - 전체 내용 표시</description></item>
|
|
/// <item><description>단축키 안내 - "Ctrl+S" 등 키보드 힌트</description></item>
|
|
/// </list>
|
|
/// </remarks>
|
|
/// <example>
|
|
/// <para><b>C# 코드에서 사용:</b></para>
|
|
/// <code>
|
|
/// // 1. 버튼에 툴팁 연결 (Initialize 불필요)
|
|
/// var saveButton = new UTKButton("", UTKMaterialIcons.Save);
|
|
/// UTKTooltipManager.Instance.AttachTooltip(saveButton, "저장 (Ctrl+S)");
|
|
///
|
|
/// // 2. 다국어 키로 툴팁 연결
|
|
/// UTKTooltipManager.Instance.AttachTooltip(settingsButton, "tooltip_settings");
|
|
///
|
|
/// // 3. 툴팁 업데이트
|
|
/// UTKTooltipManager.Instance.UpdateTooltip(button, "새로운 설명");
|
|
///
|
|
/// // 4. 툴팁 제거
|
|
/// UTKTooltipManager.Instance.DetachTooltip(button);
|
|
/// </code>
|
|
/// </example>
|
|
public class UTKTooltipManager : IDisposable
|
|
{
|
|
#region Singleton
|
|
private static UTKTooltipManager? _instance;
|
|
public static UTKTooltipManager Instance => _instance ??= new UTKTooltipManager();
|
|
|
|
protected UTKTooltipManager() { }
|
|
#endregion
|
|
|
|
#region Constants
|
|
private const string UXML_PATH = "UIToolkit/Common/UTKTooltip";
|
|
private const string USS_PATH = "UIToolkit/Common/UTKTooltip";
|
|
private const int SHOW_DELAY_MS = 500;
|
|
private const int POSITION_OFFSET = 10;
|
|
#endregion
|
|
|
|
#region Fields
|
|
private VisualElement? _tooltipContainer;
|
|
private Label? _tooltipLabel;
|
|
private bool _isVisible;
|
|
private bool _disposed;
|
|
private StyleSheet? _loadedUss;
|
|
|
|
private CancellationTokenSource? _showDelayCts;
|
|
private readonly Dictionary<VisualElement, string> _tooltipRegistry = new();
|
|
private readonly Dictionary<VisualElement, EventCallback<PointerEnterEvent>> _enterCallbacks = new();
|
|
private readonly Dictionary<VisualElement, EventCallback<PointerLeaveEvent>> _leaveCallbacks = new();
|
|
private readonly Dictionary<VisualElement, EventCallback<PointerMoveEvent>> _moveCallbacks = new();
|
|
#endregion
|
|
|
|
#region Properties
|
|
public bool IsVisible => _isVisible;
|
|
#endregion
|
|
|
|
#region Initialization
|
|
/// <summary>
|
|
/// 툴팁 UI를 생성합니다 (아직 visual tree에 추가하지 않음).
|
|
/// </summary>
|
|
private void EnsureTooltipUI()
|
|
{
|
|
if (_tooltipContainer != null) return;
|
|
|
|
// USS 로드
|
|
_loadedUss = Resources.Load<StyleSheet>(USS_PATH);
|
|
|
|
// UXML 로드 시도
|
|
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
|
|
if (visualTree != null)
|
|
{
|
|
_tooltipContainer = visualTree.Instantiate();
|
|
_tooltipContainer.name = "utk-tooltip-container";
|
|
_tooltipLabel = _tooltipContainer.Q<Label>("tooltip-label");
|
|
}
|
|
else
|
|
{
|
|
// UXML 없으면 코드로 생성
|
|
CreateTooltipUI();
|
|
}
|
|
|
|
if (_tooltipContainer != null)
|
|
{
|
|
_tooltipContainer.style.position = Position.Absolute;
|
|
_tooltipContainer.style.display = DisplayStyle.None;
|
|
_tooltipContainer.pickingMode = PickingMode.Ignore;
|
|
}
|
|
|
|
// 테마 변경 이벤트 구독
|
|
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
|
|
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 테마 변경 시 호출
|
|
/// </summary>
|
|
private void OnThemeChanged(UTKTheme theme)
|
|
{
|
|
if (_tooltipContainer != null)
|
|
{
|
|
UTKThemeManager.Instance.ApplyThemeToElement(_tooltipContainer);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 코드로 툴팁 UI 생성
|
|
/// </summary>
|
|
private void CreateTooltipUI()
|
|
{
|
|
_tooltipContainer = new VisualElement
|
|
{
|
|
name = "utk-tooltip-container",
|
|
pickingMode = PickingMode.Ignore
|
|
};
|
|
|
|
// USS 클래스로 스타일 적용
|
|
_tooltipContainer.AddToClassList("utk-tooltip-container");
|
|
|
|
_tooltipLabel = new Label
|
|
{
|
|
name = "tooltip-label",
|
|
pickingMode = PickingMode.Ignore
|
|
};
|
|
_tooltipLabel.AddToClassList("utk-tooltip-label");
|
|
|
|
_tooltipContainer.Add(_tooltipLabel);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 툴팁 컨테이너를 대상 요소의 panel.visualTree에 추가합니다.
|
|
/// </summary>
|
|
/// <param name="element">대상 요소 (panel 접근용)</param>
|
|
private void AttachToPanel(VisualElement element)
|
|
{
|
|
if (_tooltipContainer == null || element.panel == null) return;
|
|
|
|
var visualTree = element.panel.visualTree;
|
|
|
|
// 이미 해당 visualTree에 추가되어 있으면 스킵
|
|
if (_tooltipContainer.parent == visualTree) return;
|
|
|
|
// 다른 곳에 붙어 있으면 제거
|
|
_tooltipContainer.RemoveFromHierarchy();
|
|
|
|
// panel.visualTree에 추가
|
|
visualTree.Add(_tooltipContainer);
|
|
|
|
// 테마/USS 재적용
|
|
UTKThemeManager.Instance.ApplyThemeToElement(_tooltipContainer);
|
|
if (_loadedUss != null)
|
|
{
|
|
_tooltipContainer.styleSheets.Add(_loadedUss);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Public Methods
|
|
/// <summary>
|
|
/// 툴팁을 즉시 표시합니다.
|
|
/// </summary>
|
|
/// <param name="text">표시할 텍스트</param>
|
|
/// <param name="position">월드 좌표</param>
|
|
public void Show(string text, Vector2 position)
|
|
{
|
|
if (_tooltipContainer == null || _tooltipLabel == null)
|
|
return;
|
|
|
|
// 다국어 처리
|
|
string displayText = GetLocalizedText(text);
|
|
if (string.IsNullOrEmpty(displayText))
|
|
{
|
|
Hide();
|
|
return;
|
|
}
|
|
|
|
_tooltipLabel.text = displayText;
|
|
_tooltipContainer.style.display = DisplayStyle.Flex;
|
|
_tooltipContainer.BringToFront();
|
|
_isVisible = true;
|
|
|
|
// 다음 프레임에 위치 조정 (레이아웃 계산 후)
|
|
_tooltipContainer.schedule.Execute(() => AdjustPosition(position));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 지연 후 툴팁을 표시합니다.
|
|
/// </summary>
|
|
/// <param name="text">표시할 텍스트</param>
|
|
/// <param name="position">월드 좌표</param>
|
|
/// <param name="delayMs">지연 시간 (밀리초)</param>
|
|
public async UniTaskVoid ShowDelayed(string text, Vector2 position, int delayMs = SHOW_DELAY_MS)
|
|
{
|
|
CancelShowDelay();
|
|
_showDelayCts = new CancellationTokenSource();
|
|
|
|
try
|
|
{
|
|
await UniTask.Delay(delayMs, cancellationToken: _showDelayCts.Token);
|
|
Show(text, position);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 취소됨 - 무시
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 툴팁을 숨깁니다.
|
|
/// </summary>
|
|
public void Hide()
|
|
{
|
|
CancelShowDelay();
|
|
|
|
if (_tooltipContainer != null)
|
|
{
|
|
_tooltipContainer.style.display = DisplayStyle.None;
|
|
}
|
|
_isVisible = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// VisualElement에 툴팁을 연결합니다.
|
|
/// </summary>
|
|
/// <param name="element">대상 요소</param>
|
|
/// <param name="tooltip">툴팁 텍스트 또는 다국어 키</param>
|
|
public void AttachTooltip(VisualElement element, string tooltip)
|
|
{
|
|
if (element == null || string.IsNullOrEmpty(tooltip))
|
|
return;
|
|
|
|
// 기존 등록 제거
|
|
DetachTooltip(element);
|
|
|
|
// 툴팁 UI 생성 보장
|
|
EnsureTooltipUI();
|
|
|
|
_tooltipRegistry[element] = tooltip;
|
|
|
|
// 이벤트 콜백 생성 및 등록
|
|
EventCallback<PointerEnterEvent> enterCallback = evt =>
|
|
{
|
|
if (_tooltipRegistry.TryGetValue(element, out var text))
|
|
{
|
|
// panel.visualTree에 툴팁 컨테이너 추가
|
|
AttachToPanel(element);
|
|
|
|
// worldBound 기준 좌표 사용
|
|
var worldPos = element.LocalToWorld(evt.localPosition);
|
|
ShowDelayed(text, worldPos).Forget();
|
|
}
|
|
};
|
|
|
|
EventCallback<PointerLeaveEvent> leaveCallback = _ => Hide();
|
|
|
|
EventCallback<PointerMoveEvent> moveCallback = evt =>
|
|
{
|
|
if (_isVisible)
|
|
{
|
|
// worldBound 기준 좌표 사용
|
|
var worldPos = element.LocalToWorld(evt.localPosition);
|
|
AdjustPosition(worldPos);
|
|
}
|
|
};
|
|
|
|
element.RegisterCallback(enterCallback);
|
|
element.RegisterCallback(leaveCallback);
|
|
element.RegisterCallback(moveCallback);
|
|
|
|
_enterCallbacks[element] = enterCallback;
|
|
_leaveCallbacks[element] = leaveCallback;
|
|
_moveCallbacks[element] = moveCallback;
|
|
}
|
|
|
|
/// <summary>
|
|
/// VisualElement에서 툴팁을 제거합니다.
|
|
/// </summary>
|
|
/// <param name="element">대상 요소</param>
|
|
public void DetachTooltip(VisualElement element)
|
|
{
|
|
if (element == null)
|
|
return;
|
|
|
|
_tooltipRegistry.Remove(element);
|
|
|
|
if (_enterCallbacks.TryGetValue(element, out var enterCallback))
|
|
{
|
|
element.UnregisterCallback(enterCallback);
|
|
_enterCallbacks.Remove(element);
|
|
}
|
|
|
|
if (_leaveCallbacks.TryGetValue(element, out var leaveCallback))
|
|
{
|
|
element.UnregisterCallback(leaveCallback);
|
|
_leaveCallbacks.Remove(element);
|
|
}
|
|
|
|
if (_moveCallbacks.TryGetValue(element, out var moveCallback))
|
|
{
|
|
element.UnregisterCallback(moveCallback);
|
|
_moveCallbacks.Remove(element);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 등록된 툴팁 텍스트를 업데이트합니다.
|
|
/// </summary>
|
|
/// <param name="element">대상 요소</param>
|
|
/// <param name="tooltip">새 툴팁 텍스트</param>
|
|
public void UpdateTooltip(VisualElement element, string tooltip)
|
|
{
|
|
if (element == null)
|
|
return;
|
|
|
|
if (string.IsNullOrEmpty(tooltip))
|
|
{
|
|
DetachTooltip(element);
|
|
}
|
|
else if (_tooltipRegistry.ContainsKey(element))
|
|
{
|
|
_tooltipRegistry[element] = tooltip;
|
|
}
|
|
else
|
|
{
|
|
AttachTooltip(element, tooltip);
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region Private Methods
|
|
/// <summary>
|
|
/// 지연 표시 취소
|
|
/// </summary>
|
|
private void CancelShowDelay()
|
|
{
|
|
_showDelayCts?.Cancel();
|
|
_showDelayCts?.Dispose();
|
|
_showDelayCts = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 다국어 텍스트 가져오기
|
|
/// </summary>
|
|
private string GetLocalizedText(string keyOrText)
|
|
{
|
|
if (string.IsNullOrEmpty(keyOrText))
|
|
return string.Empty;
|
|
|
|
// LocalizationManager 연동
|
|
if (LocalizationManager.Instance != null)
|
|
{
|
|
string localized = LocalizationManager.Instance.GetString(keyOrText);
|
|
if (!string.IsNullOrEmpty(localized) && localized != keyOrText)
|
|
{
|
|
return localized;
|
|
}
|
|
}
|
|
|
|
return keyOrText;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 화면 경계 내에서 위치 조정 (월드 좌표 기준)
|
|
/// </summary>
|
|
private void AdjustPosition(Vector2 worldPosition)
|
|
{
|
|
if (_tooltipContainer == null || _tooltipContainer.panel == null)
|
|
return;
|
|
|
|
var panelRoot = _tooltipContainer.panel.visualTree;
|
|
|
|
var tooltipSize = new Vector2(
|
|
_tooltipContainer.resolvedStyle.width,
|
|
_tooltipContainer.resolvedStyle.height
|
|
);
|
|
|
|
var panelSize = new Vector2(
|
|
panelRoot.resolvedStyle.width,
|
|
panelRoot.resolvedStyle.height
|
|
);
|
|
|
|
// 기본 위치: 마우스 오른쪽 아래
|
|
float x = worldPosition.x + POSITION_OFFSET;
|
|
float y = worldPosition.y + POSITION_OFFSET;
|
|
|
|
// 오른쪽 경계 체크
|
|
if (x + tooltipSize.x > panelSize.x)
|
|
{
|
|
x = worldPosition.x - tooltipSize.x - POSITION_OFFSET;
|
|
}
|
|
|
|
// 아래쪽 경계 체크
|
|
if (y + tooltipSize.y > panelSize.y)
|
|
{
|
|
y = worldPosition.y - tooltipSize.y - POSITION_OFFSET;
|
|
}
|
|
|
|
// 왼쪽/위쪽 경계 체크
|
|
x = Mathf.Max(0, x);
|
|
y = Mathf.Max(0, y);
|
|
|
|
_tooltipContainer.style.left = x;
|
|
_tooltipContainer.style.top = y;
|
|
}
|
|
#endregion
|
|
|
|
#region IDisposable
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
|
|
_disposed = true;
|
|
|
|
// 테마 이벤트 구독 해제
|
|
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
|
|
|
|
CancelShowDelay();
|
|
|
|
// 모든 등록된 요소에서 이벤트 해제
|
|
foreach (var element in new List<VisualElement>(_tooltipRegistry.Keys))
|
|
{
|
|
DetachTooltip(element);
|
|
}
|
|
|
|
_tooltipRegistry.Clear();
|
|
_enterCallbacks.Clear();
|
|
_leaveCallbacks.Clear();
|
|
_moveCallbacks.Clear();
|
|
|
|
// UI 제거
|
|
_tooltipContainer?.RemoveFromHierarchy();
|
|
_tooltipContainer = null;
|
|
_tooltipLabel = null;
|
|
|
|
_isVisible = false;
|
|
_instance = null;
|
|
}
|
|
#endregion
|
|
}
|
|
}
|