From 90e7dbee2782004c99ffd58f63b9f240971f2049 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 14 Sep 2026 21:01:22 +0200 Subject: [PATCH] Give inspector drawers the record they sit in, read-only A drawer saw only its value; Aetheria's schematic-shape drawer needs sibling members of the owning document (texture path, aspect, hull tints). The model owns the seam: CultInspectorEdit.Record is a second copy of the stored document as the edit began, cloned on first read and kept for the edit. The Studio lowers it as CultInspector.Record, so a runtime CultUI panel gets the same seam from the same edit. The Draw signature is unchanged, so existing drawers keep compiling. Why not hand drawers the edit copy the Studio already draws: that copy is what Commit upserts, so a write through it would be saved without passing through the returned value, GUI.changed, or read-only metadata. The record copy is read by nothing, so writing to it saves nothing and changes neither the edit nor the cache. It does not show edits other drawers made earlier in the same frame. The Studio's separate document-type field collapses into the held edit. Co-Authored-By: Claude Opus 5 --- src/GameCult.Caching/CultInspectorModel.cs | 15 +++++++++++++-- .../Caching/Editor/CultCacheStudioDrawers.cs | 13 ++++++++----- src/GameCult.Unity/Assets/Caching/README.md | 9 +++++++++ .../CultInspectorModelTests.cs | 5 +++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index c457031f..aaa7bbaf 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -238,14 +238,24 @@ namespace GameCult.Caching // A private copy of one stored document. A lowering draws into Document; the cached object is never touched. public sealed class CultInspectorEdit { - internal CultInspectorEdit(CultStoredDocument source, object document) + private readonly Func _snapshot; + private object? _record; + + internal CultInspectorEdit(CultStoredDocument source, object document, Func snapshot) { Source = source; Document = document; + _snapshot = snapshot; } public CultStoredDocument Source { get; } public object Document { get; } + + // The record being drawn, as stored when this edit began, for drawers that read the document they sit in. It is a + // second copy, made on first read: not the cached object, and not Document, which a lowering commits. Nothing + // reads it back, so writing to it saves nothing and changes neither the edit nor the cache. + public object Record => _record ??= _snapshot(); + public bool IsSpent { get; private set; } public bool IsFor(CultStoredDocument record) => !IsSpent && ReferenceEquals(Source, record); @@ -342,7 +352,8 @@ namespace GameCult.Caching public CultInspectorEdit BeginEdit(CultStoredDocument record) { if (record == null) throw new ArgumentNullException(nameof(record)); - return new CultInspectorEdit(record, Clone(record.Document, record.Descriptor.DocumentType)); + var type = record.Descriptor.DocumentType; + return new CultInspectorEdit(record, Clone(record.Document, type), () => Clone(record.Document, type)); } // Whether CreateDefault makes a value: a string, struct, list or dictionary, or a concrete class with a parameterless constructor. diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index 12eed25e..a421a476 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -16,7 +16,7 @@ namespace GameCult.Unity.Caching.Editor // frame's edit copy, so nothing it or any other drawer did that frame is saved. 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. + // with claims. inspector.Record is the record being drawn, read-only: a copy nothing saves. public interface ICultInspectorDrawer { object Draw(CultInspector inspector, string label, Type type, object value, MemberInfo member); @@ -33,7 +33,7 @@ namespace GameCult.Unity.Caching.Editor private readonly Dictionary _foldouts = new Dictionary(StringComparer.Ordinal); private readonly Dictionary _notices = new Dictionary(StringComparer.Ordinal); private string _path = string.Empty; - private Type _document; + private CultInspectorEdit _edit; private bool _drawerFailed; internal CultInspector(CultInspectorModel model) @@ -45,6 +45,9 @@ namespace GameCult.Unity.Caching.Editor public IReadOnlyList Records { get; internal set; } = Array.Empty(); + // The document being drawn, as the model's CultInspectorEdit.Record: a copy for reading, never saved. + public object Record => _edit?.Record; + private static GUIStyle ErrorStyle => _errorStyle ??= new GUIStyle(EditorStyles.label) { normal = { textColor = new Color(1f, .35f, .3f) } }; private static CultInspectorDrawerClaims Claims @@ -62,7 +65,7 @@ namespace GameCult.Unity.Caching.Editor internal bool DrawDocument(CultInspectorEdit edit) { _drawerFailed = false; - _document = edit.Document.GetType(); + _edit = edit; DrawMembers(edit.Document, edit.Source.Descriptor.DocumentType, edit.Source.Key.Value); return !_drawerFailed; } @@ -242,7 +245,7 @@ namespace GameCult.Unity.Caching.Editor var item = DrawValue("Value", shape.ValueType, entries[i].Value, member, path + "{" + i + "}.value"); if (EditorGUI.EndChangeCheck()) { - key = Model.ReplaceKey(_document, shape.Type, entries.Select(e => e.Key).ToArray(), i, key, out var notice); + key = Model.ReplaceKey(_edit.Document.GetType(),shape.Type, entries.Select(e => e.Key).ToArray(), i, key, out var notice); Notice(path, notice); entries[i] = new KeyValuePair(key, item); changed = true; @@ -261,7 +264,7 @@ namespace GameCult.Unity.Caching.Editor if (Add(shape.ValueType, path, out var created)) { - var fresh = Model.FreshKey(_document, shape.Type, entries.Select(e => e.Key).ToArray(), Records, out var notice); + var fresh = Model.FreshKey(_edit.Document.GetType(),shape.Type, entries.Select(e => e.Key).ToArray(), Records, out var notice); Notice(path, notice); if (fresh != null) { diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index a5ea3d92..d96f674f 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -114,6 +114,15 @@ 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.Record` is the whole document the value sits in, for drawers that +read sibling members (a texture path, a hull's tints). It is read-only by +contract: the model's `CultInspectorEdit.Record`, a second copy of the stored +document as the edit began, made on first read. It is neither the cached +object nor the edit copy the Studio saves, so writing to it saves nothing; an +edit reaches the record only through the value `Draw` returns. Within one frame +it does not show edits other drawers made earlier in that frame; after a save +the next frame's edit starts from the saved record. + ## Inspector Annotations The annotations live in `GameCult.Caching`, so headless shared models carry diff --git a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs index c5fd8cb1..1de5eaf8 100644 --- a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs +++ b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs @@ -216,6 +216,11 @@ namespace GameCult.Caching.Tests var record = readOnly.AllStoredDocuments.Single(); var edit = model.BeginEdit(record); Assert.That(edit.Document, Is.Not.SameAs(record.Document)); + var drawn = (InspectItem)edit.Record; + Assert.That(drawn, Is.Not.SameAs(record.Document).And.Not.SameAs(edit.Document)); + Assert.That(edit.Record, Is.SameAs(drawn), "one record copy per edit"); + drawn.Name = "a drawer wrote here"; + Assert.That(((InspectItem)edit.Document).Name, Is.EqualTo("original"), "the record copy is not the committed copy"); ((InspectItem)edit.Document).Name = "changed"; ((InspectItem)edit.Document).Numbers.Add(3);