Lower live Eve surfaces without rebuilding UI state

This commit is contained in:
Meta Vens 2026-07-20 04:49:45 +01:00
commit 4439054977
5 changed files with 261 additions and 7 deletions

View file

@ -46,6 +46,9 @@ namespace GameCult.Eve.UnityUIToolkit
element.userData = component;
ApplyGeneratedLayout(element, component);
if (IsHidden(component) || IsExternalProjectionRoot(component.Kind))
return element;
foreach (var child in component.Children)
{
var loweredChild = LowerComponent(child, document, commandSink);
@ -129,7 +132,11 @@ namespace GameCult.Eve.UnityUIToolkit
card.style.flexDirection = FlexDirection.Column;
var title = component.GetProp("title");
if (!string.IsNullOrWhiteSpace(title))
card.Add(TitleLabel(title));
{
var titleLabel = TitleLabel(title);
titleLabel.name = "title";
card.Add(titleLabel);
}
return card;
}
case "metric":
@ -137,8 +144,12 @@ namespace GameCult.Eve.UnityUIToolkit
var metric = new VisualElement();
metric.AddToClassList("eve-metric");
metric.style.flexDirection = FlexDirection.Column;
metric.Add(MutedLabel(component.GetProp("label")));
metric.Add(ValueLabel(component.GetProp("value")));
var label = MutedLabel(component.GetProp("label"));
label.name = "label";
metric.Add(label);
var value = ValueLabel(component.GetProp("value"));
value.name = "value";
metric.Add(value);
return metric;
}
case "progress":
@ -741,6 +752,21 @@ namespace GameCult.Eve.UnityUIToolkit
return kind;
}
internal static bool IsHidden(EveSurfaceComponent component) =>
component.Layout != null &&
component.Layout.TryGetValue("display", out var display) &&
string.Equals(display, "none", StringComparison.OrdinalIgnoreCase);
internal static bool IsExternalProjectionRoot(string kind)
{
kind = NormalizeKind(kind);
return string.Equals(kind, "world.scene3d", StringComparison.Ordinal) ||
string.Equals(kind, "world.scene2d", StringComparison.Ordinal) ||
string.Equals(kind, "field.volume3d", StringComparison.Ordinal) ||
string.Equals(kind, "field.particles3d", StringComparison.Ordinal) ||
string.Equals(kind, "layer.reactive", StringComparison.Ordinal);
}
private static string SafeName(string value)
{
return string.IsNullOrWhiteSpace(value) ? "eve.component" : value;

View file

@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using GameCult.Eve.Surface;
using UnityEngine.UIElements;
#nullable enable
namespace GameCult.Eve.UnityUIToolkit
{
public sealed class EveUiToolkitSurfacePresenter
{
private readonly EveUiToolkitSurfaceLowerer _lowerer;
private string _structure = "";
public EveUiToolkitSurfacePresenter(EveUiToolkitSurfaceOptions? options = null)
{
_lowerer = new EveUiToolkitSurfaceLowerer(options);
}
public VisualElement? Root { get; private set; }
public bool Present(
EveSurfaceDocument document,
Action<EveSurfaceCommandRequest>? commandSink = null)
{
if (document == null) throw new ArgumentNullException(nameof(document));
var structure = StructureOf(document);
if (Root == null || !string.Equals(_structure, structure, StringComparison.Ordinal))
{
Root = _lowerer.Lower(document, commandSink);
_structure = structure;
return true;
}
var elements = Root.Query<VisualElement>()
.ToList()
.Where(element => element.userData is EveSurfaceComponent)
.ToDictionary(element => ((EveSurfaceComponent)element.userData).Id, StringComparer.Ordinal);
if (Root.userData is EveSurfaceComponent rootComponent)
elements[rootComponent.Id] = Root;
ApplyDynamic(document.Surface.Root, elements);
return false;
}
private static void ApplyDynamic(
EveSurfaceComponent component,
IReadOnlyDictionary<string, VisualElement> elements)
{
if (!elements.TryGetValue(component.Id, out var element))
return;
element.userData = component;
switch (NormalizeKind(component.Kind))
{
case "pane":
case "modal":
case "card":
var title = element.Q<Label>("title");
if (title != null) title.text = component.GetProp("title");
break;
case "metric":
var label = element.Q<Label>("label");
var value = element.Q<Label>("value");
if (label != null) label.text = component.GetProp("label");
if (value != null) value.text = component.GetProp("value");
break;
case "progress":
if (element is ProgressBar progress)
{
progress.title = component.GetProp("label");
progress.value = ParseRatio(component.GetProp("ratio", component.GetProp("value")));
}
break;
case "text":
if (element is Label text)
text.text = component.GetProp("text", component.GetProp("value", component.GetProp("title")));
break;
}
if (EveUiToolkitSurfaceLowerer.IsHidden(component) ||
EveUiToolkitSurfaceLowerer.IsExternalProjectionRoot(component.Kind))
return;
foreach (var child in component.Children)
ApplyDynamic(child, elements);
}
private static string StructureOf(EveSurfaceDocument document)
{
var value = new StringBuilder();
value.Append(document.ProviderId).Append('\n')
.Append(document.Surface.Id).Append('\n');
AppendStructure(value, document.Surface.Root);
foreach (var command in document.Commands)
value.Append("command\t").Append(command.Command).Append('\t').Append(command.Operation).Append('\n');
return value.ToString();
}
private static void AppendStructure(StringBuilder value, EveSurfaceComponent component)
{
var kind = NormalizeKind(component.Kind);
value.Append(component.Id).Append('\t').Append(kind).Append('\t');
foreach (var entry in component.Layout.OrderBy(entry => entry.Key, StringComparer.Ordinal))
value.Append("l:").Append(entry.Key).Append('=').Append(entry.Value).Append(';');
foreach (var entry in component.Style.OrderBy(entry => entry.Key, StringComparer.Ordinal))
value.Append("s:").Append(entry.Key).Append('=').Append(entry.Value).Append(';');
foreach (var entry in component.Props.OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
if (IsDynamicProp(kind, entry.Key)) continue;
value.Append("p:").Append(entry.Key).Append('=').Append(entry.Value).Append(';');
}
value.Append('\n');
if (EveUiToolkitSurfaceLowerer.IsHidden(component) ||
EveUiToolkitSurfaceLowerer.IsExternalProjectionRoot(component.Kind))
return;
foreach (var child in component.Children)
AppendStructure(value, child);
}
private static bool IsDynamicProp(string kind, string key)
{
if (kind == "metric") return key == "label" || key == "value";
if (kind == "progress") return key == "label" || key == "ratio" || key == "value";
if (kind == "text") return key == "text" || key == "value" || key == "title";
if (kind == "pane" || kind == "modal" || kind == "card")
return key == "title";
if (string.IsNullOrWhiteSpace(kind) || kind.StartsWith("world.", StringComparison.Ordinal) ||
kind.StartsWith("entity.", StringComparison.Ordinal))
return true;
return false;
}
private static string NormalizeKind(string kind)
{
if (string.IsNullOrWhiteSpace(kind)) return "";
if (kind == "panel.dialogue") return "panel";
if (kind.StartsWith("text.", StringComparison.Ordinal)) return "text";
if (kind.StartsWith("control.button.", StringComparison.Ordinal)) return "control.button";
if (kind.StartsWith("control.text.", StringComparison.Ordinal)) return "control.text";
return kind;
}
private static float ParseRatio(string value) =>
float.TryParse(
value,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var ratio)
? UnityEngine.Mathf.Clamp01(ratio)
: 0f;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6f8fa58aaf9a4c39a88263d18fcb75a1

View file

@ -142,6 +142,50 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
Assert.That(root.style.bottom.value.value, Is.EqualTo(32f));
}
[Test]
public void PresenterUpdatesCockpitValuesWithoutRebuildingTheSurface()
{
var presenter = new EveUiToolkitSurfacePresenter();
var first = CockpitDocument("0.25", "301.5");
Assert.That(presenter.Present(first), Is.True);
var root = presenter.Root;
var hull = root!.Q<ProgressBar>("hull");
var temperature = root.Q<VisualElement>("temperature").Q<Label>("value");
Assert.That(hull.value, Is.EqualTo(0.25f));
Assert.That(temperature.text, Is.EqualTo("301.5"));
Assert.That(root.Q<VisualElement>("world-metric"), Is.Null,
"Scene projection subtrees must not become empty UI hierarchies.");
Assert.That(presenter.Present(CockpitDocument("0.75", "318")), Is.False);
Assert.That(presenter.Root, Is.SameAs(root));
Assert.That(hull.value, Is.EqualTo(0.75f));
Assert.That(temperature.text, Is.EqualTo("318"));
}
[Test]
public void PresenterRebuildsWhenProviderUiStructureChanges()
{
var presenter = new EveUiToolkitSurfacePresenter();
presenter.Present(CockpitDocument("0.25", "301.5"));
var firstRoot = presenter.Root;
var changedRoot = Component(
"root",
"surface",
children: new[]
{
Component("status", "metric", new Dictionary<string, string>
{
["label"] = "Status",
["value"] = "Docked"
})
});
Assert.That(presenter.Present(Document(changedRoot)), Is.True);
Assert.That(presenter.Root, Is.Not.SameAs(firstRoot));
Assert.That(presenter.Root!.Q<VisualElement>("status"), Is.Not.Null);
}
[Test]
public void InventoryDropBuildsAdvertisedTypedOperationPayload()
{
@ -225,16 +269,45 @@ namespace GameCult.Eve.UnityUIToolkit.Tests
Array.Empty<EveCommandTemplate>());
}
private static EveSurfaceDocument CockpitDocument(string hull, string temperature)
{
var worldMetric = Component("world-metric", "metric", new Dictionary<string, string>
{
["label"] = "Should not mount",
["value"] = "hidden with scene"
});
var world = Component("world", "world.scene3d", children: new[] { worldMetric });
var cockpit = Component(
"cockpit",
"pane",
new Dictionary<string, string> { ["title"] = "Pilot" },
new[]
{
Component("hull", "progress", new Dictionary<string, string>
{
["label"] = "Hull",
["ratio"] = hull
}),
Component("temperature", "metric", new Dictionary<string, string>
{
["label"] = "Cockpit",
["value"] = temperature
})
});
return Document(Component("root", "surface", children: new[] { world, cockpit }));
}
private static EveSurfaceComponent Component(
string id,
string kind,
IReadOnlyDictionary<string, string>? props = null)
IReadOnlyDictionary<string, string>? props = null,
IReadOnlyList<EveSurfaceComponent>? children = null)
{
return new EveSurfaceComponent(
id,
kind,
props ?? EmptyProps(),
Array.Empty<EveSurfaceComponent>());
children ?? Array.Empty<EveSurfaceComponent>());
}
private static IReadOnlyDictionary<string, string> EmptyProps()

View file

@ -1,11 +1,11 @@
{
"name": "org.gamecult.eve.unity-uitoolkit",
"displayName": "Eve UI Toolkit Lowering",
"version": "0.1.1",
"version": "0.1.2",
"unity": "2021.3",
"description": "Unity UI Toolkit lowering for gamecult.eve.surface.v1 documents.",
"dependencies": {
"org.gamecult.eve.surface": "0.1.0"
"org.gamecult.eve.surface": "0.2.4"
},
"keywords": [
"Eve",