Merge Studio grouping Cut 2: tree lowering, create in group, drag onto refs

Soul: merge. The drop gate calls the model's one candidate rule; every ref write
ends in CreateRecordRef; node ids injective (69,905-string brute force, 0
collisions). Operator click-through still owed (steps in the Cut 2 report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 16:09:38 +02:00
commit acdf36c81e
5 changed files with 247 additions and 15 deletions

View file

@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using MessagePack;
namespace GameCult.Caching
@ -653,7 +654,27 @@ namespace GameCult.Caching
// A node's id is its members' value identities, so it survives relabelling: a ref by key, an enum by its underlying
// value, anything else by the value's invariant string.
private static string GroupId(IReadOnlyList<CultInspectorMember> members, IReadOnlyList<object?> values) =>
string.Join("/", values.Select((value, i) => members[i].Name + "=" + Uri.EscapeDataString(IdentityOf(value))));
string.Join("/", values.Select((value, i) => members[i].Name + "=" + EscapeIdentity(IdentityOf(value))));
// Injective: text is spelled as Uri.EscapeDataString spells it, which turns '/' and '%' into %XX; an unpaired
// surrogate, which that method would flatten to U+FFFD, becomes %uXXXX, a form no escaped run contains.
private static string EscapeIdentity(string identity)
{
var escaped = new StringBuilder();
var run = 0;
for (var i = 0; i < identity.Length; i++)
{
if (char.IsHighSurrogate(identity[i]) && i + 1 < identity.Length && char.IsLowSurrogate(identity[i + 1]))
i++;
else if (char.IsSurrogate(identity[i]))
{
escaped.Append(Uri.EscapeDataString(identity.Substring(run, i - run))).Append("%u").Append(((int)identity[i]).ToString("X4"));
run = i + 1;
}
}
return escaped.Append(Uri.EscapeDataString(identity.Substring(run))).ToString();
}
private static string IdentityOf(object? value) =>
value switch

View file

@ -263,22 +263,45 @@ namespace GameCult.Unity.Caching.Editor
if (candidates[i].Key.Value == key) index = i + 1;
}
names[0] = index == 0 && key.Length > 0 ? "Missing " + key : "None";
names[0] = index == 0 ? Model.RecordRefLabel(type, value, Records) : "None";
var next = key;
using (new EditorGUILayout.HorizontalScope())
using (var row = new EditorGUILayout.HorizontalScope())
{
var dropped = AcceptRecordDrop(row.rect, type, key);
var picked = EditorGUILayout.Popup(label, index, names);
var indent = EditorGUI.indentLevel;
EditorGUI.indentLevel = 0;
var typed = EditorGUILayout.DelayedTextField(key, GUILayout.Width(120));
EditorGUI.indentLevel = indent;
if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Key.Value;
if (dropped != null) next = dropped;
else if (picked != index) next = picked == 0 ? string.Empty : candidates[picked - 1].Key.Value;
else if (typed != key) next = typed;
}
return next == key ? value : Model.CreateRecordRef(type, next);
}
// A record row dragged from the Studio list onto a CultRecordRef<T> value. Returns the dropped key when the drop is
// performed and the ref's picker would offer that record, or null. The payload is the Studio's own; a foreign drag is
// left alone. Refused in a disabled scope, from another model, and for the key already held.
private string AcceptRecordDrop(Rect rect, Type type, string key)
{
var current = Event.current;
if (current.type != EventType.DragUpdated && current.type != EventType.DragPerform) return null;
if (!rect.Contains(current.mousePosition) || !(DragAndDrop.GetGenericData(CultCacheStudioRecordDrag.DragKey) is CultCacheStudioRecordDrag drag)) return null;
var perform = current.type == EventType.DragPerform;
var record = ReferenceEquals(drag.Model, Model) && drag.Key != key ? Records.FirstOrDefault(candidate => candidate.Key.Value == drag.Key) : null;
var accepted = GUI.enabled && record != null && Model.IsRecordCandidate(type, record);
DragAndDrop.visualMode = accepted ? DragAndDropVisualMode.Copy : DragAndDropVisualMode.Rejected;
current.Use();
if (!accepted || !perform) return null;
DragAndDrop.AcceptDrag();
GUI.changed = true;
return drag.Key;
}
private object DrawDictionary(string label, CultInspectorShape shape, IDictionary dictionary, MemberInfo member)
{
var path = _path;
@ -503,4 +526,19 @@ namespace GameCult.Unity.Caching.Editor
private static string LabelOf(CultInspectorMember member) => member.Metadata.Label ?? ObjectNames.NicifyVariableName(member.Name);
}
// The payload of a Studio record drag: the record's key, and the model it came from so a drop refuses another window's.
internal sealed class CultCacheStudioRecordDrag
{
internal const string DragKey = "GameCult.CultCacheStudio.Record";
internal CultCacheStudioRecordDrag(CultInspectorModel model, string key)
{
Model = model;
Key = key;
}
internal CultInspectorModel Model { get; }
internal string Key { get; }
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
@ -11,6 +12,7 @@ namespace GameCult.Unity.Caching.Editor
public sealed class CultCacheStudioWindow : EditorWindow
{
private const string LastPathKey = "GameCult.CultCacheStudio.LastPath";
private const float RowIndent = 14f;
private CultCache _cache;
private CultInspectorModel _model;
@ -23,6 +25,10 @@ namespace GameCult.Unity.Caching.Editor
private string _search = string.Empty;
private Type _selectedType;
private string _selectedKey;
private string _pressedKey;
private string _revealKey;
// Group foldouts by listed type and node id: two types grouped by the same values share node ids, not fold state.
private readonly Dictionary<(Type, string), bool> _groupFoldouts = new Dictionary<(Type, string), bool>();
private Vector2 _typeScroll;
private Vector2 _recordScroll;
private Vector2 _inspectorScroll;
@ -143,7 +149,7 @@ namespace GameCult.Unity.Caching.Editor
{
using (new EditorGUI.DisabledScope(cannotAdd || descriptor.IsGlobal))
{
if (GUILayout.Button("Add", EditorStyles.miniButtonLeft)) Add();
if (GUILayout.Button("Add", EditorStyles.miniButtonLeft)) Create(null);
}
using (new EditorGUI.DisabledScope(selected == null || selected.Descriptor.IsGlobal || ReadOnly))
@ -165,6 +171,8 @@ namespace GameCult.Unity.Caching.Editor
else
{
var records = _model.RecordCandidates(typeof(CultRecordRef<>).MakeGenericType(_selectedType), _records);
var grouping = _model.GroupingOf(_selectedType);
if (grouping.Notice != null) EditorGUILayout.HelpBox(grouping.Notice, MessageType.Warning);
if (descriptor.IsGlobal && records.Count == 0)
{
using (new EditorGUILayout.HorizontalScope())
@ -172,23 +180,100 @@ namespace GameCult.Unity.Caching.Editor
EditorGUILayout.LabelField("Global is absent.");
using (new EditorGUI.DisabledScope(cannotAdd))
{
if (GUILayout.Button("Create", GUILayout.Width(56))) Add();
if (GUILayout.Button("Create", GUILayout.Width(56))) Create(null);
}
}
}
foreach (var record in records)
// Nothing groups: the candidates are the list. Otherwise the model's tree is.
if (grouping.Members.Count == 0)
{
var label = CultInspectorModel.RecordLabel(record);
var text = record.Descriptor.DocumentType == _selectedType ? label : label + " <" + record.Descriptor.DocumentType.Name + ">";
if (GUILayout.Toggle(_selectedKey == record.Key.Value, text, EditorStyles.miniButton)) _selectedKey = record.Key.Value;
foreach (var record in records) DrawRecordRow(record, 0);
}
else
{
var groups = _model.GroupRecords(_selectedType, _records);
if (_revealKey != null) Reveal(groups, _revealKey);
DrawGroups(groups, cannotAdd);
}
_revealKey = null;
}
EditorGUILayout.EndScrollView();
}
}
private void DrawGroups(IReadOnlyList<CultInspectorRecordGroup> groups, bool cannotAdd)
{
foreach (var group in groups)
{
var id = (_selectedType, group.Id);
_groupFoldouts.TryGetValue(id, out var open);
EditorGUI.indentLevel = group.Depth;
open = EditorGUILayout.Foldout(open, group.Label + " (" + group.Count + ")", true);
EditorGUI.indentLevel = 0;
_groupFoldouts[id] = open;
if (!open) continue;
DrawGroups(group.Children, cannotAdd);
foreach (var record in group.Records) DrawRecordRow(record, group.Depth + 1);
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.Space((group.Depth + 1) * RowIndent);
using (new EditorGUI.DisabledScope(cannotAdd))
{
if (GUILayout.Button("Create", EditorStyles.miniButton, GUILayout.Width(56))) Create(group);
}
}
}
}
// Expands every node on the path to the record, so a record just made or duplicated is on screen.
private bool Reveal(IReadOnlyList<CultInspectorRecordGroup> groups, string key)
{
var found = false;
foreach (var group in groups)
{
if (!group.Records.Any(record => record.Key.Value == key) && !Reveal(group.Children, key)) continue;
_groupFoldouts[(_selectedType, group.Id)] = true;
found = true;
}
return found;
}
// A record row selects on click and starts a record drag. The drag starts from the reserved rect on the first drag
// event after a press inside it; the toggle still reads the click.
private void DrawRecordRow(CultStoredDocument record, int depth)
{
var label = CultInspectorModel.RecordLabel(record);
var text = record.Descriptor.DocumentType == _selectedType ? label : label + " <" + record.Descriptor.DocumentType.Name + ">";
var key = record.Key.Value;
var rect = GUILayoutUtility.GetRect(new GUIContent(text), EditorStyles.miniButton);
rect.xMin += depth * RowIndent;
var current = Event.current;
if (current.type == EventType.MouseDown && current.button == 0 && rect.Contains(current.mousePosition))
{
_pressedKey = key;
}
else if (current.type == EventType.MouseDrag && _pressedKey == key)
{
_pressedKey = null;
DragAndDrop.PrepareStartDrag();
DragAndDrop.SetGenericData(CultCacheStudioRecordDrag.DragKey, new CultCacheStudioRecordDrag(_model, key));
DragAndDrop.objectReferences = Array.Empty<UnityEngine.Object>();
DragAndDrop.StartDrag(label);
current.Use();
}
else if (current.type == EventType.MouseUp)
{
_pressedKey = null;
}
if (GUI.Toggle(rect, _selectedKey == key, text, EditorStyles.miniButton)) _selectedKey = key;
}
private void DrawInspector()
{
using (new EditorGUILayout.VerticalScope(GUILayout.ExpandWidth(true)))
@ -324,6 +409,9 @@ namespace GameCult.Unity.Caching.Editor
_edit = null;
_records = Array.Empty<CultStoredDocument>();
_selectedKey = null;
_pressedKey = null;
_revealKey = null;
_groupFoldouts.Clear();
}
private bool ConfirmDiscard()
@ -337,20 +425,31 @@ namespace GameCult.Unity.Caching.Editor
Run("Saved " + _path + ".", () => _cache.FlushAsync().GetAwaiter().GetResult());
}
private void Add()
// A new record of the selected type: at the root, or preset by the group it is created in.
private void Create(CultInspectorRecordGroup group)
{
var type = _selectedType;
// The model decides whether the type can be made; its notice is the refusal.
Run("Added " + type.Name + ".", () => _selectedKey = _cache.UpsertAsync(type,
_model.CreateElement(type, type, out var notice) ?? throw new InvalidOperationException(notice)).GetAwaiter().GetResult().Value);
Run("Added " + type.Name + ".", () =>
{
// The model decides whether the type can be made; its notice is the refusal.
var created = group == null ? _model.CreateElement(type, type, out var notice) : _model.CreateInGroup(type, group, out notice);
Focus(_cache.UpsertAsync(type, created ?? throw new InvalidOperationException(notice)).GetAwaiter().GetResult().Value);
});
GUIUtility.ExitGUI();
}
// Selects the record and opens the groups it sits in on the next frame.
private void Focus(string key)
{
_selectedKey = key;
_revealKey = key;
}
private void Duplicate(CultStoredDocument record)
{
var type = record.Descriptor.DocumentType;
Run("Duplicated " + CultInspectorModel.RecordLabel(record) + ".", () =>
_selectedKey = _cache.UpsertAsync(type, _model.Clone(record.Document, type)).GetAwaiter().GetResult().Value);
Focus(_cache.UpsertAsync(type, _model.Clone(record.Document, type)).GetAwaiter().GetResult().Value));
GUIUtility.ExitGUI();
}

