From 8be174b82cc9e9d8a0b5dc370095c9c93525fb1d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 13:02:49 +0200 Subject: [PATCH] Reference CultCache from the Studio at compile time; add drawers Cut 6. CultCache Studio 1.0.0 -> 1.1.0. Deleted: the CultCacheBridge string-reflection bridge (type lookups by name, GetMethod/GetProperty invocation, PullOnOpen, FlushAsync(bool)), its DescriptorInfo/CatalogMemberInfo/RecordInfo shadows, and the disabled-text-field fallback in DrawValue. The window calls CultCacheMessagePack.Create, UpsertAsync(Type, object, key), Remove, FlushAsync, IsDirty, Registry.AllDescriptors, AllStoredDocuments and BackingStores directly through GameCult.CultLib. Window: single-file and directory stores, optional read-only open with edits disabled and the reason shown, document types from the registry, records by [CultName] with key fallback including subclasses, Add, Duplicate (MessagePack round trip), Delete, immediate upsert on edit, Save = flush, Dirty indicator. An absent [CultGlobal] shows as absent with an explicit Create; nothing is invented. Opening and closing never writes. Drawers (new CultCacheStudioDrawers.cs): dictionaries with recursive keys and values, abstract/interface members through their MessagePack [Union] declarations only, CultRecordRef record picker plus raw key, CultMath float2/3/4, double2/3, int2, bool2, quaternion, rect, Color32, enums including flags, lists and arrays with add/remove, nested keyed types, a red error row for unclaimed types, and the [CultInspectorDrawer(typeof(T))] extension point discovered through TypeCache (ICultInspectorDrawer). Existing inspector attributes keep working. package.json depends on org.gamecult.cultlib 1.0.57 (Cut 7) and org.gamecult.cultmath 0.1.2. No Unity.Mathematics reference. Co-Authored-By: Claude Opus 5 --- src/GameCult.Caching/CultCache.cs | 2 +- .../Caching/Editor/CultCacheStudioDrawers.cs | 519 ++++++++++ .../Editor/CultCacheStudioDrawers.cs.meta | 2 + .../Caching/Editor/CultCacheStudioWindow.cs | 907 ++++-------------- .../GameCult.Unity.Caching.Editor.asmdef | 3 +- src/GameCult.Unity/Assets/Caching/README.md | 86 +- .../Runtime/CultCacheInspectorAttributes.cs | 35 +- .../Assets/Caching/package.json | 6 +- 8 files changed, 796 insertions(+), 764 deletions(-) create mode 100644 src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs create mode 100644 src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs.meta diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 940bfc85..e0f26b22 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -1398,7 +1398,7 @@ namespace GameCult.Caching return true; }); - // soft is dead. CultCacheStudioWindow reflects on FlushAsync(bool) until Cut 6 rewrites it. + // soft is ignored. public Task FlushAsync(bool soft = false) { FlushAllBackingStores(); diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs new file mode 100644 index 00000000..f9c9845f --- /dev/null +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -0,0 +1,519 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using GameCult.Caching; +using MessagePack; +using UnityEditor; +using UnityEngine; +using CM = CultMath; +using Object = UnityEngine.Object; + +namespace GameCult.Unity.Caching.Editor +{ + // Returns the member's next value; mutate reference values in place or return a new one. + public interface ICultInspectorDrawer + { + object Draw(string label, object value, MemberInfo member); + } + + internal sealed class CultInspector + { + private static Dictionary _drawers; + private static readonly Dictionary NameMembers = new Dictionary(); + private static GUIStyle _errorStyle; + + private readonly CultCache _cache; + private readonly Dictionary _foldouts = new Dictionary(StringComparer.Ordinal); + private readonly Dictionary _members = new Dictionary(); + + public CultInspector(CultCache cache) + { + _cache = cache; + } + + public CultStoredDocument[] Records { get; set; } = Array.Empty(); + + private static GUIStyle ErrorStyle => _errorStyle ??= new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, .35f, .3f) } }; + + private static Dictionary Drawers + { + get + { + if (_drawers != null) return _drawers; + _drawers = new Dictionary(); + foreach (var type in TypeCache.GetTypesWithAttribute()) + { + var memberType = type.GetCustomAttribute().MemberType; + if (!typeof(ICultInspectorDrawer).IsAssignableFrom(type) || type.IsAbstract || type.GetConstructor(Type.EmptyTypes) == null) + Debug.LogError($"{type.FullName} is marked [CultInspectorDrawer] but is not a concrete ICultInspectorDrawer with a parameterless constructor."); + else if (_drawers.ContainsKey(memberType)) + Debug.LogError($"{type.FullName} and {_drawers[memberType].GetType().FullName} both draw {memberType.FullName}."); + else + _drawers[memberType] = (ICultInspectorDrawer)Activator.CreateInstance(type); + } + + return _drawers; + } + } + + public static string RecordLabel(CultStoredDocument record) + { + var type = record.Descriptor.DocumentType; + if (!NameMembers.TryGetValue(type, out var member)) + { + member = record.Descriptor.NameMember == null + ? null + : type.GetMember(record.Descriptor.NameMember, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(); + NameMembers[type] = member; + } + + var name = member == null ? null : Get(member, record.Document)?.ToString(); + return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name; + } + + public void DrawDocument(CultStoredDocument record) + { + DrawMembers(record.Document, record.Descriptor.DocumentType, record.Key.Value); + } + + public object DrawValue(string label, Type type, object value, MemberInfo member, string path) + { + if (Drawers.TryGetValue(type, out var drawer) || type.IsGenericType && Drawers.TryGetValue(type.GetGenericTypeDefinition(), out drawer)) + return drawer.Draw(label, value, member); + + if (type == typeof(string)) return DrawString(label, value as string, member); + var range = member?.GetCustomAttribute(); + if (type == typeof(int)) + return range == null + ? EditorGUILayout.IntField(label, As(value)) + : EditorGUILayout.IntSlider(label, As(value), Mathf.RoundToInt(range.Min), Mathf.RoundToInt(range.Max)); + if (type == typeof(float)) + return range == null + ? EditorGUILayout.FloatField(label, As(value)) + : EditorGUILayout.Slider(label, As(value), range.Min, range.Max); + if (type == typeof(bool)) return EditorGUILayout.Toggle(label, As(value)); + if (type == typeof(double)) return EditorGUILayout.DoubleField(label, As(value)); + if (type == typeof(long)) return EditorGUILayout.LongField(label, As(value)); + if (type == typeof(uint)) return (uint)Math.Min(uint.MaxValue, Math.Max(0L, EditorGUILayout.LongField(label, As(value)))); + if (type == typeof(short)) return (short)Mathf.Clamp(EditorGUILayout.IntField(label, As(value)), short.MinValue, short.MaxValue); + if (type == typeof(byte)) return (byte)Mathf.Clamp(EditorGUILayout.IntField(label, As(value)), 0, 255); + if (type.IsEnum) + { + var current = value as Enum ?? (Enum)Enum.ToObject(type, 0); + return type.IsDefined(typeof(FlagsAttribute), false) + ? EditorGUILayout.EnumFlagsField(label, current) + : EditorGUILayout.EnumPopup(label, current); + } + + if (type == typeof(CM.float2)) { var v = As(value); var r = EditorGUILayout.Vector2Field(label, new Vector2(v.x, v.y)); return new CM.float2(r.x, r.y); } + if (type == typeof(CM.float3)) { var v = As(value); var r = EditorGUILayout.Vector3Field(label, new Vector3(v.x, v.y, v.z)); return new CM.float3(r.x, r.y, r.z); } + if (type == typeof(CM.float4)) { var v = As(value); var r = EditorGUILayout.Vector4Field(label, new Vector4(v.x, v.y, v.z, v.w)); return new CM.float4(r.x, r.y, r.z, r.w); } + if (type == typeof(CM.quaternion)) { var v = As(value); var r = EditorGUILayout.Vector4Field(label, new Vector4(v.x, v.y, v.z, v.w)); return new CM.quaternion(r.x, r.y, r.z, r.w); } + if (type == typeof(CM.int2)) { var v = As(value); var r = EditorGUILayout.Vector2IntField(label, new Vector2Int(v.x, v.y)); return new CM.int2(r.x, r.y); } + if (type == typeof(CM.double2)) { var v = As(value); var r = DrawDoubles(label, v.x, v.y); return new CM.double2(r[0], r[1]); } + if (type == typeof(CM.double3)) { var v = As(value); var r = DrawDoubles(label, v.x, v.y, v.z); return new CM.double3(r[0], r[1], r[2]); } + if (type == typeof(CM.bool2)) return DrawBool2(label, As(value)); + if (type == typeof(CM.rect)) + { + var v = As(value); + var r = EditorGUILayout.RectField(label, Rect.MinMaxRect(v.min.x, v.min.y, v.max.x, v.max.y)); + return new CM.rect(r.xMin, r.yMin, r.xMax, r.yMax); + } + + if (type == typeof(CM.Color32)) + { + var v = As(value); + Color32 c = EditorGUILayout.ColorField(label, new Color32(v.r, v.g, v.b, v.a)); + return new CM.Color32(c.r, c.g, c.b, c.a); + } + + if (type == typeof(Vector2)) return EditorGUILayout.Vector2Field(label, As(value)); + if (type == typeof(Vector3)) return EditorGUILayout.Vector3Field(label, As(value)); + if (type == typeof(Color)) return EditorGUILayout.ColorField(label, value is Color color ? color : Color.white); + if (typeof(Object).IsAssignableFrom(type)) return EditorGUILayout.ObjectField(label, value as Object, type, false); + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) return DrawRecordRef(label, type, value); + var dictionary = DictionaryArguments(type); + if (dictionary != null) return DrawDictionary(label, type, dictionary, value as IDictionary, member, path); + var element = ListElement(type); + if (element != null) return DrawList(label, type, element, value, member, path); + if (type.IsAbstract || type.IsInterface) return DrawUnion(label, type, value, path); + if (type.IsClass || type.IsValueType && !type.IsPrimitive) return DrawNested(label, type, value, path); + return ErrorRow(label, "no drawer for " + type.FullName, value); + } + + private void DrawMembers(object target, Type type, string path) + { + foreach (var member in MembersOf(type)) + { + if (member.GetCustomAttribute() != null) continue; + var current = Get(member, target); + var readOnly = !Writable(member) || member.GetCustomAttribute() != null; + using (new EditorGUI.DisabledScope(readOnly)) + { + EditorGUI.BeginChangeCheck(); + var next = DrawValue(LabelOf(member), TypeOf(member), current, member, path + "." + member.Name); + if (EditorGUI.EndChangeCheck() && !readOnly) Set(member, target, next); + } + } + } + + // Document members come from the registry's catalog; nested members are the MessagePack-keyed ones, + // or the public fields of an unkeyed struct. + private MemberInfo[] MembersOf(Type type) + { + if (_members.TryGetValue(type, out var cached)) return cached; + var candidates = type.GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(m => m is FieldInfo || m is PropertyInfo p && p.CanRead && p.GetIndexParameters().Length == 0) + .Where(m => m.GetCustomAttribute() == null) + .ToArray(); + + IEnumerable<(MemberInfo Member, int Slot)> slotted; + if (type.IsDefined(typeof(CultDocumentAttribute), false)) + { + var slots = _cache.Registry.GetRequired(type).ToCatalogEntry().Members.ToDictionary(m => m.MemberName, m => m.Slot); + slotted = candidates.Where(m => slots.ContainsKey(m.Name)).Select(m => (m, slots[m.Name])); + } + else if (candidates.Any(m => m.IsDefined(typeof(KeyAttribute), true))) + { + slotted = candidates.Where(m => m.IsDefined(typeof(KeyAttribute), true)) + .Select(m => (m, m.GetCustomAttribute().IntKey ?? int.MaxValue)); + } + else + { + slotted = type.IsValueType + ? candidates.OfType().Select(f => ((MemberInfo)f, f.MetadataToken)) + : Enumerable.Empty<(MemberInfo, int)>(); + } + + cached = slotted + .OrderBy(s => s.Member.GetCustomAttribute()?.Order ?? s.Slot) + .ThenBy(s => s.Slot) + .Select(s => s.Member) + .ToArray(); + _members[type] = cached; + return cached; + } + + private static string DrawString(string label, string value, MemberInfo member) + { + var assetPath = member?.GetCustomAttribute(); + if (assetPath != null) + { + var assetType = assetPath.AssetType != null && typeof(Object).IsAssignableFrom(assetPath.AssetType) ? assetPath.AssetType : typeof(Object); + var asset = string.IsNullOrEmpty(value) ? null : AssetDatabase.LoadAssetAtPath(value, assetType); + var next = EditorGUILayout.ObjectField(label, asset, assetType, false); + return next == asset ? value : next == null ? string.Empty : AssetDatabase.GetAssetPath(next); + } + + var textArea = member?.GetCustomAttribute(); + if (textArea == null) return EditorGUILayout.TextField(label, value ?? string.Empty); + EditorGUILayout.LabelField(label); + return EditorGUILayout.TextArea(value ?? string.Empty, + GUILayout.MinHeight(Mathf.Max(textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight), + GUILayout.MaxHeight(Mathf.Max(textArea.MaxLines, textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight)); + } + + private object DrawRecordRef(string label, Type type, object value) + { + var target = type.GetGenericArguments()[0]; + var key = value == null ? string.Empty : ((CultRecordKey)type.GetProperty(nameof(CultRecordRef.Key)).GetValue(value)).Value ?? string.Empty; + var candidates = Records.Where(r => target.IsInstanceOfType(r.Document)) + .Select(r => (Record: r, Label: RecordLabel(r))) + .OrderBy(c => c.Label, StringComparer.OrdinalIgnoreCase) + .ToArray(); + var index = Array.FindIndex(candidates, c => c.Record.Key.Value == key) + 1; + var names = new string[candidates.Length + 1]; + names[0] = index == 0 && key.Length > 0 ? "Missing " + key : "None"; + for (var i = 0; i < candidates.Length; i++) names[i + 1] = candidates[i].Label; + + var next = key; + using (new EditorGUILayout.HorizontalScope()) + { + var picked = EditorGUILayout.Popup(label, index, names); + var indent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + var typed = EditorGUILayout.DelayedTextField(key, GUILayout.Width(120)); + EditorGUI.indentLevel = indent; + if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Record.Key.Value; + else if (typed != key) next = typed; + } + + return next == key ? value : Activator.CreateInstance(type, new CultRecordKey(next)); + } + + private object DrawDictionary(string label, Type type, Type[] arguments, IDictionary dictionary, MemberInfo member, string path) + { + var entries = new List(); + if (dictionary != null) + { + var enumerator = dictionary.GetEnumerator(); + while (enumerator.MoveNext()) entries.Add(enumerator.Entry); + } + + if (!Foldout(path, label + " (" + entries.Count + ")")) return dictionary; + EditorGUI.indentLevel++; + var changed = false; + var remove = -1; + for (var i = 0; i < entries.Count; i++) + { + using (new EditorGUILayout.HorizontalScope()) + { + using (new EditorGUILayout.VerticalScope()) + { + EditorGUI.BeginChangeCheck(); + var key = DrawValue("Key", arguments[0], entries[i].Key, null, path + "{" + i + "}.key"); + var item = DrawValue("Value", arguments[1], entries[i].Value, member, path + "{" + i + "}.value"); + if (EditorGUI.EndChangeCheck()) + { + var index = i; + if (key == null || entries.Where((_, j) => j != index).Any(e => Equals(e.Key, key))) key = entries[i].Key; + entries[i] = new DictionaryEntry(key, item); + changed = true; + } + } + + if (GUILayout.Button("-", GUILayout.Width(20))) remove = i; + } + } + + if (remove >= 0) + { + entries.RemoveAt(remove); + changed = true; + } + + var fresh = CreateDefault(arguments[0]); + using (new EditorGUI.DisabledScope(fresh == null || entries.Any(e => Equals(e.Key, fresh)))) + { + if (GUILayout.Button("Add")) + { + entries.Add(new DictionaryEntry(fresh, CreateDefault(arguments[1]))); + changed = true; + } + } + + EditorGUI.indentLevel--; + if (!changed) return dictionary; + var result = dictionary ?? (IDictionary)Activator.CreateInstance(type.IsInterface || type.IsAbstract + ? typeof(Dictionary<,>).MakeGenericType(arguments) + : type); + result.Clear(); + foreach (var entry in entries) result.Add(entry.Key, entry.Value); + return result; + } + + private object DrawList(string label, Type type, Type element, object value, MemberInfo member, string path) + { + var items = new List(); + if (value is IEnumerable enumerable) + foreach (var item in enumerable) items.Add(item); + + if (!Foldout(path, label + " [" + items.Count + "]")) return value; + EditorGUI.indentLevel++; + var changed = false; + var remove = -1; + for (var i = 0; i < items.Count; i++) + { + using (new EditorGUILayout.HorizontalScope()) + { + using (new EditorGUILayout.VerticalScope()) + { + EditorGUI.BeginChangeCheck(); + var next = DrawValue("Element " + i, element, items[i], member, path + "[" + i + "]"); + if (EditorGUI.EndChangeCheck()) + { + items[i] = next; + changed = true; + } + } + + if (GUILayout.Button("-", GUILayout.Width(20))) remove = i; + } + } + + if (remove >= 0) + { + items.RemoveAt(remove); + changed = true; + } + + if (element.IsAbstract || element.IsInterface) + { + var unions = UnionsOf(element); + var picked = EditorGUILayout.Popup("Add", 0, unions.Select(u => u.Name).Prepend("...").ToArray()); + if (picked > 0 && unions[picked - 1].GetConstructor(Type.EmptyTypes) != null) + { + items.Add(Activator.CreateInstance(unions[picked - 1])); + changed = true; + } + } + else if (GUILayout.Button("Add")) + { + items.Add(CreateDefault(element)); + changed = true; + } + + EditorGUI.indentLevel--; + if (!changed) return value; + if (type.IsArray) + { + var array = Array.CreateInstance(element, items.Count); + for (var i = 0; i < items.Count; i++) array.SetValue(items[i], i); + return array; + } + + var list = value as IList ?? (IList)Activator.CreateInstance(type); + list.Clear(); + foreach (var item in items) list.Add(item); + return list; + } + + private object DrawUnion(string label, Type type, object value, string path) + { + var unions = UnionsOf(type); + if (unions.Length == 0) return ErrorRow(label, type.Name + " declares no [Union] subtypes", value); + var index = value == null ? 0 : Array.IndexOf(unions, value.GetType()) + 1; + var names = unions.Select(u => u.Name).Prepend(value == null ? "None" : value.GetType().Name + " (not a declared union)").ToArray(); + + bool expanded; + using (new EditorGUILayout.HorizontalScope()) + { + expanded = Foldout(path, label, GUILayout.Width(EditorGUIUtility.labelWidth)); + var indent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + var picked = EditorGUILayout.Popup(index, names); + EditorGUI.indentLevel = indent; + if (picked != index) + { + if (picked == 0) value = null; + else if (unions[picked - 1].GetConstructor(Type.EmptyTypes) != null) value = Activator.CreateInstance(unions[picked - 1]); + else Debug.LogError(unions[picked - 1].FullName + " has no parameterless constructor."); + } + } + + if (!expanded || value == null) return value; + EditorGUI.indentLevel++; + DrawMembers(value, value.GetType(), path); + EditorGUI.indentLevel--; + return value; + } + + private object DrawNested(string label, Type type, object value, string path) + { + if (MembersOf(type).Length == 0) return ErrorRow(label, "no drawer for " + type.FullName, value); + if (value == null) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField(label, "null"); + if (type.GetConstructor(Type.EmptyTypes) != null && GUILayout.Button("Create", GUILayout.Width(56))) + value = Activator.CreateInstance(type); + } + + return value; + } + + if (!Foldout(path, label)) return value; + EditorGUI.indentLevel++; + DrawMembers(value, type, path); + EditorGUI.indentLevel--; + return value; + } + + private static double[] DrawDoubles(string label, params double[] values) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.PrefixLabel(label); + var indent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + for (var i = 0; i < values.Length; i++) values[i] = EditorGUILayout.DoubleField(values[i]); + EditorGUI.indentLevel = indent; + } + + return values; + } + + private static CM.bool2 DrawBool2(string label, CM.bool2 value) + { + bool x, y; + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.PrefixLabel(label); + var indent = EditorGUI.indentLevel; + EditorGUI.indentLevel = 0; + x = EditorGUILayout.ToggleLeft("X", value.x, GUILayout.Width(36)); + y = EditorGUILayout.ToggleLeft("Y", value.y, GUILayout.Width(36)); + EditorGUI.indentLevel = indent; + } + + return new CM.bool2(x, y); + } + + // Foldouts stay usable inside disabled scopes so read-only data can still be browsed. + private bool Foldout(string path, string label, params GUILayoutOption[] options) + { + _foldouts.TryGetValue(path, out var expanded); + var enabled = GUI.enabled; + GUI.enabled = true; + expanded = EditorGUI.Foldout(EditorGUILayout.GetControlRect(false, options), expanded, label, true); + GUI.enabled = enabled; + _foldouts[path] = expanded; + return expanded; + } + + private static object ErrorRow(string label, string message, object value) + { + EditorGUILayout.LabelField(label, message, ErrorStyle); + return value; + } + + private static Type[] UnionsOf(Type type) => + type.GetCustomAttributes(false).OrderBy(u => u.Key).Select(u => u.SubType).ToArray(); + + private static Type[] DictionaryArguments(Type type) => + type.GetInterfaces().Prepend(type) + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>)) + ?.GetGenericArguments(); + + private static Type ListElement(Type type) + { + if (type.IsArray) return type.GetElementType(); + return type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type) + ? type.GetGenericArguments()[0] + : null; + } + + private static object CreateDefault(Type type) + { + if (type == typeof(string)) return string.Empty; + if (type.IsValueType) return Activator.CreateInstance(type); + if (type.IsArray) return Array.CreateInstance(type.GetElementType(), 0); + return type.IsAbstract || type.IsInterface || type.GetConstructor(Type.EmptyTypes) == null ? null : Activator.CreateInstance(type); + } + + private static T As(object value) => value is T typed ? typed : default; + + private static string LabelOf(MemberInfo member) + { + var label = member.GetCustomAttribute(); + return label == null || string.IsNullOrWhiteSpace(label.Label) ? ObjectNames.NicifyVariableName(member.Name) : label.Label; + } + + private static Type TypeOf(MemberInfo member) => member is FieldInfo field ? field.FieldType : ((PropertyInfo)member).PropertyType; + + private static object Get(MemberInfo member, object target) => member is FieldInfo field ? field.GetValue(target) : ((PropertyInfo)member).GetValue(target); + + private static void Set(MemberInfo member, object target, object value) + { + if (member is FieldInfo field) field.SetValue(target, value); + else ((PropertyInfo)member).SetValue(target, value); + } + + private static bool Writable(MemberInfo member) => + member is FieldInfo field ? !field.IsInitOnly && !field.IsLiteral : ((PropertyInfo)member).GetSetMethod() != null; + } +} diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs.meta b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs.meta new file mode 100644 index 00000000..a0d1bbf1 --- /dev/null +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 06d55de1c539404b9a935871a8a3a334 diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs index 6647c858..5dbc4278 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs @@ -1,37 +1,34 @@ using System; -using System.Collections; -using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using GameCult.Unity.Caching; +using GameCult.Caching; +using GameCult.Caching.MessagePack; using UnityEditor; using UnityEngine; -using Object = UnityEngine.Object; namespace GameCult.Unity.Caching.Editor { - /// - /// Unity editor window for inspecting and editing CultCache .cc stores. - /// public sealed class CultCacheStudioWindow : EditorWindow { private const string LastPathKey = "GameCult.CultCacheStudio.LastPath"; - private readonly Dictionary _foldouts = new Dictionary(StringComparer.Ordinal); - private object _cache; + private CultCache _cache; + private CultInspector _inspector; + private CultStoredDocument[] _records = Array.Empty(); private string _path = string.Empty; + private bool _directory; + private bool _readOnly; private string _search = string.Empty; - private string _selectedKey = string.Empty; private Type _selectedType; + private string _selectedKey; private Vector2 _typeScroll; private Vector2 _recordScroll; private Vector2 _inspectorScroll; - private string _status = "Open a .cc file to begin."; + private string _status = "Open a CultCache store to begin."; private MessageType _statusType = MessageType.Info; + private bool ReadOnly => _cache.BackingStores.Any(store => store.IsReadOnly); + [MenuItem("GameCult/CultCache Studio")] public static void Open() { @@ -43,34 +40,27 @@ namespace GameCult.Unity.Caching.Editor _path = EditorPrefs.GetString(LastPathKey, string.Empty); } + // Disposing never flushes; unsaved edits are dropped on close and on script reload. + private void OnDisable() + { + CloseStore(); + } + private void OnGUI() { DrawToolbar(); - EditorGUILayout.Space(6); - - if (!CultCacheBridge.IsAvailable) - { - EditorGUILayout.HelpBox( - "CultCache Studio needs GameCult.Caching and GameCult.Caching.MessagePack loaded in this Unity project.", - MessageType.Warning); - } - - if (!string.IsNullOrWhiteSpace(_status)) - { - EditorGUILayout.HelpBox(_status, _statusType); - } - - if (_cache == null) - { - DrawClosedState(); - return; - } + if (!string.IsNullOrEmpty(_status)) EditorGUILayout.HelpBox(_status, _statusType); + if (_cache == null) return; + if (ReadOnly) + EditorGUILayout.HelpBox("Read-only store: " + _path + " was opened read-only. Adding, deleting, editing and saving are disabled.", MessageType.Warning); + _records = _cache.AllStoredDocuments.ToArray(); + _inspector.Records = _records; using (new EditorGUILayout.HorizontalScope()) { - DrawTypeColumn(); - DrawRecordColumn(); - DrawInspectorColumn(); + DrawTypes(); + DrawRecords(); + DrawInspector(); } } @@ -78,91 +68,54 @@ namespace GameCult.Unity.Caching.Editor { using (new EditorGUILayout.HorizontalScope(EditorStyles.toolbar)) { - GUILayout.Label("Path", GUILayout.Width(32)); _path = GUILayout.TextField(_path, EditorStyles.toolbarTextField, GUILayout.MinWidth(180)); - - using (new EditorGUI.DisabledScope(!CultCacheBridge.IsAvailable)) - { - if (GUILayout.Button("Open", EditorStyles.toolbarButton, GUILayout.Width(52))) - { - BrowseAndOpen(); - } - - if (GUILayout.Button("New", EditorStyles.toolbarButton, GUILayout.Width(44))) - { - BrowseAndCreate(); - } - } - + _directory = GUILayout.Toggle(_directory, "Directory", EditorStyles.toolbarButton, GUILayout.Width(62)); + _readOnly = GUILayout.Toggle(_readOnly, "Read Only", EditorStyles.toolbarButton, GUILayout.Width(66)); + if (GUILayout.Button("Open", EditorStyles.toolbarButton, GUILayout.Width(44))) Browse(false); + if (GUILayout.Button("New", EditorStyles.toolbarButton, GUILayout.Width(40))) Browse(true); using (new EditorGUI.DisabledScope(_cache == null)) { - if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(58))) + if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(52)) && ConfirmDiscard()) OpenStore(_path, false); + using (new EditorGUI.DisabledScope(_cache == null || ReadOnly || !_cache.IsDirty)) { - ReloadCurrent(); + if (GUILayout.Button("Save", EditorStyles.toolbarButton, GUILayout.Width(40))) Save(); } - if (GUILayout.Button("Save", EditorStyles.toolbarButton, GUILayout.Width(48))) + if (GUILayout.Button("Close", EditorStyles.toolbarButton, GUILayout.Width(44)) && ConfirmDiscard()) { - SaveCurrent(); + CloseStore(); + SetStatus("Closed " + _path + ".", MessageType.Info); + GUIUtility.ExitGUI(); } } GUILayout.FlexibleSpace(); if (_cache != null) - { - GUILayout.Label(CultCacheBridge.IsDirty(_cache) ? "Dirty" : "Saved", EditorStyles.miniLabel, GUILayout.Width(42)); - } + GUILayout.Label(ReadOnly ? "Read Only" : _cache.IsDirty ? "Dirty" : "Saved", EditorStyles.miniBoldLabel); } } - private void DrawClosedState() + private void DrawTypes() { - GUILayout.FlexibleSpace(); - using (new EditorGUILayout.HorizontalScope()) + using (new EditorGUILayout.VerticalScope(GUILayout.Width(240))) { - GUILayout.FlexibleSpace(); - using (new EditorGUILayout.VerticalScope(GUILayout.Width(420))) - { - EditorGUILayout.LabelField("CultCache Studio", EditorStyles.boldLabel); - EditorGUILayout.LabelField("Open or create a .cc file to inspect registered CultCache documents."); - EditorGUILayout.Space(8); - using (new EditorGUI.DisabledScope(!CultCacheBridge.IsAvailable)) - { - if (GUILayout.Button("Open .cc File", GUILayout.Height(32))) - { - BrowseAndOpen(); - } - - if (GUILayout.Button("Create .cc File", GUILayout.Height(28))) - { - BrowseAndCreate(); - } - } - } - - GUILayout.FlexibleSpace(); - } - - GUILayout.FlexibleSpace(); - } - - private void DrawTypeColumn() - { - using (new EditorGUILayout.VerticalScope(GUILayout.Width(250), GUILayout.ExpandHeight(true))) - { - EditorGUILayout.LabelField("Document Types", EditorStyles.boldLabel); _search = EditorGUILayout.TextField(_search, EditorStyles.toolbarSearchField); - _typeScroll = EditorGUILayout.BeginScrollView(_typeScroll, GUI.skin.box); - - foreach (var descriptor in FilteredDescriptors()) + var counts = _records.GroupBy(r => r.Descriptor.DocumentType).ToDictionary(g => g.Key, g => g.Count()); + _typeScroll = EditorGUILayout.BeginScrollView(_typeScroll); + foreach (var descriptor in _cache.Registry.AllDescriptors) { - var count = RecordsFor(descriptor.DocumentType).Count(); - var label = descriptor.SchemaName + " (" + count.ToString(CultureInfo.InvariantCulture) + ")"; - var style = _selectedType == descriptor.DocumentType ? EditorStyles.toolbarButton : EditorStyles.miniButton; - if (GUILayout.Button(label, style)) + var type = descriptor.DocumentType; + if (_search.Length > 0 && + descriptor.SchemaName.IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0 && + type.Name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + var count = counts.Where(c => type.IsAssignableFrom(c.Key)).Sum(c => c.Value); + var label = descriptor.SchemaName + (descriptor.IsGlobal ? count == 0 ? " (absent)" : " (global)" : " (" + count + ")"); + if (GUILayout.Toggle(_selectedType == type, label, EditorStyles.miniButton) && _selectedType != type) { - _selectedType = descriptor.DocumentType; - _selectedKey = string.Empty; + _selectedType = type; + _selectedKey = null; } } @@ -170,445 +123,195 @@ namespace GameCult.Unity.Caching.Editor } } - private void DrawRecordColumn() + private void DrawRecords() { - using (new EditorGUILayout.VerticalScope(GUILayout.Width(280), GUILayout.ExpandHeight(true))) + using (new EditorGUILayout.VerticalScope(GUILayout.Width(260))) { + var descriptor = _selectedType == null ? null : _cache.Registry.GetRequired(_selectedType); + var selected = Selected(); + var constructible = _selectedType != null && _selectedType.GetConstructor(Type.EmptyTypes) != null; using (new EditorGUILayout.HorizontalScope()) { - EditorGUILayout.LabelField("Records", EditorStyles.boldLabel); - using (new EditorGUI.DisabledScope(_selectedType == null || _selectedType.GetConstructor(Type.EmptyTypes) == null)) + using (new EditorGUI.DisabledScope(descriptor == null || descriptor.IsGlobal || ReadOnly || !constructible)) { - if (GUILayout.Button("Add", GUILayout.Width(56))) - { - AddSelectedTypeRecord(); - } + if (GUILayout.Button("Add", EditorStyles.miniButtonLeft)) Add(); + } + + using (new EditorGUI.DisabledScope(selected == null || selected.Descriptor.IsGlobal || ReadOnly)) + { + if (GUILayout.Button("Duplicate", EditorStyles.miniButtonMid)) Duplicate(selected); + } + + using (new EditorGUI.DisabledScope(selected == null || ReadOnly)) + { + if (GUILayout.Button("Delete", EditorStyles.miniButtonRight)) Delete(selected); } } - _recordScroll = EditorGUILayout.BeginScrollView(_recordScroll, GUI.skin.box); - if (_selectedType == null) + _recordScroll = EditorGUILayout.BeginScrollView(_recordScroll); + if (descriptor == null) { EditorGUILayout.LabelField("Select a document type."); } else { - foreach (var record in RecordsFor(_selectedType)) + var records = _records.Where(r => _selectedType.IsAssignableFrom(r.Descriptor.DocumentType)) + .Select(r => (Record: r, Label: CultInspector.RecordLabel(r))) + .OrderBy(r => r.Label, StringComparer.OrdinalIgnoreCase) + .ToArray(); + if (descriptor.IsGlobal && records.Length == 0) { - var style = string.Equals(_selectedKey, record.Key, StringComparison.Ordinal) - ? EditorStyles.toolbarButton - : EditorStyles.miniButton; - if (GUILayout.Button(GetRecordLabel(record), style)) + using (new EditorGUILayout.HorizontalScope()) { - _selectedKey = record.Key; + EditorGUILayout.LabelField("Global is absent."); + using (new EditorGUI.DisabledScope(ReadOnly || !constructible)) + { + if (GUILayout.Button("Create", GUILayout.Width(56))) Add(); + } } } + + foreach (var (record, label) in records) + { + var text = record.Descriptor.DocumentType == _selectedType ? label : label + " <" + record.Descriptor.DocumentType.Name + ">"; + if (GUILayout.Toggle(_selectedKey == record.Key.Value, text, EditorStyles.miniButton)) _selectedKey = record.Key.Value; + } } EditorGUILayout.EndScrollView(); } } - private void DrawInspectorColumn() + private void DrawInspector() { - using (new EditorGUILayout.VerticalScope(GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true))) + using (new EditorGUILayout.VerticalScope(GUILayout.ExpandWidth(true))) { - var record = SelectedRecord(); - using (new EditorGUILayout.HorizontalScope()) - { - EditorGUILayout.LabelField("Inspector", EditorStyles.boldLabel); - using (new EditorGUI.DisabledScope(record == null)) - { - if (GUILayout.Button("Delete", GUILayout.Width(64))) - { - DeleteSelectedRecord(); - return; - } - } - } - - _inspectorScroll = EditorGUILayout.BeginScrollView(_inspectorScroll, GUI.skin.box); + var record = Selected(); if (record == null) { EditorGUILayout.LabelField("Select a record."); - EditorGUILayout.EndScrollView(); return; } - EditorGUILayout.SelectableLabel(record.Descriptor.SchemaName, EditorStyles.boldLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight)); - EditorGUILayout.SelectableLabel("Key: " + record.Key, EditorStyles.miniLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight)); - EditorGUILayout.SelectableLabel("Stored: " + record.StoredAt, EditorStyles.miniLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight)); - EditorGUILayout.Space(8); - - EditorGUI.BeginChangeCheck(); - DrawDocument(record.Document, record.Descriptor.DocumentType, record.Descriptor.SchemaName); - if (EditorGUI.EndChangeCheck()) + EditorGUILayout.LabelField(record.Descriptor.SchemaName + " " + record.Descriptor.DocumentType.Name, EditorStyles.boldLabel); + EditorGUILayout.SelectableLabel("Key " + record.Key.Value + " Stored " + record.StoredAt, EditorStyles.miniLabel, GUILayout.Height(EditorGUIUtility.singleLineHeight)); + _inspectorScroll = EditorGUILayout.BeginScrollView(_inspectorScroll); + var readOnly = ReadOnly; + using (new EditorGUI.DisabledScope(readOnly)) { - UpsertRecord(record); + EditorGUI.BeginChangeCheck(); + _inspector.DrawDocument(record); + if (EditorGUI.EndChangeCheck() && !readOnly) + Run(null, () => _cache.UpsertAsync(record.Descriptor.DocumentType, record.Document, record.Key).GetAwaiter().GetResult()); } EditorGUILayout.EndScrollView(); } } - private IEnumerable FilteredDescriptors() + private CultStoredDocument Selected() { - var descriptors = CultCacheBridge.GetDescriptors(_cache); - if (string.IsNullOrWhiteSpace(_search)) + return _selectedKey == null ? null : _records.FirstOrDefault(r => r.Key.Value == _selectedKey); + } + + private void Browse(bool create) + { + if (!ConfirmDiscard()) return; + var directory = string.IsNullOrEmpty(_path) ? Application.dataPath : Path.GetDirectoryName(_path); + var path = create + ? EditorUtility.SaveFilePanel("New CultCache store", directory, "GameData", "cc") + : EditorUtility.OpenFilePanel("Open CultCache store", directory, _directory ? "" : "cc"); + if (!string.IsNullOrEmpty(path)) OpenStore(path, create); + GUIUtility.ExitGUI(); + } + + private void OpenStore(string path, bool create) + { + CloseStore(); + if (!create && !File.Exists(path)) { - return descriptors; - } - - return descriptors.Where(descriptor => - descriptor.SchemaName.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0 || - descriptor.DocumentType.Name.IndexOf(_search, StringComparison.OrdinalIgnoreCase) >= 0); - } - - private IEnumerable RecordsFor(Type type) - { - return CultCacheBridge.GetStoredDocuments(_cache) - .Where(record => type.IsAssignableFrom(record.Descriptor.DocumentType)) - .OrderBy(GetRecordLabel, StringComparer.Ordinal); - } - - private RecordInfo SelectedRecord() - { - if (_cache == null || string.IsNullOrEmpty(_selectedKey)) - { - return null; - } - - return CultCacheBridge.GetStoredDocuments(_cache) - .FirstOrDefault(record => string.Equals(record.Key, _selectedKey, StringComparison.Ordinal)); - } - - private string GetRecordLabel(RecordInfo record) - { - var namedMember = record.Descriptor.Members.FirstOrDefault(member => member.IsName); - if (namedMember != null) - { - var member = FindInspectableMember(record.Descriptor.DocumentType, namedMember.MemberName); - if (member != null) - { - var value = member.GetValue(record.Document); - if (value != null && !string.IsNullOrWhiteSpace(value.ToString())) - { - return value + " [" + record.Key + "]"; - } - } - } - - return record.Key; - } - - private void DrawDocument(object target, Type type, string path) - { - foreach (var member in GetInspectableMembers(type)) - { - if (member.GetCustomAttribute() != null) - { - continue; - } - - var current = member.GetValue(target); - var readOnly = member.GetCustomAttribute() != null || member.IsReadOnly; - using (new EditorGUI.DisabledScope(readOnly)) - { - var next = DrawValue(GetLabel(member), member.MemberType, current, member, path + "." + member.Name); - if (!readOnly && !Equals(current, next)) - { - member.SetValue(target, next); - } - } - } - } - - private object DrawValue(string label, Type type, object value, InspectableMember member, string path) - { - if (type == typeof(string)) return DrawString(label, value as string, member); - if (type == typeof(int)) - { - var range = member.GetCustomAttribute(); - return range == null - ? EditorGUILayout.IntField(label, value == null ? 0 : (int)value) - : EditorGUILayout.IntSlider(label, value == null ? 0 : (int)value, Mathf.RoundToInt(range.Min), Mathf.RoundToInt(range.Max)); - } - - if (type == typeof(uint)) return (uint)Math.Max(0L, EditorGUILayout.LongField(label, value == null ? 0L : Convert.ToInt64(value, CultureInfo.InvariantCulture))); - if (type == typeof(long)) return EditorGUILayout.LongField(label, value == null ? 0L : (long)value); - if (type == typeof(float)) - { - var range = member.GetCustomAttribute(); - return range == null - ? EditorGUILayout.FloatField(label, value == null ? 0f : (float)value) - : EditorGUILayout.Slider(label, value == null ? 0f : (float)value, range.Min, range.Max); - } - - if (type == typeof(double)) return EditorGUILayout.DoubleField(label, value == null ? 0d : (double)value); - if (type == typeof(bool)) return EditorGUILayout.Toggle(label, value != null && (bool)value); - if (type.IsEnum) return EditorGUILayout.EnumPopup(label, value == null ? (Enum)Enum.GetValues(type).GetValue(0) : (Enum)value); - if (type == typeof(Vector2)) return EditorGUILayout.Vector2Field(label, value == null ? Vector2.zero : (Vector2)value); - if (type == typeof(Vector3)) return EditorGUILayout.Vector3Field(label, value == null ? Vector3.zero : (Vector3)value); - if (type == typeof(Color)) return EditorGUILayout.ColorField(label, value == null ? Color.white : (Color)value); - if (typeof(Object).IsAssignableFrom(type)) return EditorGUILayout.ObjectField(label, value as Object, type, false); - if (CultCacheBridge.IsCultRecordRef(type)) return CultCacheBridge.DrawRecordRef(label, type, value); - if (type.IsArray || typeof(IList).IsAssignableFrom(type)) return DrawList(label, type, value, member, path); - if (type.IsClass || IsMutableStruct(type)) return DrawNestedObject(label, type, value, path); - - using (new EditorGUI.DisabledScope(true)) - { - EditorGUILayout.TextField(label, value == null ? string.Empty : value.ToString()); - } - - return value; - } - - private string DrawString(string label, string value, InspectableMember member) - { - var assetPath = member.GetCustomAttribute(); - if (assetPath != null) - { - var assetType = assetPath.AssetType == null || !typeof(Object).IsAssignableFrom(assetPath.AssetType) - ? typeof(Object) - : assetPath.AssetType; - var asset = string.IsNullOrEmpty(value) ? null : AssetDatabase.LoadAssetAtPath(value, assetType); - var nextAsset = EditorGUILayout.ObjectField(label, asset, assetType, false); - return nextAsset == null ? string.Empty : AssetDatabase.GetAssetPath(nextAsset); - } - - var textArea = member.GetCustomAttribute(); - if (textArea != null) - { - EditorGUILayout.LabelField(label); - return EditorGUILayout.TextArea(value ?? string.Empty, GUILayout.MinHeight(Mathf.Max(textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight)); - } - - return EditorGUILayout.TextField(label, value ?? string.Empty); - } - - private object DrawList(string label, Type type, object value, InspectableMember member, string path) - { - if (!Foldout(path, label)) return value; - - var elementType = GetListElementType(type); - var list = ToMutableList(elementType, value); - EditorGUI.indentLevel++; - var size = Mathf.Max(0, EditorGUILayout.IntField("Size", list.Count)); - while (list.Count < size) list.Add(CreateDefaultValue(elementType)); - while (list.Count > size) list.RemoveAt(list.Count - 1); - - for (var index = 0; index < list.Count; index++) - { - using (new EditorGUILayout.HorizontalScope()) - { - list[index] = DrawValue("Element " + index.ToString(CultureInfo.InvariantCulture), elementType, list[index], member, path + "[" + index.ToString(CultureInfo.InvariantCulture) + "]"); - if (GUILayout.Button("-", GUILayout.Width(24))) - { - list.RemoveAt(index); - index--; - } - } - } - - if (GUILayout.Button("Add " + elementType.Name)) list.Add(CreateDefaultValue(elementType)); - EditorGUI.indentLevel--; - return FromMutableList(type, elementType, list); - } - - private object DrawNestedObject(string label, Type type, object value, string path) - { - var expanded = Foldout(path, label); - if (value == null && type.GetConstructor(Type.EmptyTypes) != null) value = Activator.CreateInstance(type); - if (!expanded || value == null) return value; - EditorGUI.indentLevel++; - DrawDocument(value, type, path); - EditorGUI.indentLevel--; - return value; - } - - private bool Foldout(string key, string label) - { - bool expanded; - _foldouts.TryGetValue(key, out expanded); - expanded = EditorGUILayout.Foldout(expanded, label, true); - _foldouts[key] = expanded; - return expanded; - } - - private IEnumerable GetInspectableMembers(Type type) - { - var descriptor = CultCacheBridge.GetDescriptor(_cache, type); - var byName = descriptor.Members.ToDictionary(member => member.MemberName, StringComparer.Ordinal); - return type.GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .Select(InspectableMember.TryCreate) - .Where(member => member != null && byName.ContainsKey(member.Name)) - .OrderBy(member => member.GetCustomAttribute() == null - ? byName[member.Name].Slot - : member.GetCustomAttribute().Order) - .ThenBy(member => byName[member.Name].Slot) - .ThenBy(member => member.Name, StringComparer.Ordinal); - } - - private static InspectableMember FindInspectableMember(Type type, string name) - { - return type.GetMembers(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) - .Select(InspectableMember.TryCreate) - .FirstOrDefault(member => member != null && string.Equals(member.Name, name, StringComparison.Ordinal)); - } - - private static string GetLabel(InspectableMember member) - { - var label = member.GetCustomAttribute(); - return label == null || string.IsNullOrWhiteSpace(label.Label) - ? ObjectNames.NicifyVariableName(member.Name) - : label.Label; - } - - private static Type GetListElementType(Type type) - { - if (type.IsArray) return type.GetElementType(); - return type.IsGenericType ? type.GetGenericArguments()[0] : typeof(object); - } - - private static IList ToMutableList(Type elementType, object value) - { - var result = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType)); - var source = value as IEnumerable; - if (source != null) - { - foreach (var item in source) result.Add(item); - } - - return result; - } - - private static object FromMutableList(Type listType, Type elementType, IList list) - { - if (listType.IsArray) - { - var array = Array.CreateInstance(elementType, list.Count); - for (var index = 0; index < list.Count; index++) array.SetValue(list[index], index); - return array; - } - - if (listType.IsAssignableFrom(list.GetType())) return list; - var target = listType.GetConstructor(Type.EmptyTypes) == null ? null : Activator.CreateInstance(listType) as IList; - if (target == null) return list; - foreach (var item in list) target.Add(item); - return target; - } - - private static object CreateDefaultValue(Type type) - { - if (type == typeof(string)) return string.Empty; - if (type.IsValueType) return Activator.CreateInstance(type); - return type.GetConstructor(Type.EmptyTypes) == null ? null : Activator.CreateInstance(type); - } - - private static bool IsMutableStruct(Type type) - { - return type.IsValueType && !type.IsPrimitive && !type.IsEnum; - } - - private void BrowseAndOpen() - { - var start = string.IsNullOrEmpty(_path) ? Application.dataPath : Path.GetDirectoryName(_path); - var selected = EditorUtility.OpenFilePanel("Open CultCache", start, "cc"); - if (!string.IsNullOrEmpty(selected)) - { - _path = selected; - OpenPath(); - } - } - - private void BrowseAndCreate() - { - var start = string.IsNullOrEmpty(_path) ? Application.dataPath : Path.GetDirectoryName(_path); - var selected = EditorUtility.SaveFilePanel("Create CultCache", start, "GameData", "cc"); - if (!string.IsNullOrEmpty(selected)) - { - _path = selected; - OpenPath(); - SaveCurrent(); - } - } - - private void OpenPath() - { - try - { - _cache = CultCacheBridge.Open(_path); - _selectedType = CultCacheBridge.GetDescriptors(_cache).FirstOrDefault()?.DocumentType; - _selectedKey = string.Empty; - EditorPrefs.SetString(LastPathKey, _path); - SetStatus("Opened " + _path, MessageType.Info); - } - catch (Exception ex) - { - _cache = null; - SetStatus("Failed to open CultCache: " + ex.GetBaseException().Message, MessageType.Error); - } - } - - private void ReloadCurrent() - { - if (string.IsNullOrWhiteSpace(_path)) - { - SetStatus("No .cc path selected.", MessageType.Warning); + SetStatus("No store at " + path + ".", MessageType.Error); return; } - OpenPath(true); + try + { + var existed = File.Exists(path); + _cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { ReadOnly = _readOnly, UseDirectoryStore = _directory }); + if (create && !existed && !_readOnly) _cache.BackingStores[0].PushAll(); + _inspector = new CultInspector(_cache); + _path = path; + EditorPrefs.SetString(LastPathKey, path); + SetStatus((create && !existed ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."), MessageType.Info); + } + catch (Exception exception) + { + CloseStore(); + SetStatus("Failed to open " + path + ": " + exception.GetBaseException().Message, MessageType.Error); + } } - private void SaveCurrent() + private void CloseStore() + { + _cache?.Dispose(); + _cache = null; + _inspector = null; + _records = Array.Empty(); + _selectedKey = null; + } + + private bool ConfirmDiscard() + { + return _cache == null || !_cache.IsDirty || + EditorUtility.DisplayDialog("CultCache Studio", "Discard unsaved changes to " + _path + "?", "Discard", "Cancel"); + } + + private void Save() + { + Run("Saved " + _path + ".", () => _cache.FlushAsync().GetAwaiter().GetResult()); + } + + private void Add() + { + var type = _selectedType; + Run("Added " + type.Name + ".", () => _selectedKey = _cache.UpsertAsync(type, Activator.CreateInstance(type)).GetAwaiter().GetResult().Value); + GUIUtility.ExitGUI(); + } + + private void Duplicate(CultStoredDocument record) + { + var type = record.Descriptor.DocumentType; + Run("Duplicated " + CultInspector.RecordLabel(record) + ".", () => + { + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(record.Document, type, _cache.Registry); + var copy = CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, _cache.Registry); + _selectedKey = _cache.UpsertAsync(type, copy).GetAwaiter().GetResult().Value; + }); + GUIUtility.ExitGUI(); + } + + private void Delete(CultStoredDocument record) + { + var label = CultInspector.RecordLabel(record); + if (!EditorUtility.DisplayDialog("CultCache Studio", "Delete " + label + "?", "Delete", "Cancel")) return; + Run("Deleted " + label + ".", () => + { + _cache.Remove(record.Key); + _selectedKey = null; + }); + GUIUtility.ExitGUI(); + } + + private void Run(string success, Action action) { try { - CultCacheBridge.Flush(_cache); - SetStatus("Saved " + _path, MessageType.Info); + action(); + if (success != null) SetStatus(success, MessageType.Info); } - catch (Exception ex) + catch (Exception exception) { - SetStatus("Failed to save CultCache: " + ex.GetBaseException().Message, MessageType.Error); - } - } - - private void AddSelectedTypeRecord() - { - try - { - var document = Activator.CreateInstance(_selectedType); - _selectedKey = CultCacheBridge.Upsert(_cache, _selectedType, document, null); - SetStatus("Added " + _selectedType.Name + ".", MessageType.Info); - } - catch (Exception ex) - { - SetStatus("Failed to add record: " + ex.GetBaseException().Message, MessageType.Error); - } - } - - private void DeleteSelectedRecord() - { - if (string.IsNullOrEmpty(_selectedKey)) return; - if (!EditorUtility.DisplayDialog("Delete CultCache Record", "Delete record " + _selectedKey + "?", "Delete", "Cancel")) return; - if (CultCacheBridge.Remove(_cache, _selectedKey)) - { - _selectedKey = string.Empty; - SetStatus("Deleted record.", MessageType.Info); - } - } - - private void UpsertRecord(RecordInfo record) - { - try - { - CultCacheBridge.Upsert(_cache, record.Descriptor.DocumentType, record.Document, record.Key); - SetStatus("Edited " + record.Descriptor.SchemaName + ". Save to persist.", MessageType.Info); - } - catch (Exception ex) - { - SetStatus("Failed to apply edit: " + ex.GetBaseException().Message, MessageType.Error); + SetStatus(exception.GetBaseException().Message, MessageType.Error); } } @@ -618,229 +321,5 @@ namespace GameCult.Unity.Caching.Editor _statusType = type; Repaint(); } - - private sealed class InspectableMember - { - private readonly FieldInfo _field; - private readonly PropertyInfo _property; - - private InspectableMember(FieldInfo field) - { - _field = field; - Name = field.Name; - MemberType = field.FieldType; - IsReadOnly = field.IsInitOnly || field.IsLiteral; - } - - private InspectableMember(PropertyInfo property) - { - _property = property; - Name = property.Name; - MemberType = property.PropertyType; - IsReadOnly = property.GetSetMethod(true) == null; - } - - public string Name { get; } - public Type MemberType { get; } - public bool IsReadOnly { get; } - - public static InspectableMember TryCreate(MemberInfo member) - { - var field = member as FieldInfo; - if (field != null && !field.IsStatic) return new InspectableMember(field); - var property = member as PropertyInfo; - return property != null && property.GetIndexParameters().Length == 0 && property.GetGetMethod(true) != null - ? new InspectableMember(property) - : null; - } - - public T GetCustomAttribute() where T : Attribute - { - return _field != null ? _field.GetCustomAttribute(true) : _property.GetCustomAttribute(true); - } - - public object GetValue(object target) - { - return _field != null ? _field.GetValue(target) : _property.GetValue(target, null); - } - - public void SetValue(object target, object value) - { - if (_field != null) _field.SetValue(target, value); - else _property.SetValue(target, value, null); - } - } - - private static class CultCacheBridge - { - private static readonly Type CacheType = FindType("GameCult.Caching.CultCache"); - private static readonly Type OpenOptionsType = FindType("GameCult.Caching.MessagePack.CultCacheOpenOptions"); - private static readonly Type MessagePackType = FindType("GameCult.Caching.MessagePack.CultCacheMessagePack"); - private static readonly Type RecordKeyType = FindType("GameCult.Caching.CultRecordKey"); - - public static bool IsAvailable => CacheType != null && OpenOptionsType != null && MessagePackType != null && RecordKeyType != null; - - public static object Open(string path) - { - EnsureAvailable(); - var options = Activator.CreateInstance(OpenOptionsType); - var method = MessagePackType.GetMethod("OpenAsync", new[] { typeof(string), OpenOptionsType }); - var task = (Task)method.Invoke(null, new[] { path, options }); - task.GetAwaiter().GetResult(); - return task.GetType().GetProperty("Result").GetValue(task, null); - } - - public static bool IsDirty(object cache) - { - return cache != null && (bool)CacheType.GetProperty("IsDirty").GetValue(cache, null); - } - - public static IEnumerable GetDescriptors(object cache) - { - if (cache == null) return Enumerable.Empty(); - var registry = CacheType.GetProperty("Registry").GetValue(cache, null); - var descriptors = (IEnumerable)registry.GetType().GetProperty("AllDescriptors").GetValue(registry, null); - return descriptors.Cast().Select(DescriptorInfo.From).ToArray(); - } - - public static DescriptorInfo GetDescriptor(object cache, Type documentType) - { - var registry = CacheType.GetProperty("Registry").GetValue(cache, null); - var method = registry.GetType().GetMethod("GetRequired", new[] { typeof(Type) }); - return DescriptorInfo.From(method.Invoke(registry, new object[] { documentType })); - } - - public static IEnumerable GetStoredDocuments(object cache) - { - if (cache == null) return Enumerable.Empty(); - var records = (IEnumerable)CacheType.GetProperty("AllStoredDocuments").GetValue(cache, null); - return records.Cast().Select(RecordInfo.From).ToArray(); - } - - public static string Upsert(object cache, Type documentType, object document, string key) - { - var method = CacheType.GetMethod("UpsertAsync", new[] { typeof(Type), typeof(object), typeof(Nullable<>).MakeGenericType(RecordKeyType) }); - var keyObject = string.IsNullOrEmpty(key) ? null : CreateRecordKey(key); - var task = (Task)method.Invoke(cache, new[] { documentType, document, keyObject }); - task.GetAwaiter().GetResult(); - var result = task.GetType().GetProperty("Result").GetValue(task, null); - return GetKeyValue(result); - } - - public static bool Remove(object cache, string key) - { - var method = CacheType.GetMethod("Remove", new[] { RecordKeyType }); - return (bool)method.Invoke(cache, new[] { CreateRecordKey(key) }); - } - - public static void Flush(object cache) - { - var method = CacheType.GetMethod("FlushAsync", new[] { typeof(bool) }); - var task = (Task)method.Invoke(cache, new object[] { false }); - task.GetAwaiter().GetResult(); - } - - public static bool IsCultRecordRef(Type type) - { - return type.IsGenericType && type.GetGenericTypeDefinition().FullName == "GameCult.Caching.CultRecordRef`1"; - } - - public static object DrawRecordRef(string label, Type type, object value) - { - var keyProperty = type.GetProperty("Key"); - var currentKey = keyProperty == null || value == null ? string.Empty : GetKeyValue(keyProperty.GetValue(value, null)); - var nextKey = EditorGUILayout.TextField(label, currentKey); - return string.Equals(currentKey, nextKey, StringComparison.Ordinal) - ? value - : Activator.CreateInstance(type, CreateRecordKey(nextKey)); - } - - private static object CreateRecordKey(string key) - { - return Activator.CreateInstance(RecordKeyType, key ?? string.Empty); - } - - private static string GetKeyValue(object key) - { - return key == null ? string.Empty : (string)RecordKeyType.GetProperty("Value").GetValue(key, null); - } - - private static void EnsureAvailable() - { - if (!IsAvailable) - { - throw new InvalidOperationException("GameCult.Caching and GameCult.Caching.MessagePack are not loaded."); - } - } - - private static Type FindType(string fullName) - { - return AppDomain.CurrentDomain.GetAssemblies() - .Where(assembly => !assembly.IsDynamic) - .Select(assembly => assembly.GetType(fullName, false)) - .FirstOrDefault(type => type != null); - } - } - - private sealed class DescriptorInfo - { - public object Raw; - public Type DocumentType; - public string SchemaName; - public CatalogMemberInfo[] Members; - - public static DescriptorInfo From(object raw) - { - var type = raw.GetType(); - var catalog = type.GetMethod("ToCatalogEntry").Invoke(raw, null); - var members = (IEnumerable)catalog.GetType().GetProperty("Members").GetValue(catalog, null); - return new DescriptorInfo - { - Raw = raw, - DocumentType = (Type)type.GetProperty("DocumentType").GetValue(raw, null), - SchemaName = (string)type.GetProperty("SchemaName").GetValue(raw, null), - Members = members.Cast().Select(CatalogMemberInfo.From).ToArray() - }; - } - } - - private sealed class CatalogMemberInfo - { - public string MemberName; - public int Slot; - public bool IsName; - - public static CatalogMemberInfo From(object raw) - { - var type = raw.GetType(); - return new CatalogMemberInfo - { - MemberName = (string)type.GetProperty("MemberName").GetValue(raw, null), - Slot = (int)type.GetProperty("Slot").GetValue(raw, null), - IsName = (bool)type.GetProperty("IsName").GetValue(raw, null) - }; - } - } - - private sealed class RecordInfo - { - public string Key; - public string StoredAt; - public DescriptorInfo Descriptor; - public object Document; - - public static RecordInfo From(object raw) - { - var type = raw.GetType(); - var key = type.GetProperty("Key").GetValue(raw, null); - return new RecordInfo - { - Key = (string)key.GetType().GetProperty("Value").GetValue(key, null), - StoredAt = (string)type.GetProperty("StoredAt").GetValue(raw, null), - Descriptor = DescriptorInfo.From(type.GetProperty("Descriptor").GetValue(raw, null)), - Document = type.GetProperty("Document").GetValue(raw, null) - }; - } - } } } diff --git a/src/GameCult.Unity/Assets/Caching/Editor/GameCult.Unity.Caching.Editor.asmdef b/src/GameCult.Unity/Assets/Caching/Editor/GameCult.Unity.Caching.Editor.asmdef index be5589c6..426232b0 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/GameCult.Unity.Caching.Editor.asmdef +++ b/src/GameCult.Unity/Assets/Caching/Editor/GameCult.Unity.Caching.Editor.asmdef @@ -2,7 +2,8 @@ "name": "GameCult.Unity.Caching.Editor", "rootNamespace": "GameCult.Unity.Caching.Editor", "references": [ - "GameCult.Unity.Caching" + "GameCult.Unity.Caching", + "GameCult.CultLib" ], "includePlatforms": [ "Editor" diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index 84b24084..e9f69b71 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -1,35 +1,77 @@ # GameCult CultCache Unity Tools -`org.gamecult.caching.unity` provides Unity editor tooling for opening, -inspecting, editing, and saving CultCache `.cc` stores. +`org.gamecult.caching.unity` is the Unity editor surface over CultCache: open a +store, browse its documents, edit them, save. -The package is intentionally a Unity editor surface over `GameCult.Caching`. -CultCache owns document identity, schema descriptors, persistence, dirty state, -and flush behavior. The editor window owns selection, presentation, and edit -commands. +CultCache owns document identity, schema descriptors, persistence, dirty state +and flush. The Studio window owns selection, presentation and edit commands. It +references `GameCult.Caching` and `GameCult.Caching.MessagePack` at compile time +through the `org.gamecult.cultlib` package (assembly `GameCult.CultLib`) and +`CultMath` through `org.gamecult.cultmath`. ## Use -1. Reference `GameCult.Caching`, `GameCult.Caching.MessagePack`, and this UPM - package from the Unity project that declares your `[CultDocument]` classes. -2. Open Unity's `GameCult/CultCache Studio` menu. -3. Open or create a `.cc` file. -4. Select a document type, select a record, edit fields, then save explicitly. +1. Add this package; it depends on `org.gamecult.cultlib` and + `org.gamecult.cultmath`. +2. Open `GameCult/CultCache Studio`. +3. `Open` a `.cc` store or make one with `New`. `Directory` opens or creates a + directory store (a manifest beside its `.records` folder; an existing one is + detected without the toggle). `Read Only` opens the store read-only. +4. Pick a document type, then a record. Edits upsert into the cache as you make + them; the toolbar shows `Dirty` until `Save` flushes the store. -The editor discovers registered CultCache document descriptors from the same -registry used by runtime code. It does not require document classes to inherit -from Unity types or storage base classes. +Document types come from the cache's `CultDocumentRegistry`. A type lists its +own records and those of its subclasses, by `[CultName]` with the key as +fallback. `Add`, `Duplicate` and `Delete` act on the selected type and record. +A `[CultGlobal]` type with no record shows as absent with a `Create` action; the +Studio never creates one on its own. + +Opening a store never writes it. Closing or reloading scripts drops unsaved +edits. + +## Drawers + +Built in: strings, integers, floats, bools, enums (flags included), the CultMath +values (`float2/3/4`, `double2/3`, `int2`, `bool2`, `quaternion`, `rect`, +`Color32`), `Vector2/3`, `Color`, Unity object +references, lists and arrays, dictionaries (keys and values drawn recursively), +`CultRecordRef` (a picker of records assignable to `T`, plus the raw key), +abstract and interface members (a subtype picker limited to the member type's +MessagePack `[Union]` declarations), and nested `[MessagePackObject]` types by +their `[Key]` members. A member no drawer claims shows a red error row. + +A project adds or overrides a drawer with an editor class: + +```csharp +using System.Reflection; +using GameCult.Unity.Caching; +using GameCult.Unity.Caching.Editor; +using UnityEditor; +using UnityEngine; + +[CultInspectorDrawer(typeof(CultMath.float3))] +public sealed class Float3ColorDrawer : ICultInspectorDrawer +{ + public object Draw(string label, object value, MemberInfo member) + { + var v = (CultMath.float3)value; + var c = EditorGUILayout.ColorField(label, new Color(v.x, v.y, v.z)); + return new CultMath.float3(c.r, c.g, c.b); + } +} +``` + +`MemberType` may be an open generic definition to claim every closed form. A +project drawer wins over the built-in one for its type. ## Inspector Annotations -Domain classes can opt into nicer Unity editor rendering with attributes from -`GameCult.Unity.Caching`: - ```csharp using GameCult.Caching; using GameCult.Unity.Caching; using MessagePack; +[MessagePackObject] [CultDocument("game.item", "game.item.v1")] public sealed class ItemData { @@ -52,8 +94,6 @@ public sealed class ItemData } ``` -Available annotations: - - `CultInspectorLabel` - `CultInspectorHidden` - `CultInspectorReadOnly` @@ -61,13 +101,7 @@ Available annotations: - `CultInspectorTextArea` - `CultInspectorRange` - `CultInspectorAssetPath` - -## Current Scope - -The first studio pass supports local `.cc` files, explicit save/reload, adding -records with parameterless constructors, deleting records, primitive fields, -enums, strings, lists/arrays, nested objects, Unity object references, asset -path pickers, and `CultRecordRef` key editing. +- `CultInspectorDrawer` (on drawer classes) CultMesh collaboration should feed the same CultCache mutation surface rather than becoming a second owner for document state. diff --git a/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs b/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs index b7202791..1005322c 100644 --- a/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs +++ b/src/GameCult.Unity/Assets/Caching/Runtime/CultCacheInspectorAttributes.cs @@ -2,9 +2,6 @@ using System; namespace GameCult.Unity.Caching { - /// - /// Overrides the label shown for a CultCache document member in Unity editor tooling. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property | AttributeTargets.Class, Inherited = true)] public sealed class CultInspectorLabelAttribute : Attribute { @@ -16,25 +13,17 @@ namespace GameCult.Unity.Caching public string Label { get; } } - /// - /// Hides a CultCache document member from Unity editor tooling. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultInspectorHiddenAttribute : Attribute { } - /// - /// Shows a CultCache document member without allowing editor mutation. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultInspectorReadOnlyAttribute : Attribute { } - /// - /// Controls member ordering in Unity editor tooling. Lower values appear first. - /// + // Lower values appear first; members without it keep their key order. [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultInspectorOrderAttribute : Attribute { @@ -46,9 +35,6 @@ namespace GameCult.Unity.Caching public int Order { get; } } - /// - /// Draws a string member as a multi-line text area. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultInspectorTextAreaAttribute : Attribute { @@ -62,9 +48,6 @@ namespace GameCult.Unity.Caching public int MaxLines { get; } } - /// - /// Draws numeric members with a slider. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultInspectorRangeAttribute : Attribute { @@ -78,9 +61,6 @@ namespace GameCult.Unity.Caching public float Max { get; } } - /// - /// Draws a string member as a Unity asset path picker. - /// [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, Inherited = true)] public sealed class CultInspectorAssetPathAttribute : Attribute { @@ -91,4 +71,17 @@ namespace GameCult.Unity.Caching public Type AssetType { get; } } + + // Marks an editor class implementing ICultInspectorDrawer as the drawer for every member of MemberType, + // or of any closed form when MemberType is an open generic definition. It wins over the built-in drawers. + [AttributeUsage(AttributeTargets.Class, Inherited = false)] + public sealed class CultInspectorDrawerAttribute : Attribute + { + public CultInspectorDrawerAttribute(Type memberType) + { + MemberType = memberType ?? throw new ArgumentNullException(nameof(memberType)); + } + + public Type MemberType { get; } + } } diff --git a/src/GameCult.Unity/Assets/Caching/package.json b/src/GameCult.Unity/Assets/Caching/package.json index b08a3ce5..cde27289 100644 --- a/src/GameCult.Unity/Assets/Caching/package.json +++ b/src/GameCult.Unity/Assets/Caching/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.caching.unity", "displayName": "GameCult CultCache Unity Tools", - "version": "1.0.0", + "version": "1.1.0", "unity": "2021.3", "description": "Unity editor tooling for inspecting and editing CultCache .cc stores.", "keywords": [ @@ -9,6 +9,10 @@ "Editor", "Data" ], + "dependencies": { + "org.gamecult.cultlib": "1.0.57", + "org.gamecult.cultmath": "0.1.2" + }, "license": "MIT", "category": "Scripting" }