diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs new file mode 100644 index 0000000..6418b4e --- /dev/null +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceBuilder.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Mesh; + +#nullable enable + +namespace GameCult.Eve.Surface +{ + /// + /// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors. + /// + public sealed class EveSurfaceBuilder + { + private readonly string _surfaceId; + private readonly List _children = new(); + private readonly List _styles = new(); + private readonly List _commands = new(); + private long _version = 1; + private string _providerId = "gamecult"; + private string _providerKind = "cultui.surface"; + private string _title = ""; + private string _updatedAtUtc = ""; + + public EveSurfaceBuilder(string surfaceId) + { + _surfaceId = RequireNonEmpty(surfaceId, nameof(surfaceId)); + } + + public EveSurfaceBuilder Provider(string providerId, string providerKind) + { + _providerId = providerId ?? ""; + _providerKind = providerKind ?? ""; + return this; + } + + public EveSurfaceBuilder Title(string title) + { + _title = title ?? ""; + _children.Add(Text($"{_surfaceId}.title", _title, "text.title")); + return this; + } + + public EveSurfaceBuilder TitleSubtitle(string title, string subtitle) + { + _title = string.IsNullOrWhiteSpace(subtitle) ? title ?? "" : $"{title} {subtitle}"; + _children.Add(Text($"{_surfaceId}.title", title ?? "", "text.title")); + _children.Add(Text($"{_surfaceId}.subtitle", subtitle ?? "", "text.subtitle")); + return this; + } + + public EveSurfaceBuilder Text(string text, string? id = null) + { + _children.Add(Text(id ?? $"{_surfaceId}.text.{_children.Count}", text, "text")); + return this; + } + + public EveSurfaceBuilder Button(string label, string command) + { + var operation = CultMesh.OperationBinding(command, label); + _commands.Add(new EveCommandTemplate(operation)); + _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); + return this; + } + + public EveSurfaceBuilder Button(string label, CultMeshOperationBindingDescriptor operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _commands.Add(new EveCommandTemplate(operation)); + _children.Add(ButtonComponent($"{_surfaceId}.button.{Slug(label)}", label, operation)); + return this; + } + + public EveSurfaceBuilder ButtonColumn(string id, Action build) + { + return Group(id, "column", build); + } + + public EveSurfaceBuilder ButtonRow(string id, Action build) + { + return Group(id, "row", build); + } + + public EveSurfaceBuilder Form(string id, Action build) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var form = new EveSurfaceFormBuilder(id, AddCommand); + build(form); + _children.Add(form.Build()); + return this; + } + + public EveSurfaceBuilder EmbeddedDocument( + string slotId, + string documentId, + string schemaId, + string presentationKind, + CultMeshRouteHint? routeHint = null) + { + _children.Add(new EveSurfaceComponent( + $"{_surfaceId}.slot.{Slug(slotId)}", + "surface.slot", + new Dictionary(StringComparer.Ordinal) + { + ["slotId"] = slotId ?? "", + ["documentId"] = documentId ?? "", + ["schemaId"] = schemaId ?? "", + ["presentationKind"] = presentationKind ?? "" + }, + Array.Empty(), + Array.Empty(), + new[] + { + new EveEmbeddedDocumentSlot( + slotId ?? "", + documentId ?? "", + schemaId ?? "", + presentationKind ?? "", + routeHint) + })); + return this; + } + + public EveSurfaceBuilder Style(string name, string value) + { + _styles.Add(new EveStyleToken(name, value)); + return this; + } + + public EveSurfaceBuilder Version(long version) + { + _version = version; + return this; + } + + public EveSurfaceBuilder UpdatedAtUtc(string updatedAtUtc) + { + _updatedAtUtc = updatedAtUtc ?? ""; + return this; + } + + public EveSurfaceDocument Build() + { + return new EveSurfaceDocument( + _providerId, + _providerKind, + _title, + _version, + string.IsNullOrWhiteSpace(_updatedAtUtc) ? DateTime.UtcNow.ToString("O") : _updatedAtUtc, + new EveSurfaceTree( + _surfaceId, + new EveSurfaceComponent( + $"{_surfaceId}.root", + "surface", + EmptyProps(), + _children.ToArray()), + _styles.ToArray()), + _commands.ToArray()); + } + + private EveSurfaceBuilder Group(string id, string kind, Action build) + { + if (build == null) throw new ArgumentNullException(nameof(build)); + var group = new EveSurfaceGroupBuilder(id, kind, AddCommand); + build(group); + _children.Add(group.Build()); + return this; + } + + private void AddCommand(EveCommandTemplate command) + { + if (command != null) + _commands.Add(command); + } + + internal static EveSurfaceComponent Text(string id, string value, string kind) + { + return new EveSurfaceComponent( + id, + kind, + new Dictionary(StringComparer.Ordinal) + { + ["value"] = value ?? "" + }, + Array.Empty()); + } + + internal static EveSurfaceComponent ButtonComponent( + string id, + string label, + CultMeshOperationBindingDescriptor operation) + { + return new EveSurfaceComponent( + id, + "control.button", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = label ?? operation.Label, + ["command"] = operation.OperationId, + ["operationId"] = operation.OperationId, + ["schemaId"] = operation.SchemaId + }, + Array.Empty()); + } + + internal static Dictionary EmptyProps() + { + return new Dictionary(StringComparer.Ordinal); + } + + internal static string Slug(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return "unnamed"; + + var chars = value + .Select(character => char.IsLetterOrDigit(character) ? char.ToLowerInvariant(character) : '.') + .ToArray(); + var slug = new string(chars).Trim('.'); + while (slug.Contains("..", StringComparison.Ordinal)) + slug = slug.Replace("..", ".", StringComparison.Ordinal); + return string.IsNullOrWhiteSpace(slug) ? "unnamed" : slug; + } + + private static string RequireNonEmpty(string value, string paramName) + { + return string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException("Value must be non-empty.", paramName) + : value; + } + } + + public sealed class EveSurfaceGroupBuilder + { + private readonly string _id; + private readonly string _kind; + private readonly Action _addCommand; + private readonly List _children = new(); + + internal EveSurfaceGroupBuilder( + string id, + string kind, + Action addCommand) + { + _id = id ?? ""; + _kind = kind ?? "column"; + _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); + } + + public EveSurfaceGroupBuilder Button(string label, string command) + { + var operation = CultMesh.OperationBinding(command, label); + _addCommand(new EveCommandTemplate(operation)); + _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); + return this; + } + + public EveSurfaceGroupBuilder Button(string label, CultMeshOperationBindingDescriptor operation) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveCommandTemplate(operation)); + _children.Add(EveSurfaceBuilder.ButtonComponent($"{_id}.button.{EveSurfaceBuilder.Slug(label)}", label, operation)); + return this; + } + + internal EveSurfaceComponent Build() + { + return new EveSurfaceComponent(_id, _kind, EveSurfaceBuilder.EmptyProps(), _children.ToArray()); + } + } + + public sealed class EveSurfaceFormBuilder + { + private readonly string _id; + private readonly Action _addCommand; + private readonly List _children = new(); + + internal EveSurfaceFormBuilder(string id, Action addCommand) + { + _id = id ?? ""; + _addCommand = addCommand ?? throw new ArgumentNullException(nameof(addCommand)); + } + + public EveSurfaceFormBuilder Text( + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveCommandTemplate(operation)); + _children.Add(Control( + "control.text", + label, + value, + operation, + binding)); + return this; + } + + public EveSurfaceFormBuilder Toggle( + string label, + bool value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding = null) + { + if (operation == null) throw new ArgumentNullException(nameof(operation)); + _addCommand(new EveCommandTemplate(operation)); + _children.Add(Control( + "control.toggle", + label, + value ? "true" : "false", + operation, + binding)); + return this; + } + + public EveSurfaceFormBuilder Metric( + string label, + string value, + CultMeshStateBindingDescriptor? binding = null) + { + var bindings = binding == null + ? Array.Empty() + : new[] { binding }; + _children.Add(new EveSurfaceComponent( + $"{_id}.metric.{EveSurfaceBuilder.Slug(label)}", + "metric", + new Dictionary(StringComparer.Ordinal) + { + ["label"] = label ?? "", + ["value"] = value ?? "" + }, + Array.Empty(), + bindings)); + return this; + } + + internal EveSurfaceComponent Build() + { + return new EveSurfaceComponent(_id, "form", EveSurfaceBuilder.EmptyProps(), _children.ToArray()); + } + + private EveSurfaceComponent Control( + string kind, + string label, + string value, + CultMeshOperationBindingDescriptor operation, + CultMeshStateBindingDescriptor? binding) + { + var bindings = binding == null + ? Array.Empty() + : new[] { binding }; + return new EveSurfaceComponent( + $"{_id}.{EveSurfaceBuilder.Slug(label)}", + kind, + new Dictionary(StringComparer.Ordinal) + { + ["label"] = label ?? "", + ["value"] = value ?? "", + ["command"] = operation.OperationId, + ["operationId"] = operation.OperationId, + ["schemaId"] = operation.SchemaId + }, + Array.Empty(), + bindings); + } + } + + public static class EveSurface + { + public static EveSurfaceBuilder Create(string surfaceId) + { + return new EveSurfaceBuilder(surfaceId); + } + } +} diff --git a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs index bdfb19f..9a52ea9 100644 --- a/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs +++ b/packages/org.gamecult.eve.surface/Runtime/EveSurfaceDocument.cs @@ -1,12 +1,33 @@ using System; using System.Collections.Generic; +using GameCult.Caching; +using GameCult.Mesh; +using MessagePack; #nullable enable namespace GameCult.Eve.Surface { + [CultDocument("gamecult.eve.surface", "gamecult.eve.surface.v1")] + [MessagePackObject] public sealed class EveSurfaceDocument { + public const string DefaultType = "surface-state"; + public const string SchemaId = "gamecult.eve.surface.v1"; + + public EveSurfaceDocument( + string providerId, + string providerKind, + string title, + long version, + string updatedAtUtc, + EveSurfaceTree surface, + IReadOnlyList commands) + : this(DefaultType, SchemaId, providerId, providerKind, title, version, updatedAtUtc, surface, commands) + { + } + + [SerializationConstructor] public EveSurfaceDocument( string type, string schema, @@ -18,52 +39,67 @@ namespace GameCult.Eve.Surface EveSurfaceTree surface, IReadOnlyList commands) { - Type = type; - Schema = schema; - ProviderId = providerId; - ProviderKind = providerKind; - Title = title; + Type = string.IsNullOrWhiteSpace(type) ? DefaultType : type; + Schema = string.IsNullOrWhiteSpace(schema) ? SchemaId : schema; + ProviderId = providerId ?? ""; + ProviderKind = providerKind ?? ""; + Title = title ?? ""; Version = version; - UpdatedAtUtc = updatedAtUtc; - Surface = surface; - Commands = commands; + UpdatedAtUtc = updatedAtUtc ?? ""; + Surface = surface ?? throw new ArgumentNullException(nameof(surface)); + Commands = commands ?? Array.Empty(); } + [Key(0)] public string Type { get; } + [Key(1)] public string Schema { get; } + [Key(2)] public string ProviderId { get; } + [Key(3)] public string ProviderKind { get; } + [Key(4)] public string Title { get; } + [Key(5)] public long Version { get; } + [Key(6)] public string UpdatedAtUtc { get; } + [Key(7)] public EveSurfaceTree Surface { get; } + [Key(8)] public IReadOnlyList Commands { get; } } + [MessagePackObject] public sealed class EveSurfaceTree { + [SerializationConstructor] public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList styles) { - Id = id; - Root = root; - Styles = styles; + Id = id ?? ""; + Root = root ?? throw new ArgumentNullException(nameof(root)); + Styles = styles ?? Array.Empty(); } + [Key(0)] public string Id { get; } + [Key(1)] public EveSurfaceComponent Root { get; } + [Key(2)] public IReadOnlyList Styles { get; } } + [MessagePackObject] public sealed class EveSurfaceComponent { public EveSurfaceComponent( @@ -71,54 +107,156 @@ namespace GameCult.Eve.Surface string kind, IReadOnlyDictionary props, IReadOnlyList children) + : this(id, kind, props, children, StateBindingsFromProps(props)) { - Id = id; - Kind = kind; - Props = props; - Children = children; } + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children, + IReadOnlyList stateBindings) + : this(id, kind, props, children, stateBindings, Array.Empty()) + { + } + + [SerializationConstructor] + public EveSurfaceComponent( + string id, + string kind, + IReadOnlyDictionary props, + IReadOnlyList children, + IReadOnlyList stateBindings, + IReadOnlyList embeddedDocuments) + { + Id = id ?? ""; + Kind = kind ?? ""; + Props = props ?? new Dictionary(StringComparer.Ordinal); + Children = children ?? Array.Empty(); + StateBindings = stateBindings ?? Array.Empty(); + EmbeddedDocuments = embeddedDocuments ?? Array.Empty(); + } + + [Key(0)] public string Id { get; } + [Key(1)] public string Kind { get; } + [Key(2)] public IReadOnlyDictionary Props { get; } + [Key(3)] public IReadOnlyList Children { get; } + [Key(4)] + public IReadOnlyList StateBindings { get; } + + [Key(5)] + public IReadOnlyList EmbeddedDocuments { get; } + public string GetProp(string key, string fallback = "") { return Props.TryGetValue(key, out var value) ? value : fallback; } + + private static IReadOnlyList StateBindingsFromProps( + IReadOnlyDictionary? props) + { + if (props == null || props.Count == 0) + return Array.Empty(); + + var bindings = new List(); + foreach (var prop in props) + { + if (string.IsNullOrWhiteSpace(prop.Value) || + !prop.Key.EndsWith("PointerId", StringComparison.Ordinal)) + { + continue; + } + + var targetProp = prop.Key.Substring(0, prop.Key.Length - "PointerId".Length); + if (targetProp.Length == 0) + targetProp = "value"; + bindings.Add(new CultMeshStateBindingDescriptor(targetProp, prop.Value)); + } + + return bindings; + } } - public sealed class EveStyleToken + [MessagePackObject] + public sealed class EveEmbeddedDocumentSlot { - public EveStyleToken(string name, string value) + [SerializationConstructor] + public EveEmbeddedDocumentSlot( + string slotId, + string documentId, + string schemaId, + string presentationKind, + CultMeshRouteHint? routeHint = null) { - Name = name; - Value = value; + SlotId = slotId ?? ""; + DocumentId = documentId ?? ""; + SchemaId = schemaId ?? ""; + PresentationKind = presentationKind ?? ""; + RouteHint = routeHint ?? CultMeshRouteHint.Automatic; } + [Key(0)] + public string SlotId { get; } + + [Key(1)] + public string DocumentId { get; } + + [Key(2)] + public string SchemaId { get; } + + [Key(3)] + public string PresentationKind { get; } + + [Key(4)] + public CultMeshRouteHint RouteHint { get; } + } + + [MessagePackObject] + public sealed class EveStyleToken + { + [SerializationConstructor] + public EveStyleToken(string name, string value) + { + Name = name ?? ""; + Value = value ?? ""; + } + + [Key(0)] public string Name { get; } + [Key(1)] public string Value { get; } } + [MessagePackObject] public sealed class EveCommandTemplate { - public EveCommandTemplate(string command, string label, string transport) + [SerializationConstructor] + public EveCommandTemplate(CultMeshOperationBindingDescriptor operation) { - Command = command; - Label = label; - Transport = transport; + Operation = operation ?? throw new ArgumentNullException(nameof(operation)); } - public string Command { get; } + [Key(0)] + public CultMeshOperationBindingDescriptor Operation { get; } - public string Label { get; } + [IgnoreMember] + public string Command => Operation.OperationId; - public string Transport { get; } + [IgnoreMember] + public string Label => Operation.Label; + + [IgnoreMember] + public string Transport => Operation.RouteHint.Description ?? ""; } public sealed class EveSurfaceCommandRequest @@ -126,15 +264,15 @@ namespace GameCult.Eve.Surface public EveSurfaceCommandRequest( string providerId, string surfaceId, - string command, - IReadOnlyDictionary payload, + CultMeshOperationInvocationDescriptor operation, + CultMeshOperationPayload payload, DateTimeOffset issuedAt, string clientId) { ProviderId = providerId; SurfaceId = surfaceId; - Command = command; - Payload = payload; + Operation = operation ?? throw new ArgumentNullException(nameof(operation)); + Payload = payload ?? CultMeshOperationPayload.Empty; IssuedAt = issuedAt; ClientId = clientId; } @@ -143,9 +281,11 @@ namespace GameCult.Eve.Surface public string SurfaceId { get; } - public string Command { get; } + public CultMeshOperationInvocationDescriptor Operation { get; } - public IReadOnlyDictionary Payload { get; } + public string Command => Operation.OperationId; + + public CultMeshOperationPayload Payload { get; } public DateTimeOffset IssuedAt { get; } diff --git a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef index d8f56be..8041c88 100644 --- a/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef +++ b/packages/org.gamecult.eve.surface/Runtime/GameCult.Eve.Surface.asmdef @@ -1,12 +1,18 @@ { "name": "GameCult.Eve.Surface", "rootNamespace": "GameCult.Eve.Surface", - "references": [], + "references": [ + "GameCult.Mesh" + ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, - "overrideReferences": false, - "precompiledReferences": [], + "overrideReferences": true, + "precompiledReferences": [ + "GameCult.Caching.dll", + "GameCult.Mesh.dll", + "MessagePack.Annotations.dll" + ], "autoReferenced": true, "defineConstraints": [], "versionDefines": [], diff --git a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs index f73fc28..d28fb8f 100644 --- a/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs +++ b/packages/org.gamecult.eve.unity-uitoolkit/Runtime/EveUiToolkitSurfaceLowerer.cs @@ -42,6 +42,29 @@ namespace GameCult.Eve.UnityUIToolkit foreach (var child in component.Children) element.Add(LowerComponent(child, document, commandSink)); + foreach (var slot in component.EmbeddedDocuments) + { + var nested = LowerEmbeddedDocument(slot, commandSink); + if (nested != null) + element.Add(nested); + } + + return element; + } + + private VisualElement? LowerEmbeddedDocument( + EveEmbeddedDocumentSlot slot, + Action? commandSink) + { + var document = _options.EmbeddedDocumentResolver?.Invoke(slot); + if (document == null) + return null; + + var element = LowerComponent(document.Surface.Root, document, commandSink); + element.name = SafeName(string.IsNullOrWhiteSpace(slot.SlotId) ? document.Surface.Id : slot.SlotId); + element.AddToClassList("eve-embedded-document"); + element.AddToClassList($"eve-embedded-kind-{SafeClass(slot.PresentationKind)}"); + ApplyStyleTokens(element, document.Surface.Styles); return element; } @@ -129,7 +152,7 @@ namespace GameCult.Eve.UnityUIToolkit { ["value"] = evt.newValue ?? "" }; - EmitCommand(document, component, command, payload, commandSink); + EmitCommand(document, component, command, GameCult.Mesh.CultMesh.OperationPayload(payload), commandSink); }); return field; } @@ -155,7 +178,7 @@ namespace GameCult.Eve.UnityUIToolkit document, component, command, - new Dictionary(component.Props, StringComparer.Ordinal), + GameCult.Mesh.CultMesh.OperationPayload(component.Props), commandSink); } @@ -163,7 +186,7 @@ namespace GameCult.Eve.UnityUIToolkit EveSurfaceDocument document, EveSurfaceComponent component, string command, - IReadOnlyDictionary payload, + GameCult.Mesh.CultMeshOperationPayload payload, Action? commandSink) { if (commandSink == null || string.IsNullOrWhiteSpace(command)) @@ -172,12 +195,25 @@ namespace GameCult.Eve.UnityUIToolkit commandSink(new EveSurfaceCommandRequest( document.ProviderId, document.Surface.Id, - command, + ResolveOperation(document, command), payload, DateTimeOffset.UtcNow, "unity-uitoolkit")); } + private static GameCult.Mesh.CultMeshOperationInvocationDescriptor ResolveOperation( + EveSurfaceDocument document, + string command) + { + foreach (var template in document.Commands) + { + if (string.Equals(template.Command, command, StringComparison.Ordinal)) + return GameCult.Mesh.CultMesh.OperationInvocation(template.Operation); + } + + return GameCult.Mesh.CultMesh.OperationInvocation(command); + } + private static Label TitleLabel(string text) { var label = new Label(text);