feat(D.2b): UI render infra — overlay layer, DrawFill, crisp text, write-mode focus

The retail-look render + focus primitives this chat pass builds on:

- TextRenderer: an OVERLAY layer (sprite/rect/text buckets flushed AFTER the
  normal layer) so an open popup composites on top of everything incl. rect
  panel backgrounds; a DrawFill primitive (solid quad via a 1x1 white texture)
  routed through the SPRITE bucket so a panel background draws UNDER its text
  instead of being washed by the later rect bucket; and the text pass now
  disables SampleAlphaToCoverage + Multisample so glyph alpha edges aren't
  dithered into MSAA coverage (the "fuzzy text") — self-contained GL state
  per feedback_render_self_contained_gl_state.
- UiRenderContext.DrawStringDat: snap the line baseline to a whole pixel ONCE
  then add the integer per-glyph offset (retail DrawCharacter takes an int
  pen-Y + schar m_VerticalOffsetBefore) — fixes the "letters dip down" jitter
  at a fractional line origin. Outline pass is now opt-in (retail gates it per
  element via SetOutline; default off = crisp fill-only). Adds DrawFill +
  Begin/EndOverlayLayer.
- UiElement: OnDrawOverlay + DrawOverlays (second traversal), FindRoot (blur
  self), ResetAnchorCapture (re-baseline an anchored element after reflow).
