Show an unkeyed struct's fields or its properties, never both

Unity's Quaternion showed eulerAngles beside x/y/z/w, Rect about eleven rows
over four values, and Bounds center/extents/size/min/max: settable properties
alias the fields they wrap. A struct with public fields now shows only those; a
struct without shows its settable properties, so record structs and CultMath
matrices (properties over private rows) still get clean rows and vectors show
fields only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-14 14:55:03 +02:00
commit ccf9bd0c0c
3 changed files with 27 additions and 15 deletions

View file

@ -552,14 +552,17 @@ namespace GameCult.Caching
var keyed = KeyedMembers(type);
if (keyed.Length > 0)
return new CultInspectorShape(type, CultInspectorValueKind.Nested) { Members = keyed };
// An unkeyed struct is edited in place: its public fields, readonly ones read-only, then its public properties with
// a setter, init-only ones read-only. Get-only properties are computed (a vector's swizzles) or kept by the
// in-place edit, so they are not rows.
// An unkeyed struct is edited in place. With public fields it shows only them, readonly ones read-only: its
// properties alias those values (a Quaternion's eulerAngles, a Rect's min and max). Without, it shows its public
// properties with a setter, init-only ones read-only (record structs, matrices over private rows). Get-only
// properties are computed or kept by the in-place edit, so they are never rows.
if (type.IsValueType)
{
var members = type.GetFields(Public).OrderBy(field => field.MetadataToken)
var fields = type.GetFields(Public);
var members = (fields.Length > 0
? fields.OrderBy(field => field.MetadataToken)
.Select(field => (Member: (MemberInfo)field, Type: field.FieldType, Assignable: !field.IsInitOnly))
.Concat(type.GetProperties(Public).OrderBy(property => property.MetadataToken)
: type.GetProperties(Public).OrderBy(property => property.MetadataToken)
.Where(property => property.GetIndexParameters().Length == 0 && property.GetGetMethod() != null && property.GetSetMethod() != null)
.Select(property => (Member: (MemberInfo)property, Type: property.PropertyType, Assignable: !IsInitOnly(property.GetSetMethod()!))))
.Select((member, slot) => new CultInspectorMember(member.Member, member.Type, slot, member.Assignable, MetadataOf(member.Member)))

View file

@ -45,7 +45,7 @@ catalog slots, `CultInspector*` metadata, whether a member is assignable), the
shape of any value (string, integer, float, bool, enum, `CultRecordRef<T>` with
its candidate records, rank-1 list or array, dictionary, `[Union]` with only
its declared subtypes, nested keyed object, struct edited in place through its
public fields and settable properties, or unsupported with a reason), drawer claim resolution
public fields, or its settable properties when it has none, or unsupported with a reason), drawer claim resolution
(`CultInspectorDrawerClaims`), and the edit decisions: the key a dictionary
entry keeps and the notice when a key is refused (null, an empty record
reference, a duplicate: serialized the same as another key under the owning
@ -59,10 +59,12 @@ renderer and drawers to show and edit the same documents under the same rules.
## Drawers
Built in: every model shape above. A struct without `[Key]` members folds out
to its public fields and its public properties with a setter, and is edited in
place; readonly fields and `init` properties show read-only, and get-only
properties are not shown (record structs work as written). Unity object
Built in: every model shape above. A struct without `[Key]` members is edited
in place. With public fields it folds out to those fields only (readonly ones
read-only), so aliasing properties such as `Quaternion.eulerAngles` or
`Rect.min` add no rows; without public fields it folds out to its public
properties with a setter, `init` ones read-only (record structs and CultMath
matrices work as written). Get-only properties are never shown. Unity object
references draw as object fields. A value no drawer claims and no shape covers
shows a red error row with the model's reason, as does a multi-dimensional
array.

View file

@ -255,8 +255,10 @@ namespace GameCult.Caching.Tests
Assert.That(open.Members.Select(member => member.Name), Is.EqualTo(new[] { "x", "y" }));
Assert.That(open.Members.All(member => member.IsAssignable), Is.True);
(string, bool)[] Rows(Type type) => model.ShapeOf(type).Members.Select(member => (member.Name, member.IsReadOnly)).ToArray();
Assert.That(Rows(typeof(InspectMixedStruct)), Is.EqualTo(new[] { ("x", false), ("y", true), ("Settable", false), ("Initable", true) }),
"fields, then settable properties; readonly and init are read-only; get-only is not a row");
Assert.That(Rows(typeof(InspectMixedStruct)), Is.EqualTo(new[] { ("x", false), ("y", true) }),
"a struct with public fields shows only them; its settable properties are aliases, not rows");
Assert.That(Rows(typeof(InspectPropertyStruct)), Is.EqualTo(new[] { ("Settable", false), ("Initable", true) }),
"without public fields, settable properties are rows; init is read-only; get-only is not a row");
Assert.That(Rows(typeof(InspectRecordStruct)), Is.EqualTo(new[] { ("A", false), ("B", false) }));
Assert.That(Rows(typeof(InspectReadonlyRecordStruct)), Is.EqualTo(new[] { ("A", true), ("B", true) }));
object boxed = new InspectRecordStruct(1, 2f);
@ -372,6 +374,11 @@ namespace GameCult.Caching.Tests
{
public float x;
public readonly float y;
public float Alias { get => x; set => x = value; }
}
public struct InspectPropertyStruct
{
public int Settable { get; set; }
public int Initable { get; init; }
public int Computed => Settable + 1;