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:
Erik 2026-08-16 20:03:24 +02:00
parent d3755eb231
commit a377b9bff7
16 changed files with 1386 additions and 9 deletions

View file

@ -330,11 +330,51 @@ public sealed class UiRoot : UiElement
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
private long _hoverStartedMs;
/// <summary>Global hover-dwell delay in ms before a tooltip fires — retail
/// <c>UIElementManager::m_tooltipDelay</c>, default 0.25 s
/// (<c>UIElementManager::UIElementManager @0x0045F5D0</c>), the
/// <c>Misc.TooltipDelay</c> preference. A hovered widget's own
/// <see cref="UiElement.AuthoredTooltipDelaySeconds"/> (dat property
/// <c>0x50</c>) overrides this per <c>UIElementManager::CheckTooltip
/// @0x0045B6E0</c> — see <see cref="EffectiveTooltipDelayMs"/>.</summary>
public int TooltipDelayMs { get; set; } = 250;
/// <summary>How long a shown tooltip stays up before auto-hiding — retail
/// <c>m_tooltipDuration</c>, a fixed 10 s
/// (<c>UIElementManager::CheckTooltip @0x0045B6E0</c>'s own auto-hide
/// check); not a user preference.</summary>
public int TooltipDurationMs { get; set; } = 10_000;
private bool _tooltipFired;
private long _tooltipShownMs;
/// <summary>
/// #409: fired once, synchronously, when a hovered widget's dwell delay
/// elapses (retail's <c>UIElementManager::StartHover</c> ->
/// <c>UIElement::MouseHover</c> edge). <see cref="RetailTooltipPresenter"/>
/// is the production consumer — it decides whether the widget actually
/// authors a tooltip and, if so, builds/positions the popup.
/// </summary>
public event Action<UiElement>? TooltipShow;
/// <summary>
/// #409: fired when a previously-shown tooltip must go away — hover
/// left the widget (<see cref="UpdateHover"/>), the widget's subtree is
/// being removed (<see cref="ClearSubtreeOwnership"/>), or the shown
/// duration elapsed (<see cref="Tick"/>'s auto-hide branch). Only fires
/// if <see cref="TooltipShow"/> actually fired for this widget first
/// (mirrors retail's own <c>m_pTooltipElement != null</c> guard at every
/// one of those three call sites).
/// </summary>
public event Action<UiElement>? TooltipHide;
/// <summary>Retail <c>UIElementManager::CheckTooltip @0x0045B6E0</c>: a
/// per-element FLOAT delay override (dat property <c>0x50</c>) replaces
/// the global <see cref="TooltipDelayMs"/> when the hovered element
/// authors one.</summary>
private int EffectiveTooltipDelayMs(UiElement widget)
=> widget.AuthoredTooltipDelaySeconds is { } seconds
? (int)(seconds * 1000f)
: TooltipDelayMs;
private long _nowMs;
/// <summary>Raised when an event was not consumed by any widget.</summary>
@ -440,6 +480,12 @@ public sealed class UiRoot : UiElement
{
var leave = new UiEvent(_hoverWidget!.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
// #409: retail UIElementManager::DeletingElement @0x0045E520 tears
// down the active tooltip when its owner element is removed
// (m_pTooltipOwner == ebp). Only fire if a tooltip actually
// showed for this widget (mirrors that null-guarded check).
if (_tooltipFired)
TooltipHide?.Invoke(_hoverWidget);
_hoverWidget = null;
_tooltipFired = false;
}
@ -475,21 +521,23 @@ public sealed class UiRoot : UiElement
{
_nowMs = nowMs;
// Tooltip timer: once mouse has hovered over the same widget for
// TooltipDelayMs, fire a Tooltip event on it exactly once.
// Tooltip timer: once mouse has hovered over the same widget for its
// effective dwell delay, fire a Tooltip event on it exactly once.
if (_hoverWidget is not null && !_tooltipFired
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
&& _nowMs - _hoverStartedMs >= EffectiveTooltipDelayMs(_hoverWidget))
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
_tooltipShownMs = _nowMs;
TooltipShow?.Invoke(_hoverWidget);
}
else if (_hoverWidget is not null && _tooltipFired
&& _nowMs - _tooltipShownMs >= TooltipDurationMs)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
TooltipHide?.Invoke(_hoverWidget);
_hoverWidget = null;
_tooltipFired = false;
}
@ -1241,6 +1289,16 @@ public sealed class UiRoot : UiElement
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
// #409: retail UIElementManager::SwitchMouseOver @0x0045B560 calls
// StopHover (which tears down m_pTooltipElement) the instant the
// hovered element changes — the ONLY confirmed dismissal trigger
// besides duration-timeout and element-removal. Notably, retail's
// MouseDownEvent @0x0045DB60 calls SwitchMouseOver with the SAME
// hit-tested element, so clicking the tooltip's own owner does
// NOT dismiss it (SwitchMouseOver no-ops when the target hasn't
// changed) — no click-dismissal is ported here for that reason.
if (_tooltipFired)
TooltipHide?.Invoke(_hoverWidget);
}
_hoverWidget = w;
_hoverStartedMs = _nowMs;