- UiRoot: runs the overlay pass after the main tree; Tab/Enter focuses the
  DefaultTextInput (write-mode activation); a left click on a non-edit target
  blurs the focused input (exit write mode without submitting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-16 15:23:48 +02:00
parent 828bec5fb5
commit ebfeaff840
4 changed files with 248 additions and 63 deletions

View file

@ -25,6 +25,7 @@ public sealed unsafe class TextRenderer : IDisposable
private readonly Shader _shader; private readonly Shader _shader;
private readonly uint _vao; private readonly uint _vao;
private readonly uint _vbo; private readonly uint _vbo;
private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket
private int _vboCapacityBytes; private int _vboCapacityBytes;
private readonly List<float> _textBuf = new(8192); private readonly List<float> _textBuf = new(8192);
@ -42,6 +43,21 @@ public sealed unsafe class TextRenderer : IDisposable
private int _rectVerts; private int _rectVerts;
private Vector2 _screenSize; private Vector2 _screenSize;
// Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text
// buckets, so open popups/menus composite on top of EVERYTHING, including translucent
// rect panel backgrounds (which otherwise always win because rects flush after
// sprites). Routed by OverlayMode; the UI root sets it for the popup traversal.
private readonly List<float> _overlayTextBuf = new(1024);
private readonly List<float> _overlayRectBuf = new(256);
private readonly List<SpriteSeg> _overlaySpriteSegs = new();
private int _overlaySegUsed;
private int _overlayTextVerts;
private int _overlayRectVerts;
/// <summary>When true, Draw* calls route to the overlay layer (flushed last, on top
/// of all normal-layer geometry). Set by the UI root around the popup/overlay pass.</summary>
public bool OverlayMode { get; set; }
public TextRenderer(GL gl, string shaderDir) public TextRenderer(GL gl, string shaderDir)
{ {
_gl = gl; _gl = gl;
@ -65,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindVertexArray(0); _gl.BindVertexArray(0);
// 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE
// bucket (the shader multiplies texel×color → white×color = color). Lets a panel
// background draw UNDER its text in painter order, which DrawRect's separate
// bucket cannot (it always composites after all sprites).
_whiteTex = _gl.GenTexture();
_gl.BindTexture(TextureTarget.Texture2D, _whiteTex);
Span<byte> whitePixel = stackalloc byte[] { 255, 255, 255, 255 };
fixed (byte* wp = whitePixel)
_gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0,
PixelFormat.Rgba, PixelType.UnsignedByte, wp);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest);
_gl.BindTexture(TextureTarget.Texture2D, 0);
} }
/// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary> /// <summary>Begin a HUD pass. Call once per frame before any Draw* calls.</summary>
@ -76,15 +106,29 @@ public sealed unsafe class TextRenderer : IDisposable
_segUsed = 0; // pool the SpriteSeg objects across frames _segUsed = 0; // pool the SpriteSeg objects across frames
_textVerts = 0; _textVerts = 0;
_rectVerts = 0; _rectVerts = 0;
_overlayTextBuf.Clear();
_overlayRectBuf.Clear();
_overlaySegUsed = 0;
_overlayTextVerts = 0;
_overlayRectVerts = 0;
OverlayMode = false;
} }
/// <summary>Draw a filled rectangle in screen pixel space.</summary> /// <summary>Draw a filled rectangle in screen pixel space.</summary>
public void DrawRect(float x, float y, float w, float h, Vector4 color) public void DrawRect(float x, float y, float w, float h, Vector4 color)
{ {
AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; }
_rectVerts += 6; else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; }
} }
/// <summary>Draw a solid-colour quad through the SPRITE bucket (and the overlay layer
/// when active), so it composites in painter order with sprites + dat-font text. Use
/// this — not <see cref="DrawRect"/> — for a panel BACKGROUND that text draws on top of:
/// DrawRect's bucket always flushes after all sprites, so a rect background would cover
/// the text instead.</summary>
public void DrawFill(float x, float y, float w, float h, Vector4 color)
=> DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color);
/// <summary>Draw a 1-pixel-thick outline rect.</summary> /// <summary>Draw a 1-pixel-thick outline rect.</summary>
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
{ {
@ -129,11 +173,8 @@ public sealed unsafe class TextRenderer : IDisposable
if (gw > 0 && gh > 0) if (gw > 0 && gh > 0)
{ {
AppendQuad(_textBuf, if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; }
gx, gy, gw, gh, else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; }
g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY,
color);
_textVerts += 6;
} }
cursorX += g.Advance; cursorX += g.Advance;
} }
@ -147,26 +188,32 @@ public sealed unsafe class TextRenderer : IDisposable
public void DrawSprite(uint texture, float x, float y, float w, float h, public void DrawSprite(uint texture, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 tint) float u0, float v0, float u1, float v1, Vector4 tint)
{ {
SpriteSeg seg; SpriteSeg seg = OverlayMode
if (_segUsed > 0 && _spriteSegs[_segUsed - 1].Texture == texture) ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture)
{ : NextSpriteSeg(_spriteSegs, ref _segUsed, texture);
seg = _spriteSegs[_segUsed - 1]; // extend the current same-texture run
}
else if (_segUsed < _spriteSegs.Count)
{
seg = _spriteSegs[_segUsed++]; // reuse a pooled segment
seg.Texture = texture;
seg.Verts.Clear();
}
else
{
seg = new SpriteSeg { Texture = texture };
_spriteSegs.Add(seg);
_segUsed++;
}
AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint);
} }
/// <summary>Pick the sprite segment for <paramref name="texture"/>: extend the current
/// same-texture run, else reuse a pooled segment, else allocate. Submission order is
/// preserved (painter z-order for sprite-on-sprite UI).</summary>
private static SpriteSeg NextSpriteSeg(List<SpriteSeg> segs, ref int used, uint texture)
{
if (used > 0 && segs[used - 1].Texture == texture)
return segs[used - 1];
if (used < segs.Count)
{
var s = segs[used++];
s.Texture = texture;
s.Verts.Clear();
return s;
}
var ns = new SpriteSeg { Texture = texture };
segs.Add(ns);
used++;
return ns;
}
private static void AppendQuad(List<float> buf, private static void AppendQuad(List<float> buf,
float x, float y, float w, float h, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, Vector4 color) float u0, float v0, float u1, float v1, Vector4 color)
@ -197,8 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable
/// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary> /// <summary>Upload + draw accumulated rects + text. font may be null if only DrawRect was used.</summary>
public void Flush(BitmapFont? font) public void Flush(BitmapFont? font)
{ {
bool hasSprites = _segUsed > 0; bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0;
if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0;
if (!anyNormal && !anyOverlay) return;
_shader.Use(); _shader.Use();
_shader.SetVec2("uScreenSize", _screenSize); _shader.SetVec2("uScreenSize", _screenSize);
@ -210,6 +258,15 @@ public sealed unsafe class TextRenderer : IDisposable
bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest);
bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasBlend = _gl.IsEnabled(EnableCap.Blend);
bool wasCull = _gl.IsEnabled(EnableCap.CullFace); bool wasCull = _gl.IsEnabled(EnableCap.CullFace);
// The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher,
// QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha
// EDGE is converted to dithered MSAA coverage instead of a clean alpha blend —
// the "text not sharp / fuzzy" artifact. The UI composites with straight alpha
// blending and must own this state (feedback_render_self_contained_gl_state).
bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage);
bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample);
_gl.Disable(EnableCap.SampleAlphaToCoverage);
_gl.Disable(EnableCap.Multisample);
_gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.DepthTest);
_gl.Disable(EnableCap.CullFace); _gl.Disable(EnableCap.CullFace);
_gl.DepthMask(false); _gl.DepthMask(false);
@ -221,19 +278,40 @@ public sealed unsafe class TextRenderer : IDisposable
// 2. Untextured rects — widget fills (e.g. vital bars) on the chrome // 2. Untextured rects — widget fills (e.g. vital bars) on the chrome
// 3. Text glyphs — on top // 3. Text glyphs — on top
// Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs, // Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs,
// so sprite-on-sprite z is preserved — each meter's dat-font number draws // so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text)
// after its own bar sprites. Buckets 2 (rects) + 3 (debug text) composite // composite on top, in that order. The OVERLAY layer repeats all three
// on top, in that order. // AFTER the normal layer, so open popups beat even the rect backgrounds.
DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font);
DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font);
// 1. RGBA dat sprites first — one draw call per distinct GL texture. // Restore GL state.
if (hasSprites) _gl.DepthMask(true);
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage);
if (wasMsaa) _gl.Enable(EnableCap.Multisample);
_gl.BindVertexArray(0);
}
/// <summary>Draw one compositing layer: sprites (submission order, one call per
/// texture) → untextured rects → debug-font text. Shared by the normal and overlay
/// layers; GL state + shader are set up by <see cref="Flush"/>.</summary>
private void DrawLayer(
List<SpriteSeg> spriteSegs, int segUsed,
List<float> rectBuf, int rectVerts,
List<float> textBuf, int textVerts, BitmapFont? font)
{
// 1. RGBA dat sprites — one draw call per distinct GL texture.
if (segUsed > 0)
{ {
_shader.SetInt("uUseTexture", 2); _shader.SetInt("uUseTexture", 2);
_gl.ActiveTexture(TextureUnit.Texture0); _gl.ActiveTexture(TextureUnit.Texture0);
_shader.SetInt("uTex", 0); _shader.SetInt("uTex", 0);
for (int i = 0; i < _segUsed; i++) for (int i = 0; i < segUsed; i++)
{ {
var seg = _spriteSegs[i]; var seg = spriteSegs[i];
if (seg.Verts.Count == 0) continue; if (seg.Verts.Count == 0) continue;
_gl.BindTexture(TextureTarget.Texture2D, seg.Texture); _gl.BindTexture(TextureTarget.Texture2D, seg.Texture);
UploadBuffer(seg.Verts); UploadBuffer(seg.Verts);
@ -242,31 +320,23 @@ public sealed unsafe class TextRenderer : IDisposable
} }
// 2. Untextured rects — widget fills on top of the chrome. // 2. Untextured rects — widget fills on top of the chrome.
if (_rectVerts > 0) if (rectVerts > 0)
{ {
_shader.SetInt("uUseTexture", 0); _shader.SetInt("uUseTexture", 0);
UploadBuffer(_rectBuf); UploadBuffer(rectBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts);
} }
// 3. Textured text glyphs on top. // 3. Textured debug-font text glyphs on top.
if (_textVerts > 0 && font is not null) if (textVerts > 0 && font is not null)
{ {
_shader.SetInt("uUseTexture", 1); _shader.SetInt("uUseTexture", 1);
_gl.ActiveTexture(TextureUnit.Texture0); _gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId);
_shader.SetInt("uTex", 0); _shader.SetInt("uTex", 0);
UploadBuffer(_textBuf); UploadBuffer(textBuf);
_gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts);
} }
// Restore GL state.
_gl.DepthMask(true);
if (!wasBlend) _gl.Disable(EnableCap.Blend);
if (wasCull) _gl.Enable(EnableCap.CullFace);
if (wasDepth) _gl.Enable(EnableCap.DepthTest);
_gl.BindVertexArray(0);
} }
private void UploadBuffer(List<float> buf) private void UploadBuffer(List<float> buf)
@ -289,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable
public void Dispose() public void Dispose()
{ {
_gl.DeleteTexture(_whiteTex);
_gl.DeleteBuffer(_vbo); _gl.DeleteBuffer(_vbo);
_gl.DeleteVertexArray(_vao); _gl.DeleteVertexArray(_vao);
_shader.Dispose(); _shader.Dispose();

View file

@ -154,6 +154,16 @@ public abstract class UiElement
/// </summary> /// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { } protected virtual void OnDraw(UiRenderContext ctx) { }
/// <summary>
/// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this
/// element's position in the tree — open menus, dropdowns, tooltips. Called in
/// a SECOND traversal after the whole tree's <see cref="OnDraw"/> pass, with the
/// same accumulated transform/alpha this element had during its normal draw.
/// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly
/// this reason; this is the equivalent without reparenting. Default: nothing.
/// </summary>
protected virtual void OnDrawOverlay(UiRenderContext ctx) { }
/// <summary>Per-frame tick (animations, timers, caret blink).</summary> /// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { } protected virtual void OnTick(double deltaSeconds) { }
@ -213,6 +223,34 @@ public abstract class UiElement
} }
} }
/// <summary>Second draw traversal: re-walks the tree applying the same
/// transform/alpha as <see cref="DrawSelfAndChildren"/> and calls
/// <see cref="OnDrawOverlay"/> on each element, so popups composite on top of
/// everything drawn in the main pass (dat-font glyphs and sprites share one
/// submission-ordered bucket, so later submissions win).</summary>
internal void DrawOverlays(UiRenderContext ctx)
{
if (!Visible) return;
ctx.PushTransform(Left, Top);
ctx.PushAlpha(Opacity);
try
{
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);
}
}
finally
{
ctx.PopAlpha();
ctx.PopTransform();
}
}
internal void TickSelfAndChildren(double dt) internal void TickSelfAndChildren(double dt)
{ {
if (!Visible) return; if (!Visible) return;
@ -275,6 +313,22 @@ public abstract class UiElement
Left = x; Top = y; Width = w; Height = h; Left = x; Top = y; Width = w; Height = h;
} }
/// <summary>Forget the captured anchor margins so the next <see cref="ApplyAnchor"/>
/// re-captures them from the CURRENT rect. Call after manually repositioning/resizing
/// an anchored element at runtime (e.g. reflowing the chat input when the channel
/// button width changes) so the new rect becomes the anchor baseline.</summary>
internal void ResetAnchorCapture() => _anchorCaptured = false;
/// <summary>Walk up to the owning <see cref="UiRoot"/> (the top of the tree), or null
/// if this element is not attached. Lets a widget reach focus/capture services — e.g.
/// a chat input blurring itself (exiting write mode) after submit.</summary>
internal UiRoot? FindRoot()
{
UiElement e = this;
while (e.Parent is not null) e = e.Parent;
return e as UiRoot;
}
/// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width /// <summary>Compute an anchored child rect. Left&amp;Right ⇒ stretch width
/// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise
/// pin left at fixed width. Same logic vertically.</summary> /// pin left at fixed width. Same logic vertically.</summary>

