acdream/src/AcDream.App/UI/UiCatalogSlot.cs
Erik 6bb4cfa795
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
feat(ui): the spell-bar drop ring — retail's authored drag-accept state, and the ring exposed a real drop off-by-one
The green ring is retail's own art: every UIItem cell carries an
authored DragAccept child (catalog 0x21000037, child 0x1000045A), and
the spell bar's drag-over handler (SpellCastSubMenu::OnItemListDragOver
@0x004C5990) flips it to the Accept state (0x10000040 -> surface
0x060011F9) for any spell payload. Ported through a per-slot
SetDragAcceptVisual seam + a catalog DragOverAcceptance hook; other
lists are untouched (null acceptance = neutral). A polarity error in
our older docs (Accept/Reject state ids swapped) was corrected against
three independent sources; the shipped art was always right, only the
labels lied.

The ring shares ONE landing computation with the drop
(FavoriteDropIndex) — and that requirement exposed a genuine #354
off-by-one: the empty-tail path double-applied the -1 adjustment
(retail gates it on the lift's removal @0x004C7157), landing a
reordered spell second-to-last instead of last. Fixed;
discriminator-verified both ways. AP-172 narrowed + its false
empty-tail claim corrected.

Clean-room complete solution: 11,545 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 20:18:51 +02:00

204 lines
8.8 KiB
C#

using System;
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Icon-list cell for non-weenie catalog entries such as spells and components.
/// It reuses the retail UIItemList geometry without pretending a spell id is an
/// object GUID; catalog drags stay distinct from physical item drag/drop.
/// </summary>
public sealed class UiCatalogSlot : UiItemSlot
{
public uint EntryId { get; set; }
public uint CatalogIconTexture { get; set; }
/// <summary>Optional second 32x32 layer (retail spell endowment item icon).</summary>
public uint CatalogOverlayTexture { get; set; }
public string Label { get; set; } = string.Empty;
public string? Detail { get; set; }
public bool ShowLabel { get; init; }
public uint BackgroundSprite { get; init; }
public UiDatFont? LabelFont { get; init; }
public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f);
public float IconLeft { get; init; }
public float IconTop { get; init; }
public float IconWidth { get; init; }
public float IconHeight { get; init; }
public float LabelLeft { get; init; }
public float LabelWidth { get; init; }
public bool SelectionBehindContent { get; init; }
public new Action? Clicked { get; set; }
public new Action? DoubleClicked { get; set; }
public object? CatalogDragPayload { get; init; }
public Action<object>? DragBegan { get; init; }
public Action<object>? DragEnded { get; init; }
public Action<object>? Dropped { get; init; }
/// <summary>
/// Per-panel drag-over acceptance — the catalog-cell port of retail's
/// per-list drag handler deciding the rollover state
/// (<c>UIElement_ItemList::ItemList_DragOver</c> @ 0x004E3400 →
/// <c>m_dragHandler->OnItemListDragOver</c>; the spell bar's is
/// <c>SpellCastSubMenu::OnItemListDragOver</c> @ 0x004C5990). While a drag
/// hovers this cell, the returned acceptance drives the authored
/// DragAccept frame (accept 0x060011F9 / reject 0x060011F8). Null (the
/// default for every list that never set one) keeps the cell neutral —
/// no overlay, exactly the pre-existing presentation.
/// </summary>
public Func<object, ItemDragAcceptance>? DragOverAcceptance { get; init; }
protected override bool IsShortcutOccupied => EntryId != 0u;
public override bool IsEmptySlot => EntryId == 0u;
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
public override bool IsDragSource => CatalogDragPayload is not null;
public override object? GetDragPayload() => CatalogDragPayload;
public override (uint tex, int w, int h)? GetDragGhost() =>
CatalogDragPayload is not null && CatalogIconTexture != 0u
? (CatalogIconTexture, 32, 32)
: null;
internal override void SetDragSourceActive(bool active, object? payload)
{
if (!active && payload is not null) DragEnded?.Invoke(payload);
}
public override bool OnEvent(in UiEvent e)
{
switch (e.Type)
{
case UiEventType.MouseDown:
if (EntryId != 0u
&& FindList() is { PrimaryCatalogEntryPressed: { } pressed })
pressed(EntryId);
return true;
case UiEventType.Click:
Clicked?.Invoke();
return true;
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
case UiEventType.RightClick:
if (EntryId != 0u
&& FindList() is { ExamineCatalogEntryRequested: { } examine })
examine(EntryId);
return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;
case UiEventType.DragEnter:
// Pointer entered mid-drag: ask the panel's acceptance seam and show
// the authored frame (retail ItemList_DragOver → the list handler's
// SetDragAcceptState on the hovered UIItem's 0x1000045A child).
SetDragAcceptVisual(e.Payload is { } enterPayload
? DragOverAcceptance?.Invoke(enterPayload) switch
{
ItemDragAcceptance.Accept => DragAcceptState.Accept,
ItemDragAcceptance.Reject => DragAcceptState.Reject,
_ => DragAcceptState.None,
}
: DragAcceptState.None);
return true;
case UiEventType.DragOver:
// UiRoot fires DragOver on LEAVE — retail's dwParam1 == 0 reset to
// ItemSlot_DragOver_Normal (0x1000003F) @ 0x004E1438-0x004E1456.
SetDragAcceptVisual(DragAcceptState.None);
return true;
case UiEventType.DropReleased:
SetDragAcceptVisual(DragAcceptState.None);
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
return true;
default:
return false;
}
}
protected override void OnDraw(UiRenderContext ctx)
{
if (BackgroundSprite != 0u && SpriteResolve is not null)
{
var (texture, _, _) = SpriteResolve(BackgroundSprite);
if (texture != 0u)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
if (SelectionBehindContent)
DrawSelection(ctx);
float iconWidth = IconWidth > 0f ? IconWidth : ShowLabel ? MathF.Min(32f, Height) : Width;
float iconHeight = IconHeight > 0f ? IconHeight : ShowLabel ? MathF.Min(32f, Height) : Height;
if (CatalogIconTexture != 0)
ctx.DrawSprite(CatalogIconTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (texture, _, _) = SpriteResolve(EmptySprite);
if (texture != 0)
ctx.DrawSprite(texture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
}
if (CatalogOverlayTexture != 0)
ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
// Spell favorites are UIElement_UIItems too. Retail's
// SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0 calls the same
// SetShortcutNum path used by physical toolbar shortcuts.
DrawShortcutOverlay(ctx);
if (!SelectionBehindContent)
DrawSelection(ctx);
if (ShowLabel)
{
float labelLeft = LabelLeft > 0f ? LabelLeft : IconLeft + iconWidth + 4f;
float labelWidth = LabelWidth > 0f ? LabelWidth : MathF.Max(0f, Width - labelLeft);
if (LabelFont is not null)
{
string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f);
ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor);
}
else
{
BitmapFont? font = ctx.DefaultFont;
string text = font is null ? Label : FitText(Label, labelWidth, font.MeasureWidth);
float y = font is null ? 3f : MathF.Max(0f, (Height - font.LineHeight) * 0.5f);
ctx.DrawString(text, labelLeft, y, LabelColor, font);
}
if (!string.IsNullOrWhiteSpace(Detail))
ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
}
// Drag-rollover frame last — same layering as the physical UIItem draw
// (accept/reject above selection; catalog cells have no cooldown layer).
DrawDragAcceptOverlay(ctx);
}
private void DrawSelection(UiRenderContext ctx)
{
if (!Selected || SpriteResolve is null || SelectedSprite == 0u) return;
var (texture, _, _) = SpriteResolve(SelectedSprite);
if (texture != 0u)
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
}
private static string FitText(string text, float width, Func<string, float> measure)
{
if (width <= 0f || string.IsNullOrEmpty(text) || measure(text) <= width)
return text;
const string ellipsis = "...";
float ellipsisWidth = measure(ellipsis);
if (ellipsisWidth >= width) return string.Empty;
int low = 0, high = text.Length;
while (low < high)
{
int mid = (low + high + 1) / 2;
if (measure(text[..mid]) + ellipsisWidth <= width) low = mid;
else high = mid - 1;
}
return text[..low] + ellipsis;
}
}