View file

@ -33,6 +33,21 @@ fallback. `Add`, `Duplicate` and `Delete` act on the selected type and record.
A `[CultGlobal]` type with no record shows as absent with a `Create` action; the
Studio never creates one on its own.
A document class marked `[CultInspectorGroupBy(nameof(Kind), nameof(Owner))]`
lists its records as foldouts nested in that order, one per value in use; a
derived type listed under a marked base groups the same way. Each foldout has
a `Create` button that makes a new record with the grouped members preset to
that foldout's values, so it lands there. A declaration the model refuses (an
unknown, hidden, read-only, repeated or ungroupable member, or a global type)
lists the records flat under a warning that says why. A variant sits in the
foldout its resolved values name.
Drag a record row onto any record-reference value in the inspector (a member,
a list element, a dictionary key or value) to set it. The drop is accepted
exactly when the reference's picker would offer that record; it is refused
in a read-only store, for the record the reference already holds, and for a
record from another Studio window. The reference picker itself is not grouped.
Opening a store never writes it, directory stores included. Closing or
reloading scripts drops unsaved edits.
@ -57,6 +72,14 @@ leaves the cached object unchanged. The Studio is one lowering of that model
and owns only widgets and layout; a runtime CultUI panel needs only its own
renderer and drawers to show and edit the same documents under the same rules.
Grouping is the model's too: `GroupingOf` resolves a listed type's
`[CultInspectorGroupBy]` members (or the notice for a refused declaration),
`GroupRecords` returns the tree of nodes (id, label, depth, values, children,
records) that a lowering draws, and `CreateInGroup` makes the record for a
node. `IsRecordCandidate` is the one test for whether a record may be a
reference's value, shared by the picker's candidates and a drop, and
`RecordRefLabel` is the one spelling of `None`, a target's label and `Missing`.
## Drawers
Built in: every model shape above. A struct without `[Key]` members is edited

