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

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 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 candidate records, rank-1 list or array, dictionary, `[Union]` with only
its declared subtypes, nested keyed object, struct edited in place through its 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 (`CultInspectorDrawerClaims`), and the edit decisions: the key a dictionary
entry keeps and the notice when a key is refused (null, an empty record 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 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 ## Drawers
Built in: every model shape above. A struct without `[Key]` members folds out Built in: every model shape above. A struct without `[Key]` members is edited
to its public fields and its public properties with a setter, and is edited in in place. With public fields it folds out to those fields only (readonly ones
place; readonly fields and `init` properties show read-only, and get-only read-only), so aliasing properties such as `Quaternion.eulerAngles` or
properties are not shown (record structs work as written). Unity object `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 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 shows a red error row with the model's reason, as does a multi-dimensional
array. 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.Select(member => member.Name), Is.EqualTo(new[] { "x", "y" }));
Assert.That(open.Members.All(member => member.IsAssignable), Is.True); 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(); (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) }), Assert.That(Rows(typeof(InspectMixedStruct)), Is.EqualTo(new[] { ("x", false), ("y", true) }),
"fields, then settable properties; readonly and init are read-only; get-only is not a row"); "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(InspectRecordStruct)), Is.EqualTo(new[] { ("A", false), ("B", false) }));
Assert.That(Rows(typeof(InspectReadonlyRecordStruct)), Is.EqualTo(new[] { ("A", true), ("B", true) })); Assert.That(Rows(typeof(InspectReadonlyRecordStruct)), Is.EqualTo(new[] { ("A", true), ("B", true) }));
object boxed = new InspectRecordStruct(1, 2f); object boxed = new InspectRecordStruct(1, 2f);
@ -372,6 +374,11 @@ namespace GameCult.Caching.Tests
{ {
public float x; public float x;
public readonly float y; public readonly float y;
public float Alias { get => x; set => x = value; }
}
public struct InspectPropertyStruct
{
public int Settable { get; set; } public int Settable { get; set; }
public int Initable { get; init; } public int Initable { get; init; }
public int Computed => Settable + 1; public int Computed => Settable + 1;