UTKShortcutList 개발 완료. Modal 개선

This commit is contained in:
logonkhi
2026-02-24 20:01:56 +09:00
parent b9b394935e
commit 8ca8bd0df9
72 changed files with 3466 additions and 68 deletions

View File

@@ -97,8 +97,17 @@ namespace UVC.UIToolkit
///
/// <!-- 에러 메시지 (C#에서 Validation 설정 권장) -->
/// <utk:UTKBoundsField label="경계" error-message="크기는 양수여야 합니다." />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKBoundsField label="경계" label-min-width="120" />
/// </UXML>
/// </code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var boundsField = new UTKBoundsField("경계");
/// boundsField.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시:</b></para>
/// <code>
/// // 게임 오브젝트의 콜라이더 경계 설정
@@ -130,6 +139,7 @@ namespace UVC.UIToolkit
private string _yLabel = "Y";
private string _zLabel = "Z";
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -253,6 +263,18 @@ namespace UVC.UIToolkit
get => _validation;
set => _validation = value;
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
#endregion
#region Constructor
@@ -283,7 +305,11 @@ namespace UVC.UIToolkit
AddToClassList("utk-boundsfield");
// 초기 라벨 설정
schedule.Execute(() => UpdateAxisLabels());
schedule.Execute(() =>
{
UpdateAxisLabels();
ApplyLabelMinWidth();
});
}
private void SetupEvents()
@@ -348,6 +374,22 @@ namespace UVC.UIToolkit
textInput.isReadOnly = _isReadOnly;
}
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -67,7 +67,16 @@ namespace UVC.UIToolkit
///
/// <!-- 비활성화 상태 -->
/// <utk:UTKDoubleField label="PI" value="3.141592653589793" is-enabled="false" />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKDoubleField label="경도" label-min-width="120" />
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var doubleField = new UTKDoubleField("경도");
/// doubleField.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시 (GPS 좌표):</b></para>
/// <code>
/// // GPS 좌표 입력 (높은 정밀도 필요)
@@ -89,6 +98,7 @@ namespace UVC.UIToolkit
private bool _disposed;
private bool _isEnabled = true;
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -140,7 +150,19 @@ namespace UVC.UIToolkit
}
}
/// <summary>읽기 전용</summary>
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
/// <summary>읽기 전용</summary>
public new bool isReadOnly
{
get => base.isReadOnly;
@@ -180,6 +202,9 @@ namespace UVC.UIToolkit
private void SetupStyles()
{
AddToClassList("utk-double-field");
// label 설정 후 LabelMinWidth 적용
schedule.Execute(() => ApplyLabelMinWidth());
}
private void SetupEvents()
@@ -212,6 +237,22 @@ namespace UVC.UIToolkit
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -78,7 +78,16 @@ namespace UVC.UIToolkit
///
/// <!-- 비활성화 상태 -->
/// <utk:UTKFloatField label="고정 값" value="3.14" is-enabled="false" />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKFloatField label="속도" label-min-width="120" />
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var floatField = new UTKFloatField("속도");
/// floatField.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시 (캐릭터 스탯):</b></para>
/// <code>
/// // 캐릭터 이동 속도 편집
@@ -101,6 +110,7 @@ namespace UVC.UIToolkit
private bool _disposed;
private bool _isEnabled = true;
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -152,6 +162,18 @@ namespace UVC.UIToolkit
}
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
/// <summary>읽기 전용</summary>
public new bool isReadOnly
{
@@ -191,6 +213,9 @@ namespace UVC.UIToolkit
private void SetupStyles()
{
AddToClassList("utk-float-field");
// label 설정 후 LabelMinWidth 적용
schedule.Execute(() => ApplyLabelMinWidth());
}
private void SetupEvents()
@@ -223,6 +248,22 @@ namespace UVC.UIToolkit
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -80,8 +80,18 @@ namespace UVC.UIToolkit
///
/// <!-- 비활성화 -->
/// <utk:UTKInputField label="읽기전용" is-enabled="false" value="수정 불가" />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKInputField label="이름" label-min-width="120" />
/// </ui:UXML>
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var input = new UTKInputField("이름");
/// input.LabelMinWidth = 120f; // 120px
/// // -1이면 미설정 (기본값)
/// </code>
/// </example>
[UxmlElement]
public partial class UTKInputField : TextField, IDisposable
@@ -94,6 +104,7 @@ namespace UVC.UIToolkit
private bool _disposed;
private bool _isEnabled = true;
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private InputFieldVariant _variant = InputFieldVariant.Default;
private Func<bool>? _validation;
private Label? _errorLabel;
@@ -160,6 +171,18 @@ namespace UVC.UIToolkit
}
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
/// <summary>읽기 전용</summary>
public new bool isReadOnly
{
@@ -237,6 +260,9 @@ namespace UVC.UIToolkit
{
AddToClassList("utk-input");
UpdateVariant();
// label 설정 후 LabelMinWidth 적용
schedule.Execute(() => ApplyLabelMinWidth());
}
private void SetupEvents()
@@ -271,6 +297,22 @@ namespace UVC.UIToolkit
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -76,7 +76,16 @@ namespace UVC.UIToolkit
///
/// <!-- 비활성화 상태 -->
/// <utk:UTKIntegerField label="고정 값" value="100" is-enabled="false" />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKIntegerField label="수량" label-min-width="120" />
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var intField = new UTKIntegerField("수량");
/// intField.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시 (인벤토리 수량):</b></para>
/// <code>
/// // 인벤토리 아이템 수량 편집
@@ -100,6 +109,7 @@ namespace UVC.UIToolkit
private bool _disposed;
private bool _isEnabled = true;
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -151,6 +161,18 @@ namespace UVC.UIToolkit
}
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
/// <summary>
/// 읽기 전용
/// </summary>
@@ -192,6 +214,9 @@ namespace UVC.UIToolkit
private void SetupStyles()
{
AddToClassList("utk-integer-field");
// label 설정 후 LabelMinWidth 적용
schedule.Execute(() => ApplyLabelMinWidth());
}
private void SetupEvents()
@@ -224,6 +249,22 @@ namespace UVC.UIToolkit
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -68,7 +68,16 @@ namespace UVC.UIToolkit
///
/// <!-- 비활성화 상태 -->
/// <utk:UTKLongField label="고정 ID" is-enabled="false" />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKLongField label="ID" label-min-width="120" />
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var longField = new UTKLongField("ID");
/// longField.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시 (파일 정보):</b></para>
/// <code>
/// // 파일 크기 표시
@@ -87,6 +96,7 @@ namespace UVC.UIToolkit
private bool _disposed;
private bool _isEnabled = true;
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -138,6 +148,18 @@ namespace UVC.UIToolkit
}
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
/// <summary>
/// 읽기 전용
/// </summary>
@@ -179,6 +201,9 @@ namespace UVC.UIToolkit
private void SetupStyles()
{
AddToClassList("utk-long-field");
// label 설정 후 LabelMinWidth 적용
schedule.Execute(() => ApplyLabelMinWidth());
}
private void SetupEvents()
@@ -211,6 +236,22 @@ namespace UVC.UIToolkit
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -84,7 +84,16 @@ namespace UVC.UIToolkit
///
/// <!-- 읽기 전용 -->
/// <utk:UTKRectField label="고정 영역" is-readonly="true" />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKRectField label="영역" label-min-width="120" />
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var rectField = new UTKRectField("영역");
/// rectField.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시 (스프라이트 영역 편집):</b></para>
/// <code>
/// // 스프라이트 UV 영역 편집기
@@ -114,6 +123,7 @@ namespace UVC.UIToolkit
private string _wLabel = "W";
private string _hLabel = "H";
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -213,6 +223,18 @@ namespace UVC.UIToolkit
}
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
/// <summary>읽기 전용 상태</summary>
[UxmlAttribute("is-readonly")]
public bool IsReadOnly
@@ -263,7 +285,11 @@ namespace UVC.UIToolkit
AddToClassList("utk-rectfield");
// 초기 라벨 설정
schedule.Execute(() => UpdateAxisLabels());
schedule.Execute(() =>
{
UpdateAxisLabels();
ApplyLabelMinWidth();
});
}
private void SetupEvents()
@@ -318,6 +344,22 @@ namespace UVC.UIToolkit
textInput.isReadOnly = _isReadOnly;
}
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -89,8 +89,17 @@ namespace UVC.UIToolkit
///
/// <!-- 에러 메시지 (C#에서 Validation 설정 권장) -->
/// <utk:UTKVector2Field label="크기" error-message="크기는 양수여야 합니다." />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKVector2Field label="크기" label-min-width="120" />
/// </UXML>
/// </code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var vec2Field = new UTKVector2Field("크기");
/// vec2Field.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시:</b></para>
/// <code>
/// // RectTransform 크기 조절
@@ -121,6 +130,7 @@ namespace UVC.UIToolkit
private string _xLabel = "X";
private string _yLabel = "Y";
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -208,6 +218,18 @@ namespace UVC.UIToolkit
get => _validation;
set => _validation = value;
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
#endregion
#region Constructor
@@ -246,7 +268,11 @@ namespace UVC.UIToolkit
AddToClassList("utk-vector2-field");
// 초기 라벨 설정
schedule.Execute(() => UpdateAxisLabels());
schedule.Execute(() =>
{
UpdateAxisLabels();
ApplyLabelMinWidth();
});
}
private void SetupEvents()
@@ -299,6 +325,22 @@ namespace UVC.UIToolkit
textInput.isReadOnly = _isReadOnly;
}
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -89,7 +89,16 @@ namespace UVC.UIToolkit
///
/// <!-- 에러 메시지 (C#에서 Validation 설정 권장) -->
/// <utk:UTKVector3Field label="위치" error-message="유효하지 않은 좌표입니다." />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKVector3Field label="위치" label-min-width="120" />
/// ]]></code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var vec3Field = new UTKVector3Field("위치");
/// vec3Field.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시 (Transform 편집기):</b></para>
/// <code>
/// // GameObject Transform 편집
@@ -121,6 +130,7 @@ namespace UVC.UIToolkit
private string _yLabel = "Y";
private string _zLabel = "Z";
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -229,6 +239,18 @@ namespace UVC.UIToolkit
get => _validation;
set => _validation = value;
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
#endregion
#region Constructor
@@ -267,7 +289,11 @@ namespace UVC.UIToolkit
AddToClassList("utk-vector3-field");
// 초기 라벨 설정
schedule.Execute(() => UpdateAxisLabels());
schedule.Execute(() =>
{
UpdateAxisLabels();
ApplyLabelMinWidth();
});
}
private void SetupEvents()
@@ -321,6 +347,22 @@ namespace UVC.UIToolkit
textInput.isReadOnly = _isReadOnly;
}
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -91,8 +91,17 @@ namespace UVC.UIToolkit
///
/// <!-- 에러 메시지 (C#에서 Validation 설정 권장) -->
/// <utk:UTKVector4Field label="색상" error-message="알파 값은 0~1 사이여야 합니다." />
///
/// <!-- label min-width 설정 -->
/// <utk:UTKVector4Field label="값" label-min-width="120" />
/// </UXML>
/// </code>
/// <para><b>Label Min-Width 설정:</b></para>
/// <code>
/// // label이 있을 때 .unity-label의 min-width를 설정
/// var vec4Field = new UTKVector4Field("값");
/// vec4Field.LabelMinWidth = 120f; // 120px
/// </code>
/// <para><b>실제 활용 예시:</b></para>
/// <code>
/// // 머티리얼 쉐이더 파라미터 설정
@@ -123,6 +132,7 @@ namespace UVC.UIToolkit
private string _zLabel = "Z";
private string _wLabel = "W";
private string _errorMessage = "";
private float _labelMinWidth = -1f;
private Func<bool>? _validation;
private Label? _errorLabel;
#endregion
@@ -234,6 +244,18 @@ namespace UVC.UIToolkit
get => _validation;
set => _validation = value;
}
/// <summary>label이 있을 때 .unity-label의 min-width (px). -1이면 미설정</summary>
[UxmlAttribute("label-min-width")]
public float LabelMinWidth
{
get => _labelMinWidth;
set
{
_labelMinWidth = value;
ApplyLabelMinWidth();
}
}
#endregion
#region Constructor
@@ -272,7 +294,11 @@ namespace UVC.UIToolkit
AddToClassList("utk-vector4field");
// 초기 라벨 설정
schedule.Execute(() => UpdateAxisLabels());
schedule.Execute(() =>
{
UpdateAxisLabels();
ApplyLabelMinWidth();
});
}
private void SetupEvents()
@@ -327,6 +353,22 @@ namespace UVC.UIToolkit
textInput.isReadOnly = _isReadOnly;
}
}
private void ApplyLabelMinWidth()
{
if (string.IsNullOrEmpty(label)) return;
var labelElement = this.Q<Label>(className: "unity-label");
if (labelElement == null) return;
if (_labelMinWidth >= 0)
{
labelElement.style.minWidth = _labelMinWidth;
}
else
{
labelElement.style.minWidth = StyleKeyword.Null;
}
}
#endregion
#region Event Handlers

