fix(ui): complete retail inventory scroll polish

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>
This commit is contained in:
Erik 2026-07-11 10:56:33 +02:00
parent ea72c395c9
commit 15aa3b9aff
15 changed files with 379 additions and 34 deletions

View file

@ -183,7 +183,9 @@ internal static class MockupDesktop
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
MaxHeight = 560f,
MaxHeight = Math.Max(
contentH + 2f * RetailChromeSprites.Border,
stack.UiHost.Root.Height - 18f),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});

View file

@ -251,6 +251,12 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
public void Populate()
{
// Retail refreshes listbox contents without resetting m_iScrollableY.
// Defer our procedural rebuild so the first re-added cell cannot collapse
// MaxScroll temporarily and clamp the user's offset back to zero.
using IDisposable? contentsLayout = _contentsGrid?.DeferLayout();
using IDisposable? containersLayout = _containerList?.DeferLayout();
uint p = _playerGuid();
uint open = EffectiveOpen();

View file

@ -581,7 +581,11 @@ public sealed class RetailUiRuntime : IDisposable
ResizeX = false,
ResizeY = true,
ResizableEdges = ResizeEdges.Bottom,
MaxHeight = 560f,
// The old 560px cap left a previously expanded window permanently
// parked at its maximum. Let it grow to the available screen height.
MaxHeight = Math.Max(
root.Height + 2f * RetailChromeSprites.Border,
Host.Root.Height - root.Top),
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
});

View file

@ -320,6 +320,13 @@ public abstract class UiElement
/// </summary>
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// <summary>
/// When true, descendant drawing and hit-testing are clipped to this element's
/// local bounds. Scrollable listboxes use this so edge rows can remain visible
/// at arbitrary pixel offsets without painting or receiving input outside the viewport.
/// </summary>
protected virtual bool ClipsChildren => false;
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
@ -378,11 +385,22 @@ public abstract class UiElement
// Children painted back-to-front (lowest ZOrder first).
if (_children.Count > 0)
{
// Avoid LINQ allocation by copying to a temp array and sorting.
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx);
bool clipsChildren = ClipsChildren;
if (clipsChildren)
ctx.PushClip(0f, 0f, Width, Height);
try
{
// Avoid LINQ allocation by copying to a temp array and sorting.
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx);
}
finally
{
if (clipsChildren)
ctx.PopClip();
}
}
// Foreground pass for this element (e.g. a window frame's border drawn
@ -411,10 +429,21 @@ public abstract class UiElement
OnDrawOverlay(ctx);
if (_children.Count > 0)
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawOverlays(ctx);
bool clipsChildren = ClipsChildren;
if (clipsChildren)
ctx.PushClip(0f, 0f, Width, Height);
try
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawOverlays(ctx);
}
finally
{
if (clipsChildren)
ctx.PopClip();
}
}
}
finally
@ -440,6 +469,9 @@ public abstract class UiElement
internal UiElement? HitTest(float localX, float localY)
{
if (!Visible || !Enabled) return null;
if (ClipsChildren
&& (localX < 0f || localX >= Width || localY < 0f || localY >= Height))
return null;
// Children first, in reverse Z-order (topmost first). ClickThrough means
// THIS element is transparent to the pointer — but its children are NOT.

View file

@ -22,6 +22,7 @@ public enum UiItemListFlow
public sealed class UiItemList : UiElement
{
private readonly List<UiItemSlot> _cells = new();
private int _layoutDeferralDepth;
/// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
/// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
@ -89,7 +90,19 @@ public sealed class UiItemList : UiElement
cell.Anchors = AnchorEdges.None;
_cells.Add(cell);
AddChild(cell);
LayoutCells();
if (_layoutDeferralDepth == 0)
LayoutCells();
}
/// <summary>
/// Batch a procedural list rebuild without repeatedly shrinking the scroll
/// extents as cells are re-added. The retained pixel offset is clamped once
/// against the completed list when the outermost scope ends.
/// </summary>
public IDisposable DeferLayout()
{
_layoutDeferralDepth++;
return new LayoutDeferral(this);
}
/// <summary>Grid columns. 1 = single column. Ignored in fill mode.</summary>
@ -143,7 +156,7 @@ public sealed class UiItemList : UiElement
/// <summary>Position every cell per the current mode: fill (CellWidth&lt;=0) sizes the single
/// cell to the list; grid (CellWidth&gt;0) tiles cells by <see cref="Flow"/>, offset by the scroll position
/// with whole-row vertical clipping (cells fully outside the view are hidden).</summary>
/// with pixel viewport clipping (partially visible edge rows are cropped).</summary>
internal void LayoutCells()
{
if (CellWidth <= 0f)
@ -175,12 +188,14 @@ public sealed class UiItemList : UiElement
float top = baseY - scrollY;
var cell = _cells[i];
cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight;
// Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully inside
// [0, Height] draws; a partially-scrolled row is hidden.
cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f;
// Retail listboxes retain intersecting edge rows at arbitrary pixel offsets.
// The UiElement child-clip traversal crops their visible portion.
cell.Visible = top < Height && top + CellHeight > 0f;
}
}
protected override bool ClipsChildren => CellWidth > 0f;
public void Flush()
{
foreach (var c in _cells) RemoveChild(c);
@ -204,4 +219,27 @@ public sealed class UiItemList : UiElement
// fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells.
LayoutCells();
}
private void EndLayoutDeferral()
{
if (_layoutDeferralDepth <= 0)
return;
_layoutDeferralDepth--;
if (_layoutDeferralDepth == 0)
LayoutCells();
}
private sealed class LayoutDeferral : IDisposable
{
private UiItemList? _owner;
public LayoutDeferral(UiItemList owner) => _owner = owner;
public void Dispose()
{
UiItemList? owner = _owner;
_owner = null;
owner?.EndLayoutDeferral();
}
}
}

View file

@ -3,6 +3,51 @@ 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
@ -21,6 +66,8 @@ public sealed class UiRenderContext
// 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
@ -68,6 +115,27 @@ public sealed class UiRenderContext
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;
@ -76,22 +144,64 @@ public sealed class UiRenderContext
// ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color)
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(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)
=> TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(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)
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
{
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)
=> TextRenderer.DrawSprite(texture,
_current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(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);
@ -172,14 +282,18 @@ public sealed class UiRenderContext
var (bu0, bv0, bu1, bv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.BackgroundWidth, font.BackgroundHeight);
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
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);
TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color);
DrawSpriteAbsolute(
font.ForegroundTexture, gx, gy, gw, gh,
fu0, fv0, fu1, fv1, color, applyAlpha: false);
}
pen += UiDatFont.GlyphAdvance(g);