Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/plugin-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/plugin-ui-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions src/AcDream.App/Plugins/BufferedUiRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginUiThemeInfo>? _themeChanged;

public PluginUiThemeInfo Theme
{
get
{
lock (_gate)
return _theme;
}
}

public event Action<PluginUiThemeInfo> ThemeChanged
{
add
{
ArgumentNullException.ThrowIfNull(value);
lock (_gate)
_themeChanged += value;
}
remove
{
lock (_gate)
_themeChanged -= value;
}
}

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// 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.
/// </summary>
internal void PublishTheme(PluginUiThemeInfo theme)
{
ArgumentNullException.ThrowIfNull(theme);
Action<PluginUiThemeInfo>? handlers;
lock (_gate)
{
if (_theme == theme)
return;
_theme = theme;
handlers = _themeChanged;
}
if (handlers is null)
return;
foreach (Delegate handler in handlers.GetInvocationList())
{
try
{
((Action<PluginUiThemeInfo>)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
Expand Down
2 changes: 1 addition & 1 deletion src/AcDream.App/UI/PluginAppearanceBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ namespace AcDream.App.UI;

public sealed class PluginAppearanceBinding(PluginUiThemeSettings settings)
{
public IReadOnlyList<string> Themes { get; } = ["Classic", "Charcoal + moss", "Warm graphite + brass"];
public IReadOnlyList<string> Themes { get; } = PluginUiThemeSettings.ThemeNames;
public string Selected => Themes[(int)settings.Theme];
public Action<string> Select => name =>
{
Expand Down
23 changes: 23 additions & 0 deletions src/AcDream.App/UI/PluginUiThemeSettings.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.Plugin.Abstractions;
using AcDream.UI.Abstractions.Panels.Settings;

namespace AcDream.App.UI;
Expand Down Expand Up @@ -27,8 +28,20 @@ public PluginUiTheme Theme
if (_theme == value) return;
_store?.SavePluginUiTheme(value.ToString());
_theme = value;
ThemeChanged?.Invoke();
}
}

/// <summary>Raised from the <see cref="Theme"/> setter when the player picks another theme.</summary>
public event Action? ThemeChanged;

/// <summary>What the Appearance picker calls each theme, in <see cref="PluginUiTheme"/> order.</summary>
internal static IReadOnlyList<string> ThemeNames { get; } = ["Classic", "Charcoal + moss", "Warm graphite + brass"];

/// <summary>The selected theme as plugins read it: its kind, name and colours.</summary>
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,
Expand All @@ -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));
/// <summary>The palette as plugins read it, each channel rounded to a byte.</summary>
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,
Expand Down
11 changes: 10 additions & 1 deletion src/AcDream.App/UI/RetailUiRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, (uint Texture, int Width, int Height)?> _pluginIcons = [];
private bool _pluginsMounted;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions src/AcDream.Core/Plugins/ScopedPluginHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginUiThemeInfo> Handler, IDisposable Subscription)> _themeHandlers = [];

public event Action<PluginUiThemeInfo> ThemeChanged
{
add
{
ArgumentNullException.ThrowIfNull(value);
Action<PluginUiThemeInfo> 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<PluginUiThemeInfo> handler)
: IDisposable
{
private Action<PluginUiThemeInfo>? _handler = handler;

public void Dispose()
{
if (Interlocked.Exchange(ref _handler, null) is { } held)
inner.ThemeChanged -= held;
}
}

public IPluginCanvas RegisterCanvas(
PluginCanvasDescriptor descriptor,
Action<IPluginPainter> paint)
Expand Down Expand Up @@ -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--)
Expand Down
20 changes: 20 additions & 0 deletions src/AcDream.Plugin.Abstractions/IUiRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,26 @@ IDisposable RegisterPanelContent(
/// </summary>
IPluginImages Images => NoOpPluginImages.Instance;

/// <summary>
/// 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
/// <see cref="ThemeChanged"/>. <see cref="PluginUiThemeInfo.Classic"/> on
/// a host without themes or without a window.
/// </summary>
PluginUiThemeInfo Theme => PluginUiThemeInfo.Classic;

/// <summary>
/// Raised on the thread the plugin's own callbacks run on when the player
/// picks another theme, with the new <see cref="Theme"/>. Never raised on
/// a host without themes or without a window.
/// </summary>
event Action<PluginUiThemeInfo> ThemeChanged
{
add { }
remove { }
}

/// <summary>
/// Registers a canvas the plugin paints, shown over the world and under
/// every window. The paint callback runs on the tick thread, at most
Expand Down
45 changes: 45 additions & 0 deletions src/AcDream.Plugin.Abstractions/PluginTheme.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
namespace AcDream.Plugin.Abstractions;

/// <summary>The plugin themes the player can pick under Appearance.</summary>
public enum PluginUiThemeKind
{
/// <summary>The client's own black and gold; themed windows draw unthemed.</summary>
Classic,

/// <summary>Charcoal with moss-green accents.</summary>
Moss,

/// <summary>Warm graphite with brass accents.</summary>
Brass,
}

/// <summary>
/// The plugin theme the player picked, read from
/// <see cref="IUiRegistry.Theme"/>, so a canvas can draw in the same colours
/// as the plugin's themed windows.
/// </summary>
/// <param name="Kind">Which theme is selected.</param>
/// <param name="DisplayName">What the Appearance picker calls it: "Classic", "Charcoal + moss" or "Warm graphite + brass".</param>
/// <param name="Palette">The theme's colours; null under Classic, where themed windows draw unthemed.</param>
public sealed record PluginUiThemeInfo(PluginUiThemeKind Kind, string DisplayName, PluginThemePalette? Palette)
{
/// <summary>Classic: what a host without themes, or without a window, answers.</summary>
public static PluginUiThemeInfo Classic { get; } = new(PluginUiThemeKind.Classic, "Classic", null);
}

/// <summary>The colours a plugin theme draws themed windows in.</summary>
/// <param name="Background">The window's face.</param>
/// <param name="Field">The face of inputs, lists and other sunken areas.</param>
/// <param name="Border">Window and control edges.</param>
/// <param name="Text">Body text.</param>
/// <param name="Muted">Secondary text: captions, hints and disabled labels.</param>
/// <param name="Accent">Highlights: a switch that is on, focus, links.</param>
/// <param name="Selected">The fill behind a selected row or tab.</param>
public sealed record PluginThemePalette(
PluginColor Background,
PluginColor Field,
PluginColor Border,
PluginColor Text,
PluginColor Muted,
PluginColor Accent,
PluginColor Selected);
Loading
Loading