diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 2b84dc005..4fe67825c 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 01805ec88..43e3cf3a0 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 9d58bd55a..131d9250d 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 24f7ede5e..7b97dd3db 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 eafd9c04e..62bcf8526 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 5f9abbd68..a01c4ba1f 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 0c203ca49..3c24843c8 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 e66dcb20d..ceb0b9a8c 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 000000000..2788fbfcd --- /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 000000000..31d610387 --- /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 000000000..fc384b6e4 --- /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 4c48090cc..c9ea475b2 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 000000000..13be60adf --- /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())); + } +}