Preserve pixel scroll offsets across inventory rebuilds, crop partially visible rows with nested geometry/UV clips, and replace the obsolete 560px resize ceiling with available screen height. Keep retail's row-sized wheel step while allowing continuous scrollbar thumb positions. Co-Authored-By: Codex <codex@openai.com>
316 lines
13 KiB
C#
316 lines
13 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
internal readonly record struct UiClipRect(float Left, float Top, float Right, float Bottom)
|
|
{
|
|
public bool IsEmpty => Right <= Left || Bottom <= Top;
|
|
|
|
public static UiClipRect Intersect(UiClipRect a, UiClipRect b)
|
|
=> new(
|
|
MathF.Max(a.Left, b.Left),
|
|
MathF.Max(a.Top, b.Top),
|
|
MathF.Min(a.Right, b.Right),
|
|
MathF.Min(a.Bottom, b.Bottom));
|
|
|
|
public static bool TryClipSprite(
|
|
UiClipRect clip,
|
|
ref float x, ref float y, ref float w, ref float h,
|
|
ref float u0, ref float v0, ref float u1, ref float v1)
|
|
{
|
|
if (clip.IsEmpty || w <= 0f || h <= 0f)
|
|
return false;
|
|
|
|
float left = MathF.Max(x, clip.Left);
|
|
float top = MathF.Max(y, clip.Top);
|
|
float right = MathF.Min(x + w, clip.Right);
|
|
float bottom = MathF.Min(y + h, clip.Bottom);
|
|
if (right <= left || bottom <= top)
|
|
return false;
|
|
|
|
float oldX = x, oldY = y, oldW = w, oldH = h;
|
|
float oldU0 = u0, oldV0 = v0, oldU1 = u1, oldV1 = v1;
|
|
float tx0 = (left - oldX) / oldW;
|
|
float tx1 = (right - oldX) / oldW;
|
|
float ty0 = (top - oldY) / oldH;
|
|
float ty1 = (bottom - oldY) / oldH;
|
|
|
|
x = left;
|
|
y = top;
|
|
w = right - left;
|
|
h = bottom - top;
|
|
u0 = oldU0 + (oldU1 - oldU0) * tx0;
|
|
u1 = oldU0 + (oldU1 - oldU0) * tx1;
|
|
v0 = oldV0 + (oldV1 - oldV0) * ty0;
|
|
v1 = oldV0 + (oldV1 - oldV0) * ty1;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-frame drawing context passed through the <see cref="UiElement"/>
|
|
/// tree. Wraps a <see cref="TextRenderer"/> (our 2D sprite batcher) and a
|
|
/// transform stack so elements can draw in local coordinates.
|
|
///
|
|
/// Retail equivalent: the implicit context <c>FUN_005da8f0</c> walks with
|
|
/// when iterating the UI tree. Our version is explicit so it plugs
|
|
/// cleanly into Silk.NET.
|
|
/// </summary>
|
|
public sealed class UiRenderContext
|
|
{
|
|
public TextRenderer TextRenderer { get; }
|
|
public BitmapFont? DefaultFont { get; set; }
|
|
public Vector2 ScreenSize { get; }
|
|
|
|
// Transform stack — simple 2D translate (no rotation/scale for UI).
|
|
private readonly System.Collections.Generic.List<Vector2> _stack = new();
|
|
private Vector2 _current;
|
|
private readonly System.Collections.Generic.List<UiClipRect?> _clipStack = new();
|
|
private UiClipRect? _clip;
|
|
|
|
// Alpha (opacity) stack — a window pushes its Opacity so its background/sprite
|
|
// draws fade (retail's translucent-chat effect). Text draws bypass this (they go
|
|
// straight to TextRenderer), so text stays sharp over a translucent background.
|
|
private readonly System.Collections.Generic.List<float> _alphaStack = new();
|
|
private float _alpha = 1f;
|
|
|
|
/// <summary>Current cumulative opacity multiplier applied to sprite + rect draws.</summary>
|
|
public float AlphaMod => _alpha;
|
|
|
|
/// <summary>Multiply <paramref name="a"/> into the running opacity. Pair with <see cref="PopAlpha"/>.</summary>
|
|
public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; }
|
|
|
|
/// <summary>Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays
|
|
/// that must stay opaque even inside a translucent window. Pair with <see cref="PopAlpha"/>.</summary>
|
|
public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; }
|
|
|
|
public void PopAlpha()
|
|
{
|
|
if (_alphaStack.Count == 0) return;
|
|
_alpha = _alphaStack[^1];
|
|
_alphaStack.RemoveAt(_alphaStack.Count - 1);
|
|
}
|
|
|
|
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
|
|
{
|
|
TextRenderer = tr;
|
|
ScreenSize = screenSize;
|
|
DefaultFont = defaultFont;
|
|
}
|
|
|
|
/// <summary>Push a relative translate. Must be paired with <see cref="PopTransform"/>.</summary>
|
|
public void PushTransform(float dx, float dy)
|
|
{
|
|
_stack.Add(_current);
|
|
_current += new Vector2(dx, dy);
|
|
}
|
|
|
|
public void PopTransform()
|
|
{
|
|
if (_stack.Count == 0) return;
|
|
_current = _stack[^1];
|
|
_stack.RemoveAt(_stack.Count - 1);
|
|
}
|
|
|
|
public Vector2 CurrentOrigin => _current;
|
|
|
|
/// <summary>Intersect descendant drawing with a local-space viewport.</summary>
|
|
public void PushClip(float x, float y, float w, float h)
|
|
{
|
|
_clipStack.Add(_clip);
|
|
var next = new UiClipRect(
|
|
_current.X + x,
|
|
_current.Y + y,
|
|
_current.X + x + MathF.Max(0f, w),
|
|
_current.Y + y + MathF.Max(0f, h));
|
|
_clip = _clip is { } current
|
|
? UiClipRect.Intersect(current, next)
|
|
: next;
|
|
}
|
|
|
|
public void PopClip()
|
|
{
|
|
if (_clipStack.Count == 0) return;
|
|
_clip = _clipStack[^1];
|
|
_clipStack.RemoveAt(_clipStack.Count - 1);
|
|
}
|
|
|
|
/// <summary>Route subsequent draws to the overlay layer (flushed on top of the whole
|
|
/// UI). Used by the root for the popup/overlay traversal. Pair with <see cref="EndOverlayLayer"/>.</summary>
|
|
public void BeginOverlayLayer() => TextRenderer.OverlayMode = true;
|
|
public void EndOverlayLayer() => TextRenderer.OverlayMode = false;
|
|
|
|
// ── Pass-through draw helpers (add current translate) ──────────────
|
|
|
|
public void DrawRect(float x, float y, float w, float h, Vector4 color)
|
|
{
|
|
x += _current.X;
|
|
y += _current.Y;
|
|
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
|
|
TextRenderer.DrawRect(x, y, w, h, ApplyAlpha(color));
|
|
}
|
|
|
|
/// <summary>Solid-colour fill drawn in the SPRITE bucket (painter order with text), for
|
|
/// a panel BACKGROUND that text draws on top of. <see cref="DrawRect"/> composites after
|
|
/// all sprites and would cover the text — use this for backgrounds, that for foreground
|
|
/// fills (carets, vital bars).</summary>
|
|
public void DrawFill(float x, float y, float w, float h, Vector4 color)
|
|
{
|
|
x += _current.X;
|
|
y += _current.Y;
|
|
if (!ClipRect(ref x, ref y, ref w, ref h)) return;
|
|
TextRenderer.DrawFill(x, y, w, h, ApplyAlpha(color));
|
|
}
|
|
|
|
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
|
|
{
|
|
if (thickness <= 0f || w <= 0f || h <= 0f) return;
|
|
float t = MathF.Min(thickness, MathF.Min(w, h) * 0.5f);
|
|
DrawRect(x, y, w, t, color);
|
|
DrawRect(x, y + h - t, w, t, color);
|
|
DrawRect(x, y + t, t, h - 2f * t, color);
|
|
DrawRect(x + w - t, y + t, t, h - 2f * t, color);
|
|
}
|
|
|
|
public void DrawSprite(uint texture, float x, float y, float w, float h,
|
|
float u0, float v0, float u1, float v1, Vector4 tint)
|
|
{
|
|
x += _current.X;
|
|
y += _current.Y;
|
|
DrawSpriteAbsolute(texture, x, y, w, h, u0, v0, u1, v1, tint, applyAlpha: true);
|
|
}
|
|
|
|
private void DrawSpriteAbsolute(
|
|
uint texture, float x, float y, float w, float h,
|
|
float u0, float v0, float u1, float v1, Vector4 tint, bool applyAlpha)
|
|
{
|
|
if (_clip is { } clip
|
|
&& !UiClipRect.TryClipSprite(
|
|
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1))
|
|
return;
|
|
TextRenderer.DrawSprite(
|
|
texture, x, y, w, h, u0, v0, u1, v1,
|
|
applyAlpha ? ApplyAlpha(tint) : tint);
|
|
}
|
|
|
|
private bool ClipRect(ref float x, ref float y, ref float w, ref float h)
|
|
{
|
|
if (_clip is not { } clip)
|
|
return w > 0f && h > 0f;
|
|
float u0 = 0f, v0 = 0f, u1 = 1f, v1 = 1f;
|
|
return UiClipRect.TryClipSprite(
|
|
clip, ref x, ref y, ref w, ref h, ref u0, ref v0, ref u1, ref v1);
|
|
}
|
|
|
|
/// <summary>Multiply the current window opacity into a draw color's alpha.</summary>
|
|
private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha);
|
|
|
|
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
|
|
{
|
|
var f = font ?? DefaultFont;
|
|
if (f is null) return;
|
|
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
|
|
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
|
|
/// typographic block (in this element's local space). Mirrors retail's
|
|
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
|
|
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
|
|
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
|
|
/// fill). The pen advances by
|
|
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
|
|
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
|
|
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
|
|
/// glyph's OffsetY into the atlas.
|
|
///
|
|
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
|
|
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
|
|
/// runs the outline pass only when <c>m_bitField & 0x10</c> is set — i.e. the
|
|
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
|
|
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
|
|
/// always-on outline shows as a grey halo over the solid menu panel. Pass
|
|
/// <c>outline:true</c> only for elements retail outlines.</para>
|
|
/// </summary>
|
|
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
|
|
{
|
|
if (font is null || string.IsNullOrEmpty(text)) return;
|
|
|
|
// Baseline of this line in local space; retail draws glyphs whose
|
|
// descriptor OffsetY already places them relative to the line top, so we
|
|
// anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
|
|
float originX = _current.X + x;
|
|
float originY = _current.Y + y;
|
|
float pen = originX;
|
|
|
|
// Snap the LINE baseline to a whole pixel ONCE. Retail's
|
|
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
|
|
// (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every
|
|
// glyph on a line shares one integer baseline. If we instead round EACH glyph's
|
|
// Y independently and the caller passes a fractional line Y (e.g. a channel-menu
|
|
// item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round
|
|
// to different rows and the line looks crooked ("letters dip down"). The vitals
|
|
// digits never showed it because their bar baseline lands on an integer; chat text
|
|
// does. Snapping the baseline once, then adding the integer offset, keeps the whole
|
|
// line on one row and pixel-aligned.
|
|
float baseY = System.MathF.Round(originY);
|
|
|
|
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
|
|
|
|
for (int i = 0; i < text.Length; i++)
|
|
{
|
|
if (!font.TryGetGlyph(text[i], out var g))
|
|
continue;
|
|
|
|
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
|
|
// true fractional advance). Vertical: integer baseline + integer per-glyph
|
|
// offset — never an independent per-glyph round (see baseY note above).
|
|
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
|
|
float gy = baseY + g.VerticalOffsetBefore;
|
|
float gw = g.Width;
|
|
float gh = g.Height;
|
|
|
|
if (gw > 0f && gh > 0f)
|
|
{
|
|
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
|
|
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
|
|
// text is crisp fill-only and free of the grey halo over solid panels.
|
|
if (outline && font.BackgroundTexture != 0)
|
|
{
|
|
var (bu0, bv0, bu1, bv1) = AtlasUv(
|
|
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
|
font.BackgroundWidth, font.BackgroundHeight);
|
|
DrawSpriteAbsolute(
|
|
font.BackgroundTexture, gx, gy, gw, gh,
|
|
bu0, bv0, bu1, bv1, outlineTint, applyAlpha: false);
|
|
}
|
|
|
|
// Foreground (fill) atlas pass, tinted with the requested color.
|
|
var (fu0, fv0, fu1, fv1) = AtlasUv(
|
|
g.OffsetX, g.OffsetY, g.Width, g.Height,
|
|
font.ForegroundWidth, font.ForegroundHeight);
|
|
DrawSpriteAbsolute(
|
|
font.ForegroundTexture, gx, gy, gw, gh,
|
|
fu0, fv0, fu1, fv1, color, applyAlpha: false);
|
|
}
|
|
|
|
pen += UiDatFont.GlyphAdvance(g);
|
|
}
|
|
}
|
|
|
|
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
|
|
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
|
|
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>
|
|
private static (float u0, float v0, float u1, float v1) AtlasUv(
|
|
int offsetX, int offsetY, int width, int height, int atlasW, int atlasH)
|
|
{
|
|
if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f);
|
|
float u0 = offsetX / (float)atlasW;
|
|
float v0 = offsetY / (float)atlasH;
|
|
float u1 = (offsetX + width) / (float)atlasW;
|
|
float v1 = (offsetY + height) / (float)atlasH;
|
|
return (u0, v0, u1, v1);
|
|
}
|
|
}
|