View file

@ -68,11 +68,23 @@ public sealed class UiRenderContext
public Vector2 CurrentOrigin => _current; public Vector2 CurrentOrigin => _current;
/// <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) ────────────── // ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color) 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)); => TextRenderer.DrawRect(_current.X + x, _current.Y + 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));
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) 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); => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness);
@ -102,10 +114,17 @@ public sealed class UiRenderContext
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each /// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis /// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the /// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
/// glyph's OffsetY into the atlas. If the font has no background atlas the /// glyph's OffsetY into the atlas.
/// outline pass is skipped. ///
/// <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 &amp; 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> /// </summary>
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color) public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
{ {
if (font is null || string.IsNullOrEmpty(text)) return; if (font is null || string.IsNullOrEmpty(text)) return;
@ -116,32 +135,44 @@ public sealed class UiRenderContext
float originY = _current.Y + y; float originY = _current.Y + y;
float pen = originX; float pen = originX;
var outline = new Vector4(0f, 0f, 0f, color.W); // 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++) for (int i = 0; i < text.Length; i++)
{ {
if (!font.TryGetGlyph(text[i], out var g)) if (!font.TryGetGlyph(text[i], out var g))
continue; continue;
// Pixel-snap each glyph's destination to whole pixels so the atlas samples // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// texel-aligned. Without this, a fractional bar width after resize puts the // true fractional advance). Vertical: integer baseline + integer per-glyph
// centered number on a sub-pixel x and linear filtering smears the glyphs // offset — never an independent per-glyph round (see baseY note above).
// (the "unsharp at certain sizes" artifact). The pen keeps its true
// fractional advance, so only the per-glyph dest is snapped.
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = System.MathF.Round(originY + g.VerticalOffsetBefore); float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width; float gw = g.Width;
float gh = g.Height; float gh = g.Height;
if (gw > 0f && gh > 0f) if (gw > 0f && gh > 0f)
{ {
// Background (outline) atlas pass, tinted black — drawn behind. // Background (outline) atlas pass, tinted black — drawn behind. Gated by
if (font.BackgroundTexture != 0) // `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( var (bu0, bv0, bu1, bv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height, g.OffsetX, g.OffsetY, g.Width, g.Height,
font.BackgroundWidth, font.BackgroundHeight); font.BackgroundWidth, font.BackgroundHeight);
TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outline); TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint);
} }
// Foreground (fill) atlas pass, tinted with the requested color. // Foreground (fill) atlas pass, tinted with the requested color.