View File

@@ -0,0 +1,42 @@
#nullable enable
namespace UVC.UIToolkit
{
/// <summary>
/// 단축키 아이템 데이터.
/// <see cref="UTKShortcutList"/>의 각 행에 대한 데이터를 담습니다.
/// </summary>
/// <example>
/// <code>
/// var data = new UTKShortcutItemData
/// {
/// Id = "file.new_project",
/// CommandName = "File > New Project",
/// UseCtrl = true,
/// UseShift = false,
/// UseAlt = false,
/// Key = "N"
/// };
/// </code>
/// </example>
public class UTKShortcutItemData
{
/// <summary>단축키 고유 ID (예: "file.new_project")</summary>
public string Id { get; set; } = "";
/// <summary>표시 명령 이름 (예: "File > New Project")</summary>
public string CommandName { get; set; } = "";
/// <summary>Ctrl 키 사용 여부</summary>
public bool UseCtrl { get; set; }
/// <summary>Shift 키 사용 여부</summary>
public bool UseShift { get; set; }
/// <summary>Alt 키 사용 여부</summary>
public bool UseAlt { get; set; }
/// <summary>주요 키 표시 텍스트 (예: "N", "Delete", "F1")</summary>
public string Key { get; set; } = "";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: caa768e3069c01c49863e091a09d622f

View File

@@ -0,0 +1,639 @@
#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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 700c91f027891a64c8d4a91e4107a90d

View File

@@ -3,6 +3,7 @@ using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.UIToolkit.Util;
namespace UVC.UIToolkit
{
@@ -478,6 +479,7 @@ namespace UVC.UIToolkit
okBtn.OnClicked += Close;
_buttonContainer.Add(okBtn);
}
UTKChildAnnotator.AnnotateChild(_buttonContainer);
}
/// <summary>

View File

@@ -1,7 +1,9 @@
#nullable enable
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.UIToolkit.Util;
namespace UVC.UIToolkit
{
@@ -21,8 +23,8 @@ namespace UVC.UIToolkit
/// // 초기화 (root 설정 필요)
/// UTKModal.SetRoot(rootVisualElement);
///
/// // Static Factory로 표시
/// var modal = UTKModal.Show("설정", UTKModal.ModalSize.Medium);
/// // Static Factory로 생성
/// var modal = UTKModal.Create("설정", UTKModal.ModalSize.Medium);
/// modal.OnClosed += () => Debug.Log("모달 닫힘");
///
/// // 콘텐츠 추가
@@ -32,6 +34,37 @@ namespace UVC.UIToolkit
/// // 푸터에 버튼 추가
/// modal.AddToFooter(new UTKButton("확인", "", UTKButton.ButtonVariant.Primary));
/// modal.AddToFooter(new UTKButton("취소", "", UTKButton.ButtonVariant.Normal));
///
/// // 화면에 표시
/// modal.Show();
/// </code>
/// <para><b>Async/Await 방식:</b></para>
/// <code>
/// // 모달이 닫힐 때까지 대기
/// var modal = UTKModal.Create("설정", UTKModal.ModalSize.Medium);
/// modal.Add(new Label("모달 내용"));
///
/// var closeBtn = new UTKButton("닫기", "", UTKButton.ButtonVariant.Primary);
/// closeBtn.OnClicked += () => modal.Close();
/// modal.AddToFooter(closeBtn);
///
/// await modal.ShowAsync();
/// Debug.Log("모달이 닫혔습니다.");
/// </code>
/// <para><b>Async/Await + IUTKModalContent 방식:</b></para>
/// <code>
/// // IUTKModalContent<T> 구현 콘텐츠에서 결과 반환
/// var modal = UTKModal.Create("사용자 정보", UTKModal.ModalSize.Medium);
/// var form = new UserFormContent(); // VisualElement + IUTKModalContent<UserData>
/// modal.Add(form);
///
/// var submitBtn = new UTKButton("제출", "", UTKButton.ButtonVariant.Primary);
/// submitBtn.OnClicked += () => modal.Close();
/// modal.AddToFooter(submitBtn);
///
/// UserData? result = await modal.ShowAsync<UserData>();
/// if (result != null)
/// Debug.Log($"이름: {result.Name}");
/// </code>
/// <para><b>UXML에서 사용:</b></para>
/// <code>
@@ -42,6 +75,41 @@ namespace UVC.UIToolkit
/// </ui:UXML>
/// </code>
/// </example>
/// <summary>
/// 모달 콘텐츠 인터페이스.
/// VisualElement를 상속한 클래스에서 구현하여 모달의 결과 값을 반환합니다.
/// </summary>
/// <typeparam name="T">결과 타입</typeparam>
/// <example>
/// <code>
/// public class UserFormContent : VisualElement, IUTKModalContent<UserData>
/// {
/// private UTKInputField _nameField;
/// private UTKInputField _emailField;
///
/// public UserFormContent()
/// {
/// _nameField = new UTKInputField("이름");
/// _emailField = new UTKInputField("이메일");
/// Add(_nameField);
/// Add(_emailField);
/// }
///
/// public UserData? GetResult()
/// {
/// return new UserData(_nameField.value, _emailField.value);
/// }
/// }
/// </code>
/// </example>
public interface IUTKModalContent<T> where T : class
{
/// <summary>
/// 모달 닫힐 때 호출되어 결과 값을 반환합니다.
/// </summary>
T? GetResult();
}
[UxmlElement]
public partial class UTKModal : VisualElement, IDisposable
{
@@ -64,6 +132,9 @@ namespace UVC.UIToolkit
private bool _showCloseButton = true;
private bool _closeOnBackdropClick = false;
private ModalSize _size = ModalSize.Medium;
private UniTaskCompletionSource? _closeTcs;
private Action? _onCloseResultHandler;
#endregion
#region Events
@@ -104,7 +175,12 @@ namespace UVC.UIToolkit
public bool CloseOnBackdropClick
{
get => _closeOnBackdropClick;
set => _closeOnBackdropClick = value;
set
{
if (_closeOnBackdropClick == value) return;
_closeOnBackdropClick = value;
UpdateBackdropClickHandler();
}
}
/// <summary>모달 크기</summary>
@@ -186,31 +262,12 @@ namespace UVC.UIToolkit
#region Static Factory
/// <summary>
/// 모달 표시
/// 모달 생성 (화면에 표시하려면 Show() 호출 필요)
/// </summary>
public static UTKModal Show(string title, ModalSize size = ModalSize.Medium)
public static UTKModal Create(string title, ModalSize size = ModalSize.Medium)
{
ValidateRoot();
var modal = new UTKModal(title, size);
modal._blocker = UTKModalBlocker.Show(_root!, 0.5f, false);
if (modal._closeOnBackdropClick)
{
modal._blocker.OnBlockerClicked += modal.Close;
}
// panel.visualTree에 직접 추가
var root = _root!.panel?.visualTree ?? _root!;
root.Add(modal);
// 중앙 정렬
modal.style.position = Position.Absolute;
modal.style.left = Length.Percent(50);
modal.style.top = Length.Percent(50);
modal.style.translate = new Translate(Length.Percent(-50), Length.Percent(-50));
return modal;
return new UTKModal(title, size);
}
#endregion
@@ -305,6 +362,22 @@ namespace UVC.UIToolkit
AddToClassList(sizeClass);
}
/// <summary>
/// backdrop 클릭 핸들러 등록/해제
/// </summary>
private void UpdateBackdropClickHandler()
{
if (_blocker == null) return;
// 항상 먼저 해제하여 중복 등록 방지
_blocker.OnBlockerClicked -= Close;
if (_closeOnBackdropClick)
{
_blocker.OnBlockerClicked += Close;
}
}
/// <summary>
/// 콘텐츠 추가
/// </summary>
@@ -319,6 +392,8 @@ namespace UVC.UIToolkit
public void AddToFooter(VisualElement element)
{
_footer?.Add(element);
_footer?.AddToClassList("utk-modal__footer--has-children");
UTKChildAnnotator.AnnotateChild(_footer);
}
/// <summary>
@@ -332,11 +407,81 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 모달을 화면에 표시
/// </summary>
public void Show()
{
ValidateRoot();
_blocker = UTKModalBlocker.Show(_root!, 0.5f, false);
UpdateBackdropClickHandler();
// panel.visualTree에 직접 추가
var root = _root!.panel?.visualTree ?? _root!;
root.Add(this);
// 중앙 정렬
style.position = Position.Absolute;
style.left = Length.Percent(50);
style.top = Length.Percent(50);
style.translate = new Translate(Length.Percent(-50), Length.Percent(-50));
}
/// <summary>
/// 모달을 화면에 표시하고 닫힐 때까지 대기
/// </summary>
public UniTask ShowAsync()
{
_closeTcs = new UniTaskCompletionSource();
Show();
return _closeTcs.Task;
}
/// <summary>
/// 모달을 화면에 표시하고 닫힐 때 IUTKModalContent<T> 콘텐츠의 결과를 반환.
/// Add()된 자식 중 IUTKModalContent<T>를 구현한 첫 번째 요소에서 GetResult()를 호출합니다.
/// </summary>
/// <typeparam name="T">결과 타입</typeparam>
/// <returns>콘텐츠의 결과 값. 콘텐츠가 없으면 default(T)</returns>
public UniTask<T?> ShowAsync<T>() where T : class
{
var tcs = new UniTaskCompletionSource<T?>();
_onCloseResultHandler = () =>
{
var result = FindModalContent<T>()?.GetResult();
tcs.TrySetResult(result);
};
Show();
return tcs.Task;
}
/// <summary>
/// 콘텐츠에서 IUTKModalContent<T>를 구현한 첫 번째 요소를 찾습니다.
/// </summary>
private IUTKModalContent<T>? FindModalContent<T>() where T : class
{
if (_content == null) return null;
for (int i = 0; i < _content.childCount; i++)
{
if (_content[i] is IUTKModalContent<T> modalContent)
return modalContent;
}
return null;
}
/// <summary>
/// 모달 닫기
/// </summary>
public void Close()
{
// 결과 핸들러 먼저 호출 (ShowAsync<T> 사용 시)
_onCloseResultHandler?.Invoke();
_onCloseResultHandler = null;
OnClosed?.Invoke();
RemoveFromHierarchy();
if (_blocker != null)
@@ -345,6 +490,8 @@ namespace UVC.UIToolkit
_blocker.Hide();
}
_blocker = null;
_closeTcs?.TrySetResult();
_closeTcs = null;
}
#endregion

View File

@@ -3,6 +3,7 @@ using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.UIToolkit.Util;
namespace UVC.UIToolkit
{
@@ -411,6 +412,7 @@ namespace UVC.UIToolkit
btn.Size = UTKButton.ButtonSize.Small;
btn.OnClicked += () => OnActionClicked?.Invoke(actionId);
_actions.Add(btn);
UTKChildAnnotator.AnnotateChild(_actions);
}
/// <summary>

View File

@@ -0,0 +1,18 @@
#nullable enable
using Cysharp.Threading.Tasks;
namespace UVC.UIToolkit
{
/// <summary>
/// 탭 콘텐츠 인터페이스.
/// UTKTabView에서 탭 전환 시 자동으로 Show/Hide를 호출합니다.
/// </summary>
public interface IUTKTabContent
{
/// <summary>탭이 선택되어 표시될 때 호출</summary>
void Show(object? data);
/// <summary>탭이 선택 해제되어 숨겨질 때 호출</summary>
UniTask Hide();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f5401783ae68ec49b0dca98d448e209

View File

@@ -1,6 +1,7 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
@@ -126,6 +127,10 @@ namespace UVC.UIToolkit
private bool _disposed;
private readonly List<UTKTab> _utkTabs = new();
private TabAlign _align = TabAlign.Top;
private float _tabWidth = 0;
private float _tabHeight = 0;
private VisualElement? _contentViewport;
private int _previousTabIndex = -1;
#endregion
#region Events
@@ -156,6 +161,30 @@ namespace UVC.UIToolkit
ApplyAlignment();
}
}
/// <summary>탭 콘텐츠 영역 너비 (0 이하이면 미설정)</summary>
[UxmlAttribute("tab-width")]
public float TabWidth
{
get => _tabWidth;
set
{
_tabWidth = value;
ApplyContentViewportSize();
}
}
/// <summary>탭 콘텐츠 영역 높이 (0 이하이면 미설정)</summary>
[UxmlAttribute("tab-height")]
public float TabHeight
{
get => _tabHeight;
set
{
_tabHeight = value;
ApplyContentViewportSize();
}
}
#endregion
#region Constructor
@@ -185,6 +214,7 @@ namespace UVC.UIToolkit
private void SetupEvents()
{
this.RegisterCallback<ChangeEvent<int>>(OnTabIndexChanged);
activeTabChanged += OnActiveTabChanged;
}
private void SubscribeToThemeChanges()
@@ -213,12 +243,71 @@ namespace UVC.UIToolkit
#endregion
#region Event Handlers
/// <summary>
/// 코드에서 selectedTabIndex를 변경했을 때 호출됩니다.
/// </summary>
private void OnTabIndexChanged(ChangeEvent<int> evt)
{
_previousTabIndex = evt.previousValue;
NotifyTabContent(evt.previousValue, evt.newValue).Forget();
UpdateTabSelection();
OnTabChanged?.Invoke(evt.newValue, activeTab);
}
/// <summary>
/// 마우스 클릭 등으로 탭이 변경되었을 때 호출됩니다.
/// </summary>
private void OnActiveTabChanged(Tab previousTab, Tab newTab)
{
int prevIndex = previousTab != null ? _utkTabs.FindIndex(t => t == previousTab) : -1;
int newIndex = newTab != null ? _utkTabs.FindIndex(t => t == newTab) : -1;
// ChangeEvent<int>와 중복 호출 방지
if (prevIndex == _previousTabIndex && newIndex == selectedTabIndex)
return;
_previousTabIndex = prevIndex;
NotifyTabContent(prevIndex, newIndex).Forget();
UpdateTabSelection();
OnTabChanged?.Invoke(newIndex, newTab);
}
/// <summary>
/// 이전 탭 콘텐츠의 Hide, 새 탭 콘텐츠의 Show를 호출합니다.
/// </summary>
private async UniTaskVoid NotifyTabContent(int previousIndex, int newIndex)
{
// 이전 탭 Hide
if (previousIndex >= 0 && previousIndex < _utkTabs.Count)
{
if (FindTabContent(_utkTabs[previousIndex]) is IUTKTabContent prevContent)
{
await prevContent.Hide();
}
}
// 새 탭 Show
if (newIndex >= 0 && newIndex < _utkTabs.Count)
{
if (FindTabContent(_utkTabs[newIndex]) is IUTKTabContent newContent)
{
newContent.Show(null);
}
}
}
/// <summary>
/// 탭 내부에서 IUTKTabContent를 구현한 자식 요소를 찾습니다.
/// </summary>
public static IUTKTabContent? FindTabContent(Tab tab)
{
for (int i = 0; i < tab.childCount; i++)
{
if (tab[i] is IUTKTabContent content) return content;
}
return null;
}
private void UpdateTabSelection()
{
for (int i = 0; i < _utkTabs.Count; i++)
@@ -258,6 +347,23 @@ namespace UVC.UIToolkit
}
}
/// <summary>
/// 탭 콘텐츠 영역 크기 적용
/// </summary>
private void ApplyContentViewportSize()
{
_contentViewport ??= this.Q(className: "unity-tab-view__content-viewport");
if (_contentViewport == null) return;
_contentViewport.style.width = _tabWidth > 0
? new StyleLength(_tabWidth)
: new StyleLength(StyleKeyword.Auto);
_contentViewport.style.height = _tabHeight > 0
? new StyleLength(_tabHeight)
: new StyleLength(StyleKeyword.Auto);
}
/// <summary>
/// UTK 탭 추가
/// </summary>
@@ -322,6 +428,8 @@ namespace UVC.UIToolkit
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
UnregisterCallback<ChangeEvent<int>>(OnTabIndexChanged);
activeTabChanged -= OnActiveTabChanged;
foreach (var tab in _utkTabs)
{

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ad370f2935493cc439100336b1d0c334
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,41 @@
using System.Linq;
using UnityEngine.UIElements;
namespace UVC.UIToolkit.Util
{
/// <summary>
/// UTKChildAnnotator는 VisualElement의 자식 요소들을 순회하며 첫 번째 자식에게 "first-child" 클래스를, 마지막 자식에게 "last-child" 클래스를 추가하는 유틸리티 클래스입니다.
/// </summary>
public static class UTKChildAnnotator
{
/// <summary>
/// 주어진 VisualElement의 자식 요소들을 순회하며 첫 번째 자식에게는 "first-child" 클래스를, 마지막 자식에게는 "last-child" 클래스를 추가합니다.
/// </summary>
/// <param name="parent">클래스를 추가할 VisualElement 부모 요소</param>
public static void AnnotateChild(VisualElement parent)
{
var children = parent.Children().ToList();
for (int i = 0; i < children.Count; i++)
{
var child = children[i];
if (i == 0)
{
child.RemoveFromClassList("first-child");
child.AddToClassList("first-child");
}
else if (i == children.Count - 1)
{
child.RemoveFromClassList("last-child");
child.AddToClassList("last-child");
}
else
{
child.RemoveFromClassList("first-child");
child.RemoveFromClassList("last-child");
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 26dda967fd6d0d84bb1f40f8a6ad9c5f