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