#nullable enable using Cysharp.Threading.Tasks; using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; using UVC.Core; using UVC.Locale; using UVC.Studio.Config; using UVC.UI.Modal; using UVC.UI.Tab; namespace UVC.Studio.Modal.Settings { public class SettingShortcutTabContent : MonoBehaviour, ITabContent { [SerializeField] private ScrollRect? scrollRect; [SerializeField] private LayoutGroup? labelGroup; [SerializeField] private LayoutGroup? valueGroup; [SerializeField] private TextMeshProUGUI? firstLabelTxt; [SerializeField] private Toggle? firstCtrlToggle; [SerializeField] private Toggle? firstShiftToggle; [SerializeField] private Toggle? firstAltToggle; [SerializeField] private TMP_InputField? firstValueTxt; private Setting? setting; private List labelTxts = new List(); private List valueTxts = new List(); private List ctrlToggles = new List(); private List shiftToggles = new List(); private List altToggles = new List(); private bool changedValue = false; // 단축키로 사용할 수 없는 키 목록 (카메라 조작 등에 사용됨) private static readonly HashSet reservedKeys = new HashSet { "A", "D", "Q", "E", "S", "W", "V" }; /// /// 해당 인덱스의 수정자 키(Ctrl, Shift, Alt) 중 하나라도 체크되어 있는지 확인합니다. /// private bool HasAnyModifier(int index) { if (index < 0 || index >= ctrlToggles.Count) return false; return ctrlToggles[index].isOn || shiftToggles[index].isOn || altToggles[index].isOn; } /// /// 해당 인덱스의 키가 예약된 키인지 확인합니다. /// private bool IsReservedKey(int index) { if (index < 0 || index >= valueTxts.Count) return false; return reservedKeys.Contains(valueTxts[index].text.ToUpper()); } /// /// 지정된 단축키 조합이 다른 항목과 중복되는지 확인합니다. /// /// 중복 체크에서 제외할 인덱스 (자기 자신) /// 확인할 키 /// Ctrl 체크 여부 /// Shift 체크 여부 /// Alt 체크 여부 /// 중복이 있으면 true private bool IsDuplicateShortcut(int excludeIndex, string key, bool ctrl, bool shift, bool alt) { if (string.IsNullOrEmpty(key)) return false; string upperKey = key.ToUpper(); for (int i = 0; i < valueTxts.Count; i++) { if (i == excludeIndex) continue; if (i >= ctrlToggles.Count || i >= shiftToggles.Count || i >= altToggles.Count) continue; string otherKey = valueTxts[i].text.ToUpper(); if (string.IsNullOrEmpty(otherKey)) continue; bool otherCtrl = ctrlToggles[i].isOn; bool otherShift = shiftToggles[i].isOn; bool otherAlt = altToggles[i].isOn; // 키와 모든 수정자가 동일하면 중복 if (upperKey == otherKey && ctrl == otherCtrl && shift == otherShift && alt == otherAlt) { return true; } } return false; } /// /// 탭 콘텐츠에 데이터를 전달합니다. /// /// 전달할 데이터 객체 public async void SetContentData(object? data) { if (setting == null) { // 동적으로 로드되는 Prefab이므로 [Inject]가 자동으로 동작하지 않음 await InjectorAppContext.Instance.WaitForInitializationAsync(); setting = InjectorAppContext.Instance.Get(); } labelTxts.Clear(); valueTxts.Clear(); ctrlToggles.Clear(); shiftToggles.Clear(); altToggles.Clear(); if (setting != null && labelGroup != null && valueGroup != null) { changedValue = false; ShortcutsSetting shortcuts = setting.Data.shortcuts; // menu와 tools의 모든 ShortcutItem 필드를 순회 var shortcutGroups = new object[] { shortcuts.menu, shortcuts.tools }; foreach (var group in shortcutGroups) { var fields = group.GetType().GetFields(); for (int i = 0; i < fields.Length; i++) { var field = fields[i]; if (field.FieldType == typeof(ShortcutItem)) { var shortcut = (ShortcutItem?)field.GetValue(group); if (shortcut == null) continue; if (i == 0 && group == shortcuts.menu) { firstLabelTxt!.text = shortcut.label; firstValueTxt!.text = shortcut.key; labelTxts.Add(firstLabelTxt); valueTxts.Add(firstValueTxt); // Toggle 초기값 설정 int currentIndex = 0; // 첫 번째 항목의 인덱스 if (firstCtrlToggle != null) { firstCtrlToggle.isOn = shortcut.ctrl; ctrlToggles.Add(firstCtrlToggle); firstCtrlToggle.onValueChanged.AddListener((value) => { // 체크 해제 시 예약된 키가 있으면 차단 if (!value && IsReservedKey(currentIndex) && !HasAnyModifier(currentIndex)) { firstCtrlToggle.isOn = true; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 string key = valueTxts[currentIndex].text; bool newCtrl = value; bool newShift = shiftToggles[currentIndex].isOn; bool newAlt = altToggles[currentIndex].isOn; if (IsDuplicateShortcut(currentIndex, key, newCtrl, newShift, newAlt)) { firstCtrlToggle.isOn = !value; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } shortcut.ctrl = value; changedValue = true; }); } if (firstShiftToggle != null) { firstShiftToggle.isOn = shortcut.shift; shiftToggles.Add(firstShiftToggle); firstShiftToggle.onValueChanged.AddListener((value) => { // 체크 해제 시 예약된 키가 있으면 차단 if (!value && IsReservedKey(currentIndex) && !HasAnyModifier(currentIndex)) { firstShiftToggle.isOn = true; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 string key = valueTxts[currentIndex].text; bool newCtrl = ctrlToggles[currentIndex].isOn; bool newShift = value; bool newAlt = altToggles[currentIndex].isOn; if (IsDuplicateShortcut(currentIndex, key, newCtrl, newShift, newAlt)) { firstShiftToggle.isOn = !value; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } shortcut.shift = value; changedValue = true; }); } if (firstAltToggle != null) { firstAltToggle.isOn = shortcut.alt; altToggles.Add(firstAltToggle); firstAltToggle.onValueChanged.AddListener((value) => { // 체크 해제 시 예약된 키가 있으면 차단 if (!value && IsReservedKey(currentIndex) && !HasAnyModifier(currentIndex)) { firstAltToggle.isOn = true; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 string key = valueTxts[currentIndex].text; bool newCtrl = ctrlToggles[currentIndex].isOn; bool newShift = shiftToggles[currentIndex].isOn; bool newAlt = value; if (IsDuplicateShortcut(currentIndex, key, newCtrl, newShift, newAlt)) { firstAltToggle.isOn = !value; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } shortcut.alt = value; changedValue = true; }); } // 영문 대문자만 입력되도록 필터링 및 예약된 키 체크 string previousKey = shortcut.key; firstValueTxt.onValueChanged.AddListener((value) => { string upperValue = value.ToUpper(); // 수정자 키가 하나도 없을 때만 예약된 키 체크 if (reservedKeys.Contains(upperValue) && !HasAnyModifier(currentIndex)) { firstValueTxt.text = previousKey; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 bool ctrl = ctrlToggles[currentIndex].isOn; bool shift = shiftToggles[currentIndex].isOn; bool alt = altToggles[currentIndex].isOn; if (IsDuplicateShortcut(currentIndex, upperValue, ctrl, shift, alt)) { firstValueTxt.text = previousKey; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } previousKey = upperValue; firstValueTxt.text = upperValue; }); // 값 변경 리스너 추가 firstValueTxt.onEndEdit.AddListener((value) => { shortcut.key = value; changedValue = true; }); } else { // 라벨 복제 if (firstLabelTxt != null) { TextMeshProUGUI labelInstance = Instantiate(firstLabelTxt, labelGroup.transform); labelInstance.text = shortcut.label; labelTxts.Add(labelInstance); } // Toggle 복제 int itemIndex = ctrlToggles.Count; // 현재 항목의 인덱스 (추가 전) if (firstCtrlToggle != null) { Toggle ctrlInstance = Instantiate(firstCtrlToggle, firstCtrlToggle.transform.parent); ctrlInstance.isOn = shortcut.ctrl; ctrlToggles.Add(ctrlInstance); int capturedIndex = itemIndex; ctrlInstance.onValueChanged.AddListener((value) => { // 체크 해제 시 예약된 키가 있으면 차단 if (!value && IsReservedKey(capturedIndex) && !HasAnyModifier(capturedIndex)) { ctrlInstance.isOn = true; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 string key = valueTxts[capturedIndex].text; bool newCtrl = value; bool newShift = shiftToggles[capturedIndex].isOn; bool newAlt = altToggles[capturedIndex].isOn; if (IsDuplicateShortcut(capturedIndex, key, newCtrl, newShift, newAlt)) { ctrlInstance.isOn = !value; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } shortcut.ctrl = value; changedValue = true; }); } if (firstShiftToggle != null) { Toggle shiftInstance = Instantiate(firstShiftToggle, firstShiftToggle.transform.parent); shiftInstance.isOn = shortcut.shift; shiftToggles.Add(shiftInstance); int capturedIndex = itemIndex; shiftInstance.onValueChanged.AddListener((value) => { // 체크 해제 시 예약된 키가 있으면 차단 if (!value && IsReservedKey(capturedIndex) && !HasAnyModifier(capturedIndex)) { shiftInstance.isOn = true; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 string key = valueTxts[capturedIndex].text; bool newCtrl = ctrlToggles[capturedIndex].isOn; bool newShift = value; bool newAlt = altToggles[capturedIndex].isOn; if (IsDuplicateShortcut(capturedIndex, key, newCtrl, newShift, newAlt)) { shiftInstance.isOn = !value; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } shortcut.shift = value; changedValue = true; }); } if (firstAltToggle != null) { Toggle altInstance = Instantiate(firstAltToggle, firstAltToggle.transform.parent); altInstance.isOn = shortcut.alt; altToggles.Add(altInstance); int capturedIndex = itemIndex; altInstance.onValueChanged.AddListener((value) => { // 체크 해제 시 예약된 키가 있으면 차단 if (!value && IsReservedKey(capturedIndex) && !HasAnyModifier(capturedIndex)) { altInstance.isOn = true; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 string key = valueTxts[capturedIndex].text; bool newCtrl = ctrlToggles[capturedIndex].isOn; bool newShift = shiftToggles[capturedIndex].isOn; bool newAlt = value; if (IsDuplicateShortcut(capturedIndex, key, newCtrl, newShift, newAlt)) { altInstance.isOn = !value; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } shortcut.alt = value; changedValue = true; }); } // 값 복제 if (firstValueTxt != null) { TMP_InputField valueInstance = Instantiate(firstValueTxt, valueGroup.transform); valueInstance.text = shortcut.key; valueTxts.Add(valueInstance); int capturedIndex = itemIndex; string prevKey = shortcut.key; // 영문 대문자만 입력되도록 필터링 및 예약된 키 체크 valueInstance.onValueChanged.AddListener((value) => { string upperValue = value.ToUpper(); // 수정자 키가 하나도 없을 때만 예약된 키 체크 if (reservedKeys.Contains(upperValue) && !HasAnyModifier(capturedIndex)) { valueInstance.text = prevKey; Toast.Show(LocalizationManager.Instance.GetString("msg_not_shortcut")); return; } // 중복 단축키 체크 bool ctrl = ctrlToggles[capturedIndex].isOn; bool shift = shiftToggles[capturedIndex].isOn; bool alt = altToggles[capturedIndex].isOn; if (IsDuplicateShortcut(capturedIndex, upperValue, ctrl, shift, alt)) { valueInstance.text = prevKey; Toast.Show(LocalizationManager.Instance.GetString("msg_duplicate_shortcut")); return; } prevKey = upperValue; valueInstance.text = upperValue; }); valueInstance.onEndEdit.AddListener((value) => { shortcut.key = value; changedValue = true; }); } } } } } } } private void Update() { // Tab 키 입력 처리 if (Input.GetKeyDown(KeyCode.Tab) && valueTxts.Count > 0) { int currentIndex = GetCurrentFocusedIndex(); if (currentIndex >= 0) { int nextIndex = Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift) ? (currentIndex - 1 + valueTxts.Count) % valueTxts.Count // Shift+Tab: 이전 : (currentIndex + 1) % valueTxts.Count; // Tab: 다음 valueTxts[nextIndex].Select(); ScrollToItem(nextIndex); } } } private int GetCurrentFocusedIndex() { for (int i = 0; i < valueTxts.Count; i++) { if (valueTxts[i] != null && valueTxts[i].isFocused) { return i; } } return -1; } /// /// 해당 인덱스의 항목이 보이도록 스크롤합니다. /// private void ScrollToItem(int index) { if (scrollRect == null || scrollRect.content == null || index < 0 || index >= valueTxts.Count) return; RectTransform targetRect = valueTxts[index].GetComponent(); RectTransform contentRect = scrollRect.content; RectTransform viewportRect = scrollRect.viewport != null ? scrollRect.viewport : scrollRect.GetComponent(); // 타겟 항목의 위치 계산 float contentHeight = contentRect.rect.height; float viewportHeight = viewportRect.rect.height; if (contentHeight <= viewportHeight) return; // 스크롤 필요 없음 // 타겟의 로컬 위치 (content 기준) float targetY = -targetRect.anchoredPosition.y; float itemHeight = targetRect.rect.height; // 현재 스크롤 위치에서 보이는 영역 float scrollY = contentRect.anchoredPosition.y; float visibleTop = scrollY; float visibleBottom = scrollY + viewportHeight; // 항목이 보이지 않으면 스크롤 if (targetY < visibleTop) { // 위로 스크롤 float normalizedPos = targetY / (contentHeight - viewportHeight); scrollRect.verticalNormalizedPosition = 1f - Mathf.Clamp01(normalizedPos); } else if (targetY + itemHeight > visibleBottom) { // 아래로 스크롤 float normalizedPos = (targetY + itemHeight - viewportHeight) / (contentHeight - viewportHeight); scrollRect.verticalNormalizedPosition = 1f - Mathf.Clamp01(normalizedPos); } } /// /// 탭 전환 시 데이터가 있는 경우 전달 되는 데이터. SetContentData 이후 호출 됨 /// /// 전달할 데이터 객체 public void UpdateContentData(object? data) { if (data != null && data is string content) { Debug.Log($"UpdateContentData: {content}"); } } /// /// 닫힐 때 실행되는 로직을 처리합니다. /// /// 비동기 닫기 작업을 나타내는 입니다. public async UniTask OnCloseAsync() { foreach (var labelTxt in labelTxts) { if(labelTxt != firstLabelTxt) { Destroy(labelTxt.gameObject); } } foreach (var valueTxt in valueTxts) { valueTxt.onValueChanged.RemoveAllListeners(); valueTxt.onEndEdit.RemoveAllListeners(); if(valueTxt != firstValueTxt) { Destroy(valueTxt.gameObject); } } // Toggle 리스너 정리 및 복제된 Toggle 삭제 foreach (var toggle in ctrlToggles) { toggle.onValueChanged.RemoveAllListeners(); if (toggle != firstCtrlToggle) { Destroy(toggle.gameObject); } } foreach (var toggle in shiftToggles) { toggle.onValueChanged.RemoveAllListeners(); if (toggle != firstShiftToggle) { Destroy(toggle.gameObject); } } foreach (var toggle in altToggles) { toggle.onValueChanged.RemoveAllListeners(); if (toggle != firstAltToggle) { Destroy(toggle.gameObject); } } Debug.Log($"SettingShortcutTabContent OnCloseAsync: changedValue={changedValue} setting == null:{setting == null}"); if (changedValue && setting != null) { await setting.SaveAsync(); Debug.Log("Shortcut settings saved."); // TopMenu 단축키 갱신 if (StudioSceneMain.Instance != null) { StudioSceneMain.Instance.RefreshMenuShortcuts(); } } } } }