From ebfeaff840d3d017c84662c491c2eaaad70d71b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:23:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(D.2b):=20UI=20render=20infra=20=E2=80=94?= =?UTF-8?q?=20overlay=20layer,=20DrawFill,=20crisp=20text,=20write-mode=20?= =?UTF-8?q?focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/TextRenderer.cs | 167 +++++++++++++++------- src/AcDream.App/UI/UiElement.cs | 54 +++++++ src/AcDream.App/UI/UiRenderContext.cs | 57 ++++++-- src/AcDream.App/UI/UiRoot.cs | 33 ++++- 4 files changed, 248 insertions(+), 63 deletions(-) diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index bef2e2ca..88592057 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -25,6 +25,7 @@ public sealed unsafe class TextRenderer : IDisposable private readonly Shader _shader; private readonly uint _vao; private readonly uint _vbo; + private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private int _vboCapacityBytes; private readonly List _textBuf = new(8192); @@ -42,6 +43,21 @@ public sealed unsafe class TextRenderer : IDisposable private int _rectVerts; 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 _overlayTextBuf = new(1024); + private readonly List _overlayRectBuf = new(256); + private readonly List _overlaySpriteSegs = new(); + private int _overlaySegUsed; + private int _overlayTextVerts; + private int _overlayRectVerts; + + /// 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. + public bool OverlayMode { get; set; } + public TextRenderer(GL gl, string shaderDir) { _gl = gl; @@ -65,6 +81,20 @@ public sealed unsafe class TextRenderer : IDisposable _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 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 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); } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -76,15 +106,29 @@ public sealed unsafe class TextRenderer : IDisposable _segUsed = 0; // pool the SpriteSeg objects across frames _textVerts = 0; _rectVerts = 0; + _overlayTextBuf.Clear(); + _overlayRectBuf.Clear(); + _overlaySegUsed = 0; + _overlayTextVerts = 0; + _overlayRectVerts = 0; + OverlayMode = false; } /// Draw a filled rectangle in screen pixel space. public void DrawRect(float x, float y, float w, float h, Vector4 color) { - AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); - _rectVerts += 6; + if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; } + else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; } } + /// 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 — 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. + public void DrawFill(float x, float y, float w, float h, Vector4 color) + => DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color); + /// Draw a 1-pixel-thick outline rect. 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) { - AppendQuad(_textBuf, - gx, gy, gw, gh, - g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, - color); - _textVerts += 6; + if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; } + else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; } } 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, float u0, float v0, float u1, float v1, Vector4 tint) { - SpriteSeg seg; - if (_segUsed > 0 && _spriteSegs[_segUsed - 1].Texture == 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++; - } + SpriteSeg seg = OverlayMode + ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) + : NextSpriteSeg(_spriteSegs, ref _segUsed, texture); AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); } + /// Pick the sprite segment for : 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). + private static SpriteSeg NextSpriteSeg(List 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 buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) @@ -197,8 +244,9 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - bool hasSprites = _segUsed > 0; - if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; + bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0; + bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; + if (!anyNormal && !anyOverlay) return; _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); @@ -210,6 +258,15 @@ public sealed unsafe class TextRenderer : IDisposable bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasBlend = _gl.IsEnabled(EnableCap.Blend); 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.CullFace); _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 // 3. Text glyphs — on top // Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs, - // so sprite-on-sprite z is preserved — each meter's dat-font number draws - // after its own bar sprites. Buckets 2 (rects) + 3 (debug text) composite - // on top, in that order. + // so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text) + // composite on top, in that order. The OVERLAY layer repeats all three + // 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. - if (hasSprites) + // Restore GL state. + _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); + } + + /// 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 . + private void DrawLayer( + List spriteSegs, int segUsed, + List rectBuf, int rectVerts, + List textBuf, int textVerts, BitmapFont? font) + { + // 1. RGBA dat sprites — one draw call per distinct GL texture. + if (segUsed > 0) { _shader.SetInt("uUseTexture", 2); _gl.ActiveTexture(TextureUnit.Texture0); _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; _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); UploadBuffer(seg.Verts); @@ -242,31 +320,23 @@ public sealed unsafe class TextRenderer : IDisposable } // 2. Untextured rects — widget fills on top of the chrome. - if (_rectVerts > 0) + if (rectVerts > 0) { _shader.SetInt("uUseTexture", 0); - UploadBuffer(_rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); + UploadBuffer(rectBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); } - // 3. Textured text glyphs on top. - if (_textVerts > 0 && font is not null) + // 3. Textured debug-font text glyphs on top. + if (textVerts > 0 && font is not null) { _shader.SetInt("uUseTexture", 1); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _shader.SetInt("uTex", 0); - UploadBuffer(_textBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); + UploadBuffer(textBuf); + _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 buf) @@ -289,6 +359,7 @@ public sealed unsafe class TextRenderer : IDisposable public void Dispose() { + _gl.DeleteTexture(_whiteTex); _gl.DeleteBuffer(_vbo); _gl.DeleteVertexArray(_vao); _shader.Dispose(); diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index a1c5f4ab..a65a573b 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -154,6 +154,16 @@ public abstract class UiElement /// protected virtual void OnDraw(UiRenderContext ctx) { } + /// + /// 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 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. + /// + protected virtual void OnDrawOverlay(UiRenderContext ctx) { } + /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -213,6 +223,34 @@ public abstract class UiElement } } + /// Second draw traversal: re-walks the tree applying the same + /// transform/alpha as and calls + /// 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). + 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) { if (!Visible) return; @@ -275,6 +313,22 @@ public abstract class UiElement Left = x; Top = y; Width = w; Height = h; } + /// Forget the captured anchor margins so the next + /// 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. + internal void ResetAnchorCapture() => _anchorCaptured = false; + + /// Walk up to the owning (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. + internal UiRoot? FindRoot() + { + UiElement e = this; + while (e.Parent is not null) e = e.Parent; + return e as UiRoot; + } + /// Compute an anchored child rect. Left&Right ⇒ stretch width /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise /// pin left at fixed width. Same logic vertically. diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index 5b97492e..ebf6fc69 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -68,11 +68,23 @@ public sealed class UiRenderContext public Vector2 CurrentOrigin => _current; + /// 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 . + 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) => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); + /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for + /// a panel BACKGROUND that text draws on top of. composites after + /// all sprites and would cover the text — use this for backgrounds, that for foreground + /// fills (carets, vital bars). + 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) => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness); @@ -102,10 +114,17 @@ public sealed class UiRenderContext /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the - /// glyph's OffsetY into the atlas. If the font has no background atlas the - /// outline pass is skipped. + /// glyph's OffsetY into the atlas. + /// + /// gates the black outline pass. Retail decides + /// this PER text element: UIElement_Text::DrawSelf (acclient 0x00467aa0) + /// runs the outline pass only when m_bitField & 0x10 is set — i.e. the + /// element called SetOutline(true) (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 + /// outline:true only for elements retail outlines. /// - 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; @@ -116,32 +135,44 @@ public sealed class UiRenderContext float originY = _current.Y + y; 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++) { if (!font.TryGetGlyph(text[i], out var g)) continue; - // Pixel-snap each glyph's destination to whole pixels so the atlas samples - // texel-aligned. Without this, a fractional bar width after resize puts the - // centered number on a sub-pixel x and linear filtering smears the glyphs - // (the "unsharp at certain sizes" artifact). The pen keeps its true - // fractional advance, so only the per-glyph dest is snapped. + // 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 = System.MathF.Round(originY + g.VerticalOffsetBefore); + 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. - if (font.BackgroundTexture != 0) + // 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); - 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. diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index e57d02e3..91fd219d 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -44,6 +44,10 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } + /// 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. + public UiElement? DefaultTextInput { get; set; } + /// /// Single modal overlay; while set, mouse clicks outside its rect /// 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 // sits on top. 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) ── @@ -200,12 +211,18 @@ public sealed class UiRoot : UiElement var (target, _, _) = HitTestTopDown(x, y); 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); return; } - // Set keyboard focus if target accepts it. - if (target.AcceptsFocus) SetKeyboardFocus(target); + // Keyboard focus follows a left click: the input bar (an edit control) takes + // 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); @@ -355,6 +372,18 @@ public sealed class UiRoot : UiElement 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. if (KeyboardFocus is not null) {