#nullable enable
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace UVC.UIToolkit
{
///
/// 단축키 설정 리스트 컴포넌트.
/// Command 이름, Ctrl / Shift / Alt 체크박스, Key 입력(읽기전용 + 클릭 시 키 캡처)으로 구성됩니다.
///
///
///
/// 열 구성 (왼쪽 → 오른쪽):
///
/// - Command 이름 (flex-grow)
/// - Ctrl 체크박스 (52 px)
/// - Shift 체크박스 (52 px)
/// - Alt 체크박스 (52 px)
/// - Key 입력 필드 (76 px) – 클릭 시 다음 키 자동 캡처
///
///
/// Key 캡처 방법:
///
/// - Key 필드 클릭 → 캡처 모드 진입 ("···" 표시)
/// - 원하는 키 입력 → 자동 저장 후 캡처 종료
/// - Escape 키 → 취소 (이전 값 복원)
///
///
/// 가상화: UTKListView(ListView) 를 사용하여 대량 항목도 성능 저하 없이 표시합니다.
///
/// 관련 리소스:
///
/// - Resources/UIToolkit/List/UTKShortcutList.uxml
/// - Resources/UIToolkit/List/UTKShortcutListItem.uxml
/// - Resources/UIToolkit/List/UTKShortcutListUss.uss
///
///
///
///
///
/// var list = new UTKShortcutList();
/// list.OnDataChanged += (item) => Debug.Log($"변경: {item.CommandName}");
///
/// list.SetData(new List
/// {
/// new() { Id = "file.new", CommandName = "File > New Project", UseCtrl = true, Key = "N" },
/// new() { Id = "edit.undo", CommandName = "Edit > Undo", UseCtrl = true, Key = "Z" },
/// });
///
///
[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 _allItems = new();
private readonly List _filteredItems = new();
// UXML 캐싱 (makeItem 호출마다 Resources.Load 방지)
private VisualTreeAsset? _itemTemplate;
#endregion
#region Events
///
/// 단축키 데이터가 변경될 때 발생합니다.
/// 변경된 인스턴스를 전달합니다.
///
public event Action? OnDataChanged;
#endregion
#region Constructor
public UTKShortcutList() : base()
{
// 1. 테마 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 2. USS 로드 (테마 변수 스타일시트 이후에 로드되어야 변수가 해석됨)
var uss = Resources.Load(USS_PATH);
if (uss != null)
styleSheets.Add(uss);
else
Debug.LogWarning($"[UTKShortcutList] USS not found: {USS_PATH}");
// 3. UXML 로드 → 요소 구성
var visualTree = Resources.Load(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
/// UXML 로드 성공 시 – 자식 요소 참조 획득 및 초기화.
private void InitializeFromUxml()
{
AddToClassList("utk-shortcut-list");
_searchField = this.Q("search-field");
_clearButton = this.Q("clear-btn");
_listView = this.Q("list-view");
BindSearchField();
SetupListView();
}
/// UXML 로드 실패 시 – 코드로 UI 구성.
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;
}
/// 검색 필드 이벤트 연결.
private void BindSearchField()
{
// Clear 버튼 초기 숨김
if (_clearButton != null)
{
_clearButton.style.display = DisplayStyle.None;
_clearButton.OnClicked += OnClearButtonClicked;
}
if (_searchField == null) return;
// Enter 키 또는 포커스 잃을 때 검색 실행 (UTKComponentList 방식)
_searchField.OnSubmit += OnSearch;
}
/// ListView makeItem / bindItem / unbindItem 설정.
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(OnAttachToPanelForTheme);
RegisterCallback(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
/// Clear 버튼 클릭 처리 – 검색어 초기화 및 재검색.
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;
}
/// 검색 실행 (Enter 키 또는 포커스 잃을 때 호출).
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
///
/// 아이템 VisualElement 생성 (가상화 재사용 요소).
/// UXML을 캐싱하여 매 호출마다 Resources.Load 를 방지합니다.
///
private VisualElement MakeItem()
{
_itemTemplate ??= Resources.Load(ITEM_UXML_PATH);
if (_itemTemplate != null)
return _itemTemplate.Instantiate();
Debug.LogWarning($"[UTKShortcutList] Item UXML not found: {ITEM_UXML_PATH}, using fallback");
return CreateItemFallback();
}
/// UXML 로드 실패 시 코드로 아이템 행 생성.
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;
}
///
/// 데이터를 VisualElement 에 바인딩합니다.
/// 이전 콜백을 해제하고 새 콜백을 등록하여 중복 이벤트를 방지합니다.
///
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("item-container");
var cmdLabel = root?.Q("command-label");
var ctrlBox = root?.Q("ctrl-checkbox");
var shiftBox = root?.Q("shift-checkbox");
var altBox = root?.Q("alt-checkbox");
var keyField = root?.Q("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 onCtrl = v => { data.UseCtrl = v; OnDataChanged?.Invoke(data); };
Action onShift = v => { data.UseShift = v; OnDataChanged?.Invoke(data); };
Action 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 onFocusIn = _ => capture.StartCapture();
EventCallback onKeyDown = evt => capture.HandleKeyDown(evt, () => OnDataChanged?.Invoke(data));
EventCallback 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);
}
/// 가상화 재사용 전 콜백 정리.
private void UnbindItem(VisualElement element, int index)
{
CleanupItemCallbacks(element);
}
/// element.userData 에 저장된 모든 이벤트 콜백을 해제합니다.
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
///
/// 단축키 목록을 설정하고 ListView 를 갱신합니다.
///
/// 표시할 단축키 데이터 목록.
public void SetData(List items)
{
_allItems = items ?? new List();
OnSearch(_searchField?.value ?? string.Empty);
}
///
/// 현재 단축키 목록(원본 전체)을 반환합니다.
///
/// 복사본 목록.
public List GetData() => new(_allItems);
///
/// ListView 를 강제로 새로고침합니다.
/// 외부에서 데이터를 직접 변경한 후 호출하세요.
///
public void RefreshItems() => _listView?.RefreshItems();
#endregion
#region Internal Types
///
/// Key 캡처 상태 관리.
/// FocusIn → 캡처 시작, KeyDown → 키 저장, FocusOut/Escape → 취소.
///
private sealed class KeyCaptureState
{
private readonly UTKShortcutItemData _data;
private readonly UTKInputField _keyField;
private bool _isCapturing;
private string _originalKey = "";
/// 캡처 대기 중 표시 문자열.
private const string CAPTURE_PLACEHOLDER = "···";
public KeyCaptureState(UTKShortcutItemData data, UTKInputField keyField)
{
_data = data;
_keyField = keyField;
}
/// 캡처 모드 진입 – 대기 표시 문자열로 교체.
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);
}
/// 캡처 취소 – 원래 값 복원.
public void CancelCapture()
{
if (!_isCapturing) return;
_isCapturing = false;
_keyField.RemoveFromClassList("utk-shortcut-list-item__key--capturing");
_keyField.SetValue(_originalKey, notify: false);
}
///
/// KeyDown 이벤트 처리.
///
/// - Escape → 캡처 취소.
/// - 수정자 키(Ctrl/Shift/Alt 단독) → 무시.
/// - 그 외 → 키 이름 저장 후 캡처 종료.
///
///
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();
}
/// KeyCode → 표시 문자열 변환.
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()
};
}
}
///
/// 아이템 바인딩 시 등록하는 이벤트 콜백 참조 보관.
/// UnbindItem 에서 정확히 해제하기 위해 사용합니다.
///
private sealed class ShortcutItemCallbackInfo
{
public readonly UTKCheckBox CtrlBox;
public readonly UTKCheckBox ShiftBox;
public readonly UTKCheckBox AltBox;
public readonly UTKInputField KeyField;
public readonly Action OnCtrlHandler;
public readonly Action OnShiftHandler;
public readonly Action OnAltHandler;
public readonly EventCallback OnFocusIn;
public readonly EventCallback OnKeyDown;
public readonly EventCallback OnFocusOut;
public ShortcutItemCallbackInfo(
UTKCheckBox ctrl, UTKCheckBox shift, UTKCheckBox alt, UTKInputField key,
Action onCtrl, Action onShift, Action onAlt,
EventCallback onFocusIn,
EventCallback onKeyDown,
EventCallback 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(OnAttachToPanelForTheme);
UnregisterCallback(OnDetachFromPanelForTheme);
// 검색 필드 이벤트 해제
if (_searchField != null)
_searchField.OnSubmit -= OnSearch;
// Clear 버튼 이벤트 해제
if (_clearButton != null)
_clearButton.OnClicked -= OnClearButtonClicked;
// ListView 정리
_listView?.Dispose();
// 이벤트 · 캐시 정리
OnDataChanged = null;
_itemTemplate = null;
}
#endregion
}
}