View file

@ -538,6 +538,57 @@ namespace GameCult.Caching.Tests
Assert.That(ids.Distinct().Count(), Is.EqualTo(12), "a path never spells another node's id");
}
[Test]
public async Task GroupNodeIdsSeparateAnEscapeFromTheTextItSpells()
{
// "a/b" and "a%2Fb" collide under an id that escapes '/' alone; the unpaired surrogates, U+FFFD and "a%uD800"
// are the values a replacement character or a sloppy %u form would merge; the valid pair is one node, not two halves.
var kinds = new[] { "a/b", "a%2Fb", "a%2fb", "a%252Fb", "a", "A", "\uD800", "\uD801", "\uDC00", "<22>", "a%uD800", "a\uD800", "😀", "<22>\uDE00", "\uDE00\uD83D", "a\uD800b\uD801c" };
using var cache = OpenGroups();
for (var i = 0; i < kinds.Length; i++)
await cache.UpsertAsync(typeof(GroupRedeclared), new GroupRedeclared { Kind = kinds[i] }, new CultRecordKey("k" + i));
var roots = GroupModel().GroupRecords(typeof(GroupRedeclared), cache.AllStoredDocuments);
Assert.That(roots, Has.Count.EqualTo(kinds.Length), "every distinct value is its own node, 'a' and 'A' included");
Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(kinds.Length), "and none spells another's id");
Assert.That(roots.Select(group => group.Label).Where(label => label == "a" || label == "A"), Is.EqualTo(new[] { "A", "a" }),
"labels equal ignoring case fall back to ordinal order");
}
[Test]
public async Task GroupRefsWhoseKeysDifferOnlyByCaseAreTwoNodes()
{
using var cache = OpenGroups();
await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "lower" }, new CultRecordKey("o-a"));
await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "upper" }, new CultRecordKey("O-A"));
await PutLeaf(cache, "r1", "one", "o-a", GroupHull.Cruiser);
await PutLeaf(cache, "r2", "two", "O-A", GroupHull.Cruiser);
var model = GroupModel();
var roots = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments);
Assert.That(roots.Select(group => group.Label), Is.EqualTo(new[] { "lower", "upper" }), "each key finds its own target");
Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(2));
Assert.That(model.RecordRefLabel(typeof(CultRecordRef<InspectOther>), OwnerRef("O-A"), cache.AllStoredDocuments), Is.EqualTo("upper"));
}
[Test]
public async Task GroupAnUngroupedListedTypeHasNoTreeAndItsCandidatesStayFlat()
{
using var cache = OpenGroups();
await cache.UpsertAsync(typeof(GroupGlobal), new GroupGlobal { Kind = "k" }, new CultRecordKey("global"));
var model = GroupModel();
var grouping = model.GroupingOf(typeof(GroupGlobal));
Assert.That(grouping.Members, Is.Empty);
Assert.That(grouping.Notice, Is.Not.Null, "a refused declaration still says why");
Assert.That(model.GroupRecords(typeof(GroupGlobal), cache.AllStoredDocuments), Is.Empty, "no tree: the lowering lists candidates flat");
Assert.That(model.RecordCandidates(typeof(CultRecordRef<GroupGlobal>), cache.AllStoredDocuments).Select(record => record.Key.Value),
Is.EqualTo(new[] { "global" }));
}
[Test]
public async Task GroupCreateInGroupRoundTripsIntoTheSameNode()
{