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:
commit
acdf36c81e
5 changed files with 247 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue