feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost
Adds the plugin-facing UI registration surface (Task 9, final D.2b task). Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are buffered in BufferedUiRegistry before the GL window opens, then drained into UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first- party vitals panel. Faulty plugin markup is isolated (try/catch per panel, logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
07bf6cbf60
commit
019350fa31
8 changed files with 102 additions and 4 deletions
|
|
@ -4,14 +4,16 @@ namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
public sealed class AppPluginHost : IPluginHost
|
public sealed class AppPluginHost : IPluginHost
|
||||||
{
|
{
|
||||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
|
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
|
||||||
{
|
{
|
||||||
Log = log;
|
Log = log;
|
||||||
State = state;
|
State = state;
|
||||||
Events = events;
|
Events = events;
|
||||||
|
Ui = ui;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IPluginLogger Log { get; }
|
public IPluginLogger Log { get; }
|
||||||
public IGameState State { get; }
|
public IGameState State { get; }
|
||||||
public IEvents Events { get; }
|
public IEvents Events { get; }
|
||||||
|
public IUiRegistry Ui { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/AcDream.App/Plugins/BufferedUiRegistry.cs
Normal file
27
src/AcDream.App/Plugins/BufferedUiRegistry.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
|
||||||
|
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
||||||
|
/// UiHost tree after construction.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BufferedUiRegistry : IUiRegistry
|
||||||
|
{
|
||||||
|
public readonly record struct Pending(string MarkupPath, object Binding);
|
||||||
|
|
||||||
|
private readonly List<Pending> _pending = new();
|
||||||
|
|
||||||
|
public void AddMarkupPanel(string markupPath, object binding)
|
||||||
|
=> _pending.Add(new Pending(markupPath, binding));
|
||||||
|
|
||||||
|
/// <summary>Return + clear all buffered registrations.</summary>
|
||||||
|
public IReadOnlyList<Pending> Drain()
|
||||||
|
{
|
||||||
|
var copy = _pending.ToArray();
|
||||||
|
_pending.Clear();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
||||||
|
|
||||||
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
|
||||||
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
|
||||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
|
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
||||||
|
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
|
||||||
|
|
||||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||||
|
|
@ -56,7 +57,7 @@ try
|
||||||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
|
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
|
||||||
window.Run();
|
window.Run();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
|
||||||
|
|
@ -614,6 +614,8 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||||
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
||||||
private AcDream.App.UI.UiHost? _uiHost;
|
private AcDream.App.UI.UiHost? _uiHost;
|
||||||
|
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||||
|
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||||
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
|
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
|
||||||
// _panelHost does. Self-subscribes to CombatState in its ctor, so
|
// _panelHost does. Self-subscribes to CombatState in its ctor, so
|
||||||
// disposing isn't required (panel host holds the only ref).
|
// disposing isn't required (panel host holds the only ref).
|
||||||
|
|
@ -864,12 +866,14 @@ public sealed class GameWindow : IDisposable
|
||||||
private int _liveAnimRejectSingleFrame;
|
private int _liveAnimRejectSingleFrame;
|
||||||
private int _liveAnimRejectPartFrames;
|
private int _liveAnimRejectPartFrames;
|
||||||
|
|
||||||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents)
|
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
|
||||||
|
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||||||
{
|
{
|
||||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||||
_datDir = options.DatDir;
|
_datDir = options.DatDir;
|
||||||
_worldGameState = worldGameState;
|
_worldGameState = worldGameState;
|
||||||
_worldEvents = worldEvents;
|
_worldEvents = worldEvents;
|
||||||
|
_uiRegistry = uiRegistry;
|
||||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||||
}
|
}
|
||||||
|
|
@ -1758,6 +1762,28 @@ public sealed class GameWindow : IDisposable
|
||||||
var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
|
var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
|
||||||
_uiHost.Root.AddChild(panel);
|
_uiHost.Root.AddChild(panel);
|
||||||
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
|
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
|
||||||
|
|
||||||
|
// Drain plugin-registered markup panels (buffered before the GL
|
||||||
|
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||||||
|
// file is isolated — logged + skipped, never crashes the client.
|
||||||
|
if (_uiRegistry is not null)
|
||||||
|
{
|
||||||
|
foreach (var p in _uiRegistry.Drain())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
|
||||||
|
var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
|
||||||
|
pluginXml, p.Binding, ResolveChrome, controls);
|
||||||
|
_uiHost.Root.AddChild(pluginPanel);
|
||||||
|
Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||||
|
|
|
||||||
|
|
@ -10,4 +10,5 @@ public interface IPluginHost
|
||||||
IPluginLogger Log { get; }
|
IPluginLogger Log { get; }
|
||||||
IGameState State { get; }
|
IGameState State { get; }
|
||||||
IEvents Events { get; }
|
IEvents Events { get; }
|
||||||
|
IUiRegistry Ui { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
src/AcDream.Plugin.Abstractions/IUiRegistry.cs
Normal file
14
src/AcDream.Plugin.Abstractions/IUiRegistry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
||||||
|
/// a binding object exposing the data properties the markup binds to, and
|
||||||
|
/// registers it from <c>Enable()</c>. Calls made before the GL window opens are
|
||||||
|
/// buffered and drained once the UI host exists.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUiRegistry
|
||||||
|
{
|
||||||
|
/// <param name="markupPath">Absolute path to the plugin's panel markup file.</param>
|
||||||
|
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
||||||
|
void AddMarkupPanel(string markupPath, object binding);
|
||||||
|
}
|
||||||
21
tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Normal file
21
tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using AcDream.App.Plugins;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Plugins;
|
||||||
|
|
||||||
|
public class BufferedUiRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Drain_YieldsBufferedRegistrationsOnceThenEmpty()
|
||||||
|
{
|
||||||
|
var reg = new BufferedUiRegistry();
|
||||||
|
reg.AddMarkupPanel("a.xml", new object());
|
||||||
|
reg.AddMarkupPanel("b.xml", new object());
|
||||||
|
|
||||||
|
var drained = reg.Drain();
|
||||||
|
Assert.Equal(2, drained.Count);
|
||||||
|
Assert.Equal("a.xml", drained[0].MarkupPath);
|
||||||
|
Assert.Equal("b.xml", drained[1].MarkupPath);
|
||||||
|
|
||||||
|
Assert.Empty(reg.Drain()); // consumed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,12 @@ public class PluginLoaderTests
|
||||||
public IPluginLogger Log { get; } = new StubLogger();
|
public IPluginLogger Log { get; } = new StubLogger();
|
||||||
public IGameState State { get; } = new StubState();
|
public IGameState State { get; } = new StubState();
|
||||||
public IEvents Events { get; } = new StubEvents();
|
public IEvents Events { get; } = new StubEvents();
|
||||||
|
public IUiRegistry Ui { get; } = new StubUiRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubUiRegistry : IUiRegistry
|
||||||
|
{
|
||||||
|
public void AddMarkupPanel(string markupPath, object binding) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubLogger : IPluginLogger
|
private sealed class StubLogger : IPluginLogger
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue