fix(ui): chat window retail parity — focus rails, authored captions, menu flick, drag-stable text

Four of the five owner-reported chat deltas (2026-08-24), each traced to
its retail mechanism:

1. Missing gold separator left of the input: the chat input authors two
   1px Type-3 rail CHILDREN (0x10000017 at X=0, 0x10000018
   right-anchored) whose only media is Normal_focussed (0x06004D67,
   live-DAT probed). UiField consumes its DAT children, so the rails
   were swallowed and never drawn. The factory now folds them into the
   field, which draws both while focused.

2. Button says "General", retail says "Gen": the talk button's short
   caption comes from per-target ID_Chat_ChatTargetMenu* strings
   (HandleSelection @0x004cd540, StringTable 0x23000001 via
   compute_str_hash — recovered from the raw binary after BN elided the
   ids into name-hash globals). Authored set: Chat/Tell/Fell/Pat/Mon/
   Vas/Alg/Gen/Trade/LFG/RP/Soc/Olt. Menu rows + squelch/tell specials
   resolve from the same table (ID_Chat_TellTo*); production resolves
   through DatStringResolver, fallbacks ARE the authored EoR English.
   ChatStringsLiveDatTests pins the whole set against the installed DAT.

3. Channel button stayed green while the popup was open: retail's
   pressed face is the momentary physical press ("flicks"); the OPEN
   state drives only the arrow-cap child's StateDesc swap
   (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe).
   UiMenu now keys the face on the press, not on IsOpen.

4. Window-title/button text "vibrates" while dragging windows:
   DrawStringDatPass snapped glyphs with MathF.Round — banker's
   rounding. A centered label with a constant .5 fraction alternates
   round-up/round-down across successive integers, double-stepping then
   sticking while the background glides. Half-up Floor(v+0.5) snaps
   every tie one way: uniform 1px steps in lock-step with sprites.

The fifth report (input row sticking out on window resize) did not
reproduce: a controller-bound fixture resize at 220/300/600px keeps the
whole input row inside the window (test added) — awaiting the owner's
exact gesture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 20:27:04 +02:00
parent 2d6333f84c
commit c12a95b6e8
11 changed files with 401 additions and 46 deletions

View file

@ -196,6 +196,11 @@ public sealed class UiMenu : UiElement
/// reason).</summary>
public uint CurrentArrowCapSprite => _open ? ArrowCapOpenSprite : ArrowCapClosedSprite;
/// <summary>The face sprite <see cref="OnDraw"/> would pick right now —
/// keyed on the momentary physical press (<c>_facePressed</c>), NOT on
/// open state (see <c>_facePressed</c>'s doc). Test seam.</summary>
public uint CurrentFaceSpriteForTest => _facePressed ? PressedSprite : NormalSprite;
public UiDatFont? DatFont { get; set; }
public AcDream.App.Rendering.BitmapFont? Font { get; set; }
@ -272,6 +277,19 @@ public sealed class UiMenu : UiElement
private bool _open;
/// <summary>
/// True only while the pointer is physically pressed on the button FACE.
/// 2026-08-24 owner report: the chat channel button stayed green (pressed
/// art) the whole time the popup was open — retail's pressed face
/// (0x06004D66) is the ordinary momentary button press ("flicks green"),
/// while the OPEN state drives only the arrow-cap child's state swap
/// (UIElement_Menu::UpdateState @0x0046cad0 writes attribute 0xe, which
/// the cap element's own StateDesc consumes — see
/// <see cref="ArrowCapClosedSprite"/>'s doc). The face must key on the
/// physical press, not on <see cref="_open"/>.
/// </summary>
private bool _facePressed;
/// <summary>Whether the popup is currently open (test/inspection seam,
/// same rationale as <see cref="PopupScroll"/>/<see cref="CurrentArrowCapSprite"/>).</summary>
public bool IsOpen => _open;
@ -374,7 +392,7 @@ public sealed class UiMenu : UiElement
// Button face (3-sliced so it can widen to fit the label) + the active-target label.
if (resolve is not null)
{
var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite);
var (tex, tw, _) = resolve(_facePressed ? PressedSprite : NormalSprite);
if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw);
}
string caption = ButtonLabelProvider?.Invoke() ?? "";
@ -660,6 +678,14 @@ public sealed class UiMenu : UiElement
}
}
if (e.Type is UiEventType.MouseUp
or UiEventType.HoverLeave
or UiEventType.CaptureChanged)
{
_facePressed = false; // the momentary face flick ends here
return false;
}
if (e.Type != UiEventType.MouseDown) return false;
float lx = e.Data1, ly = e.Data2;
@ -700,6 +726,7 @@ public sealed class UiMenu : UiElement
// Retail Open @0x0046cc42 refuses an empty list (gates on
// m_listBox->m_listItems.m_num != 0) — a bare click on an itemless
// menu is a no-op rather than an empty popup.
_facePressed = true; // momentary press flick
if (!_open && Items.Count == 0) return true;
SetOpen(!_open); // toggle on button click
return true;