355 lines
12 KiB
C#
355 lines
12 KiB
C#
using System.Numerics;
|
||
using AcDream.Plugin.Abstractions;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
|
||
/// presentation visibility; it never touches plugin enable/session lifetime.
|
||
/// </summary>
|
||
public sealed class PluginSidePanel : UiPanel, IDisposable
|
||
{
|
||
private const float OuterPadding = 4f;
|
||
private const float ButtonExtent = 28f;
|
||
private const float ButtonGap = 4f;
|
||
private const float DefaultTop = 116f;
|
||
|
||
private readonly RetailWindowManager _windows;
|
||
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
|
||
private readonly UiDatFont? _font;
|
||
private readonly Dictionary<RetailWindowHandle, ShelfEntry> _entries = [];
|
||
private bool _disposed;
|
||
private float _lastLayoutHeight = -1f;
|
||
|
||
public PluginSidePanel(
|
||
RetailWindowManager windows,
|
||
Func<uint, (uint tex, int width, int height)> resolve,
|
||
UiDatFont? font)
|
||
{
|
||
_windows = windows ?? throw new ArgumentNullException(nameof(windows));
|
||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||
_font = font;
|
||
|
||
Width = ButtonExtent + OuterPadding * 2f;
|
||
Height = OuterPadding * 2f;
|
||
Top = DefaultTop;
|
||
Anchors = AnchorEdges.None;
|
||
Draggable = false;
|
||
Resizable = false;
|
||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.88f);
|
||
BorderColor = new Vector4(0.62f, 0.48f, 0.16f, 1f);
|
||
BorderThickness = 1f;
|
||
Visible = false;
|
||
|
||
_windows.WindowUnregistered += OnWindowUnregistered;
|
||
}
|
||
|
||
/// <summary>Number of live plugin-window entries, exposed for gates.</summary>
|
||
public int EntryCount => _entries.Count;
|
||
|
||
/// <summary>
|
||
/// Adds one manifest-scoped plugin window and its minimize affordance.
|
||
/// Duplicate handles are idempotent.
|
||
/// </summary>
|
||
public void Add(
|
||
PluginUiOwner owner,
|
||
PluginPanelDescriptor descriptor,
|
||
RetailWindowHandle handle)
|
||
{
|
||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||
ArgumentException.ThrowIfNullOrWhiteSpace(owner.Id);
|
||
ArgumentNullException.ThrowIfNull(descriptor);
|
||
ArgumentNullException.ThrowIfNull(handle);
|
||
if (_entries.ContainsKey(handle))
|
||
return;
|
||
|
||
// Plugin windows are ordinary retained windows, but unlike imported
|
||
// retail windows they have no authored MoveTo override. Keep their
|
||
// chrome reachable at the minimum 800x600 canvas and after a display
|
||
// resize. An oversized window follows retail's top-left-priority rule:
|
||
// pin to zero rather than stranding the title/minimize controls.
|
||
handle.OuterFrame.ConstrainDragToParent = true;
|
||
handle.OuterFrame.ConstrainResizeToParent = true;
|
||
KeepWindowReachable(handle);
|
||
|
||
var button = new PluginShelfButton(
|
||
descriptor,
|
||
owner.DisplayName,
|
||
handle,
|
||
_resolve,
|
||
_font)
|
||
{
|
||
Width = ButtonExtent,
|
||
Height = ButtonExtent,
|
||
};
|
||
button.Click += () =>
|
||
{
|
||
if (handle.IsVisible)
|
||
handle.Hide();
|
||
else
|
||
handle.Show();
|
||
};
|
||
|
||
var minimize = new PluginMinimizeButton(handle, _font)
|
||
{
|
||
Left = MathF.Max(8f, handle.OuterFrame.Width - 23f),
|
||
Top = 3f,
|
||
Width = 18f,
|
||
Height = 17f,
|
||
Anchors = AnchorEdges.Top | AnchorEdges.Right,
|
||
};
|
||
handle.OuterFrame.AddChild(minimize);
|
||
|
||
_entries.Add(handle, new ShelfEntry(button, minimize));
|
||
AddChild(button);
|
||
Reflow();
|
||
}
|
||
|
||
protected override void OnTick(double deltaSeconds)
|
||
{
|
||
base.OnTick(deltaSeconds);
|
||
|
||
// Screen-edge dock: root bounds become authoritative at draw time, so
|
||
// compute this from the live parent rather than capturing an anchor
|
||
// margin while the pre-first-frame root still measures 0x0.
|
||
if (Parent is { } parent)
|
||
{
|
||
float availableHeight = MathF.Max(
|
||
ButtonExtent + OuterPadding * 2f,
|
||
parent.Height - Top - OuterPadding);
|
||
if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f)
|
||
{
|
||
_lastLayoutHeight = availableHeight;
|
||
Reflow(availableHeight);
|
||
}
|
||
Left = MathF.Max(0f, parent.Width - Width - OuterPadding);
|
||
}
|
||
|
||
foreach (RetailWindowHandle handle in _entries.Keys)
|
||
KeepWindowReachable(handle);
|
||
|
||
// The shelf remains reachable even after ordinary windows are raised.
|
||
if (Parent is { } root)
|
||
{
|
||
int highest = 0;
|
||
foreach (UiElement sibling in root.Children)
|
||
{
|
||
if (!ReferenceEquals(sibling, this))
|
||
highest = Math.Max(highest, sibling.ZOrder);
|
||
}
|
||
if (ZOrder <= highest)
|
||
ZOrder = highest == int.MaxValue ? highest : highest + 1;
|
||
}
|
||
}
|
||
|
||
private void OnWindowUnregistered(RetailWindowHandle handle)
|
||
{
|
||
if (!_entries.Remove(handle, out ShelfEntry entry))
|
||
return;
|
||
|
||
RemoveChild(entry.Button);
|
||
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
|
||
handle.OuterFrame.RemoveChild(entry.Minimize);
|
||
entry.Button.DisposeSubscriptions();
|
||
Reflow();
|
||
}
|
||
|
||
private static void KeepWindowReachable(RetailWindowHandle handle)
|
||
{
|
||
if (handle.OuterFrame.Parent is not { } parent
|
||
|| parent.Width <= 0f
|
||
|| parent.Height <= 0f)
|
||
{
|
||
return;
|
||
}
|
||
|
||
float left = Math.Clamp(
|
||
handle.Left,
|
||
0f,
|
||
MathF.Max(0f, parent.Width - handle.Width));
|
||
float top = Math.Clamp(
|
||
handle.Top,
|
||
0f,
|
||
MathF.Max(0f, parent.Height - handle.Height));
|
||
if (left != handle.Left || top != handle.Top)
|
||
handle.MoveTo(left, top);
|
||
}
|
||
|
||
private void Reflow(float maximumHeight = float.PositiveInfinity)
|
||
{
|
||
int maximumRows = float.IsPositiveInfinity(maximumHeight)
|
||
? Math.Max(1, _entries.Count)
|
||
: Math.Max(
|
||
1,
|
||
(int)MathF.Floor(
|
||
(maximumHeight - OuterPadding * 2f + ButtonGap)
|
||
/ (ButtonExtent + ButtonGap)));
|
||
int index = 0;
|
||
foreach (ShelfEntry entry in _entries.Values)
|
||
{
|
||
int column = index / maximumRows;
|
||
int row = index % maximumRows;
|
||
entry.Button.Left = OuterPadding
|
||
+ column * (ButtonExtent + ButtonGap);
|
||
entry.Button.Top = OuterPadding
|
||
+ row * (ButtonExtent + ButtonGap);
|
||
index++;
|
||
}
|
||
|
||
int rows = Math.Min(index, maximumRows);
|
||
int columns = index == 0 ? 1 : (index + maximumRows - 1) / maximumRows;
|
||
Width = OuterPadding * 2f
|
||
+ columns * ButtonExtent
|
||
+ Math.Max(0, columns - 1) * ButtonGap;
|
||
Height = OuterPadding * 2f
|
||
+ rows * ButtonExtent
|
||
+ Math.Max(0, rows - 1) * ButtonGap;
|
||
Visible = index > 0;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
_disposed = true;
|
||
_windows.WindowUnregistered -= OnWindowUnregistered;
|
||
|
||
foreach ((RetailWindowHandle handle, ShelfEntry entry) in _entries)
|
||
{
|
||
entry.Button.DisposeSubscriptions();
|
||
if (ReferenceEquals(entry.Minimize.Parent, handle.OuterFrame))
|
||
handle.OuterFrame.RemoveChild(entry.Minimize);
|
||
}
|
||
_entries.Clear();
|
||
Visible = false;
|
||
}
|
||
|
||
private readonly record struct ShelfEntry(
|
||
PluginShelfButton Button,
|
||
PluginMinimizeButton Minimize);
|
||
|
||
private sealed class PluginShelfButton : UiSimpleButton
|
||
{
|
||
private static readonly Vector4 HiddenBackground =
|
||
new(0.025f, 0.025f, 0.02f, 0.96f);
|
||
private static readonly Vector4 VisibleBackground =
|
||
new(0.09f, 0.19f, 0.055f, 0.96f);
|
||
private static readonly Vector4 HiddenBorder =
|
||
new(0.48f, 0.38f, 0.14f, 1f);
|
||
private static readonly Vector4 VisibleBorder =
|
||
new(0.76f, 0.64f, 0.25f, 1f);
|
||
|
||
private readonly RetailWindowHandle _handle;
|
||
private readonly Func<uint, (uint tex, int width, int height)> _resolve;
|
||
private readonly uint _iconSurfaceId;
|
||
private readonly string _tooltip;
|
||
|
||
internal PluginShelfButton(
|
||
PluginPanelDescriptor descriptor,
|
||
string ownerDisplayName,
|
||
RetailWindowHandle handle,
|
||
Func<uint, (uint tex, int width, int height)> resolve,
|
||
UiDatFont? font)
|
||
{
|
||
_handle = handle;
|
||
_resolve = resolve;
|
||
_iconSurfaceId = descriptor.IconSurfaceId;
|
||
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
|
||
StringComparison.Ordinal)
|
||
? descriptor.Title
|
||
: $"{ownerDisplayName} — {descriptor.Title}";
|
||
Text = _iconSurfaceId == 0
|
||
? Initials(descriptor.IconText, descriptor.Title)
|
||
: string.Empty;
|
||
DatFont = font;
|
||
Outline = true;
|
||
BorderThickness = 1f;
|
||
_handle.Shown += OnVisibilityChanged;
|
||
_handle.Hidden += OnVisibilityChanged;
|
||
RefreshPresentation();
|
||
}
|
||
|
||
public override string? GetTooltipText() => _tooltip;
|
||
|
||
protected override void OnTick(double deltaSeconds)
|
||
{
|
||
base.OnTick(deltaSeconds);
|
||
RefreshPresentation();
|
||
}
|
||
|
||
protected override void OnDraw(UiRenderContext ctx)
|
||
{
|
||
base.OnDraw(ctx);
|
||
if (_iconSurfaceId == 0)
|
||
return;
|
||
|
||
(uint texture, int width, int height) = _resolve(_iconSurfaceId);
|
||
if (texture == 0 || width <= 0 || height <= 0)
|
||
return;
|
||
float extent = MathF.Min(Width - 6f, Height - 6f);
|
||
ctx.DrawSprite(
|
||
texture,
|
||
(Width - extent) * 0.5f,
|
||
(Height - extent) * 0.5f,
|
||
extent,
|
||
extent,
|
||
0f,
|
||
0f,
|
||
1f,
|
||
1f,
|
||
Vector4.One);
|
||
}
|
||
|
||
internal void DisposeSubscriptions()
|
||
{
|
||
_handle.Shown -= OnVisibilityChanged;
|
||
_handle.Hidden -= OnVisibilityChanged;
|
||
}
|
||
|
||
private void OnVisibilityChanged(RetailWindowHandle _) =>
|
||
RefreshPresentation();
|
||
|
||
private void RefreshPresentation()
|
||
{
|
||
BackgroundColor = _handle.IsVisible
|
||
? VisibleBackground
|
||
: HiddenBackground;
|
||
BorderColor = _handle.IsVisible ? VisibleBorder : HiddenBorder;
|
||
}
|
||
|
||
private static string Initials(string? requested, string title)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(requested))
|
||
return requested.Trim()[..Math.Min(3, requested.Trim().Length)];
|
||
|
||
string[] words = title.Split(
|
||
' ',
|
||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||
if (words.Length == 0)
|
||
return "?";
|
||
if (words.Length == 1)
|
||
return words[0][..Math.Min(2, words[0].Length)].ToUpperInvariant();
|
||
return string.Concat(words.Take(2).Select(static word =>
|
||
char.ToUpperInvariant(word[0])));
|
||
}
|
||
}
|
||
|
||
private sealed class PluginMinimizeButton : UiSimpleButton
|
||
{
|
||
private readonly RetailWindowHandle _handle;
|
||
|
||
internal PluginMinimizeButton(RetailWindowHandle handle, UiDatFont? font)
|
||
{
|
||
_handle = handle;
|
||
Text = "–";
|
||
DatFont = font;
|
||
Outline = true;
|
||
BackgroundColor = new Vector4(0.02f, 0.02f, 0.015f, 0.94f);
|
||
BorderColor = new Vector4(0.58f, 0.46f, 0.17f, 1f);
|
||
BorderThickness = 1f;
|
||
Click += () => _handle.Hide();
|
||
}
|
||
|
||
public override string? GetTooltipText() => "Minimize to plugin sidepanel";
|
||
}
|
||
}
|