Collects the post-gate polish left uncommitted by the killed round-5 agent (S1/S3 + review fixes N1/N3/N4) and completes the missing S2 half: - S1: UiText multi-line transcript + colored-run label now submit EVERY line/run's outline pass before ANY fill pass, matching retail's UIElement_Text::DrawSelf @0x00467aa0 whole-block walk. DrawStringDatPass is exposed for block-level batching; single lines keep DrawStringDat. - S2 (completed this commit): authored outline 0x21/0x22 now reaches every text-bearing widget — UiButton, UiDatElement, UiField, UiMeter, UiMenu, UiCatalogSlot — seeded from the element's effective-default state exactly like UiText (BuildButton lifts the label-bearing Text child's authored value first, same chain as the label color). Per-STATE outline switching (dialog/character/combat buttons author 0x21 in state 0x3 only) is NOT ported — filed as register row AP-192 in this commit. - S3: ChatWindowController reconciliation comment corrects the misread indicator action ids 0x10000514-17 -> 0x10000114-17 and re-attributes the id-coincidence to the pagination widget's m_prevButton/m_nextButton, not gmFriendsUI; register + window-shell research doc corrected to match. - N1: LayoutImporter's duplicate per-state any-state-first-wins 0x21 read is deleted — ElementReader.ApplyCanonicalLegacyProjection's DirectState-then- effective-default resolution is the single source (the duplicate would have lit state-0x3-only outlines permanently once S2 widened consumption). - N3: the outline pass tints with the outline color's OWN alpha, not the fill's (retail tints m_curOutlineColor and m_curTextColor independently). - N4: the outline-inflated glyph SOURCE rect is clamped to the atlas bounds with matching dest shrink, porting CreateCharRectPair @0x00441480's edge behavior — edge glyphs crop instead of sampling a neighbour's texels. Full Release suite: 12,610 passed / 4 skipped / 0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
215 lines
9.4 KiB
C#
215 lines
9.4 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);
|
|
|
|
/// <summary>Retail LayoutDesc property <c>0x21</c> (two-pass glyph outline,
|
|
/// <c>UIElement_Text::SetOutline @0x0046a81c</c>). Catalog slots are
|
|
/// controller-built (no authored element), so this is settable-only, for
|
|
/// parity with the other text-bearing widgets (round-5 review S2 —
|
|
/// per-STATE switching is AP-192).</summary>
|
|
public bool Outline { get; init; }
|
|
|
|
/// <summary>Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>,
|
|
/// ctor default black). Only meaningful when <see cref="Outline"/> is true.</summary>
|
|
public Vector4 OutlineColor { get; init; } = UiRenderContext.DefaultOutlineColor;
|
|
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, Outline, OutlineColor);
|
|
}
|
|
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;
|
|
}
|
|
}
|