Files
XRLib/Assets/Scripts/UVC/UIToolkit/List/UTKShortcutList.cs
2026-02-24 20:01:56 +09:00

640 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
/// <summary>
/// 단축키 설정 리스트 컴포넌트.
/// Command 이름, Ctrl / Shift / Alt 체크박스, Key 입력(읽기전용 + 클릭 시 키 캡처)으로 구성됩니다.
/// </summary>
///
/// <remarks>
/// <para><b>열 구성 (왼쪽 → 오른쪽):</b></para>
/// <list type="bullet">
/// <item>Command 이름 (flex-grow)</item>
/// <item>Ctrl 체크박스 (52 px)</item>
/// <item>Shift 체크박스 (52 px)</item>
/// <item>Alt 체크박스 (52 px)</item>
/// <item>Key 입력 필드 (76 px) 클릭 시 다음 키 자동 캡처</item>
/// </list>
///
/// <para><b>Key 캡처 방법:</b></para>
/// <list type="number">
/// <item>Key 필드 클릭 → 캡처 모드 진입 ("···" 표시)</item>
/// <item>원하는 키 입력 → 자동 저장 후 캡처 종료</item>
/// <item>Escape 키 → 취소 (이전 값 복원)</item>
/// </list>
///
/// <para><b>가상화:</b> UTKListView(ListView) 를 사용하여 대량 항목도 성능 저하 없이 표시합니다.</para>
///
/// <para><b>관련 리소스:</b></para>
/// <list type="bullet">
/// <item>Resources/UIToolkit/List/UTKShortcutList.uxml</item>
/// <item>Resources/UIToolkit/List/UTKShortcutListItem.uxml</item>
/// <item>Resources/UIToolkit/List/UTKShortcutListUss.uss</item>
/// </list>
/// </remarks>
///
/// <example>
/// <code>
/// var list = new UTKShortcutList();
/// list.OnDataChanged += (item) => Debug.Log($"변경: {item.CommandName}");
///
/// list.SetData(new List<UTKShortcutItemData>
/// {
/// new() { Id = "file.new", CommandName = "File > New Project", UseCtrl = true, Key = "N" },
/// new() { Id = "edit.undo", CommandName = "Edit > Undo", UseCtrl = true, Key = "Z" },
/// });
/// </code>
/// </example>
[UxmlElement]
public partial class UTKShortcutList : VisualElement, IDisposable
{
#region Constants
private const string UXML_PATH = "UIToolkit/List/UTKShortcutList";
private const string USS_PATH = "UIToolkit/List/UTKShortcutListUss";
private const string ITEM_UXML_PATH = "UIToolkit/List/UTKShortcutListItem";
private const float ITEM_HEIGHT = 36f;
#endregion
#region Fields
private bool _disposed;
private UTKListView? _listView;
private UTKInputField? _searchField;
private UTKButton? _clearButton;
// 전체 데이터 · 검색 필터링된 데이터 분리
private List<UTKShortcutItemData> _allItems = new();
private readonly List<UTKShortcutItemData> _filteredItems = new();
// UXML 캐싱 (makeItem 호출마다 Resources.Load 방지)
private VisualTreeAsset? _itemTemplate;
#endregion
#region Events
/// <summary>
/// 단축키 데이터가 변경될 때 발생합니다.
/// 변경된 <see cref="UTKShortcutItemData"/> 인스턴스를 전달합니다.
/// </summary>
public event Action<UTKShortcutItemData>? OnDataChanged;
#endregion
#region Constructor
public UTKShortcutList() : base()
{
// 1. 테마 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 2. USS 로드 (테마 변수 스타일시트 이후에 로드되어야 변수가 해석됨)
var uss = Resources.Load<StyleSheet>(USS_PATH);
if (uss != null)
styleSheets.Add(uss);
else
Debug.LogWarning($"[UTKShortcutList] USS not found: {USS_PATH}");
// 3. UXML 로드 → 요소 구성
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (visualTree != null)
{
visualTree.CloneTree(this);
InitializeFromUxml();
}
else
{
Debug.LogWarning($"[UTKShortcutList] UXML not found: {UXML_PATH}, using fallback");
CreateFallbackUI();
}
// 4. 테마 변경 구독
SubscribeToThemeChanges();
}
#endregion
#region UI Creation
/// <summary>UXML 로드 성공 시 자식 요소 참조 획득 및 초기화.</summary>
private void InitializeFromUxml()
{
AddToClassList("utk-shortcut-list");
_searchField = this.Q<UTKInputField>("search-field");
_clearButton = this.Q<UTKButton>("clear-btn");
_listView = this.Q<UTKListView>("list-view");
BindSearchField();
SetupListView();
}
/// <summary>UXML 로드 실패 시 코드로 UI 구성.</summary>
private void CreateFallbackUI()
{
AddToClassList("utk-shortcut-list");
// 검색 영역
var searchContainer = new VisualElement { name = "search-container" };
searchContainer.AddToClassList("utk-shortcut-list__search-container");
Add(searchContainer);
_searchField = new UTKInputField { name = "search-field" };
_searchField.AddToClassList("utk-shortcut-list__search");
searchContainer.Add(_searchField);
_clearButton = new UTKButton { name = "clear-btn" };
_clearButton.Variant = UTKButton.ButtonVariant.Text;
_clearButton.SetMaterialIcon(UTKMaterialIcons.Close, 12);
_clearButton.AddToClassList("utk-shortcut-list__clear-btn");
searchContainer.Add(_clearButton);
// 컬럼 헤더
var header = new VisualElement { name = "header" };
header.AddToClassList("utk-shortcut-list__header");
header.Add(MakeHeaderLabel("", "utk-shortcut-list__header-command"));
header.Add(MakeHeaderLabel("Ctrl", "utk-shortcut-list__header-modifier"));
header.Add(MakeHeaderLabel("Shift", "utk-shortcut-list__header-modifier"));
header.Add(MakeHeaderLabel("Alt", "utk-shortcut-list__header-modifier"));
header.Add(MakeHeaderLabel("Key", "utk-shortcut-list__header-key"));
Add(header);
// ListView
_listView = new UTKListView { name = "list-view" };
_listView.AddToClassList("utk-shortcut-list__listview");
Add(_listView);
BindSearchField();
SetupListView();
}
private static Label MakeHeaderLabel(string text, string className)
{
var label = new Label(text);
label.AddToClassList(className);
return label;
}
/// <summary>검색 필드 이벤트 연결.</summary>
private void BindSearchField()
{
// Clear 버튼 초기 숨김
if (_clearButton != null)
{
_clearButton.style.display = DisplayStyle.None;
_clearButton.OnClicked += OnClearButtonClicked;
}
if (_searchField == null) return;
// Enter 키 또는 포커스 잃을 때 검색 실행 (UTKComponentList 방식)
_searchField.OnSubmit += OnSearch;
}
/// <summary>ListView makeItem / bindItem / unbindItem 설정.</summary>
private void SetupListView()
{
if (_listView == null) return;
_listView.makeItem = MakeItem;
_listView.bindItem = BindItem;
_listView.unbindItem = UnbindItem;
_listView.fixedItemHeight = ITEM_HEIGHT;
_listView.selectionType = SelectionType.None;
_listView.itemsSource = _filteredItems;
}
#endregion
#region Theme
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
#endregion
#region Search
/// <summary>Clear 버튼 클릭 처리 검색어 초기화 및 재검색.</summary>
private void OnClearButtonClicked()
{
if (_searchField != null && _searchField.value.Length > 0)
{
_searchField.value = string.Empty;
OnSearch(string.Empty);
}
if (_clearButton != null)
_clearButton.style.display = DisplayStyle.None;
}
/// <summary>검색 실행 (Enter 키 또는 포커스 잃을 때 호출).</summary>
private void OnSearch(string query)
{
// Clear 버튼 표시/숨김
if (_clearButton != null)
_clearButton.style.display = string.IsNullOrEmpty(query) ? DisplayStyle.None : DisplayStyle.Flex;
_filteredItems.Clear();
if (string.IsNullOrWhiteSpace(query))
{
_filteredItems.AddRange(_allItems);
}
else
{
var lower = query.ToLowerInvariant();
foreach (var item in _allItems)
{
if (item.CommandName.ToLowerInvariant().Contains(lower))
_filteredItems.Add(item);
}
}
_listView?.RefreshItems();
}
#endregion
#region ListView Callbacks
/// <summary>
/// 아이템 VisualElement 생성 (가상화 재사용 요소).
/// UXML을 캐싱하여 매 호출마다 Resources.Load 를 방지합니다.
/// </summary>
private VisualElement MakeItem()
{
_itemTemplate ??= Resources.Load<VisualTreeAsset>(ITEM_UXML_PATH);
if (_itemTemplate != null)
return _itemTemplate.Instantiate();
Debug.LogWarning($"[UTKShortcutList] Item UXML not found: {ITEM_UXML_PATH}, using fallback");
return CreateItemFallback();
}
/// <summary>UXML 로드 실패 시 코드로 아이템 행 생성.</summary>
private static VisualElement CreateItemFallback()
{
var container = new VisualElement { name = "item-container" };
container.AddToClassList("utk-shortcut-list-item");
var cmd = new UTKLabel { name = "command-label" };
cmd.AddToClassList("utk-shortcut-list-item__command");
container.Add(cmd);
foreach (var nm in new[] { "ctrl-checkbox", "shift-checkbox", "alt-checkbox" })
{
var cb = new UTKCheckBox { name = nm };
cb.AddToClassList("utk-shortcut-list-item__modifier");
container.Add(cb);
}
var key = new UTKInputField { name = "key-field" };
key.AddToClassList("utk-shortcut-list-item__key");
container.Add(key);
return container;
}
/// <summary>
/// 데이터를 VisualElement 에 바인딩합니다.
/// 이전 콜백을 해제하고 새 콜백을 등록하여 중복 이벤트를 방지합니다.
/// </summary>
private void BindItem(VisualElement element, int index)
{
if (index < 0 || index >= _filteredItems.Count) return;
// ListView 가 내부적으로 flex-grow: 0 을 인라인으로 강제하므로 덮어씁니다.
element.style.flexGrow = 1;
var data = _filteredItems[index];
// 요소 참조 획득
var root = element.Q<VisualElement>("item-container");
var cmdLabel = root?.Q<UTKLabel>("command-label");
var ctrlBox = root?.Q<UTKCheckBox>("ctrl-checkbox");
var shiftBox = root?.Q<UTKCheckBox>("shift-checkbox");
var altBox = root?.Q<UTKCheckBox>("alt-checkbox");
var keyField = root?.Q<UTKInputField>("key-field");
if (cmdLabel == null || ctrlBox == null || shiftBox == null || altBox == null || keyField == null)
{
Debug.LogWarning("[UTKShortcutList] BindItem: 일부 자식 요소를 찾을 수 없습니다.");
return;
}
// 이전 바인딩 해제
CleanupItemCallbacks(element);
// ── 값 설정 (notify: false → 이벤트 미발생) ──────────────
cmdLabel.Text = data.CommandName;
ctrlBox.SetChecked(data.UseCtrl, notify: false);
shiftBox.SetChecked(data.UseShift, notify: false);
altBox.SetChecked(data.UseAlt, notify: false);
keyField.SetValue(data.Key, notify: false);
keyField.isReadOnly = true; // 직접 타이핑 방지 (캡처만으로 설정)
// ── 수정자 키 체크박스 콜백 ──────────────────────────────
Action<bool> onCtrl = v => { data.UseCtrl = v; OnDataChanged?.Invoke(data); };
Action<bool> onShift = v => { data.UseShift = v; OnDataChanged?.Invoke(data); };
Action<bool> onAlt = v => { data.UseAlt = v; OnDataChanged?.Invoke(data); };
ctrlBox.OnValueChanged += onCtrl;
shiftBox.OnValueChanged += onShift;
altBox.OnValueChanged += onAlt;
// ── Key 캡처 콜백 ─────────────────────────────────────────
var capture = new KeyCaptureState(data, keyField);
EventCallback<FocusInEvent> onFocusIn = _ => capture.StartCapture();
EventCallback<KeyDownEvent> onKeyDown = evt => capture.HandleKeyDown(evt, () => OnDataChanged?.Invoke(data));
EventCallback<FocusOutEvent> onFocusOut = _ => capture.CancelCapture();
keyField.RegisterCallback(onFocusIn);
// TrickleDown 으로 등록 → 내부 TextElement 가 키 이벤트를 받기 전에 가로채기
keyField.RegisterCallback(onKeyDown, TrickleDown.TrickleDown);
keyField.RegisterCallback(onFocusOut);
// 해제 정보 저장
element.userData = new ShortcutItemCallbackInfo(
ctrlBox, shiftBox, altBox, keyField,
onCtrl, onShift, onAlt,
onFocusIn, onKeyDown, onFocusOut);
}
/// <summary>가상화 재사용 전 콜백 정리.</summary>
private void UnbindItem(VisualElement element, int index)
{
CleanupItemCallbacks(element);
}
/// <summary>element.userData 에 저장된 모든 이벤트 콜백을 해제합니다.</summary>
private static void CleanupItemCallbacks(VisualElement element)
{
if (element.userData is not ShortcutItemCallbackInfo info) return;
info.CtrlBox.OnValueChanged -= info.OnCtrlHandler;
info.ShiftBox.OnValueChanged -= info.OnShiftHandler;
info.AltBox.OnValueChanged -= info.OnAltHandler;
info.KeyField.UnregisterCallback(info.OnFocusIn);
info.KeyField.UnregisterCallback(info.OnKeyDown, TrickleDown.TrickleDown);
info.KeyField.UnregisterCallback(info.OnFocusOut);
element.userData = null;
}
#endregion
#region Public API
/// <summary>
/// 단축키 목록을 설정하고 ListView 를 갱신합니다.
/// </summary>
/// <param name="items">표시할 단축키 데이터 목록.</param>
public void SetData(List<UTKShortcutItemData> items)
{
_allItems = items ?? new List<UTKShortcutItemData>();
OnSearch(_searchField?.value ?? string.Empty);
}
/// <summary>
/// 현재 단축키 목록(원본 전체)을 반환합니다.
/// </summary>
/// <returns>복사본 목록.</returns>
public List<UTKShortcutItemData> GetData() => new(_allItems);
/// <summary>
/// ListView 를 강제로 새로고침합니다.
/// 외부에서 데이터를 직접 변경한 후 호출하세요.
/// </summary>
public void RefreshItems() => _listView?.RefreshItems();
#endregion
#region Internal Types
/// <summary>
/// Key 캡처 상태 관리.
/// FocusIn → 캡처 시작, KeyDown → 키 저장, FocusOut/Escape → 취소.
/// </summary>
private sealed class KeyCaptureState
{
private readonly UTKShortcutItemData _data;
private readonly UTKInputField _keyField;
private bool _isCapturing;
private string _originalKey = "";
/// <summary>캡처 대기 중 표시 문자열.</summary>
private const string CAPTURE_PLACEHOLDER = "···";
public KeyCaptureState(UTKShortcutItemData data, UTKInputField keyField)
{
_data = data;
_keyField = keyField;
}
/// <summary>캡처 모드 진입 대기 표시 문자열로 교체.</summary>
public void StartCapture()
{
if (_isCapturing) return;
_isCapturing = true;
_originalKey = _data.Key;
_keyField.AddToClassList("utk-shortcut-list-item__key--capturing");
_keyField.SetValue(CAPTURE_PLACEHOLDER, notify: false);
}
/// <summary>캡처 취소 원래 값 복원.</summary>
public void CancelCapture()
{
if (!_isCapturing) return;
_isCapturing = false;
_keyField.RemoveFromClassList("utk-shortcut-list-item__key--capturing");
_keyField.SetValue(_originalKey, notify: false);
}
/// <summary>
/// KeyDown 이벤트 처리.
/// <list type="bullet">
/// <item>Escape → 캡처 취소.</item>
/// <item>수정자 키(Ctrl/Shift/Alt 단독) → 무시.</item>
/// <item>그 외 → 키 이름 저장 후 캡처 종료.</item>
/// </list>
/// </summary>
public void HandleKeyDown(KeyDownEvent evt, Action onChanged)
{
if (!_isCapturing) return;
var code = evt.keyCode;
// Escape: 취소
if (code == KeyCode.Escape)
{
evt.StopImmediatePropagation();
CancelCapture();
_keyField.Blur();
return;
}
// 수정자 키 단독 입력 → 무시 (Ctrl/Shift/Alt 체크박스로 설정)
if (code is KeyCode.LeftControl or KeyCode.RightControl or
KeyCode.LeftShift or KeyCode.RightShift or
KeyCode.LeftAlt or KeyCode.RightAlt or
KeyCode.LeftCommand or KeyCode.RightCommand or
KeyCode.None)
return;
// 키 저장
_isCapturing = false;
_keyField.RemoveFromClassList("utk-shortcut-list-item__key--capturing");
var keyName = ResolveKeyName(code, evt.character);
_data.Key = keyName;
_keyField.SetValue(keyName, notify: false);
onChanged.Invoke();
evt.StopImmediatePropagation();
evt.PreventDefault();
_keyField.Blur();
}
/// <summary>KeyCode → 표시 문자열 변환.</summary>
private static string ResolveKeyName(KeyCode code, char character)
{
return code switch
{
KeyCode.Delete => "Delete",
KeyCode.Backspace => "Backspace",
KeyCode.Return => "Enter",
KeyCode.KeypadEnter => "Enter",
KeyCode.Tab => "Tab",
KeyCode.Space => "Space",
KeyCode.Insert => "Insert",
KeyCode.Home => "Home",
KeyCode.End => "End",
KeyCode.PageUp => "PgUp",
KeyCode.PageDown => "PgDn",
KeyCode.UpArrow => "↑",
KeyCode.DownArrow => "↓",
KeyCode.LeftArrow => "←",
KeyCode.RightArrow => "→",
KeyCode.F1 => "F1",
KeyCode.F2 => "F2",
KeyCode.F3 => "F3",
KeyCode.F4 => "F4",
KeyCode.F5 => "F5",
KeyCode.F6 => "F6",
KeyCode.F7 => "F7",
KeyCode.F8 => "F8",
KeyCode.F9 => "F9",
KeyCode.F10 => "F10",
KeyCode.F11 => "F11",
KeyCode.F12 => "F12",
KeyCode.Keypad0 => "Num0",
KeyCode.Keypad1 => "Num1",
KeyCode.Keypad2 => "Num2",
KeyCode.Keypad3 => "Num3",
KeyCode.Keypad4 => "Num4",
KeyCode.Keypad5 => "Num5",
KeyCode.Keypad6 => "Num6",
KeyCode.Keypad7 => "Num7",
KeyCode.Keypad8 => "Num8",
KeyCode.Keypad9 => "Num9",
KeyCode.KeypadPlus => "Num+",
KeyCode.KeypadMinus => "Num-",
KeyCode.KeypadMultiply => "Num*",
KeyCode.KeypadDivide => "Num/",
KeyCode.KeypadPeriod => "Num.",
// 일반 문자 키 character 우선, 없으면 KeyCode.ToString()
_ => character != '\0' && !char.IsControl(character)
? character.ToString().ToUpperInvariant()
: code.ToString()
};
}
}
/// <summary>
/// 아이템 바인딩 시 등록하는 이벤트 콜백 참조 보관.
/// UnbindItem 에서 정확히 해제하기 위해 사용합니다.
/// </summary>
private sealed class ShortcutItemCallbackInfo
{
public readonly UTKCheckBox CtrlBox;
public readonly UTKCheckBox ShiftBox;
public readonly UTKCheckBox AltBox;
public readonly UTKInputField KeyField;
public readonly Action<bool> OnCtrlHandler;
public readonly Action<bool> OnShiftHandler;
public readonly Action<bool> OnAltHandler;
public readonly EventCallback<FocusInEvent> OnFocusIn;
public readonly EventCallback<KeyDownEvent> OnKeyDown;
public readonly EventCallback<FocusOutEvent> OnFocusOut;
public ShortcutItemCallbackInfo(
UTKCheckBox ctrl, UTKCheckBox shift, UTKCheckBox alt, UTKInputField key,
Action<bool> onCtrl, Action<bool> onShift, Action<bool> onAlt,
EventCallback<FocusInEvent> onFocusIn,
EventCallback<KeyDownEvent> onKeyDown,
EventCallback<FocusOutEvent> onFocusOut)
{
CtrlBox = ctrl; ShiftBox = shift; AltBox = alt; KeyField = key;
OnCtrlHandler = onCtrl; OnShiftHandler = onShift; OnAltHandler = onAlt;
OnFocusIn = onFocusIn;
OnKeyDown = onKeyDown;
OnFocusOut = onFocusOut;
}
}
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 테마 구독 해제
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
// 검색 필드 이벤트 해제
if (_searchField != null)
_searchField.OnSubmit -= OnSearch;
// Clear 버튼 이벤트 해제
if (_clearButton != null)
_clearButton.OnClicked -= OnClearButtonClicked;
// ListView 정리
_listView?.Dispose();
// 이벤트 · 캐시 정리
OnDataChanged = null;
_itemTemplate = null;
}
#endregion
}
}