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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 21:01:22 +02:00
commit 90e7dbee27
4 changed files with 35 additions and 7 deletions

View file

@ -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<object> _snapshot;
private object? _record;
internal CultInspectorEdit(CultStoredDocument source, object document, Func<object> 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.

View file

@ -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<string, bool> _foldouts = new Dictionary<string, bool>(StringComparer.Ordinal);
private readonly Dictionary<string, string> _notices = new Dictionary<string, string>(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<CultStoredDocument> Records { get; internal set; } = Array.Empty<CultStoredDocument>();
// 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<object, object>(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)
{

View file

@ -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

View file

@ -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);