using System.Numerics;
using AcDream.Plugin.Abstractions;
namespace AcDream.App.UI;
///
/// Host-owned shelf for running gameplay plugins. A shelf button changes only
/// presentation visibility; it never touches plugin enable/session lifetime.
///
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 _resolve;
private readonly UiDatFont? _font;
private readonly Dictionary _entries = [];
private bool _disposed;
private float _lastLayoutHeight = -1f;
public PluginSidePanel(
RetailWindowManager windows,
Func 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;
}
/// Number of live plugin-window entries, exposed for gates.
public int EntryCount => _entries.Count;
///
/// Adds one manifest-scoped plugin window and its minimize affordance.
/// Duplicate handles are idempotent.
///
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 _resolve;
private readonly uint _iconSurfaceId;
private readonly string _tooltip;
internal PluginShelfButton(
PluginPanelDescriptor descriptor,
string ownerDisplayName,
RetailWindowHandle handle,
Func 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";
}
}