Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a second (background) glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (Font.NumHorizontalBorderPixels/ NumVerticalBorderPixels) that acdream's font reader never read — so even the pre-existing outline parameter drew almost nothing once enabled. Landed together (either half alone is a no-op or a regression): - UiDatFont carries BorderX/BorderY from the DAT font resource. - UiRenderContext.DrawStringDat inflates the background blit's source and destination rect by that margin and restructures into retail's exact two-pass whole-string outline-then-fill model (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for fonts with no background atlas. Corrects the stale "property 0xd" comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor). - LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/ OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/ ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every authored-outline element across the DAT set is correct at once. - SpewBox: RetailFontId corrected from a round-3 heuristic (0x40000025) to the actually-authored 0x40000001 (18px bold serif), Outline=true set on the controller's UiText. Fill colour stays the user-gate-round-1-pinned yellow — font atlases are alpha-only (PFID_A8), so there is no baked shading that could explain the screenshot's gold as anything other than the outline itself. - Chat transcript: default fill now seeds from its authored ARGB(255,204,204,204) instead of an unrelated color-table slot (ChatTranscriptRenderer.BuildLines takes the transcript's own DefaultColor as a parameter); the 34-entry LogTextType table is untouched, and every existing CH1 conformance test stays green unmodified. Regenerated the committed chat_2100006f.json fixture from the real installed DAT, confirming end to end (not by missing-field default) that the transcript carries no outline. Tests: font-reader border fields + inflation math pinned against the real DAT font, two-pass draw ordering/tint/inflation via a new TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22 import at both the ElementReader.Merge and StateDesc-property layers, SpewBox font/outline, and the chat default-shade seed with the color table proven untouched. Full Release suite: 12,610 passed / 4 skipped / 0 failed (AcDream.slnx, complete solution). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
396 lines
22 KiB
C#
396 lines
22 KiB
C#
using System.Numerics;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.UI.Layout;
|
||
using AcDream.UI.Abstractions.Panels.SpewBox;
|
||
|
||
namespace AcDream.App.UI;
|
||
|
||
/// <summary>
|
||
/// Retained presentation of retail's <c>gmSpewBoxUI</c> (research doc
|
||
/// §1.1/§7.3/§7.4) — the transient top-of-viewport interface-text queue.
|
||
/// A single <c>ClickThrough</c> <see cref="UiText"/> block at a high
|
||
/// <see cref="UiElement.ZOrder"/>. Campaign CH user-gate round 2, item 2:
|
||
/// this is now the ONLY on-screen interface-text presentation surface —
|
||
/// the former <c>PortalWaitNoticeController</c> (a dedicated centered
|
||
/// overlay with a single overwrite-only slot) is deleted; the portal-space
|
||
/// wait notice routes here too, through the same
|
||
/// <c>RuntimeCommunicationState.AddText</c> chokepoint every other
|
||
/// <c>ClientLocal</c> refusal uses. This reads <see cref="SpewBoxVM"/>'s
|
||
/// bounded, newest-on-top, per-entry-expiring queue every frame.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>CH2 REJECT-review rework, BLOCKER 1
|
||
/// (<c>docs/research/2026-08-09-ch2-review-findings.md</c>):</b> the
|
||
/// original landing drove the queue drain from
|
||
/// <see cref="UiText.LinesProvider"/>, which <c>UiText.OnDraw</c> only
|
||
/// calls when the element is ALREADY <c>Visible</c> — and the element
|
||
/// starts invisible, so the provider was never invoked, no line ever drew,
|
||
/// and <see cref="SpewBoxState"/>'s pending queue never drained (an
|
||
/// unbounded per-session leak). Retail's own <c>gmSpewBoxUI::Update</c>
|
||
/// drains off the UI tick (global message 3,
|
||
/// <c>UIElementManager::UseTime @0x0045CFD0</c>), not off drawing —
|
||
/// <see cref="GlobalTimeSink"/> reproduces that: it is a zero-size child
|
||
/// mounted alongside <see cref="_text"/> purely so <see cref="UiRoot"/>'s
|
||
/// per-frame <c>BroadcastGlobalUiTime</c> walk reaches it (the same
|
||
/// pattern <c>VendorUiController.DragOverGlobalTimeSink</c> uses for
|
||
/// <c>gmVendorUI::ListenToGlobalMessage</c>). <see cref="Tick"/> pulls
|
||
/// <see cref="SpewBoxVM.Lines"/>, caches the resulting lines, and sets
|
||
/// <see cref="_text"/>'s <c>Visible</c> flag; <see cref="UiText.LinesProvider"/>
|
||
/// now only ever returns the cache — it is polled by drawing, but no
|
||
/// longer double-duties as the tick source, so lines become visible and
|
||
/// the queue drains even across a frame where nothing gets drawn (headless,
|
||
/// a hidden window, or simply before the first render pass).
|
||
/// </remarks>
|
||
/// <remarks>
|
||
/// <b>Position / font / colour are still PLACEHOLDERS; extent and
|
||
/// max-items are now AUTHORED.</b> CH2 REJECT-review rework, NIT 3
|
||
/// (<c>docs/research/2026-08-09-ch2-review-findings.md</c>): the task C.7
|
||
/// LayoutDesc dump (<c>SpewBoxLayoutDumpDiagnostic</c>) originally searched
|
||
/// only <c>dats.Portal</c> — EXHAUSTIVELY, against the entire installed
|
||
/// LayoutDesc id range (<c>0x21000000</c>-<c>0x21000075</c>, 101 of 118
|
||
/// possible ids populated, sanity-checked against 3 independently-known
|
||
/// ids) — and found ZERO elements of class <c>0x10000016</c> there.
|
||
/// Extending the identical sweep to <c>dats.Local</c>
|
||
/// (<c>client_local_English.dat</c>) found it: LayoutDesc
|
||
/// <c>0x21000011</c>, element <c>0x10000048</c> (<c>gmSpewBoxUI</c>),
|
||
/// position <c>(0,0)</c> RELATIVE TO ITS PARENT (edge codes
|
||
/// <c>leftEdge=3/rightEdge=3</c> — <c>ElementReader.ToAnchors</c>'s own doc
|
||
/// comment names 3 as "centered", a mode that projection cannot represent;
|
||
/// <c>topEdge=1</c> — top-anchored per that same helper), size
|
||
/// <c>450×72</c>, one child (ListBox <c>0x10000049</c>, matching
|
||
/// <c>gmSpewBoxUI::PostInit</c>'s <c>GetChildRecursive(0x10000049)</c>
|
||
/// verbatim) carrying <c>MaxConcurrentItems</c> (property
|
||
/// <c>0x10000028</c>) = <c>4</c>, not retail's code-default <c>1</c>. The
|
||
/// PARENT this element mounts under (and therefore the ABSOLUTE screen
|
||
/// position) is still unresolved — <c>(0,0)</c> is parent-relative, and the
|
||
/// parent is presumably assigned by the same C++ code the research doc's
|
||
/// §1.1 describes, not by another LayoutDesc this sweep can walk to. See
|
||
/// the divergence register rows this class cites for each remaining
|
||
/// placeholder.
|
||
/// </remarks>
|
||
/// <remarks>
|
||
/// <b>Campaign CH user-gate round 3 (2026-08-10), finding (a) — position
|
||
/// and font.</b> The user reported live: "still not aligned all the way to
|
||
/// the top" and "not the correct font and size (retail's is SMALLER than
|
||
/// ours)". Two changes, both still user-DIRECTED approximations (not
|
||
/// resolved retail values — the true absolute position/parent and the true
|
||
/// retail font remain unmeasurable statically; <c>SpewBoxLayoutDumpDiagnostic</c>
|
||
/// re-run this round still finds no <c>FontDid</c>/colour property on
|
||
/// element <c>0x10000048</c> or its ListBox child):
|
||
/// <list type="bullet">
|
||
/// <item><b>Position:</b> <see cref="TopOffset"/> is now <c>0</c> — flush
|
||
/// to the viewport top, per the user's explicit direction ("mount at
|
||
/// viewport top-center, exactly"). The centered-X, recompute-every-frame
|
||
/// behavior from CH2 nit 1 is unchanged.</item>
|
||
/// <item><b>Font:</b> <see cref="SpewBoxController"/> now accepts a
|
||
/// resolved <see cref="UiDatFont"/> (retail font id
|
||
/// <see cref="RetailFontId"/>, <c>0x40000025</c>) instead of silently
|
||
/// falling back to the debug <see cref="BitmapFont"/> at its ad hoc 15px
|
||
/// pixel height (the pre-round-3 behavior — no <c>DatFont</c>/<c>Font</c>
|
||
/// was ever wired here at all). <c>0x40000025</c> is <c>MaxCharHeight=11</c>
|
||
/// px (confirmed via <c>AcDream.Cli dump-font-atlas</c> against the
|
||
/// installed DAT, sweeping every populated font id
|
||
/// <c>0x40000000</c>-<c>0x40000032</c>) — the SMALLEST font id actually
|
||
/// confirmed IN USE by any of acdream's currently-imported retail
|
||
/// LayoutDesc fixtures (cross-referenced across every
|
||
/// <c>tests/AcDream.App.Tests/UI/Layout/fixtures/*.json</c> dump), and
|
||
/// it is ALSO the chat window's own smallest font — the same
|
||
/// <c>0x2100006F</c> floating-window 1/2/3/4 indicator badges
|
||
/// (<c>ChatWindowController.Indicator1-4Id</c>) use it. Both selection
|
||
/// criteria from the round-3 brief ("smallest DAT font used by retail UI
|
||
/// imports" vs "the chat window's own font, whichever is smaller") land
|
||
/// on the SAME id, so there was no tension to resolve. This is visibly
|
||
/// smaller than the previous 15px debug font, matching the user's
|
||
/// report. Falls back to the debug font only if the dat resolve fails
|
||
/// (matching every other retained-UI controller's pattern, e.g.
|
||
/// <c>ChatWindowController.Bind</c>).</item>
|
||
/// </list>
|
||
/// Register row AP-178 updated to record both dispositions.
|
||
/// </remarks>
|
||
/// <remarks>
|
||
/// <b>Campaign CH user-gate round 4 (2026-08-10),
|
||
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c>:</b> the earlier
|
||
/// "absent from both dats" finding for the SpewBox's own line template
|
||
/// (element <c>0x1000004A</c>, base style <c>0x10000377</c> in layout
|
||
/// <c>0x2100003F</c>) was WRONG — it was missed because the element is a
|
||
/// ROOT of its layout (a children-only walk skips it) and its font/color
|
||
/// live in a BaseElement in a DIFFERENT LayoutDesc plus a NAMED state, not
|
||
/// its own DirectState. Extending the sweep to walk roots, BaseElement/
|
||
/// BaseLayoutId inheritance, and named-state properties found it, resolving
|
||
/// three of AP-178's four remaining open sub-claims:
|
||
/// <list type="bullet">
|
||
/// <item><b>Font:</b> <see cref="RetailFontId"/> is now
|
||
/// <b>0x40000001</b> (18px bold serif), not the round-3 smallest-font
|
||
/// heuristic <c>0x40000025</c>. Three independent cross-checks: (1) base
|
||
/// style <c>0x10000377</c> authors FontDID <c>[0x40000001]</c> directly;
|
||
/// (2) the line template's authored height is 18px, exactly
|
||
/// <c>0x40000001</c>'s <c>MaxCharHeight</c>; (3) 4 items × 18px = 72px,
|
||
/// the authored box height (<see cref="SpewBoxHeight"/>).</item>
|
||
/// <item><b>Outline:</b> the line template's state <c>0x10000002</c>
|
||
/// authors property <c>0x21</c> (Outline) = <c>true</c>, outline color
|
||
/// NOT authored → ctor default black
|
||
/// (<see cref="UiRenderContext.DefaultOutlineColor"/>). This is the
|
||
/// "heavy black border" the user's screenshot showed and the earlier
|
||
/// rounds never reproduced — <see cref="UiText.Outline"/> is now set on
|
||
/// construction.</item>
|
||
/// <item><b>Position/extent:</b> confirmed AUTHORED, not merely
|
||
/// user-matched by luck. <c>0x10000048</c> (the gmSpewBoxUI root) is a
|
||
/// ROOT element of its own layout, so its parent is the viewport:
|
||
/// <c>pos(0,0)</c>, edge codes <c>L3/R3</c> ("centered" per
|
||
/// <c>ElementReader.ToAnchors</c>'s doc comment) and <c>T1</c>
|
||
/// (top-anchored) resolve to a 450×72 block horizontally centered, flush
|
||
/// to the viewport top — exactly what <see cref="TopOffset"/>=0 and the
|
||
/// per-frame centered <c>Left</c> recompute already do. AP-178's position
|
||
/// sub-claim retires.</item>
|
||
/// </list>
|
||
/// Only fill COLOR (see <see cref="SpewBoxColor"/> — the atlas is PFID_A8,
|
||
/// alpha-only, so it cannot carry baked shading; the user-pinned yellow
|
||
/// stands pending an exact retail measurement) and the AP-177 line-lifetime
|
||
/// timeout remain open.
|
||
/// </remarks>
|
||
internal sealed class SpewBoxController : IDisposable
|
||
{
|
||
/// <summary>
|
||
/// Retail dat Font id this controller resolves for its text. Campaign CH
|
||
/// user-gate round 4 (2026-08-10): AUTHORED, not a heuristic — base
|
||
/// style <c>0x10000377</c> (layout <c>0x2100003F</c>), which the
|
||
/// SpewBox's own line template (<c>0x1000004A</c> in layout
|
||
/// <c>0x21000011</c>) inherits, authors FontDID <c>[0x40000001]</c>
|
||
/// directly. <c>0x40000001</c> is the classic AC bold-serif UI face at
|
||
/// 18px (<c>MaxCharHeight=18</c>, matching the line template's own
|
||
/// authored 18px height and the 4×18=72px authored box height). See the
|
||
/// class remarks for the three-way cross-check and
|
||
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c> §4.2.
|
||
/// </summary>
|
||
internal const uint RetailFontId = 0x40000001u;
|
||
|
||
/// <summary>
|
||
/// Register row AP-178 (screen position): CONFIRMED authored, not a
|
||
/// placeholder that happens to match. Campaign CH user-gate round 4
|
||
/// (2026-08-10): the SpewBox root (<c>0x10000048</c>) is a ROOT element
|
||
/// of its own layout (<c>0x21000011</c>), so its parent is the
|
||
/// viewport — <c>pos(0,0)</c> plus edge codes <c>L3/R3</c> (centered)
|
||
/// and <c>T1</c> (top-anchored) resolve to exactly this: a fixed-width
|
||
/// block horizontally centered and flush to the viewport top. The
|
||
/// round-3 "user-directed approximation pending the true retail
|
||
/// parent/offset" framing is retired — the parent (the viewport root)
|
||
/// and the offset (0) are now both resolved. (The SIBLING row AP-177 —
|
||
/// the invented line-lifetime timeout — lives in
|
||
/// <see cref="SpewBoxState.DefaultLifetime"/>'s own doc comment, not
|
||
/// here; this controller does not own that concern.)
|
||
/// </summary>
|
||
private const float TopOffset = 0f;
|
||
|
||
/// <summary>
|
||
/// Register row AP-178 (extent): AUTHORED, not a placeholder — the
|
||
/// LayoutDesc dump (see class remarks) found the SpewBox element sized
|
||
/// <c>450×72</c> in <c>dats.Local</c>. Retail's own edge codes
|
||
/// (<c>leftEdge=3</c>/<c>rightEdge=3</c>, "centered" per
|
||
/// <c>ElementReader.ToAnchors</c>'s doc comment) mean the box is a
|
||
/// FIXED-width block horizontally centered in its parent, not a
|
||
/// full-viewport stretch. <see cref="AnchorEdges"/> has no "centered,
|
||
/// fixed-width" flag combination to express retail's mode 3 directly, so
|
||
/// <see cref="Tick"/> recomputes a centered <see cref="UiText.Left"/>
|
||
/// against the CURRENT <see cref="UiRoot.Width"/> every frame instead of
|
||
/// anchoring.
|
||
/// <para>
|
||
/// CH2 re-review nit 1
|
||
/// (<c>docs/plans/2026-08-09-chat-parity-campaign.md</c>): the original
|
||
/// rework anchored with <see cref="AnchorEdges.Top"/> only, meaning
|
||
/// <see cref="UiElement.ApplyAnchor"/> captured the constructor's
|
||
/// one-time centered <c>Left</c> as a fixed left MARGIN
|
||
/// (<c>ComputeAnchoredRect</c>'s Left/Right-both-false branch) and
|
||
/// replayed that absolute pixel position forever — a window resize left
|
||
/// the box off-center. <see cref="Anchors"/> is now
|
||
/// <see cref="AnchorEdges.None"/> (so <c>ApplyAnchor</c> is a no-op) and
|
||
/// centering is owned entirely by the per-frame recompute below.
|
||
/// </para>
|
||
/// </summary>
|
||
private const float SpewBoxWidth = 450f;
|
||
private const float SpewBoxHeight = 72f;
|
||
|
||
/// <summary>
|
||
/// Register row AP-178 (colour): CH user-gate round 1 (2026-08-09)
|
||
/// PINNED this — the user confirmed live, side-by-side against retail,
|
||
/// that the on-screen SpewBox text is the same bright yellow as an
|
||
/// incoming Tell (<c>0x81C4C8</c>, <c>RetailChatColorTable.Yellow</c> =
|
||
/// <c>(1, 1, 0.247, 1)</c>). The chat colour table's <c>0x1A</c> entry
|
||
/// (<c>colorBrightRed</c>) is still explicitly NOT this — retail's own
|
||
/// <c>BuildChatColorLookupTable</c> writes to <c>ChatInterface::m_chatLog</c>,
|
||
/// a completely different element tree the SpewBox never touches
|
||
/// (research doc §3.2.3). POSITION and FONT were resolved as AUTHORED at
|
||
/// Campaign CH user-gate round 4 (2026-08-10) — see the class remarks and
|
||
/// the <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments. The
|
||
/// AUTHORED value for THIS element's state <c>0x10000002</c> is actually
|
||
/// pure red <c>ARGB(255,255,0,0)</c> — but the user's live retail
|
||
/// screenshot shows gold/amber, not red, so the user's own eye remains
|
||
/// the axiom here (per <c>feedback_retail_oracle_no_whack_a_mole</c>).
|
||
/// Round 4 checked whether font <c>0x40000001</c>'s FILL-plane atlas
|
||
/// could explain the gold as baked shading over the pinned yellow: it
|
||
/// cannot — every dat font atlas (this one included) is <c>PFID_A8</c>,
|
||
/// alpha-only coverage with no per-pixel colour channel, so there is no
|
||
/// baked tint to compose with. The gold reading is therefore the OUTLINE
|
||
/// itself (a black border around a bright yellow glyph reads warmer/
|
||
/// richer than the same colour drawn flat) rather than atlas shading.
|
||
/// The user-pinned yellow stands; only an exact cdb capture of live
|
||
/// retail's <c>m_curFontColor</c> would resolve the remaining gap.
|
||
/// </summary>
|
||
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f);
|
||
|
||
private readonly UiRoot _root;
|
||
private readonly UiText _text;
|
||
private readonly SpewBoxVM _vm;
|
||
private readonly GlobalTimeSink _timeSink;
|
||
private UiText.Line[] _lines = Array.Empty<UiText.Line>();
|
||
private bool _disposed;
|
||
|
||
/// <param name="root">Retained-UI root this element mounts under.</param>
|
||
/// <param name="vm">SpewBox view-model (bounded, newest-on-top queue).</param>
|
||
/// <param name="font">
|
||
/// Resolved retail dat font (<see cref="RetailFontId"/>) — Campaign CH
|
||
/// user-gate round 3. Null falls back to <paramref name="debugFont"/>,
|
||
/// matching every other retained-UI controller's dat-font pattern (e.g.
|
||
/// <c>ChatWindowController.Bind</c>).
|
||
/// </param>
|
||
/// <param name="debugFont">Fallback bitmap font, used only when
|
||
/// <paramref name="font"/> is null.</param>
|
||
public SpewBoxController(
|
||
UiRoot root, SpewBoxVM vm, UiDatFont? font = null, BitmapFont? debugFont = null)
|
||
{
|
||
_root = root ?? throw new ArgumentNullException(nameof(root));
|
||
_vm = vm ?? throw new ArgumentNullException(nameof(vm));
|
||
_text = new UiText
|
||
{
|
||
Name = "SpewBox",
|
||
// Centered fixed-width block (retail's "mode 3" edge code on
|
||
// both left and right) — see the AP-178 extent comment above.
|
||
// This is only the FIRST frame's value; Tick recomputes it
|
||
// every frame against the current root width (CH2 re-review
|
||
// nit 1 — see the extent comment's nit-1 paragraph).
|
||
Left = (root.Width - SpewBoxWidth) / 2f,
|
||
Top = TopOffset,
|
||
Width = SpewBoxWidth,
|
||
Height = SpewBoxHeight,
|
||
Anchors = AnchorEdges.None,
|
||
Centered = true,
|
||
// Campaign CH user-gate round 3: retail dat font (RetailFontId)
|
||
// when resolved, falling back to the debug bitmap font exactly
|
||
// like every other retained-UI controller (ChatWindowController
|
||
// et al.) — see the class remarks.
|
||
DatFont = font,
|
||
Font = debugFont,
|
||
// AUTHORED MaxConcurrentItems is 4, not retail's code-default 1
|
||
// (see SpewBoxState.MaxConcurrentItems) — OneLine=true would
|
||
// silently collapse the box back down to showing only the
|
||
// newest of up to 4 concurrent lines.
|
||
OneLine = false,
|
||
// CH2 re-review nit 2 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
||
// retail's own authored vertical justification for THIS element
|
||
// is unknown (register row AP-178 covers presentation
|
||
// unknowns) — top-aligned flow is acdream's own choice, made
|
||
// because it is the only placement consistent with "lines flow
|
||
// from the top, newest on top" (see Tick's ordering comment).
|
||
// HonorVerticalJustification opts the scrollable multi-line
|
||
// path into VerticalJustify without a full ConfigureDatState
|
||
// LayoutDesc binding, which this synthesized element does not
|
||
// have.
|
||
VerticalJustify = VJustify.Top,
|
||
HonorVerticalJustification = true,
|
||
ClickThrough = true,
|
||
ZOrder = int.MaxValue,
|
||
DefaultColor = SpewBoxColor,
|
||
// Campaign CH user-gate round 4: the line template's authored state
|
||
// 0x10000002 sets property 0x21 (Outline) = true with no authored
|
||
// outline colour, i.e. the ctor black default
|
||
// (UiRenderContext.DefaultOutlineColor) — the heavy black border in
|
||
// the user's screenshot. See the class remarks.
|
||
Outline = true,
|
||
Visible = false,
|
||
};
|
||
_text.LinesProvider = () => _lines;
|
||
_root.AddChild(_text);
|
||
|
||
_timeSink = new GlobalTimeSink(Tick);
|
||
_root.AddChild(_timeSink);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The SpewBox's per-frame tick, driven by <see cref="UiRoot"/>'s
|
||
/// global-message-3 broadcast via <see cref="GlobalTimeSink"/> — the
|
||
/// direct analogue of <c>gmSpewBoxUI::Update</c>. Drains
|
||
/// <see cref="SpewBoxState"/>'s pending queue and prunes expired
|
||
/// entries (see <see cref="SpewBoxVM.Lines"/>), caches the resulting
|
||
/// display lines, and sets <see cref="_text"/>'s visibility. Runs
|
||
/// whether or not a draw pass follows.
|
||
/// </summary>
|
||
/// <param name="nowSeconds">
|
||
/// <see cref="UiRoot"/>'s own per-frame clock — <b>not</b>
|
||
/// <c>Environment.TickCount64</c> — matching every other
|
||
/// <see cref="IUiGlobalTimeListener"/> consumer's time source.
|
||
/// </param>
|
||
private void Tick(double nowSeconds)
|
||
{
|
||
// CH2 re-review nit 1 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
||
// Anchors is AnchorEdges.None (see the AP-178 extent comment on
|
||
// SpewBoxWidth), so nothing else recenters this element on a
|
||
// window resize — recompute every frame against the CURRENT root
|
||
// width rather than the width captured once at construction.
|
||
_text.Left = (_root.Width - SpewBoxWidth) / 2f;
|
||
|
||
// CH2 re-review nit 3 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
||
// deliberate inversion of UiText.LinesProvider's documented
|
||
// contract ("Provider of the lines to show, oldest-first" —
|
||
// UiText.cs). SpewBoxVM.Lines returns newest-first, matching
|
||
// retail's InsertItem(item, 0), and this controller feeds that
|
||
// order straight through WITHOUT reversing it. That is correct
|
||
// here specifically because the box is top-aligned
|
||
// (VerticalJustify.Top + HonorVerticalJustification above): index 0
|
||
// of the lines array draws at the TOP of the box, so feeding
|
||
// newest-first into a slot documented as oldest-first is exactly
|
||
// what makes "newest line on top" true. Reversing the feed to
|
||
// satisfy the doc comment literally would flip the visible order to
|
||
// oldest-on-top, which is wrong for this element — see
|
||
// SpewBoxControllerTests' rendered-order test. With OneLine now
|
||
// false and the AUTHORED MaxConcurrentItems == 4 (see
|
||
// SpewBoxState.MaxConcurrentItems), up to 4 lines render.
|
||
IReadOnlyList<SpewBoxLine> lines = _vm.Lines(nowSeconds);
|
||
_text.Visible = lines.Count > 0;
|
||
if (lines.Count == 0)
|
||
{
|
||
_lines = Array.Empty<UiText.Line>();
|
||
return;
|
||
}
|
||
|
||
var result = new UiText.Line[lines.Count];
|
||
for (int i = 0; i < lines.Count; i++)
|
||
result[i] = new UiText.Line(lines[i].Text, SpewBoxColor);
|
||
_lines = result;
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
|
||
_root.RemoveChild(_text);
|
||
_root.RemoveChild(_timeSink);
|
||
_disposed = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A runtime-only, zero-size, always-invisible-to-hit-testing helper
|
||
/// that opts this controller into retail's global UI message 3 — see
|
||
/// the class remarks and <c>VendorUiController.DragOverGlobalTimeSink</c>
|
||
/// for the identical pattern. <see cref="SpewBoxController"/> is not
|
||
/// itself a <see cref="UiElement"/> (it wraps one), so it cannot
|
||
/// directly implement <see cref="IUiGlobalTimeListener"/> the way
|
||
/// <see cref="UiButton"/> does — <see cref="UiRoot.Tick"/>'s broadcast
|
||
/// walks the ELEMENT tree, not arbitrary controllers.
|
||
/// </summary>
|
||
private sealed class GlobalTimeSink : UiElement, IUiGlobalTimeListener
|
||
{
|
||
private readonly Action<double> _onGlobalUiTime;
|
||
public GlobalTimeSink(Action<double> onGlobalUiTime) => _onGlobalUiTime = onGlobalUiTime;
|
||
public void OnGlobalUiTime(double nowSeconds) => _onGlobalUiTime(nowSeconds);
|
||
}
|
||
}
|