diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index f9c9845f..0d488f47 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using GameCult.Caching; +using GameCult.Caching.MessagePack; using MessagePack; using UnityEditor; using UnityEngine; @@ -21,12 +22,14 @@ namespace GameCult.Unity.Caching.Editor internal sealed class CultInspector { private static Dictionary _drawers; + private static Dictionary _conflicts; 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(); + private readonly Dictionary _notices = new Dictionary(StringComparer.Ordinal); public CultInspector(CultCache cache) { @@ -43,15 +46,32 @@ namespace GameCult.Unity.Caching.Editor { 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."); - else if (_drawers.ContainsKey(memberType)) - Debug.LogError($"{type.FullName} and {_drawers[memberType].GetType().FullName} both draw {memberType.FullName}."); + 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; @@ -73,15 +93,36 @@ namespace GameCult.Unity.Caching.Editor return string.IsNullOrWhiteSpace(name) ? record.Key.Value : name; } - public void DrawDocument(CultStoredDocument record) + // 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) { - DrawMembers(record.Document, record.Descriptor.DocumentType, record.Key.Value); + 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)) - return drawer.Draw(label, value, member); + { + var changed = GUI.changed; + try + { + return drawer.Draw(label, value, member); + } + catch (ExitGUIException) + { + throw; + } + catch (Exception exception) + { + GUI.changed = changed; + return ErrorRow(label, drawer.GetType().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(); @@ -269,7 +310,24 @@ namespace GameCult.Unity.Caching.Editor if (EditorGUI.EndChangeCheck()) { var index = i; - if (key == null || entries.Where((_, j) => j != index).Any(e => Equals(e.Key, key))) key = entries[i].Key; + var identity = KeyIdentity(key); + if (identity != KeyIdentity(entries[i].Key)) + { + 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); + } + } + entries[i] = new DictionaryEntry(key, item); changed = true; } @@ -285,16 +343,22 @@ namespace GameCult.Unity.Caching.Editor changed = true; } - var fresh = CreateDefault(arguments[0]); - using (new EditorGUI.DisabledScope(fresh == null || entries.Any(e => Equals(e.Key, fresh)))) + if (GUILayout.Button("Add")) { - if (GUILayout.Button("Add")) + var fresh = FreshKey(arguments[0], entries); + if (fresh == null) + { + _notices[path] = "No unused " + arguments[0].Name + " key is available; nothing was added."; + } + else { entries.Add(new DictionaryEntry(fresh, CreateDefault(arguments[1]))); + _notices.Remove(path); changed = true; } } + 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 @@ -305,6 +369,39 @@ namespace GameCult.Unity.Caching.Editor return result; } + 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) { var items = new List(); @@ -376,7 +473,7 @@ namespace GameCult.Unity.Caching.Editor 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(); + var names = unions.Select(u => u.Name).Prepend(value == null || index > 0 ? "None" : value.GetType().Name + " (not a declared union)").ToArray(); bool expanded; using (new EditorGUILayout.HorizontalScope()) @@ -453,14 +550,17 @@ namespace GameCult.Unity.Caching.Editor return new CM.bool2(x, y); } - // Foldouts stay usable inside disabled scopes so read-only data can still be browsed. + // 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) { _foldouts.TryGetValue(path, out var expanded); var enabled = GUI.enabled; + var changed = GUI.changed; GUI.enabled = true; expanded = EditorGUI.Foldout(EditorGUILayout.GetControlRect(false, options), expanded, label, true); GUI.enabled = enabled; + GUI.changed = changed; _foldouts[path] = expanded; return expanded; } @@ -481,7 +581,7 @@ namespace GameCult.Unity.Caching.Editor private static Type ListElement(Type type) { - if (type.IsArray) return type.GetElementType(); + 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; diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs index 5dbc4278..ecef9c4b 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs @@ -21,6 +21,8 @@ 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; @@ -72,7 +74,12 @@ namespace GameCult.Unity.Caching.Editor _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(_readOnly || _directory)) + { + if (GUILayout.Button(new GUIContent("New", "Creates a writable single-file store. Turn off Read Only and Directory to enable."), + 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); @@ -196,13 +203,26 @@ 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); + } + var readOnly = ReadOnly; using (new EditorGUI.DisabledScope(readOnly)) { EditorGUI.BeginChangeCheck(); - _inspector.DrawDocument(record); + _inspector.DrawDocument(_editCopy, type, record.Key.Value); if (EditorGUI.EndChangeCheck() && !readOnly) - Run(null, () => _cache.UpsertAsync(record.Descriptor.DocumentType, record.Document, record.Key).GetAwaiter().GetResult()); + { + var edited = _editCopy; + _editSource = null; + Run(null, () => _cache.UpsertAsync(type, edited, record.Key).GetAwaiter().GetResult()); + } } EditorGUILayout.EndScrollView(); @@ -225,24 +245,35 @@ 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. private void OpenStore(string path, bool create) { - CloseStore(); - if (!create && !File.Exists(path)) + var exists = File.Exists(path) || Directory.Exists(DirectoryMessagePackBackingStore.DefaultRecordDirectoryPath(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 (refusal != null) { - SetStatus("No store at " + path + ".", MessageType.Error); + SetStatus(refusal, MessageType.Error); return; } + CloseStore(); try { - var existed = File.Exists(path); - _cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { ReadOnly = _readOnly, UseDirectoryStore = _directory }); - if (create && !existed && !_readOnly) _cache.BackingStores[0].PushAll(); + _cache = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { ReadOnly = !create && _readOnly, UseDirectoryStore = !create && _directory }); + if (create) + { + _cache.BackingStores[0].PushAll(); + if (!File.Exists(path)) throw new IOException("the store wrote no file"); + } + _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); + SetStatus((create ? "Created " : "Opened ") + path + (_cache.BackingStores[0] is DirectoryMessagePackBackingStore ? " (directory store)." : "."), MessageType.Info); } catch (Exception exception) { @@ -258,6 +289,8 @@ namespace GameCult.Unity.Caching.Editor _inspector = null; _records = Array.Empty(); _selectedKey = null; + _editSource = null; + _editCopy = null; } private bool ConfirmDiscard() @@ -282,14 +315,13 @@ namespace GameCult.Unity.Caching.Editor { 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; - }); + _selectedKey = _cache.UpsertAsync(type, 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);