diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index efb602d3..7aa6ab74 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -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 members, IReadOnlyList 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 diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs index 8f008f24..14bb10e4 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioDrawers.cs @@ -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 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; } + } } diff --git a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs index 6c9fedf8..20199d60 100644 --- a/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs +++ b/src/GameCult.Unity/Assets/Caching/Editor/CultCacheStudioWindow.cs @@ -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 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 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(); + 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(); _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(); } diff --git a/src/GameCult.Unity/Assets/Caching/README.md b/src/GameCult.Unity/Assets/Caching/README.md index 7f1d7ab6..32a1fa69 100644 --- a/src/GameCult.Unity/Assets/Caching/README.md +++ b/src/GameCult.Unity/Assets/Caching/README.md @@ -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 diff --git a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs index cddcb626..4ab5c680 100644 --- a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs +++ b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs @@ -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", "�", "a%uD800", "a\uD800", "😀", "�\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), 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), cache.AllStoredDocuments).Select(record => record.Key.Value), + Is.EqualTo(new[] { "global" })); + } + [Test] public async Task GroupCreateInGroupRoundTripsIntoTheSameNode() {