feat(ui): #409 — client-wide retail tooltip system
Full re-derivation from named-retail decomp: UIElement::StartTooltipAtMouse @0x00460D70 -> UIElementManager::StartTooltip @0x0045DE90/@0x00459700, UIElement::MouseHover @0x00462520 (P0x4B TooltipOn gate + global m_tooltipEnable), UIElementManager::CheckTooltip @0x0045B6E0 (dwell/ auto-hide timer, default 0.25s/10s), SwitchMouseOver/DeletingElement (dismissal). Corrects the earlier GF-16 investigation: P0x47 is the element-desc id WITHIN the popup LayoutDesc (P0x48), not a "behavior enum"; P0x4A is read off the popup's own instantiated root, not the trigger element. - ElementInfo/UiElement gain six tooltip data fields (P0x47/48/49/4A/4B/50), read generically by ElementReader and copied through LayoutImporter, mirroring the existing AuthoredInvisible passthrough pattern. - UiRoot's existing CheckTooltip-derived hover timer gains TooltipShow/ TooltipHide events, a per-element P0x50 delay override, and dismissal wiring at every retail-confirmed teardown site. - RetailTooltipPresenter (owned by RetailUiRuntime, mounted alongside RetailDialogFactory) builds the popup via the existing LayoutImporter dat-lock seam, auto-resizes by the measured-vs-authored text delta (word-wrapped via the existing UiText.WrapWords primitive), positions at the mouse clamped to the display, and stays topmost over dialogs via its own later per-tick BringToFront (register AD-106). - Misc.TooltipEnable/Misc.TooltipDelay are client-local UserPreferences (retail's own 2013 Config tab authors no visible row for either) — SettingsStore gains a MiscSettings section, no new options-panel row. - Live-DAT sweep: 434 elements author >=1 trigger property (243 with literal text this port shows; 191 rely on retail's dynamic InqProperty(0x49) override, deferred as register TS-85 alongside the unmodeled P0x3D wrap-width override). Gates: Release build 0 errors; App suite (live-DAT env) 5410/5407 passed/ 3 skipped (was 5379/3); Runtime 1735/0 unchanged; UI.Abstractions 926/0; full solution 14,617/14,548 passed/69 skipped/0 failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d3755eb231
commit
a377b9bff7
16 changed files with 1386 additions and 9 deletions
244
src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
Normal file
244
src/AcDream.App/UI/Layout/RetailTooltipPresenter.cs
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// #409 — client-wide retail hover-tooltip system. Consumes <see cref="UiRoot"/>'s
|
||||
/// existing hover-dwell timer (<see cref="UiRoot.TooltipShow"/>/
|
||||
/// <see cref="UiRoot.TooltipHide"/>) and, for any widget that authors the five
|
||||
/// tooltip properties, instantiates retail's popup LayoutDesc, auto-sizes and
|
||||
/// positions it, and mounts/unmounts it as a topmost sibling of the retained tree.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail mechanism (decomp anchors — see also each <c>ElementInfo.Tooltip*</c>
|
||||
/// field's own doc comment for the individual property citations):
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>UIElementManager::CheckTooltip @0x0045B6E0</c> — the
|
||||
/// per-frame dwell/auto-hide timer, ported into <see cref="UiRoot.Tick"/>.</description></item>
|
||||
/// <item><description><c>UIElement::MouseHover @0x00462520</c> — the per-element
|
||||
/// gate (<c>P0x4B</c> TooltipOn bit + the global <c>m_tooltipEnable</c>
|
||||
/// preference); ported as this class's <see cref="Enabled"/> +
|
||||
/// <see cref="UiElement.AuthoredTooltipEnabled"/> check.</description></item>
|
||||
/// <item><description><c>UIElement::StartTooltipAtMouse @0x00460D70</c> — text
|
||||
/// resolution (<c>P0x49</c>) and dispatch to the manager.</description></item>
|
||||
/// <item><description><c>UIElementManager::StartTooltip @0x0045DE90</c> —
|
||||
/// instantiates the popup (<c>P0x47</c> root element id within <c>P0x48</c>'s
|
||||
/// LayoutDesc), resolves the text child (<c>P0x4A</c>, read off the POPUP's own
|
||||
/// root), sets its text, and auto-resizes the root by the measured-vs-authored
|
||||
/// text delta.</description></item>
|
||||
/// <item><description><c>UIElementManager::StartTooltip @0x00459700</c> —
|
||||
/// positions the popup's top-left AT the mouse cursor (no hotspot offset),
|
||||
/// clamped to the display.</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Live-DAT-probed structure (#409 investigation): layout <c>0x21000041</c>'s
|
||||
/// root (id 0, 800x600, a pure catalog container) holds four 30x30 popup skins
|
||||
/// (<c>0x10000487</c>/<c>0x10000395</c>/<c>0x10000397</c>/<c>0x10000398</c>),
|
||||
/// each a four-piece bevel frame around one Type-12 text child
|
||||
/// <c>0x10000396</c> — every one of those four roots' own <c>P0x4A</c> resolves
|
||||
/// to exactly <c>0x10000396</c>, confirming the decomp reading. 434 installed
|
||||
/// elements author at least one of the five properties (243 with literal
|
||||
/// <c>P0x49</c> text this port can show; the other 191 rely on retail's DYNAMIC
|
||||
/// <c>InqProperty(0x49)</c> override, out of this port's scope — see the
|
||||
/// divergence register). A fifth root id (<c>0x100001F0</c>) and a second popup
|
||||
/// layout (<c>0x21000026</c>) also appear on a handful of elements; this class
|
||||
/// is fully data-driven off each widget's own authored properties, so neither
|
||||
/// needed special-casing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RetailTooltipPresenter : IDisposable
|
||||
{
|
||||
private readonly UiRoot _host;
|
||||
|
||||
/// <summary>Resolves a tooltip popup's LayoutDesc + root element (the
|
||||
/// widget's own <see cref="UiElement.AuthoredTooltipLayoutDid"/> /
|
||||
/// <see cref="UiElement.AuthoredTooltipRootElementId"/>) to a mounted-ready
|
||||
/// <see cref="ImportedLayout"/>. Wraps <c>LayoutImporter.Import</c> with the
|
||||
/// runtime's dat lock/resolve/font context — the same shape
|
||||
/// <c>RetailUiRuntime.CreateLayout</c> already gives <see cref="RetailDialogFactory"/>.</summary>
|
||||
private readonly Func<uint, uint, ImportedLayout?> _createLayout;
|
||||
|
||||
private UiElement? _popupRoot;
|
||||
private UiElement? _owner;
|
||||
private bool _disposed;
|
||||
|
||||
public RetailTooltipPresenter(UiRoot host, Func<uint, uint, ImportedLayout?> createLayout)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_createLayout = createLayout ?? throw new ArgumentNullException(nameof(createLayout));
|
||||
_host.TooltipShow += OnTooltipShow;
|
||||
_host.TooltipHide += OnTooltipHide;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The client-side <c>Misc.TooltipEnable</c> preference
|
||||
/// (<c>UIElementManager::Init @0x0045EE10</c> registers it as a
|
||||
/// <c>UserPreferences.ini</c> key via <c>UIPreferences::AttachPreference</c>,
|
||||
/// default true — <c>m_tooltipEnable=1 @0x0045F756</c>). NOT part of the
|
||||
/// server-synced <c>CharacterOptionTable</c> — retail's 2013 Config tab
|
||||
/// doesn't expose a row for it either (research confirms it's UserPreferences-
|
||||
/// only, absent from the visible tab), so this stays a plain client-local
|
||||
/// setting on the presenter rather than routing through
|
||||
/// <c>RuntimeCharacterOptionsState</c>. Gates ONLY the popup presentation —
|
||||
/// matching retail's own gate point at <c>UIElement::MouseHover</c> — NOT
|
||||
/// <see cref="UiRoot"/>'s dwell timer, which keeps running either way exactly
|
||||
/// as retail's <c>CheckTooltip</c> does.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
private void OnTooltipShow(UiElement widget)
|
||||
{
|
||||
RemovePopup();
|
||||
|
||||
if (!Enabled || !widget.AuthoredTooltipEnabled)
|
||||
return;
|
||||
if (string.IsNullOrEmpty(widget.AuthoredTooltipText))
|
||||
return;
|
||||
if (widget.AuthoredTooltipLayoutDid == 0u || widget.AuthoredTooltipRootElementId == 0u)
|
||||
return;
|
||||
|
||||
ImportedLayout? layout;
|
||||
try
|
||||
{
|
||||
layout = _createLayout(widget.AuthoredTooltipLayoutDid, widget.AuthoredTooltipRootElementId);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[UI] #409 tooltip popup layout=0x{widget.AuthoredTooltipLayoutDid:X8} "
|
||||
+ $"root=0x{widget.AuthoredTooltipRootElementId:X8} failed to build: {error.Message}");
|
||||
return;
|
||||
}
|
||||
if (layout is null)
|
||||
return;
|
||||
|
||||
UiElement root = layout.Root;
|
||||
UiElement? textChild = root.AuthoredTooltipTextChildElementId != 0u
|
||||
? layout.FindElement(root.AuthoredTooltipTextChildElementId)
|
||||
: null;
|
||||
if (textChild is UiText text)
|
||||
ApplyTooltipText(root, text, widget.AuthoredTooltipText!);
|
||||
|
||||
SetClickThroughRecursive(root);
|
||||
PositionAtMouse(root);
|
||||
|
||||
_host.AddChild(root);
|
||||
_host.BringToFront(root);
|
||||
_popupRoot = root;
|
||||
_owner = widget;
|
||||
}
|
||||
|
||||
private void OnTooltipHide(UiElement widget)
|
||||
{
|
||||
if (ReferenceEquals(_owner, widget))
|
||||
RemovePopup();
|
||||
}
|
||||
|
||||
private void RemovePopup()
|
||||
{
|
||||
if (_popupRoot is null)
|
||||
return;
|
||||
_host.RemoveChild(_popupRoot);
|
||||
_popupRoot = null;
|
||||
_owner = null;
|
||||
}
|
||||
|
||||
/// <summary>Force-hides whatever tooltip is currently showing, if any.
|
||||
/// Session-reset callers use this (mirrors <see cref="RetailDialogFactory.Reset"/>'s
|
||||
/// own role for dialogs) so a stale popup cannot survive a reconnect.</summary>
|
||||
public void HideCurrent() => RemovePopup();
|
||||
|
||||
/// <summary>Retail <c>UIElementManager::StartTooltip @0x0045DE90</c>'s text
|
||||
/// + auto-resize step. Wraps at the display width (retail's
|
||||
/// <c>UIElement_Text::InqSizewMargins(..., UITS_MAX_WIDTH)</c> falls back to
|
||||
/// <c>RenderDevice::GetDisplayWidth()</c> when the text element authors no
|
||||
/// <c>P0x3D</c> max-width override — unmodeled here, no probed tooltip
|
||||
/// element authors one), then grows the popup ROOT by exactly the delta
|
||||
/// between the measured wrapped size and the text child's AUTHORED size —
|
||||
/// the authored gap becomes the popup's padding. Retail's final branch
|
||||
/// (grow further if the text child has vertical scroll overflow) has no
|
||||
/// acdream analog for a freshly-built, unscrolled popup and is a
|
||||
/// structural no-op here.</summary>
|
||||
private void ApplyTooltipText(UiElement root, UiText text, string tooltipText)
|
||||
{
|
||||
float authoredTextWidth = text.Width;
|
||||
float authoredTextHeight = text.Height;
|
||||
|
||||
Func<string, float> measure = text.DatFont is { } datFont
|
||||
? datFont.MeasureWidth
|
||||
: text.Font is { } bitmapFont
|
||||
? bitmapFont.MeasureWidth
|
||||
: static s => s.Length * 8f;
|
||||
|
||||
float wrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X);
|
||||
var wrapped = UiText.WrapWords(tooltipText, measure, wrapWidth);
|
||||
text.LinesProvider = () => wrapped
|
||||
.Select(line => new UiText.Line(line, text.DefaultColor))
|
||||
.ToArray();
|
||||
|
||||
float measuredWidth = wrapped.Count == 0 ? 0f : wrapped.Max(measure);
|
||||
float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f;
|
||||
float measuredHeight = wrapped.Count * lineHeight;
|
||||
|
||||
root.Width += measuredWidth - authoredTextWidth;
|
||||
root.Height += measuredHeight - authoredTextHeight;
|
||||
text.Width = measuredWidth;
|
||||
text.Height = measuredHeight;
|
||||
}
|
||||
|
||||
/// <summary>Retail <c>UIElementManager::StartTooltip @0x00459700</c>: the
|
||||
/// popup's top-left lands EXACTLY at the mouse cursor (no hotspot offset),
|
||||
/// clamped so it never crosses the right/bottom display edge (nor goes
|
||||
/// negative, mirroring retail's own <c>max(0, ...)</c> defensive clamp).</summary>
|
||||
private void PositionAtMouse(UiElement root)
|
||||
{
|
||||
var canvas = _host.EffectiveCanvasSize;
|
||||
float x = Math.Clamp(_host.MouseX, 0, MathF.Max(0f, canvas.X - root.Width));
|
||||
float y = Math.Clamp(_host.MouseY, 0, MathF.Max(0f, canvas.Y - root.Height));
|
||||
root.Left = x;
|
||||
root.Top = y;
|
||||
}
|
||||
|
||||
/// <summary>A tooltip must never intercept the pointer — the very next
|
||||
/// hover-hit-test would otherwise find the popup itself and immediately
|
||||
/// dismiss it (no decomp counterpart needed: retail's tooltip is a
|
||||
/// separate, non-hit-tested presentation layer by construction, per the
|
||||
/// AP-229 register row's own finding on dialogs). <see cref="UiDatElement"/>
|
||||
/// and <see cref="UiText"/> already default <c>ClickThrough=true</c>, but
|
||||
/// this walk makes the guarantee unconditional across whatever the popup
|
||||
/// LayoutDesc happens to be authored with.</summary>
|
||||
private static void SetClickThroughRecursive(UiElement element)
|
||||
{
|
||||
element.ClickThrough = true;
|
||||
foreach (UiElement child in element.Children)
|
||||
SetClickThroughRecursive(child);
|
||||
}
|
||||
|
||||
/// <summary>Re-asserts the popup's z-order above whatever
|
||||
/// <see cref="RetailDialogFactory.Tick"/> raised this frame — mirrors that
|
||||
/// factory's own per-tick <c>BringToFront</c> re-raise (see its own doc
|
||||
/// comment on the dialog/screen sibling z-order war, register AP-229) so a
|
||||
/// dialog opened WHILE a tooltip is already showing cannot bury it. Must
|
||||
/// run after <see cref="RetailUiRuntime"/> ticks both
|
||||
/// <see cref="RetailDialogFactory"/> and <see cref="UiRoot"/> in the same
|
||||
/// frame — see the divergence register row this class's own commit files
|
||||
/// for the acknowledged "sibling with a later re-raise" shape.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
if (_popupRoot is not null)
|
||||
_host.BringToFront(_popupRoot);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_host.TooltipShow -= OnTooltipShow;
|
||||
_host.TooltipHide -= OnTooltipHide;
|
||||
RemovePopup();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue