acdream/src/AcDream.App/UI/PluginSidePanel.cs

355 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
}
}