#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 } }