diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index 0d488f47..d442d1d7 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -4,109 +4,118 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using GameCult.Caching; -using GameCult.Caching.MessagePack; -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. + // An IMGUI drawer claimed through [CultInspectorDrawer]. Returns the value's next value; a reference value may be + // mutated in place (it is always the edit's private copy). type is the value's declared type. member is the member + // the value belongs to, also for list elements and dictionary keys and values, and null for a bare value. + // inspector.DrawDefault hands the value back to built-in drawing; inspector.DrawValue draws a sub-value with claims. public interface ICultInspectorDrawer { - object Draw(string label, object value, MemberInfo member); + object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member); } - internal sealed class CultInspector + // The IMGUI lowering of CultInspectorModel. The model decides members, metadata, shapes, drawer claims and edit + // rules; this class draws them and hands every change back through the model. + public sealed class CultInspector { - private static Dictionary _drawers; - private static Dictionary _conflicts; - private static readonly Dictionary NameMembers = new Dictionary(); + private static CultInspectorDrawerClaims _claims; + private static readonly Dictionary DrawerInstances = new Dictionary(); private static GUIStyle _errorStyle; - private readonly CultCache _cache; private readonly Dictionary _foldouts = new Dictionary(StringComparer.Ordinal); - private readonly Dictionary _members = new Dictionary(); private readonly Dictionary _notices = new Dictionary(StringComparer.Ordinal); + private string _path = string.Empty; - public CultInspector(CultCache cache) + internal CultInspector(CultInspectorModel model) { - _cache = cache; + Model = model; } - public CultStoredDocument[] Records { get; set; } = Array.Empty(); + public CultInspectorModel Model { get; } + + public IReadOnlyList Records { get; internal set; } = Array.Empty(); private static GUIStyle ErrorStyle => _errorStyle ??= new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, .35f, .3f) } }; - private static Dictionary Drawers + private static CultInspectorDrawerClaims Claims { get { - if (_drawers != null) return _drawers; - _drawers = new Dictionary(); - _conflicts = 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."); - continue; - } - - // A contested type is claimed by nobody: it draws the red row until one claimant is removed. - if (_drawers.TryGetValue(memberType, out var claimed)) - { - _drawers.Remove(memberType); - _conflicts[memberType] = claimed.GetType().FullName; - } - - if (_conflicts.TryGetValue(memberType, out var claimants)) - { - _conflicts[memberType] = claimants + ", " + type.FullName; - Debug.LogError($"{_conflicts[memberType]} all draw {memberType.FullName}; none of them is used."); - } - else - { - _drawers[memberType] = (ICultInspectorDrawer)Activator.CreateInstance(type); - } - } - - return _drawers; + if (_claims != null) return _claims; + _claims = new CultInspectorDrawerClaims(TypeCache.GetTypesWithAttribute(), typeof(ICultInspectorDrawer)); + foreach (var error in _claims.Errors) Debug.LogError(error); + return _claims; } } - public static string RecordLabel(CultStoredDocument record) + internal void DrawDocument(CultInspectorEdit edit) { - var type = record.Descriptor.DocumentType; - if (!NameMembers.TryGetValue(type, out var member)) + DrawMembers(edit.Document, edit.Source.Descriptor.DocumentType, edit.Source.Key.Value); + } + + // Draws a sub-value of the value being drawn, claims applied. + public object DrawValue(string label, Type type, object value, MemberInfo member) + { + return DrawValue(label, type, value, member, _path + "/" + label); + } + + // Built-in drawing for a value, ignoring drawer claims on it. + public object DrawDefault(string label, Type type, object value, MemberInfo member) + { + var shape = Model.ShapeOf(type); + var metadata = Model.MetadataOf(member); + switch (shape.Kind) { - member = record.Descriptor.NameMember == null - ? null - : type.GetMember(record.Descriptor.NameMember, BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(); - NameMembers[type] = member; + case CultInspectorValueKind.String: + return DrawString(label, value as string, metadata); + case CultInspectorValueKind.Integer: + return DrawInteger(label, type, value, metadata); + case CultInspectorValueKind.Float: + if (type == typeof(double)) return EditorGUILayout.DoubleField(label, value is double d ? d : 0d); + var f = value is float single ? single : 0f; + return metadata.Range == null ? EditorGUILayout.FloatField(label, f) : EditorGUILayout.Slider(label, f, metadata.Range.Min, metadata.Range.Max); + case CultInspectorValueKind.Bool: + return EditorGUILayout.Toggle(label, value is bool b && b); + case CultInspectorValueKind.Enum: + var current = value as Enum ?? (Enum)Enum.ToObject(type, 0); + return type.IsDefined(typeof(FlagsAttribute), false) ? EditorGUILayout.EnumFlagsField(label, current) : EditorGUILayout.EnumPopup(label, current); + case CultInspectorValueKind.Composite: + return DrawComposite(label, shape, value); + case CultInspectorValueKind.RecordRef: + return DrawRecordRef(label, type, value); + case CultInspectorValueKind.List: + return DrawList(label, shape, value, member); + case CultInspectorValueKind.Dictionary: + return DrawDictionary(label, shape, value as IDictionary, member); + case CultInspectorValueKind.Union: + return DrawUnion(label, shape, value); + case CultInspectorValueKind.Nested: + return DrawNested(label, type, value); + default: + return typeof(Object).IsAssignableFrom(type) + ? EditorGUILayout.ObjectField(label, value as Object, type, false) + : ErrorRow(label, shape.Reason, value); } - - var name = member == null ? null : Get(member, record.Document)?.ToString(); - return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name; } - // Draws into the document it is given and mutates it in place; the caller hands over a working copy. - public void DrawDocument(object document, Type type, string key) + private object DrawValue(string label, Type type, object value, MemberInfo member, string path) { - DrawMembers(document, type, key); - } - - 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)) + var outer = _path; + _path = path; + try { + var claim = Claims.Resolve(type, member); + if (claim.Conflict != null) return ErrorRow(label, "no drawer: " + claim.Conflict, value); + if (claim.Drawer == null) return DrawDefault(label, type, value, member); var changed = GUI.changed; try { - return drawer.Draw(label, value, member); + return Drawer(claim.Drawer).Draw(this, label, type, value, member); } catch (ExitGUIException) { @@ -115,141 +124,47 @@ namespace GameCult.Unity.Caching.Editor catch (Exception exception) { GUI.changed = changed; - return ErrorRow(label, drawer.GetType().Name + " failed: " + exception.Message, value); + return ErrorRow(label, claim.Drawer.Name + " failed: " + exception.Message, value); } } - - if (_conflicts.TryGetValue(type, out var claimants) || type.IsGenericType && _conflicts.TryGetValue(type.GetGenericTypeDefinition(), out claimants)) - return ErrorRow(label, "no drawer for " + type.FullName + " (contested by " + claimants + ")", value); - if (type.IsArray && type.GetArrayRank() != 1) - return ErrorRow(label, type.Name + " is a multi-dimensional array; the Studio cannot edit it", value); - - 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) + finally { - var current = value as Enum ?? (Enum)Enum.ToObject(type, 0); - return type.IsDefined(typeof(FlagsAttribute), false) - ? EditorGUILayout.EnumFlagsField(label, current) - : EditorGUILayout.EnumPopup(label, current); + _path = outer; } + } - 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 static ICultInspectorDrawer Drawer(Type type) + { + if (!DrawerInstances.TryGetValue(type, out var drawer)) + DrawerInstances[type] = drawer = (ICultInspectorDrawer)Activator.CreateInstance(type); + return drawer; } private void DrawMembers(object target, Type type, string path) { - foreach (var member in MembersOf(type)) + foreach (var member in Model.MembersOf(type)) { - if (member.GetCustomAttribute() != null) continue; - var current = Get(member, target); - var readOnly = !Writable(member) || member.GetCustomAttribute() != null; - using (new EditorGUI.DisabledScope(readOnly)) + if (member.Metadata.Hidden) continue; + using (new EditorGUI.DisabledScope(member.IsReadOnly)) { EditorGUI.BeginChangeCheck(); - var next = DrawValue(LabelOf(member), TypeOf(member), current, member, path + "." + member.Name); - if (EditorGUI.EndChangeCheck() && !readOnly) Set(member, target, next); + var next = DrawValue(LabelOf(member), member.ValueType, member.GetValue(target), member.Member, path + "." + member.Name); + if (EditorGUI.EndChangeCheck() && !member.IsReadOnly) member.SetValue(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) + private static string DrawString(string label, string value, CultInspectorMetadata metadata) { - 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)) + if (metadata.AssetPath != null) { - 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 assetType = metadata.AssetPath.AssetType != null && typeof(Object).IsAssignableFrom(metadata.AssetPath.AssetType) ? metadata.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(); + var textArea = metadata.TextArea; if (textArea == null) return EditorGUILayout.TextField(label, value ?? string.Empty); EditorGUILayout.LabelField(label); return EditorGUILayout.TextArea(value ?? string.Empty, @@ -257,19 +172,85 @@ namespace GameCult.Unity.Caching.Editor GUILayout.MaxHeight(Mathf.Max(textArea.MaxLines, textArea.MinLines, 1) * EditorGUIUtility.singleLineHeight)); } + private static object DrawInteger(string label, Type type, object value, CultInspectorMetadata metadata) + { + var current = value == null ? 0L : Convert.ToInt64(value); + if (type == typeof(int) && metadata.Range != null) + return EditorGUILayout.IntSlider(label, (int)current, Mathf.RoundToInt(metadata.Range.Min), Mathf.RoundToInt(metadata.Range.Max)); + var next = EditorGUILayout.LongField(label, current); + if (next == current) return value ?? Activator.CreateInstance(type); + try + { + return Convert.ChangeType(next, type); + } + catch (OverflowException) + { + return value ?? Activator.CreateInstance(type); + } + } + + // Scalar components draw on one row; anything else folds out. A change composes a new value. + private object DrawComposite(string label, CultInspectorShape shape, object value) + { + var path = _path; + value = value ?? Model.CreateDefault(shape.Type); + var components = shape.Members; + var values = components.Select(component => component.GetValue(value)).ToArray(); + var changed = false; + if (components.All(component => Model.ShapeOf(component.ValueType).Kind is CultInspectorValueKind.Float or CultInspectorValueKind.Integer or CultInspectorValueKind.Bool)) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.PrefixLabel(label); + var indent = EditorGUI.indentLevel; + var labelWidth = EditorGUIUtility.labelWidth; + EditorGUI.indentLevel = 0; + EditorGUIUtility.labelWidth = 8 + 7 * components.Max(component => component.Name.Length); + for (var i = 0; i < components.Count; i++) + { + EditorGUI.BeginChangeCheck(); + var next = DrawDefault(components[i].Name, components[i].ValueType, values[i], null); + if (!EditorGUI.EndChangeCheck()) continue; + values[i] = next; + changed = true; + } + + EditorGUIUtility.labelWidth = labelWidth; + EditorGUI.indentLevel = indent; + } + } + else + { + if (!Foldout(path, label)) return value; + EditorGUI.indentLevel++; + for (var i = 0; i < components.Count; i++) + { + EditorGUI.BeginChangeCheck(); + var next = DrawValue(LabelOf(components[i]), components[i].ValueType, values[i], components[i].Member, path + "." + components[i].Name); + if (!EditorGUI.EndChangeCheck()) continue; + values[i] = next; + changed = true; + } + + EditorGUI.indentLevel--; + } + + return changed ? Model.Compose(shape, values) : value; + } + 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 key = CultInspectorModel.RecordKey(value); + var candidates = Model.RecordCandidates(type, Records); + var index = 0; + var names = new string[candidates.Count + 1]; + for (var i = 0; i < candidates.Count; i++) + { + names[i + 1] = CultInspectorModel.RecordLabel(candidates[i]); + if (candidates[i].Key.Value == key) index = i + 1; + } + names[0] = index == 0 && key.Length > 0 ? "Missing " + key : "None"; var next = key; using (new EditorGUILayout.HorizontalScope()) { @@ -278,20 +259,21 @@ namespace GameCult.Unity.Caching.Editor 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; + if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Key.Value; else if (typed != key) next = typed; } - return next == key ? value : Activator.CreateInstance(type, new CultRecordKey(next)); + return next == key ? value : Model.CreateRecordRef(type, next); } - private object DrawDictionary(string label, Type type, Type[] arguments, IDictionary dictionary, MemberInfo member, string path) + private object DrawDictionary(string label, CultInspectorShape shape, IDictionary dictionary, MemberInfo member) { - var entries = new List(); + var path = _path; + var entries = new List>(); if (dictionary != null) { var enumerator = dictionary.GetEnumerator(); - while (enumerator.MoveNext()) entries.Add(enumerator.Entry); + while (enumerator.MoveNext()) entries.Add(new KeyValuePair(enumerator.Key, enumerator.Value)); } if (!Foldout(path, label + " (" + entries.Count + ")")) return dictionary; @@ -305,30 +287,23 @@ namespace GameCult.Unity.Caching.Editor 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"); + var key = DrawValue("Key", shape.KeyType, entries[i].Key, member, path + "{" + i + "}.key"); + var item = DrawValue("Value", shape.ValueType, entries[i].Value, member, path + "{" + i + "}.value"); if (EditorGUI.EndChangeCheck()) { var index = i; - var identity = KeyIdentity(key); - if (identity != KeyIdentity(entries[i].Key)) + var refusal = Model.RefuseKey(key, entries[i].Key, entries.Where((_, j) => j != index).Select(e => e.Key)); + if (refusal != null) { - var refusal = identity == null ? "a null key" - : identity == EmptyRefIdentity ? "an empty record reference as a key" - : entries.Where((_, j) => j != index).Any(e => KeyIdentity(e.Key) == identity) ? "a duplicate key" - : null; - if (refusal != null) - { - _notices[path] = "Refused " + refusal + " on entry " + i + "; its key was kept."; - key = entries[i].Key; - } - else - { - _notices.Remove(path); - } + _notices[path] = "Refused " + refusal + " on entry " + i + "; its key was kept."; + key = entries[i].Key; + } + else + { + _notices.Remove(path); } - entries[i] = new DictionaryEntry(key, item); + entries[i] = new KeyValuePair(key, item); changed = true; } } @@ -345,14 +320,14 @@ namespace GameCult.Unity.Caching.Editor if (GUILayout.Button("Add")) { - var fresh = FreshKey(arguments[0], entries); + var fresh = Model.FreshKey(shape.KeyType, entries.Select(e => e.Key), Records); if (fresh == null) { - _notices[path] = "No unused " + arguments[0].Name + " key is available; nothing was added."; + _notices[path] = "No unused " + shape.KeyType.Name + " key is available; nothing was added."; } else { - entries.Add(new DictionaryEntry(fresh, CreateDefault(arguments[1]))); + entries.Add(new KeyValuePair(fresh, Model.CreateDefault(shape.ValueType))); _notices.Remove(path); changed = true; } @@ -360,50 +335,13 @@ namespace GameCult.Unity.Caching.Editor if (_notices.TryGetValue(path, out var notice)) EditorGUILayout.HelpBox(notice, MessageType.Warning); 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; + return changed ? Model.BuildDictionary(shape.Type, entries) : dictionary; } - private const string EmptyRefIdentity = "ref:"; - - // Keys are the same key when they serialize the same; a CultRecordRef is its key string, null and "" alike. - private static string KeyIdentity(object key) - { - if (key == null) return null; - var type = key.GetType(); - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) return EmptyRefIdentity + key; - try - { - return Convert.ToBase64String(MessagePackSerializer.Serialize(type, key, CultDocumentMessagePackSerialization.Options)); - } - catch (Exception) - { - return type.FullName + ":" + key; - } - } - - // A record-reference key takes the first record of its type not already used; nothing else invents a key. - private object FreshKey(Type type, List entries) - { - var used = new HashSet(entries.Select(e => KeyIdentity(e.Key)), StringComparer.Ordinal); - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>)) - { - var target = type.GetGenericArguments()[0]; - var record = Records.FirstOrDefault(r => target.IsInstanceOfType(r.Document) && r.Key.Value.Length > 0 && !used.Contains(EmptyRefIdentity + r.Key.Value)); - return record == null ? null : Activator.CreateInstance(type, new CultRecordKey(record.Key.Value)); - } - - var fresh = CreateDefault(type); - return fresh == null || used.Contains(KeyIdentity(fresh)) ? null : fresh; - } - - private object DrawList(string label, Type type, Type element, object value, MemberInfo member, string path) + private object DrawList(string label, CultInspectorShape shape, object value, MemberInfo member) { + var path = _path; + var element = shape.ElementType; var items = new List(); if (value is IEnumerable enumerable) foreach (var item in enumerable) items.Add(item); @@ -437,43 +375,39 @@ namespace GameCult.Unity.Caching.Editor changed = true; } - if (element.IsAbstract || element.IsInterface) + var elementShape = Model.ShapeOf(element); + if (elementShape.Kind == CultInspectorValueKind.Union) { - 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) + var picked = EditorGUILayout.Popup("Add", 0, elementShape.UnionChoices.Select(u => u.Name).Prepend("...").ToArray()); + if (picked > 0) { - items.Add(Activator.CreateInstance(unions[picked - 1])); - changed = true; + try + { + items.Add(Model.CreateUnionValue(element, elementShape.UnionChoices[picked - 1])); + changed = true; + } + catch (InvalidOperationException exception) + { + Debug.LogError(exception.Message); + } } } else if (GUILayout.Button("Add")) { - items.Add(CreateDefault(element)); + items.Add(Model.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; + return changed ? Model.BuildList(shape.Type, items) : value; } - private object DrawUnion(string label, Type type, object value, string path) + private object DrawUnion(string label, CultInspectorShape shape, object value) { - 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 || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray(); + var path = _path; + var choices = shape.UnionChoices; + var index = value == null ? 0 : IndexOf(choices, value.GetType()) + 1; + var names = choices.Select(u => u.Name).Prepend(value == null || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray(); bool expanded; using (new EditorGUILayout.HorizontalScope()) @@ -485,9 +419,14 @@ namespace GameCult.Unity.Caching.Editor 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."); + try + { + value = picked == 0 ? null : Model.CreateUnionValue(shape.Type, choices[picked - 1]); + } + catch (InvalidOperationException exception) + { + Debug.LogError(exception.Message); + } } } @@ -498,16 +437,16 @@ namespace GameCult.Unity.Caching.Editor return value; } - private object DrawNested(string label, Type type, object value, string path) + private object DrawNested(string label, Type type, object value) { - if (MembersOf(type).Length == 0) return ErrorRow(label, "no drawer for " + type.FullName, value); + var path = _path; 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); + var created = Model.CreateDefault(type); + if (created != null && GUILayout.Button("Create", GUILayout.Width(56))) value = created; } return value; @@ -520,36 +459,6 @@ namespace GameCult.Unity.Caching.Editor 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. Expanding is view state: // it never counts as a change, so it never upserts. private bool Foldout(string path, string label, params GUILayoutOption[] options) @@ -571,49 +480,13 @@ namespace GameCult.Unity.Caching.Editor 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) + private static int IndexOf(IReadOnlyList types, Type type) { - if (type.IsArray) return type.GetArrayRank() == 1 ? type.GetElementType() : null; - return type.IsGenericType && !type.IsAbstract && !type.IsInterface && typeof(IList).IsAssignableFrom(type) - ? type.GetGenericArguments()[0] - : null; + for (var i = 0; i < types.Count; i++) + if (types[i] == type) return i; + return -1; } - 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; + private static string LabelOf(CultInspectorMember member) => member.Metadata.Label ?? ObjectNames.NicifyVariableName(member.Name); } } diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs index ecef9c4b..c4920d31 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs @@ -13,7 +13,9 @@ namespace GameCult.Unity.Caching.Editor private const string LastPathKey = "GameCult.CultCacheStudio.LastPath"; private CultCache _cache; + private CultInspectorModel _model; private CultInspector _inspector; + private CultInspectorEdit _edit; private CultStoredDocument[] _records = Array.Empty(); private string _path = string.Empty; private bool _directory; @@ -21,8 +23,6 @@ namespace GameCult.Unity.Caching.Editor private string _search = string.Empty; private Type _selectedType; private string _selectedKey; - private CultStoredDocument _editSource; - private object _editCopy; private Vector2 _typeScroll; private Vector2 _recordScroll; private Vector2 _inspectorScroll; @@ -31,6 +31,8 @@ namespace GameCult.Unity.Caching.Editor private bool ReadOnly => _cache.BackingStores.Any(store => store.IsReadOnly); + private static string ProjectRoot => Path.GetDirectoryName(Path.GetFullPath(Application.dataPath)); + [MenuItem("GameCult/CultCache Studio")] public static void Open() { @@ -80,6 +82,7 @@ namespace GameCult.Unity.Caching.Editor EditorStyles.toolbarButton, GUILayout.Width(40))) Browse(true); } + using (new EditorGUI.DisabledScope(_cache == null)) { if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(52)) && ConfirmDiscard()) OpenStore(_path, false); @@ -163,7 +166,7 @@ namespace GameCult.Unity.Caching.Editor else { var records = _records.Where(r => _selectedType.IsAssignableFrom(r.Descriptor.DocumentType)) - .Select(r => (Record: r, Label: CultInspector.RecordLabel(r))) + .Select(r => (Record: r, Label: CultInspectorModel.RecordLabel(r))) .OrderBy(r => r.Label, StringComparer.OrdinalIgnoreCase) .ToArray(); if (descriptor.IsGlobal && records.Length == 0) @@ -203,26 +206,17 @@ namespace GameCult.Unity.Caching.Editor 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); - // Drawers edit a copy. The cached object changes only through an admitted upsert; after any upsert - // attempt the copy is dropped and re-cloned from whatever the cache now holds. - var type = record.Descriptor.DocumentType; - if (!ReferenceEquals(_editSource, record)) - { - _editSource = record; - _editCopy = Clone(record.Document, type); - } + // Drawers edit the edit's private copy. A commit spends the edit, admitted or refused, and the next frame + // begins a new one from whatever record the cache then holds. + if (_edit == null || !_edit.IsFor(record)) _edit = _model.BeginEdit(record); var readOnly = ReadOnly; using (new EditorGUI.DisabledScope(readOnly)) { EditorGUI.BeginChangeCheck(); - _inspector.DrawDocument(_editCopy, type, record.Key.Value); - if (EditorGUI.EndChangeCheck() && !readOnly) - { - var edited = _editCopy; - _editSource = null; - Run(null, () => _cache.UpsertAsync(type, edited, record.Key).GetAwaiter().GetResult()); - } + _inspector.DrawDocument(_edit); + if (EditorGUI.EndChangeCheck() && !readOnly && !_edit.Commit(_cache, out var error)) + SetStatus(error, MessageType.Error); } EditorGUILayout.EndScrollView(); @@ -234,10 +228,12 @@ namespace GameCult.Unity.Caching.Editor return _selectedKey == null ? null : _records.FirstOrDefault(r => r.Key.Value == _selectedKey); } + // Dialogs start in the last store's folder, or the project root; never under Assets. private void Browse(bool create) { if (!ConfirmDiscard()) return; - var directory = string.IsNullOrEmpty(_path) ? Application.dataPath : Path.GetDirectoryName(_path); + var last = string.IsNullOrEmpty(_path) ? null : Path.GetDirectoryName(FullPathOrNull(_path) ?? string.Empty); + var directory = !string.IsNullOrEmpty(last) && Directory.Exists(last) && !InsideAssets(last) ? last : ProjectRoot; var path = create ? EditorUtility.SaveFilePanel("New CultCache store", directory, "GameData", "cc") : EditorUtility.OpenFilePanel("Open CultCache store", directory, _directory ? "" : "cc"); @@ -245,15 +241,22 @@ namespace GameCult.Unity.Caching.Editor GUIUtility.ExitGUI(); } - // New writes an empty single-file store and never touches an existing path; directory stores and read-only - // opens are refused because neither can write a store on creation. + // New writes an empty single-file store outside Assets and never touches an existing path; directory stores and + // read-only opens are refused because neither writes a store on creation. Game data never lives under Assets: + // Unity would import and manage it, and CultCache exists so Unity does not manage game data. private void OpenStore(string path, bool create) { var exists = File.Exists(path) || Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(path)); + var insideAssets = InsideAssets(path); string refusal = null; - if (create && (_readOnly || _directory)) refusal = "New creates writable single-file stores only; turn off Read Only and Directory. Nothing was created."; - else if (create && exists) refusal = path + " already exists; nothing was created or replaced. Use Open to edit it."; - else if (!create && !File.Exists(path)) refusal = "No store at " + path + "."; + if (create && (_readOnly || _directory)) + refusal = "New creates writable single-file stores only; turn off Read Only and Directory. Nothing was created."; + else if (create && insideAssets) + refusal = "New refuses " + path + ": game data does not live under Assets, where Unity imports and manages files, and CultCache exists so Unity does not manage game data. Choose a folder outside " + Application.dataPath + ". Nothing was created."; + else if (create && exists) + refusal = path + " already exists; nothing was created or replaced. Use Open to edit it."; + else if (!create && !File.Exists(path)) + refusal = "No store at " + path + "."; if (refusal != null) { SetStatus(refusal, MessageType.Error); @@ -270,10 +273,18 @@ namespace GameCult.Unity.Caching.Editor if (!File.Exists(path)) throw new IOException("the store wrote no file"); } - _inspector = new CultInspector(_cache); + var registry = _cache.Registry; + _model = new CultInspectorModel(registry, + (value, type) => CultDocumentMessagePackSerialization.SerializeUntyped(value, type, registry), + (type, bytes) => CultDocumentMessagePackSerialization.DeserializeUntyped(type, bytes, registry)); + _inspector = new CultInspector(_model); _path = path; EditorPrefs.SetString(LastPathKey, path); - SetStatus((create ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."), MessageType.Info); + var opened = (create ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."); + if (insideAssets) + SetStatus(opened + " This store is under Assets, where Unity imports and manages files; game data belongs outside Assets.", MessageType.Warning); + else + SetStatus(opened, MessageType.Info); } catch (Exception exception) { @@ -282,15 +293,36 @@ namespace GameCult.Unity.Caching.Editor } } + private static bool InsideAssets(string path) + { + var full = FullPathOrNull(path); + var assets = FullPathOrNull(Application.dataPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return full != null && assets != null && + (full.Equals(assets, StringComparison.OrdinalIgnoreCase) || + full.StartsWith(assets + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)); + } + + private static string FullPathOrNull(string path) + { + try + { + return Path.GetFullPath(path); + } + catch (Exception) + { + return null; + } + } + private void CloseStore() { _cache?.Dispose(); _cache = null; + _model = null; _inspector = null; + _edit = null; _records = Array.Empty(); _selectedKey = null; - _editSource = null; - _editCopy = null; } private bool ConfirmDiscard() @@ -314,17 +346,14 @@ namespace GameCult.Unity.Caching.Editor private void Duplicate(CultStoredDocument record) { var type = record.Descriptor.DocumentType; - Run("Duplicated " + CultInspector.RecordLabel(record) + ".", () => - _selectedKey = _cache.UpsertAsync(type, Clone(record.Document, type)).GetAwaiter().GetResult().Value); + Run("Duplicated " + CultInspectorModel.RecordLabel(record) + ".", () => + _selectedKey = _cache.UpsertAsync(type, _model.Clone(record.Document, type)).GetAwaiter().GetResult().Value); GUIUtility.ExitGUI(); } - private object Clone(object document, Type type) => - CultDocumentMessagePackSerialization.DeserializeUntyped(type, CultDocumentMessagePackSerialization.SerializeUntyped(document, type, _cache.Registry), _cache.Registry); - private void Delete(CultStoredDocument record) { - var label = CultInspector.RecordLabel(record); + var label = CultInspectorModel.RecordLabel(record); if (!EditorUtility.DisplayDialog("CultCache Studio", "Delete " + label + "?", "Delete", "Cancel")) return; Run("Deleted " + label + ".", () => { 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 426232b0..0a5b140d 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,6 @@ "name": "GameCult.Unity.Caching.Editor", "rootNamespace": "GameCult.Unity.Caching.Editor", "references": [ - "GameCult.Unity.Caching", "GameCult.CultLib" ], "includePlatforms": [ diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index e9f69b71..6556dbba 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -4,56 +4,89 @@ store, browse its documents, edit them, save. 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`. +and flush. `CultInspectorModel` (in `GameCult.Caching`) owns what an inspector +shows and which edits it may make. The Studio window owns selection, the +toolbar and IMGUI widgets. It references `GameCult.Caching` and +`GameCult.Caching.MessagePack` at compile time through the +`org.gamecult.cultlib` package (assembly `GameCult.CultLib`). ## Use -1. Add this package; it depends on `org.gamecult.cultlib` and - `org.gamecult.cultmath`. +1. Add this package; it depends on `org.gamecult.cultlib`. 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. +3. `Open` a `.cc` store. `Directory` opens a directory store (a manifest beside + its `.records` folder; an existing one is detected without the toggle). + `Read Only` opens the store read-only. `New` creates an empty writable + single-file store; it is disabled while `Read Only` or `Directory` is on and + refuses a path that already exists. 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. +Game data stores do not live under the project's `Assets` folder: Unity would +import and manage them, and CultCache exists so Unity does not manage game +data. `New` refuses a path under `Assets`, `Open` warns about one, and the file +dialogs start in the last store's folder or the project root. + 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. +Opening a store never writes it, directory stores included. Closing or +reloading scripts drops unsaved edits. + +## Inspection Model + +`CultInspectorModel` is engine-free and lives beside the inspector attributes +in `GameCult.Caching`. Given a registry and the store's document codec it gives +the member list of a type (registry catalog slots, `CultInspector*` metadata, +whether a member is assignable), the shape of any value (string, integer, +float, bool, enum, composite value rebuilt through its constructor, +`CultRecordRef` with its candidate records, rank-1 list or array, +dictionary, `[Union]` with only its declared subtypes, nested keyed object, or +unsupported with a reason), drawer claim resolution +(`CultInspectorDrawerClaims`), dictionary key identity and refusal (null keys, +empty record references and duplicates), and edits that work on a copy +(`CultInspectorEdit`): a refused upsert leaves the cached object unchanged. The +Studio is one lowering of that model and owns only widgets and layout; a +runtime CultUI panel needs only its own renderer and drawers to show and edit +the same documents under the same rules. ## 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. +Built in: every model shape above. Composite values of scalars (`float2/3/4`, +`int2`, `double2/3`, `bool2`, `quaternion`, `Color32`) draw as one row; +`rect` folds out to its corners. Unity object references draw as object +fields. A value no drawer claims and no shape covers shows a red error row, as +does a multi-dimensional array. -A project adds or overrides a drawer with an editor class: +A project adds or overrides a drawer with an editor class. `Claimed` is either +a value type (an open generic definition claims every closed form) or an +attribute type, which claims every member carrying it. Attribute claims win +over type claims, which win over built-in drawing. A claim two drawers make is +used by neither: both are logged and the member shows the error row. A drawer +that throws draws an error row and leaves the value unchanged. ```csharp +using System; using System.Reflection; -using GameCult.Unity.Caching; +using GameCult.Caching; using GameCult.Unity.Caching.Editor; using UnityEditor; using UnityEngine; -[CultInspectorDrawer(typeof(CultMath.float3))] -public sealed class Float3ColorDrawer : ICultInspectorDrawer +public sealed class ColorAttribute : Attribute { - public object Draw(string label, object value, MemberInfo member) +} + +[CultInspectorDrawer(typeof(ColorAttribute))] +public sealed class ColorDrawer : ICultInspectorDrawer +{ + public object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member) { + if (type != typeof(CultMath.float3)) + return inspector.DrawDefault(label, type, value, 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); @@ -61,14 +94,18 @@ public sealed class Float3ColorDrawer : ICultInspectorDrawer } ``` -`MemberType` may be an open generic definition to claim every closed form. A -project drawer wins over the built-in one for its type. +`Draw` receives the value's declared type and the member it belongs to, also +for list elements and dictionary keys and values, so an attribute drawer +checks the type it is handed. `inspector.DrawDefault` hands a value back to +built-in drawing; `inspector.DrawValue` draws a sub-value with claims applied. ## Inspector Annotations +The annotations live in `GameCult.Caching`, so headless shared models carry +them without referencing Unity. + ```csharp using GameCult.Caching; -using GameCult.Unity.Caching; using MessagePack; [MessagePackObject] @@ -89,7 +126,7 @@ public sealed class ItemData public string Notes = string.Empty; [Key(3)] - [CultInspectorAssetPath(typeof(UnityEngine.Texture2D))] + [CultInspectorAssetPath] public string IconPath = string.Empty; } ``` @@ -100,7 +137,8 @@ public sealed class ItemData - `CultInspectorOrder` - `CultInspectorTextArea` - `CultInspectorRange` -- `CultInspectorAssetPath` +- `CultInspectorAssetPath` (optional engine asset `Type`, named by the + consumer; the Studio uses it when it is a `UnityEngine.Object` type) - `CultInspectorDrawer` (on drawer classes) CultMesh collaboration should feed the same CultCache mutation surface rather diff --git a/src/GameCult.Unity/Assets/Caching/package.json b/src/GameCult.Unity/Assets/Caching/package.json index cde27289..dff80c97 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.1.0", + "version": "1.2.0", "unity": "2021.3", "description": "Unity editor tooling for inspecting and editing CultCache .cc stores.", "keywords": [ @@ -10,8 +10,7 @@ "Data" ], "dependencies": { - "org.gamecult.cultlib": "1.0.57", - "org.gamecult.cultmath": "0.1.2" + "org.gamecult.cultlib": "1.0.57" }, "license": "MIT", "category": "Scripting"