From 9d233853a137bcce27d2918ac9d60743d3cf0861 Mon Sep 17 00:00:00 2001 From: David Smith Date: Wed, 7 Oct 2026 21:00:10 +0200 Subject: [PATCH] feat(plugins): canvases can read the plugin theme A canvas paints its own pixels, so it was never told which plugin theme the player picked under Plugin appearance and could only draw one fixed look. IUiRegistry now carries the selected theme (kind, picker name and palette as PluginColors) and raises ThemeChanged on the plugin callback thread when the player picks another. The graphical host follows PluginUiThemeSettings through BufferedUiRegistry.FollowTheme, so plugins read the saved theme from the moment the interface comes up. The scoped registry both hosts share forwards the theme and drops a plugin's handlers with the plugin. The windowless host answers Classic and never raises a change. Additive with inert defaults: a host that predates this answers Classic. Claude-Session: https://claude.ai/code/session_01SY57mHJLZ6djpWZ5nP37Tw --- docs/plugin-api.md | 45 ++++++ docs/plugin-ui-markup.md | 8 + src/AcDream.App/Plugins/BufferedUiRegistry.cs | 86 ++++++++++ src/AcDream.App/UI/PluginAppearanceBinding.cs | 2 +- src/AcDream.App/UI/PluginUiThemeSettings.cs | 23 +++ src/AcDream.App/UI/RetailUiRuntime.cs | 11 +- src/AcDream.Core/Plugins/ScopedPluginHost.cs | 53 ++++++ .../IUiRegistry.cs | 20 +++ .../PluginTheme.cs | 45 ++++++ .../Plugins/BufferedUiRegistryThemeTests.cs | 153 ++++++++++++++++++ .../Plugins/ScopedUiRegistryThemeTests.cs | 132 +++++++++++++++ .../HeadlessPluginApiSurfaceTests.cs | 19 +++ .../PluginThemeContractTests.cs | 57 +++++++ 13 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 src/AcDream.Plugin.Abstractions/PluginTheme.cs create mode 100644 tests/AcDream.App.Tests/Plugins/BufferedUiRegistryThemeTests.cs create mode 100644 tests/AcDream.Core.Tests/Plugins/ScopedUiRegistryThemeTests.cs create mode 100644 tests/AcDream.Plugin.Tests/PluginThemeContractTests.cs diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 2b84dc00..4fe67825 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -1300,6 +1300,51 @@ call. Without a window, or before the client's interface is up, images are dropped when the interface is torn down (for example on a reconnect), after which the plugin asks again. +## Plugin theme + +The player picks **Classic**, **Charcoal + moss** or **Warm graphite + +brass** under Plugin appearance. The client restyles markup windows that +opt in (see the [markup guide](plugin-ui-markup.md)) itself, but a canvas +paints its own pixels, so it reads the choice and repaints when it changes: + +```csharp +PluginUiThemeInfo theme = host.Ui.Theme; + +host.Ui.ThemeChanged += next => +{ + theme = next; + canvas.Invalidate(); +}; + +// in a paint callback: +if (theme.Palette is { } p) +{ + painter.FillRect(new PluginRect(0, 0, painter.Width, painter.Height), p.Background); + painter.DrawText("Golem", new PluginPoint(8, 6), p.Text); +} +else +{ + // Classic: the plugin's own look. +} +``` + +`Theme.Kind` says which theme is selected and `Theme.DisplayName` what the +picker calls it. `Theme.Palette` holds the seven colours themed windows draw +in -- `Background`, `Field`, `Border`, `Text`, `Muted`, `Accent` and +`Selected` -- and is null under Classic, where themed windows draw +unthemed. + +`ThemeChanged` is raised on the tick thread, once for each change, with the +new theme; reading `Theme` inside the handler gives the same value. A +handler that throws is logged and does not stop the next plugin from +hearing. The plugin's handlers are removed when it is unloaded. The client +keeps the choice across a reconnect, and it is the saved choice from the +moment the interface comes up; before that, `Theme` answers Classic, and a +plugin loaded early hears the saved theme arrive through `ThemeChanged`. + +Without a window, and on a host that predates themes, `Theme` answers +`PluginUiThemeInfo.Classic` and `ThemeChanged` is never raised. + ## Canvases A canvas is a rectangle the plugin paints, shown over the world and under diff --git a/docs/plugin-ui-markup.md b/docs/plugin-ui-markup.md index 01805ec8..43e3cf3a 100644 --- a/docs/plugin-ui-markup.md +++ b/docs/plugin-ui-markup.md @@ -58,6 +58,10 @@ original Classic fallback, for example `color="theme:text|#FFE8DEC3"` or `background="theme:field|#FF0C0906"`. Tokens are `text`, `muted`, `field`, `border`, `accent`, and `background`; the fallback uses `#AARRGGBB`. +Canvases are not restyled: a plugin reads the theme and its palette from +`host.Ui.Theme` and repaints on `host.Ui.ThemeChanged`. See "Plugin theme" +in the plugin API guide. + Set `searchable="true"` on a menu to add an editable search band. Each typed word must match its label, ignoring case. Filtering does not select an item; click a result or press Enter to select it, use arrows to navigate, or Escape @@ -400,6 +404,10 @@ and canvases (see the plugin API guide) are covered by under `tests/AcDream.App.Tests/`, by `ScopedUiRegistryImagesTests` and `ScopedUiRegistryCanvasTests` for the scoped forwarder, and by the contract and headless suites for the inert answers a host without a window gives. +The theme plugins read is covered by `BufferedUiRegistryThemeTests` under +`tests/AcDream.App.Tests/`, by `ScopedUiRegistryThemeTests` for the scoped +forwarder, and by `PluginThemeContractTests` and the headless suite for the +Classic answer. ## Scrolling transcripts diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs index 9d58bd55..131d9250 100644 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ b/src/AcDream.App/Plugins/BufferedUiRegistry.cs @@ -200,6 +200,92 @@ internal void UnbindImageServices() return _images.GetValueOrDefault(owner.Id); } + // The plugin theme, told to the registry by the interface: the one the + // player saved when the interface comes up, then each one they pick. It + // outlives the interface, so a reconnect keeps answering the same theme. + private PluginUiThemeInfo _theme = PluginUiThemeInfo.Classic; + private Action? _themeChanged; + + public PluginUiThemeInfo Theme + { + get + { + lock (_gate) + return _theme; + } + } + + public event Action ThemeChanged + { + add + { + ArgumentNullException.ThrowIfNull(value); + lock (_gate) + _themeChanged += value; + } + remove + { + lock (_gate) + _themeChanged -= value; + } + } + + /// + /// Publishes the theme the player saved and then every one they pick, + /// until the returned token is disposed. The interface calls this once it + /// has its appearance settings, and disposes the token when it goes. + /// + internal IDisposable FollowTheme(PluginUiThemeSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + Action publish = () => PublishTheme(settings.ThemeInfo); + publish(); + settings.ThemeChanged += publish; + return new ThemeFollow(settings, publish); + } + + private sealed class ThemeFollow(PluginUiThemeSettings settings, Action publish) : IDisposable + { + private Action? _publish = publish; + + public void Dispose() + { + if (Interlocked.Exchange(ref _publish, null) is { } held) + settings.ThemeChanged -= held; + } + } + + /// + /// Takes the selected theme and, when it differs from the last one, tells + /// every plugin on the calling thread, the interface thread. One plugin's + /// handler throwing does not stop the next from hearing. + /// + internal void PublishTheme(PluginUiThemeInfo theme) + { + ArgumentNullException.ThrowIfNull(theme); + Action? handlers; + lock (_gate) + { + if (_theme == theme) + return; + _theme = theme; + handlers = _themeChanged; + } + if (handlers is null) + return; + foreach (Delegate handler in handlers.GetInvocationList()) + { + try + { + ((Action)handler)(theme); + } + catch (Exception error) + { + Serilog.Log.Warning(error, "A plugin's theme handler threw"); + } + } + } + // Canvases follow the same path as windows: registered at any time, // drained by the interface once, taken down through the registration // when the plugin disposes it, and handed back to be mounted again when diff --git a/src/AcDream.App/UI/PluginAppearanceBinding.cs b/src/AcDream.App/UI/PluginAppearanceBinding.cs index 24f7ede5..7b97dd3d 100644 --- a/src/AcDream.App/UI/PluginAppearanceBinding.cs +++ b/src/AcDream.App/UI/PluginAppearanceBinding.cs @@ -2,7 +2,7 @@ namespace AcDream.App.UI; public sealed class PluginAppearanceBinding(PluginUiThemeSettings settings) { - public IReadOnlyList Themes { get; } = ["Classic", "Charcoal + moss", "Warm graphite + brass"]; + public IReadOnlyList Themes { get; } = PluginUiThemeSettings.ThemeNames; public string Selected => Themes[(int)settings.Theme]; public Action Select => name => { diff --git a/src/AcDream.App/UI/PluginUiThemeSettings.cs b/src/AcDream.App/UI/PluginUiThemeSettings.cs index eafd9c04..62bcf852 100644 --- a/src/AcDream.App/UI/PluginUiThemeSettings.cs +++ b/src/AcDream.App/UI/PluginUiThemeSettings.cs @@ -1,4 +1,5 @@ using System.Numerics; +using AcDream.Plugin.Abstractions; using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.UI; @@ -27,8 +28,20 @@ public PluginUiTheme Theme if (_theme == value) return; _store?.SavePluginUiTheme(value.ToString()); _theme = value; + ThemeChanged?.Invoke(); } } + + /// Raised from the setter when the player picks another theme. + public event Action? ThemeChanged; + + /// What the Appearance picker calls each theme, in order. + internal static IReadOnlyList ThemeNames { get; } = ["Classic", "Charcoal + moss", "Warm graphite + brass"]; + + /// The selected theme as plugins read it: its kind, name and colours. + public PluginUiThemeInfo ThemeInfo => Theme == PluginUiTheme.Classic + ? PluginUiThemeInfo.Classic + : new PluginUiThemeInfo((PluginUiThemeKind)Theme, ThemeNames[(int)Theme], Palette?.ToPluginPalette()); public PluginUiPalette? Palette => Theme switch { PluginUiTheme.Moss => PluginUiPalette.Moss, @@ -46,6 +59,16 @@ public sealed record PluginUiPalette(Vector4 Background, Vector4 Field, Vector4 C(0x35443B), C(0xE0E8E2), C(0x9AA99E), C(0x97BE81), C(0x344B37)); public static PluginUiPalette Brass { get; } = new(C(0x221F1B), C(0x181612), C(0x4C4335), C(0xE9E2D5), C(0xB2A58E), C(0xC9A665), C(0x51442D)); + /// The palette as plugins read it, each channel rounded to a byte. + public PluginThemePalette ToPluginPalette() => new( + ToColor(Background), ToColor(Field), ToColor(Border), + ToColor(Text), ToColor(Muted), ToColor(Accent), ToColor(Selected)); + + private static PluginColor ToColor(Vector4 c) => new(Byte(c.X), Byte(c.Y), Byte(c.Z), Byte(c.W)); + + private static byte Byte(float channel) => + (byte)MathF.Round(Math.Clamp(channel, 0f, 1f) * 255f, MidpointRounding.AwayFromZero); + public Vector4 Token(string name) => name switch { "text" => Text, "muted" => Muted, "field" => Field, diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 5f9abbd6..a01c4ba1 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -404,6 +404,7 @@ private string ResolveAppropriateItemName(ClientObject obj) private CharacterCreationUiMountCoordinator? _characterCreationMount; private PluginSidePanel? _pluginSidePanel; private PluginUiThemeSettings? _pluginThemes; + private IDisposable? _pluginThemeFollow; private UiNineSlicePanel? _pluginAppearance; private readonly Dictionary _pluginIcons = []; private bool _pluginsMounted; @@ -4064,7 +4065,13 @@ private void MountPlugins() { if (_bindings.Plugins is null) return; - _pluginThemes ??= new PluginUiThemeSettings(_bindings.Chat.Store, _bindings.Assets.ModernFont); + if (_pluginThemes is null) + { + _pluginThemes = new PluginUiThemeSettings(_bindings.Chat.Store, _bindings.Assets.ModernFont); + // Canvases paint their own pixels, so plugins are told the theme + // the player saved and every one they pick after it. + _pluginThemeFollow = _bindings.Plugins.FollowTheme(_pluginThemes); + } IMarkupIconResolver iconResolver = new RetailMarkupIconResolver( _bindings.Assets.Dats, @@ -5148,6 +5155,8 @@ public void Dispose() if (SalvageController is { } salvage) _bindings.Inventory.ItemInteraction.PolicyActionRequested -= salvage.HandlePolicyAction; _bindings.Plugins?.UnbindClientWindowControl(); + _pluginThemeFollow?.Dispose(); + _pluginThemeFollow = null; // Canvases come down before their images: a canvas may be // showing one. Both hand the plugins' handles back to the // registry for the next interface to mount again. diff --git a/src/AcDream.Core/Plugins/ScopedPluginHost.cs b/src/AcDream.Core/Plugins/ScopedPluginHost.cs index 0c203ca4..3c24843c 100644 --- a/src/AcDream.Core/Plugins/ScopedPluginHost.cs +++ b/src/AcDream.Core/Plugins/ScopedPluginHost.cs @@ -2075,6 +2075,58 @@ public IPluginImages Images } } + public PluginUiThemeInfo Theme => _inner.Theme; + + // Each handler is added to the host's event behind a guard that drops + // calls once the plugin is gone, and its removal is tracked like any + // other registration, so an unloaded plugin is never told of a theme. + private readonly List<(Action Handler, IDisposable Subscription)> _themeHandlers = []; + + public event Action ThemeChanged + { + add + { + ArgumentNullException.ThrowIfNull(value); + Action guarded = theme => + { + if (!Volatile.Read(ref _disposed)) + value(theme); + }; + _inner.ThemeChanged += guarded; + var subscription = new ThemeSubscription(_inner, guarded); + IDisposable tracked = TrackRegistration(subscription); + lock (_gate) + _themeHandlers.Add((value, tracked)); + } + remove + { + if (value is null) + return; + IDisposable? tracked = null; + lock (_gate) + { + int index = _themeHandlers.FindLastIndex(entry => entry.Handler == value); + if (index < 0) + return; + tracked = _themeHandlers[index].Subscription; + _themeHandlers.RemoveAt(index); + } + tracked.Dispose(); + } + } + + private sealed class ThemeSubscription(IScopedUiRegistry inner, Action handler) + : IDisposable + { + private Action? _handler = handler; + + public void Dispose() + { + if (Interlocked.Exchange(ref _handler, null) is { } held) + inner.ThemeChanged -= held; + } + } + public IPluginCanvas RegisterCanvas( PluginCanvasDescriptor descriptor, Action paint) @@ -2135,6 +2187,7 @@ public void Dispose() _disposed = true; registrations = _registrations.ToArray(); _registrations.Clear(); + _themeHandlers.Clear(); } for (int index = registrations.Length - 1; index >= 0; index--) diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs index e66dcb20..ceb0b9a8 100644 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs @@ -178,6 +178,26 @@ IDisposable RegisterPanelContent( /// IPluginImages Images => NoOpPluginImages.Instance; + /// + /// The plugin theme the player picked under Appearance, and its colours, + /// so a canvas can draw like the plugin's themed windows. The host + /// restyles markup windows itself; a canvas reads this and repaints on + /// . on + /// a host without themes or without a window. + /// + PluginUiThemeInfo Theme => PluginUiThemeInfo.Classic; + + /// + /// Raised on the thread the plugin's own callbacks run on when the player + /// picks another theme, with the new . Never raised on + /// a host without themes or without a window. + /// + event Action ThemeChanged + { + add { } + remove { } + } + /// /// Registers a canvas the plugin paints, shown over the world and under /// every window. The paint callback runs on the tick thread, at most diff --git a/src/AcDream.Plugin.Abstractions/PluginTheme.cs b/src/AcDream.Plugin.Abstractions/PluginTheme.cs new file mode 100644 index 00000000..2788fbfc --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/PluginTheme.cs @@ -0,0 +1,45 @@ +namespace AcDream.Plugin.Abstractions; + +/// The plugin themes the player can pick under Appearance. +public enum PluginUiThemeKind +{ + /// The client's own black and gold; themed windows draw unthemed. + Classic, + + /// Charcoal with moss-green accents. + Moss, + + /// Warm graphite with brass accents. + Brass, +} + +/// +/// The plugin theme the player picked, read from +/// , so a canvas can draw in the same colours +/// as the plugin's themed windows. +/// +/// Which theme is selected. +/// What the Appearance picker calls it: "Classic", "Charcoal + moss" or "Warm graphite + brass". +/// The theme's colours; null under Classic, where themed windows draw unthemed. +public sealed record PluginUiThemeInfo(PluginUiThemeKind Kind, string DisplayName, PluginThemePalette? Palette) +{ + /// Classic: what a host without themes, or without a window, answers. + public static PluginUiThemeInfo Classic { get; } = new(PluginUiThemeKind.Classic, "Classic", null); +} + +/// The colours a plugin theme draws themed windows in. +/// The window's face. +/// The face of inputs, lists and other sunken areas. +/// Window and control edges. +/// Body text. +/// Secondary text: captions, hints and disabled labels. +/// Highlights: a switch that is on, focus, links. +/// The fill behind a selected row or tab. +public sealed record PluginThemePalette( + PluginColor Background, + PluginColor Field, + PluginColor Border, + PluginColor Text, + PluginColor Muted, + PluginColor Accent, + PluginColor Selected); diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryThemeTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryThemeTests.cs new file mode 100644 index 00000000..31d61038 --- /dev/null +++ b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryThemeTests.cs @@ -0,0 +1,153 @@ +using AcDream.App.Plugins; +using AcDream.App.UI; +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.App.Tests.Plugins; + +/// +/// The graphical host tells plugins the theme the player picked under +/// Appearance, in the palette themed windows draw in. +/// +public sealed class BufferedUiRegistryThemeTests +{ + private sealed class SilentLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? error = null) { } + } + + private static (IPluginHost Host, BufferedUiRegistry Registry) Host() + { + var registry = new BufferedUiRegistry(); + var host = new AppPluginHost( + new SilentLogger(), + new WorldGameState(), + new WorldEvents(), + new SelectionState(), + registry, + NoOpAutomationSurface.Instance); + return (host, registry); + } + + [Fact] + public void ClassicIsWhatPluginsReadUntilTheInterfaceSaysOtherwise() + { + (IPluginHost host, _) = Host(); + + Assert.Same(PluginUiThemeInfo.Classic, host.Ui.Theme); + } + + [Fact] + public void EachThemeIsReadWithItsNameAndItsWindowColours() + { + var settings = new PluginUiThemeSettings(); + Assert.Same(PluginUiThemeInfo.Classic, settings.ThemeInfo); + + settings.Theme = PluginUiTheme.Moss; + PluginUiThemeInfo moss = settings.ThemeInfo; + Assert.Equal(PluginUiThemeKind.Moss, moss.Kind); + Assert.Equal("Charcoal + moss", moss.DisplayName); + Assert.Equal(new PluginThemePalette( + new(0x17, 0x1D, 0x1B), new(0x10, 0x16, 0x13), new(0x35, 0x44, 0x3B), new(0xE0, 0xE8, 0xE2), + new(0x9A, 0xA9, 0x9E), new(0x97, 0xBE, 0x81), new(0x34, 0x4B, 0x37)), moss.Palette); + + settings.Theme = PluginUiTheme.Brass; + PluginUiThemeInfo brass = settings.ThemeInfo; + Assert.Equal(PluginUiThemeKind.Brass, brass.Kind); + Assert.Equal("Warm graphite + brass", brass.DisplayName); + Assert.Equal(new PluginColor(0x22, 0x1F, 0x1B), brass.Palette!.Background); + Assert.Equal(new PluginColor(0xC9, 0xA6, 0x65), brass.Palette.Accent); + Assert.Equal(new PluginColor(0x51, 0x44, 0x2D), brass.Palette.Selected); + } + + [Fact] + public void TheNamesAreTheOnesTheAppearancePickerShows() + { + var binding = new PluginAppearanceBinding(new PluginUiThemeSettings()); + + foreach (PluginUiTheme theme in Enum.GetValues()) + { + var settings = new PluginUiThemeSettings { Theme = theme }; + Assert.Equal(binding.Themes[(int)theme], settings.ThemeInfo.DisplayName); + Assert.Equal(theme.ToString(), settings.ThemeInfo.Kind.ToString()); + } + } + + [Fact] + public void TheSettingsSayWhenThePlayerPicksAnotherTheme() + { + var settings = new PluginUiThemeSettings(); + int changes = 0; + settings.ThemeChanged += () => changes++; + var binding = new PluginAppearanceBinding(settings); + + binding.Select("Warm graphite + brass"); + binding.Select("Warm graphite + brass"); + + Assert.Equal(1, changes); + } + + [Fact] + public void FollowingTheSettingsPublishesTheSavedThemeThenEachPickUntilLetGo() + { + (IPluginHost host, BufferedUiRegistry registry) = Host(); + var settings = new PluginUiThemeSettings { Theme = PluginUiTheme.Moss }; + var heard = new List(); + host.Ui.ThemeChanged += theme => heard.Add(theme.Kind); + var binding = new PluginAppearanceBinding(settings); + + IDisposable follow = registry.FollowTheme(settings); + binding.Select("Warm graphite + brass"); + follow.Dispose(); + follow.Dispose(); + binding.Select("Classic"); + + Assert.Equal([PluginUiThemeKind.Moss, PluginUiThemeKind.Brass], heard); + Assert.Equal(PluginUiThemeKind.Brass, host.Ui.Theme.Kind); + } + + [Fact] + public void APublishedThemeIsReadAndHeardOnceByEveryPlugin() + { + (IPluginHost host, BufferedUiRegistry registry) = Host(); + var heard = new List(); + host.Ui.ThemeChanged += heard.Add; + PluginUiThemeInfo moss = new PluginUiThemeSettings { Theme = PluginUiTheme.Moss }.ThemeInfo; + + registry.PublishTheme(moss); + registry.PublishTheme(new PluginUiThemeSettings { Theme = PluginUiTheme.Moss }.ThemeInfo); + + Assert.Equal(moss, host.Ui.Theme); + Assert.Equal([moss], heard); + } + + [Fact] + public void APluginWhoseHandlerThrowsDoesNotStopTheNextFromHearing() + { + (IPluginHost host, BufferedUiRegistry registry) = Host(); + int heard = 0; + host.Ui.ThemeChanged += _ => throw new InvalidOperationException("plugin bug"); + host.Ui.ThemeChanged += _ => heard++; + + registry.PublishTheme(new PluginUiThemeSettings { Theme = PluginUiTheme.Brass }.ThemeInfo); + + Assert.Equal(1, heard); + } + + [Fact] + public void ARemovedHandlerIsNotCalled() + { + (IPluginHost host, BufferedUiRegistry registry) = Host(); + int heard = 0; + Action handler = _ => heard++; + host.Ui.ThemeChanged += handler; + host.Ui.ThemeChanged -= handler; + + registry.PublishTheme(new PluginUiThemeSettings { Theme = PluginUiTheme.Brass }.ThemeInfo); + + Assert.Equal(0, heard); + } +} diff --git a/tests/AcDream.Core.Tests/Plugins/ScopedUiRegistryThemeTests.cs b/tests/AcDream.Core.Tests/Plugins/ScopedUiRegistryThemeTests.cs new file mode 100644 index 00000000..fc384b6e --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/ScopedUiRegistryThemeTests.cs @@ -0,0 +1,132 @@ +using AcDream.Core.Plugins; +using AcDream.Core.Selection; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +/// +/// Both hosts hand a plugin the theme through the scoped registry: it reads +/// the host's theme, hears each change, and stops hearing once the handler is +/// removed or the plugin goes. +/// +public sealed class ScopedUiRegistryThemeTests +{ + private static readonly PluginUiThemeInfo Moss = new( + PluginUiThemeKind.Moss, + "Charcoal + moss", + new PluginThemePalette( + new(1, 2, 3), new(4, 5, 6), new(7, 8, 9), new(10, 11, 12), + new(13, 14, 15), new(16, 17, 18), new(19, 20, 21))); + + private sealed class FakeScopedUiRegistry : IScopedUiRegistry + { + public PluginUiThemeInfo Theme { get; set; } = PluginUiThemeInfo.Classic; + + public event Action? ThemeChanged; + + public int HandlerCount => ThemeChanged?.GetInvocationList().Length ?? 0; + + public void Raise(PluginUiThemeInfo theme) + { + Theme = theme; + ThemeChanged?.Invoke(theme); + } + + public void AddMarkupPanel(string markupPath, object binding) + { + } + + public IDisposable RegisterMarkupPanel(string markupPath, object binding) => + NoOpUiRegistration.Instance; + } + + private sealed class StubHost(IScopedUiRegistry ui) : IPluginHost + { + public bool HasUi => true; + public IPluginLogger Log { get; } = new SilentLogger(); + public IGameState State { get; } = new EmptyGameState(); + public IEvents Events { get; } = new WorldEvents(); + public ISelectionService Selection { get; } = new SelectionState(); + public IUiRegistry Ui { get; } = ui; + public IAutomationSurface Automation { get; } = NoOpAutomationSurface.Instance; + + private sealed class SilentLogger : IPluginLogger + { + public void Info(string message) { } + public void Warn(string message) { } + public void Error(string message, Exception? error = null) { } + } + + private sealed class EmptyGameState : IGameState + { + public IReadOnlyList Entities { get; } = []; + } + } + + [Fact] + public void ThePluginReadsTheHostsTheme() + { + var inner = new FakeScopedUiRegistry(); + using var scoped = new ScopedPluginHost(new StubHost(inner), "example.plugin", "Example"); + + Assert.Same(PluginUiThemeInfo.Classic, scoped.Ui.Theme); + inner.Theme = Moss; + Assert.Same(Moss, scoped.Ui.Theme); + } + + [Fact] + public void AHandlerHearsEachChangeUntilItIsRemoved() + { + var inner = new FakeScopedUiRegistry(); + using var scoped = new ScopedPluginHost(new StubHost(inner), "example.plugin", "Example"); + var heard = new List(); + Action handler = heard.Add; + + scoped.Ui.ThemeChanged += handler; + inner.Raise(Moss); + scoped.Ui.ThemeChanged -= handler; + inner.Raise(PluginUiThemeInfo.Classic); + + Assert.Equal([Moss], heard); + Assert.Equal(0, inner.HandlerCount); + } + + [Fact] + public void DisposingThePluginTakesItsHandlersOffTheHost() + { + var inner = new FakeScopedUiRegistry(); + var scoped = new ScopedPluginHost(new StubHost(inner), "example.plugin", "Example"); + int heard = 0; + scoped.Ui.ThemeChanged += _ => heard++; + scoped.Ui.ThemeChanged += _ => heard++; + Assert.Equal(2, inner.HandlerCount); + + scoped.Dispose(); + inner.Raise(Moss); + + Assert.Equal(0, heard); + Assert.Equal(0, inner.HandlerCount); + } + + [Fact] + public void RemovingAHandlerThatWasNeverAddedIsHarmless() + { + var inner = new FakeScopedUiRegistry(); + using var scoped = new ScopedPluginHost(new StubHost(inner), "example.plugin", "Example"); + + scoped.Ui.ThemeChanged -= _ => { }; + + Assert.Equal(0, inner.HandlerCount); + } + + [Fact] + public void OnAHostWithoutThemesThePluginReadsClassic() + { + using var scoped = new ScopedPluginHost( + new StubHost(NoOpUiRegistry.Instance), "example.plugin", "Example"); + + scoped.Ui.ThemeChanged += _ => throw new InvalidOperationException("never raised"); + + Assert.Same(PluginUiThemeInfo.Classic, scoped.Ui.Theme); + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessPluginApiSurfaceTests.cs b/tests/AcDream.Headless.Tests/HeadlessPluginApiSurfaceTests.cs index 4c48090c..c9ea475b 100644 --- a/tests/AcDream.Headless.Tests/HeadlessPluginApiSurfaceTests.cs +++ b/tests/AcDream.Headless.Tests/HeadlessPluginApiSurfaceTests.cs @@ -990,6 +990,25 @@ public void WithoutAWindowTheImageSurfaceAnswersInertly() Assert.Equal(0, images.Count); } + /// + /// The theme is asked of the same surface on both hosts. Without a window + /// there is no Appearance picker, so the plugin reads Classic, a handler + /// is accepted and never called. + /// + [Fact] + public void WithoutAWindowTheThemeIsClassicAndNeverChanges() + { + using GameRuntime runtime = NewRuntime(); + using var host = NewHost(runtime); + IPluginHost pluginHost = host; + Action handler = _ => throw new InvalidOperationException("never raised"); + + pluginHost.Ui.ThemeChanged += handler; + pluginHost.Ui.ThemeChanged -= handler; + + Assert.Same(PluginUiThemeInfo.Classic, pluginHost.Ui.Theme); + } + /// /// The same canvas registration a plugin makes with a window is /// accepted without one: the plugin keeps its handle, sets what it diff --git a/tests/AcDream.Plugin.Tests/PluginThemeContractTests.cs b/tests/AcDream.Plugin.Tests/PluginThemeContractTests.cs new file mode 100644 index 00000000..13be60ad --- /dev/null +++ b/tests/AcDream.Plugin.Tests/PluginThemeContractTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) OpenAC contributors. +// Distributed under the terms of the MIT license. + +using AcDream.Plugin.Abstractions; + +namespace AcDream.Plugin.Tests; + +/// +/// The theme is additive with an inert default: a host that never heard of +/// it answers Classic and never raises a change. +/// +public sealed class PluginThemeContractTests +{ + private sealed class BareRegistry : IUiRegistry + { + public void AddMarkupPanel(string markupPath, object binding) + { + } + } + + [Fact] + public void AHostThatPredatesThemesAnswersClassic() + { + IUiRegistry ui = new BareRegistry(); + + Assert.Same(PluginUiThemeInfo.Classic, ui.Theme); + Assert.Equal(PluginUiThemeKind.Classic, ui.Theme.Kind); + Assert.Equal("Classic", ui.Theme.DisplayName); + Assert.Null(ui.Theme.Palette); + } + + [Fact] + public void AHostThatPredatesThemesAcceptsAndIgnoresHandlers() + { + IUiRegistry ui = new BareRegistry(); + Action handler = _ => { }; + + ui.ThemeChanged += handler; + ui.ThemeChanged -= handler; + } + + [Fact] + public void TheNoOpRegistryAnswersClassic() => + Assert.Same(PluginUiThemeInfo.Classic, ((IUiRegistry)NoOpUiRegistry.Instance).Theme); + + [Fact] + public void ThemesWithTheSameColoursAreEqual() + { + PluginThemePalette Palette() => new( + new(1, 2, 3), new(4, 5, 6), new(7, 8, 9), new(10, 11, 12), + new(13, 14, 15), new(16, 17, 18), new(19, 20, 21)); + + Assert.Equal( + new PluginUiThemeInfo(PluginUiThemeKind.Moss, "Charcoal + moss", Palette()), + new PluginUiThemeInfo(PluginUiThemeKind.Moss, "Charcoal + moss", Palette())); + } +}