View file

@ -44,6 +44,10 @@ public sealed class UiRoot : UiElement
/// <summary>Widget currently receiving keyboard events.</summary> /// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; } public UiElement? KeyboardFocus { get; private set; }
/// <summary>The edit control activated by Tab/Enter when nothing is focused — retail's
/// chat input "write mode" toggle. Set by the host once the chat window is built.</summary>
public UiElement? DefaultTextInput { get; set; }
/// <summary> /// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect /// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48. /// are ignored. Retail sets this via Device vtable +0x48.
@ -131,6 +135,13 @@ public sealed class UiRoot : UiElement
// Render children (panels) sorted by z-order — modal last so it // Render children (panels) sorted by z-order — modal last so it
// sits on top. // sits on top.
DrawSelfAndChildren(ctx); DrawSelfAndChildren(ctx);
// Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the
// chat channel menu isn't greyed by the translucent chat panel that draws
// after it in the main pass). Routed to the renderer's overlay layer so it
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
ctx.BeginOverlayLayer();
DrawOverlays(ctx);
ctx.EndOverlayLayer();
} }
// ── Input entry points (called from GameWindow's Silk.NET handlers) ── // ── Input entry points (called from GameWindow's Silk.NET handlers) ──
@ -200,12 +211,18 @@ public sealed class UiRoot : UiElement
var (target, _, _) = HitTestTopDown(x, y); var (target, _, _) = HitTestTopDown(x, y);
if (target is null) if (target is null)
{ {
// Clicking the 3D world exits write mode (no submit) and returns control to
// the character — retail blurs the chat input on an outside click.
if (btn == UiMouseButton.Left) SetKeyboardFocus(null);
WorldMouseFallThrough?.Invoke(btn, x, y, flags); WorldMouseFallThrough?.Invoke(btn, x, y, flags);
return; return;
} }
// Set keyboard focus if target accepts it. // Keyboard focus follows a left click: the input bar (an edit control) takes
if (target.AcceptsFocus) SetKeyboardFocus(target); // focus = enters write mode; clicking anything else (chrome, Send, scrollbar,
// menu, another window) blurs the input = exits write mode WITHOUT submitting.
if (btn == UiMouseButton.Left)
SetKeyboardFocus(target.AcceptsFocus ? target : null);
SetCapture(target); SetCapture(target);
@ -355,6 +372,18 @@ public sealed class UiRoot : UiElement
public void OnKeyDown(int vk, uint lparam = 0) public void OnKeyDown(int vk, uint lparam = 0)
{ {
// Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat
// input (retail's chat-activation hotkeys). Consumed so the same press doesn't
// also fall through to a game hotkey.
if (KeyboardFocus is null && DefaultTextInput is not null
&& (vk == (int)Silk.NET.Input.Key.Tab
|| vk == (int)Silk.NET.Input.Key.Enter
|| vk == (int)Silk.NET.Input.Key.KeypadEnter))
{
SetKeyboardFocus(DefaultTextInput);
return;
}
// Focus widget first. // Focus widget first.
if (KeyboardFocus is not null) if (KeyboardFocus is not null)
{ {