Make Eve surface documents canonical

This commit is contained in:
Meta Vens 2026-07-01 15:26:48 +01:00
commit f909b274fa
4 changed files with 596 additions and 37 deletions

View file

@ -0,0 +1,377 @@
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Mesh;
#nullable enable
namespace GameCult.Eve.Surface
{
/// <summary>
/// Fluent CultUI/Eve surface builder. Eve owns the surface document; CultMesh only supplies typed binding descriptors.
/// </summary>
public sealed class EveSurfaceBuilder
{
private readonly string _surfaceId;
private readonly List<EveSurfaceComponent> _children = new();
private readonly List<EveStyleToken> _styles = new();
private readonly List<EveCommandTemplate> _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<EveSurfaceGroupBuilder> build)
{
return Group(id, "column", build);
}
public EveSurfaceBuilder ButtonRow(string id, Action<EveSurfaceGroupBuilder> build)
{
return Group(id, "row", build);
}
public EveSurfaceBuilder Form(string id, Action<EveSurfaceFormBuilder> 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<string, string>(StringComparer.Ordinal)
{
["slotId"] = slotId ?? "",
["documentId"] = documentId ?? "",
["schemaId"] = schemaId ?? "",
["presentationKind"] = presentationKind ?? ""
},
Array.Empty<EveSurfaceComponent>(),
Array.Empty<CultMeshStateBindingDescriptor>(),
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<EveSurfaceGroupBuilder> 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<string, string>(StringComparer.Ordinal)
{
["value"] = value ?? ""
},
Array.Empty<EveSurfaceComponent>());
}
internal static EveSurfaceComponent ButtonComponent(
string id,
string label,
CultMeshOperationBindingDescriptor operation)
{
return new EveSurfaceComponent(
id,
"control.button",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? operation.Label,
["command"] = operation.OperationId,
["operationId"] = operation.OperationId,
["schemaId"] = operation.SchemaId
},
Array.Empty<EveSurfaceComponent>());
}
internal static Dictionary<string, string> EmptyProps()
{
return new Dictionary<string, string>(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<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
internal EveSurfaceGroupBuilder(
string id,
string kind,
Action<EveCommandTemplate> 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<EveCommandTemplate> _addCommand;
private readonly List<EveSurfaceComponent> _children = new();
internal EveSurfaceFormBuilder(string id, Action<EveCommandTemplate> 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<CultMeshStateBindingDescriptor>()
: new[] { binding };
_children.Add(new EveSurfaceComponent(
$"{_id}.metric.{EveSurfaceBuilder.Slug(label)}",
"metric",
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? "",
["value"] = value ?? ""
},
Array.Empty<EveSurfaceComponent>(),
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<CultMeshStateBindingDescriptor>()
: new[] { binding };
return new EveSurfaceComponent(
$"{_id}.{EveSurfaceBuilder.Slug(label)}",
kind,
new Dictionary<string, string>(StringComparer.Ordinal)
{
["label"] = label ?? "",
["value"] = value ?? "",
["command"] = operation.OperationId,
["operationId"] = operation.OperationId,
["schemaId"] = operation.SchemaId
},
Array.Empty<EveSurfaceComponent>(),
bindings);
}
}
public static class EveSurface
{
public static EveSurfaceBuilder Create(string surfaceId)
{
return new EveSurfaceBuilder(surfaceId);
}
}
}

View file

@ -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<EveCommandTemplate> 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<EveCommandTemplate> 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<EveCommandTemplate>();
}
[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<EveCommandTemplate> Commands { get; }
}
[MessagePackObject]
public sealed class EveSurfaceTree
{
[SerializationConstructor]
public EveSurfaceTree(string id, EveSurfaceComponent root, IReadOnlyList<EveStyleToken> styles)
{
Id = id;
Root = root;
Styles = styles;
Id = id ?? "";
Root = root ?? throw new ArgumentNullException(nameof(root));
Styles = styles ?? Array.Empty<EveStyleToken>();
}
[Key(0)]
public string Id { get; }
[Key(1)]
public EveSurfaceComponent Root { get; }
[Key(2)]
public IReadOnlyList<EveStyleToken> Styles { get; }
}
[MessagePackObject]
public sealed class EveSurfaceComponent
{
public EveSurfaceComponent(
@ -71,54 +107,156 @@ namespace GameCult.Eve.Surface
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children)
: this(id, kind, props, children, StateBindingsFromProps(props))
{
Id = id;
Kind = kind;
Props = props;
Children = children;
}
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings)
: this(id, kind, props, children, stateBindings, Array.Empty<EveEmbeddedDocumentSlot>())
{
}
[SerializationConstructor]
public EveSurfaceComponent(
string id,
string kind,
IReadOnlyDictionary<string, string> props,
IReadOnlyList<EveSurfaceComponent> children,
IReadOnlyList<CultMeshStateBindingDescriptor> stateBindings,
IReadOnlyList<EveEmbeddedDocumentSlot> embeddedDocuments)
{
Id = id ?? "";
Kind = kind ?? "";
Props = props ?? new Dictionary<string, string>(StringComparer.Ordinal);
Children = children ?? Array.Empty<EveSurfaceComponent>();
StateBindings = stateBindings ?? Array.Empty<CultMeshStateBindingDescriptor>();
EmbeddedDocuments = embeddedDocuments ?? Array.Empty<EveEmbeddedDocumentSlot>();
}
[Key(0)]
public string Id { get; }
[Key(1)]
public string Kind { get; }
[Key(2)]
public IReadOnlyDictionary<string, string> Props { get; }
[Key(3)]
public IReadOnlyList<EveSurfaceComponent> Children { get; }
[Key(4)]
public IReadOnlyList<CultMeshStateBindingDescriptor> StateBindings { get; }
[Key(5)]
public IReadOnlyList<EveEmbeddedDocumentSlot> EmbeddedDocuments { get; }
public string GetProp(string key, string fallback = "")
{
return Props.TryGetValue(key, out var value) ? value : fallback;
}
private static IReadOnlyList<CultMeshStateBindingDescriptor> StateBindingsFromProps(
IReadOnlyDictionary<string, string>? props)
{
if (props == null || props.Count == 0)
return Array.Empty<CultMeshStateBindingDescriptor>();
var bindings = new List<CultMeshStateBindingDescriptor>();
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<string, string> 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<string, string> Payload { get; }
public string Command => Operation.OperationId;
public CultMeshOperationPayload Payload { get; }
public DateTimeOffset IssuedAt { get; }

View file

@ -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": [],

View file

@ -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<EveSurfaceCommandRequest>? 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<string, string>(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<string, string> payload,
GameCult.Mesh.CultMeshOperationPayload payload,
Action<EveSurfaceCommandRequest>? 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);