Files
XRLib/Assets/Editor/UIToolkit/UTKMaterialIconsGenerator.cs
2026-01-22 20:12:22 +09:00

589 lines
28 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
namespace UVC.UIToolkit.Editor
{
/// <summary>
/// MaterialSymbolsOutlinedCodepoints.txt 파일을 파싱하여
/// UTKMaterialIcons.cs 정적 클래스를 자동 생성하는 에디터 도구입니다.
/// </summary>
public class UTKMaterialIconsGenerator : EditorWindow
{
#region EditorPrefs Keys
private const string PrefKeyCodepointsPath = "UTK_MaterialIcons_CodepointsPath";
private const string PrefKeyOutputPath = "UTK_MaterialIcons_OutputPath";
private const string PrefKeyClassName = "UTK_MaterialIcons_ClassName";
private const string PrefKeyNamespace = "UTK_MaterialIcons_Namespace";
private const string PrefKeyFontPath = "UTK_MaterialIcons_FontPath";
#endregion
#region Default Values
private const string DefaultCodepointsPath = "Assets/Resources/Fonts/Icons/MaterialSymbolsOutlinedCodepoints.txt";
private const string DefaultOutputPath = "Assets/Scripts/UVC/UIToolkit/Common/UTKMaterialIcons.cs";
private const string DefaultClassName = "UTKMaterialIcons";
private const string DefaultNamespace = "UVC.UIToolkit";
private const string DefaultFontPath = "Fonts/Icons/MaterialSymbolsOutlined";
#endregion
#region Fields
private string _codepointsPath = DefaultCodepointsPath;
private string _outputPath = DefaultOutputPath;
private string _className = DefaultClassName;
private string _namespace = DefaultNamespace;
private string _fontPath = DefaultFontPath;
#endregion
[MenuItem("Tools/UTK/Material Icons Generator")]
public static void ShowWindow()
{
var window = GetWindow<UTKMaterialIconsGenerator>("Material Icons Generator");
window.minSize = new Vector2(450, 250);
window.LoadSettings();
}
[MenuItem("Tools/UTK/Generate Material Icons Class (Quick)")]
public static void GenerateQuick()
{
var codepointsPath = EditorPrefs.GetString(PrefKeyCodepointsPath, DefaultCodepointsPath);
var outputPath = EditorPrefs.GetString(PrefKeyOutputPath, DefaultOutputPath);
var className = EditorPrefs.GetString(PrefKeyClassName, DefaultClassName);
var namespaceName = EditorPrefs.GetString(PrefKeyNamespace, DefaultNamespace);
var fontPath = EditorPrefs.GetString(PrefKeyFontPath, DefaultFontPath);
Generate(codepointsPath, outputPath, className, namespaceName, fontPath);
}
private void OnEnable()
{
LoadSettings();
}
private void LoadSettings()
{
_codepointsPath = EditorPrefs.GetString(PrefKeyCodepointsPath, DefaultCodepointsPath);
_outputPath = EditorPrefs.GetString(PrefKeyOutputPath, DefaultOutputPath);
_className = EditorPrefs.GetString(PrefKeyClassName, DefaultClassName);
_namespace = EditorPrefs.GetString(PrefKeyNamespace, DefaultNamespace);
_fontPath = EditorPrefs.GetString(PrefKeyFontPath, DefaultFontPath);
}
private void SaveSettings()
{
EditorPrefs.SetString(PrefKeyCodepointsPath, _codepointsPath);
EditorPrefs.SetString(PrefKeyOutputPath, _outputPath);
EditorPrefs.SetString(PrefKeyClassName, _className);
EditorPrefs.SetString(PrefKeyNamespace, _namespace);
EditorPrefs.SetString(PrefKeyFontPath, _fontPath);
}
private void OnGUI()
{
EditorGUILayout.Space(10);
EditorGUILayout.LabelField("Material Icons Class Generator", EditorStyles.boldLabel);
EditorGUILayout.Space(5);
EditorGUILayout.HelpBox("Codepoints 파일을 파싱하여 C# 정적 클래스를 생성합니다.", MessageType.Info);
EditorGUILayout.Space(10);
// Codepoints 파일 경로
EditorGUILayout.BeginHorizontal();
_codepointsPath = EditorGUILayout.TextField("Codepoints File", _codepointsPath);
if (GUILayout.Button("...", GUILayout.Width(30)))
{
var path = EditorUtility.OpenFilePanel("Select Codepoints File", "Assets", "txt");
if (!string.IsNullOrEmpty(path))
{
// 상대 경로로 변환
if (path.StartsWith(Application.dataPath))
{
path = "Assets" + path.Substring(Application.dataPath.Length);
}
_codepointsPath = path;
}
}
EditorGUILayout.EndHorizontal();
// 출력 파일 경로
EditorGUILayout.BeginHorizontal();
_outputPath = EditorGUILayout.TextField("Output File", _outputPath);
if (GUILayout.Button("...", GUILayout.Width(30)))
{
var directory = Path.GetDirectoryName(_outputPath) ?? "Assets";
var filename = Path.GetFileName(_outputPath);
var path = EditorUtility.SaveFilePanel("Save Generated Class", directory, filename, "cs");
if (!string.IsNullOrEmpty(path))
{
// 상대 경로로 변환
if (path.StartsWith(Application.dataPath))
{
path = "Assets" + path.Substring(Application.dataPath.Length);
}
_outputPath = path;
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(5);
// 클래스명
_className = EditorGUILayout.TextField("Class Name", _className);
// 네임스페이스
_namespace = EditorGUILayout.TextField("Namespace", _namespace);
// 폰트 경로 (Resources 기준)
_fontPath = EditorGUILayout.TextField("Font Path (Resources)", _fontPath);
EditorGUILayout.HelpBox("Resources.Load에 사용할 폰트 경로 (확장자 제외)", MessageType.None);
EditorGUILayout.Space(15);
// 파일 존재 여부 표시
var codepointsExists = File.Exists(_codepointsPath);
var outputExists = File.Exists(_outputPath);
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Codepoints File:", GUILayout.Width(100));
EditorGUILayout.LabelField(codepointsExists ? "Found" : "Not Found",
codepointsExists ? EditorStyles.label : EditorStyles.boldLabel);
EditorGUILayout.EndHorizontal();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Output File:", GUILayout.Width(100));
EditorGUILayout.LabelField(outputExists ? "Exists (will overwrite)" : "New file",
EditorStyles.label);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(15);
// 버튼들
EditorGUILayout.BeginHorizontal();
GUI.enabled = codepointsExists;
if (GUILayout.Button("Generate", GUILayout.Height(30)))
{
SaveSettings();
Generate(_codepointsPath, _outputPath, _className, _namespace, _fontPath);
}
GUI.enabled = true;
if (GUILayout.Button("Reset to Defaults", GUILayout.Height(30)))
{
_codepointsPath = DefaultCodepointsPath;
_outputPath = DefaultOutputPath;
_className = DefaultClassName;
_namespace = DefaultNamespace;
_fontPath = DefaultFontPath;
SaveSettings();
}
EditorGUILayout.EndHorizontal();
}
public static void Generate(string codepointsPath, string outputPath, string className, string namespaceName, string fontPath = DefaultFontPath)
{
if (!File.Exists(codepointsPath))
{
Debug.LogError($"Codepoints 파일을 찾을 수 없습니다: {codepointsPath}");
return;
}
var icons = ParseCodepoints(codepointsPath);
if (icons.Count == 0)
{
Debug.LogError("파싱된 아이콘이 없습니다.");
return;
}
var code = GenerateCode(icons, className, namespaceName, fontPath);
// 디렉토리 생성
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(outputPath, code, Encoding.UTF8);
AssetDatabase.Refresh();
Debug.Log($"{className}.cs 생성 완료: {icons.Count}개 아이콘 ({outputPath})");
}
private static List<(string name, string unicode)> ParseCodepoints(string path)
{
var result = new List<(string, string)>();
var lines = File.ReadAllLines(path);
foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line)) continue;
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
result.Add((parts[0], parts[1]));
}
}
return result;
}
private static string GenerateCode(List<(string name, string unicode)> icons, string className, string namespaceName, string fontPath)
{
var sb = new StringBuilder();
var usedNames = new HashSet<string>();
sb.AppendLine("// <auto-generated>");
sb.AppendLine("// 이 파일은 UTKMaterialIconsGenerator에 의해 자동 생성되었습니다.");
sb.AppendLine("// 직접 수정하지 마세요. Tools > UTK > Material Icons Generator 메뉴로 재생성하세요.");
sb.AppendLine("// </auto-generated>");
sb.AppendLine();
sb.AppendLine("#nullable enable");
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.Threading;");
sb.AppendLine("using Cysharp.Threading.Tasks;");
sb.AppendLine("using UnityEngine;");
sb.AppendLine("using UnityEngine.UIElements;");
sb.AppendLine();
sb.AppendLine($"namespace {namespaceName}");
sb.AppendLine("{");
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Material Symbols Outlined 아이콘 유니코드 문자 상수 클래스입니다.");
sb.AppendLine($" /// 총 {icons.Count}개의 아이콘을 포함합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <example>");
sb.AppendLine(" /// <code>");
sb.AppendLine($" /// // 동기 폰트 로드 (캐싱됨)");
sb.AppendLine($" /// Font font = {className}.LoadFont();");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 비동기 폰트 로드 (UniTask)");
sb.AppendLine($" /// Font? font = await {className}.LoadFontAsync(cancellationToken);");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // Label에 아이콘 적용");
sb.AppendLine($" /// var label = new Label({className}.Home);");
sb.AppendLine($" /// {className}.ApplyIconStyle(label);");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 비동기로 아이콘 스타일 적용");
sb.AppendLine($" /// await {className}.ApplyIconStyleAsync(label, cancellationToken);");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 또는 StyleFontDefinition 직접 사용");
sb.AppendLine($" /// label.style.unityFontDefinition = {className}.GetFontDefinition();");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 이름으로 아이콘 조회");
sb.AppendLine($" /// string icon = {className}.GetIcon(\"settings\");");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 아이콘 존재 여부 확인");
sb.AppendLine($" /// if ({className}.HasIcon(\"search\")) {{ }}");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 존재하는 유니코드 문자인지 확인");
sb.AppendLine($" /// if ({className}.IsIconChar(\"\")) {{ }}");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 전체 아이콘 이름 순회");
sb.AppendLine($" /// foreach (var name in {className}.GetAllIconNames()) {{ }}");
sb.AppendLine(" /// ");
sb.AppendLine($" /// // 아이콘 총 개수");
sb.AppendLine($" /// int count = {className}.Count;");
sb.AppendLine(" /// </code>");
sb.AppendLine(" /// </example>");
sb.AppendLine(" /// <remarks>");
sb.AppendLine(" /// <para><b>UXML에서 사용하기:</b></para>");
sb.AppendLine(" /// <para>UXML에서 Material Icons를 사용하려면 USS에서 폰트를 설정하고, Label의 text에 유니코드 문자를 직접 입력합니다.</para>");
sb.AppendLine(" /// <code>");
sb.AppendLine(" /// /* USS 파일 */");
sb.AppendLine(" /// .material-icon {");
sb.AppendLine(" /// -unity-font: resource('Fonts/Icons/MaterialSymbolsOutlined');");
sb.AppendLine(" /// font-size: 24px;");
sb.AppendLine(" /// }");
sb.AppendLine(" /// </code>");
sb.AppendLine(" /// <code>");
sb.AppendLine(" /// <!-- UXML 파일 -->");
sb.AppendLine(" /// <ui:Label class=\"material-icon\" text=\"&#xe88a;\" /> <!-- home 아이콘 -->");
sb.AppendLine(" /// <ui:Label class=\"material-icon\" text=\"&#xe8b8;\" /> <!-- settings 아이콘 -->");
sb.AppendLine(" /// </code>");
sb.AppendLine(" /// <para><b>C# 코드에서 UXML Label에 아이콘 적용:</b></para>");
sb.AppendLine(" /// <code>");
sb.AppendLine($" /// var iconLabel = root.Q<Label>(\"my-icon\");");
sb.AppendLine($" /// iconLabel.text = {className}.Settings;");
sb.AppendLine($" /// {className}.ApplyIconStyle(iconLabel);");
sb.AppendLine(" /// </code>");
sb.AppendLine(" /// </remarks>");
sb.AppendLine($" public static class {className}");
sb.AppendLine(" {");
sb.AppendLine();
sb.AppendLine(" #region Font");
sb.AppendLine();
sb.AppendLine($" private const string FontResourcePath = \"{fontPath}\";");
sb.AppendLine(" private static Font? _cachedFont;");
sb.AppendLine(" private static StyleFontDefinition? _cachedFontDefinition;");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 아이콘 폰트를 동기로 로드합니다. (캐싱됨)");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <returns>로드된 Font, 실패 시 null</returns>");
sb.AppendLine(" public static Font? LoadFont()");
sb.AppendLine(" {");
sb.AppendLine(" if (_cachedFont == null)");
sb.AppendLine(" {");
sb.AppendLine(" _cachedFont = Resources.Load<Font>(FontResourcePath);");
sb.AppendLine(" }");
sb.AppendLine(" return _cachedFont;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 아이콘 폰트를 비동기로 로드합니다. (캐싱됨)");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <param name=\"ct\">취소 토큰</param>");
sb.AppendLine(" /// <returns>로드된 Font, 실패 시 null</returns>");
sb.AppendLine(" public static async UniTask<Font?> LoadFontAsync(CancellationToken ct = default)");
sb.AppendLine(" {");
sb.AppendLine(" if (_cachedFont != null)");
sb.AppendLine(" {");
sb.AppendLine(" return _cachedFont;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" var request = Resources.LoadAsync<Font>(FontResourcePath);");
sb.AppendLine(" await request.ToUniTask(cancellationToken: ct);");
sb.AppendLine();
sb.AppendLine(" _cachedFont = request.asset as Font;");
sb.AppendLine(" return _cachedFont;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// UI Toolkit용 StyleFontDefinition을 동기로 반환합니다. (캐싱됨)");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <returns>StyleFontDefinition, 폰트 로드 실패 시 기본값</returns>");
sb.AppendLine(" public static StyleFontDefinition GetFontDefinition()");
sb.AppendLine(" {");
sb.AppendLine(" if (_cachedFontDefinition == null)");
sb.AppendLine(" {");
sb.AppendLine(" var font = LoadFont();");
sb.AppendLine(" if (font != null)");
sb.AppendLine(" {");
sb.AppendLine(" _cachedFontDefinition = new StyleFontDefinition(font);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" return _cachedFontDefinition ?? default;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// UI Toolkit용 StyleFontDefinition을 비동기로 반환합니다. (캐싱됨)");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <param name=\"ct\">취소 토큰</param>");
sb.AppendLine(" /// <returns>StyleFontDefinition, 폰트 로드 실패 시 기본값</returns>");
sb.AppendLine(" public static async UniTask<StyleFontDefinition> GetFontDefinitionAsync(CancellationToken ct = default)");
sb.AppendLine(" {");
sb.AppendLine(" if (_cachedFontDefinition == null)");
sb.AppendLine(" {");
sb.AppendLine(" var font = await LoadFontAsync(ct);");
sb.AppendLine(" if (font != null)");
sb.AppendLine(" {");
sb.AppendLine(" _cachedFontDefinition = new StyleFontDefinition(font);");
sb.AppendLine(" }");
sb.AppendLine(" }");
sb.AppendLine(" return _cachedFontDefinition ?? default;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// VisualElement에 아이콘 폰트 스타일을 동기로 적용합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <param name=\"element\">스타일을 적용할 요소</param>");
sb.AppendLine(" /// <param name=\"fontSize\">폰트 크기 (기본값: 24)</param>");
sb.AppendLine(" public static void ApplyIconStyle(VisualElement element, int? fontSize = 24)");
sb.AppendLine(" {");
sb.AppendLine(" element.style.unityFontDefinition = GetFontDefinition();");
sb.AppendLine(" if(fontSize != null) element.style.fontSize = fontSize.Value;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// VisualElement에 아이콘 폰트 스타일을 비동기로 적용합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <param name=\"element\">스타일을 적용할 요소</param>");
sb.AppendLine(" /// <param name=\"ct\">취소 토큰</param>");
sb.AppendLine(" /// <param name=\"fontSize\">폰트 크기 (기본값: 24)</param>");
sb.AppendLine(" public static async UniTask ApplyIconStyleAsync(VisualElement element, CancellationToken ct = default, int fontSize = 24)");
sb.AppendLine(" {");
sb.AppendLine(" element.style.unityFontDefinition = await GetFontDefinitionAsync(ct);");
sb.AppendLine(" element.style.fontSize = fontSize;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 폰트 캐시를 클리어합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static void ClearFontCache()");
sb.AppendLine(" {");
sb.AppendLine(" _cachedFont = null;");
sb.AppendLine(" _cachedFontDefinition = null;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" #endregion");
sb.AppendLine();
// 아이콘 상수 생성
foreach (var (name, unicode) in icons)
{
var fieldName = ConvertToFieldName(name, usedNames);
var character = GetCharacterFromUnicode(unicode);
var escapedChar = EscapeForCSharp(character);
sb.AppendLine($" /// <summary>{name} (U+{unicode.ToUpper()})</summary>");
sb.AppendLine($" public const string {fieldName} = \"{escapedChar}\";");
}
sb.AppendLine();
sb.AppendLine(" #region Lookup");
sb.AppendLine();
// 이름으로 아이콘 조회 딕셔너리
sb.AppendLine(" private static readonly Dictionary<string, string> _iconsByName = new()");
sb.AppendLine(" {");
foreach (var (name, unicode) in icons)
{
var character = GetCharacterFromUnicode(unicode);
var escapedChar = EscapeForCSharp(character);
sb.AppendLine($" [\"{name}\"] = \"{escapedChar}\",");
}
sb.AppendLine(" };");
sb.AppendLine();
// 조회 메서드
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 아이콘 이름으로 유니코드 문자를 조회합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" /// <param name=\"iconName\">아이콘 이름 (예: \"home\", \"settings\")</param>");
sb.AppendLine(" /// <returns>아이콘 문자, 없으면 빈 문자열</returns>");
sb.AppendLine(" public static string GetIcon(string iconName)");
sb.AppendLine(" {");
sb.AppendLine(" if(IsIconChar(iconName)) return iconName;");
sb.AppendLine(" return _iconsByName.TryGetValue(iconName, out var icon) ? icon : string.Empty;");
sb.AppendLine(" }");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 아이콘이 존재하는지 확인합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static bool HasIcon(string iconName) => _iconsByName.ContainsKey(iconName);");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 유니코드 문자로 아이콘이 존재하는지 확인합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static bool IsIconChar(string iconChar) => _iconsByName.Values.Contains(iconChar);");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 모든 아이콘 이름 목록을 반환합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public static IEnumerable<string> GetAllIconNames() => _iconsByName.Keys;");
sb.AppendLine();
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// 전체 아이콘 수를 반환합니다.");
sb.AppendLine(" /// </summary>");
sb.AppendLine($" public static int Count => {icons.Count};");
sb.AppendLine();
sb.AppendLine(" #endregion");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
private static string ConvertToFieldName(string iconName, HashSet<string> usedNames)
{
var sb = new StringBuilder();
var words = iconName.Split('_', StringSplitOptions.RemoveEmptyEntries);
foreach (var word in words)
{
if (word.Length == 0) continue;
// 첫 글자 대문자, 나머지 소문자 (PascalCase)
sb.Append(char.ToUpper(word[0]));
if (word.Length > 1)
{
sb.Append(word.Substring(1).ToLower());
}
}
var result = sb.ToString();
// 숫자로 시작하면 앞에 _ 추가
if (result.Length > 0 && char.IsDigit(result[0]))
{
result = "_" + result;
}
// 빈 문자열이면 기본값
if (string.IsNullOrEmpty(result))
{
result = "_Icon";
}
// 중복 처리
var originalResult = result;
var counter = 2;
while (usedNames.Contains(result))
{
result = $"{originalResult}_{counter}";
counter++;
}
usedNames.Add(result);
return result;
}
private static string GetCharacterFromUnicode(string hexCode)
{
try
{
var codepoint = int.Parse(hexCode, NumberStyles.HexNumber);
return char.ConvertFromUtf32(codepoint);
}
catch
{
return "?";
}
}
private static string EscapeForCSharp(string str)
{
var sb = new StringBuilder();
foreach (var c in str)
{
// 유니코드 이스케이프 시퀀스로 변환
if (c > 127 || c < 32)
{
// Surrogate pair 처리
sb.Append($"\\u{(int)c:X4}");
}
else if (c == '"')
{
sb.Append("\\\"");
}
else if (c == '\\')
{
sb.Append("\\\\");
}
else
{
sb.Append(c);
}
}
return sb.ToString();
}
}
}