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
|
|
@ -1183,4 +1183,21 @@ public static class DatWidgetFactory
|
|||
/// </summary>
|
||||
private static string? NormalizeEscapes(string? raw) =>
|
||||
raw?.Replace("\\n", "\n").Replace("\r", string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// #409 (client-wide retail tooltip system): resolves the already-
|
||||
/// extracted <see cref="ElementInfo.TooltipText"/> (dat property
|
||||
/// <c>0x49</c>) through <paramref name="stringResolve"/>, applying the
|
||||
/// SAME escape normalization every other authored <c>StringInfo</c>
|
||||
/// (captions, <c>0x17</c>) gets at this one choke point. Null when the
|
||||
/// element authors no tooltip text or no resolver is available.
|
||||
/// </summary>
|
||||
internal static string? ResolveTooltipText(
|
||||
ElementInfo info,
|
||||
Func<UiStringInfoValue, string?>? stringResolve)
|
||||
{
|
||||
if (stringResolve is null || info.TooltipText is not { } tooltipText)
|
||||
return null;
|
||||
return NormalizeEscapes(stringResolve(tooltipText));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,6 +266,95 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public int MarginLeft, MarginRight, MarginTop, MarginBottom;
|
||||
|
||||
/// <summary>
|
||||
/// #409 (client-wide retail tooltip system): whether THIS element is
|
||||
/// eligible to trigger retail's hover tooltip at all — dat property
|
||||
/// <c>0x4B</c> (<c>BoolBaseProperty</c>). Retail
|
||||
/// <c>UIElement::OnSetAttribute @0x00462d80</c> case <c>0x18</c>
|
||||
/// (<c>GetPropertyName()-0x33==0x18</c>, i.e. property id
|
||||
/// <c>0x33+0x18=0x4B</c>) calls <c>UIRegion::SetTooltipOn</c>, which
|
||||
/// toggles bit 5 of <c>__bitfield164</c>; <c>UIElement::MouseHover
|
||||
/// @0x00462520</c> checks exactly that bit (plus the GLOBAL
|
||||
/// <c>UIElementManager::m_tooltipEnable</c> preference) before calling
|
||||
/// <c>StartTooltipAtMouse</c>. Default false — the ctor-cleared bit,
|
||||
/// matching "an element must explicitly author <c>P0x4B=true</c> to
|
||||
/// ever show a tooltip."
|
||||
/// </summary>
|
||||
public bool TooltipEnabled;
|
||||
|
||||
/// <summary>
|
||||
/// #409: the tooltip's TEXT, dat property <c>0x49</c>
|
||||
/// (<c>StringInfo</c>). Retail <c>UIElement::StartTooltipAtMouse
|
||||
/// @0x00460D70</c> prefers the element's own runtime-settable
|
||||
/// <c>m_TTText</c> field (set only by the separate, non-dat-authored
|
||||
/// <c>UIElement::SetTooltip</c> API — game code showing dynamic
|
||||
/// status text, out of this property's scope) and falls back to
|
||||
/// <c>vtable->InqProperty(0x49, ...)</c> — the generic dat-authored
|
||||
/// property bag read — whenever <c>m_TTText</c> is empty, which for a
|
||||
/// pure DAT-imported element is ALWAYS (nothing in
|
||||
/// <c>OnSetAttribute</c>'s switch populates <c>m_TTText</c> from a dat
|
||||
/// property). Kept RAW (unresolved <see cref="UiStringInfoValue"/>) at
|
||||
/// this layer — <see cref="ElementReader"/> is dat-free/string-free by
|
||||
/// design; resolution through <c>DatStringResolver</c> happens in
|
||||
/// <c>DatWidgetFactory</c>, mirroring the existing <c>0x17</c> caption
|
||||
/// pattern exactly.
|
||||
/// </summary>
|
||||
public UiStringInfoValue? TooltipText;
|
||||
|
||||
/// <summary>
|
||||
/// #409: the ROOT ELEMENT ID to instantiate WITHIN the tooltip popup
|
||||
/// LayoutDesc (<see cref="TooltipLayoutDid"/>) — dat property
|
||||
/// <c>0x47</c> (<c>EnumBaseProperty</c>, read via
|
||||
/// <c>UIElement::GetAttribute_Enum(this, 0x47, ...)</c>). Retail
|
||||
/// <c>UIElementManager::StartTooltip @0x0045DE90</c> passes this value
|
||||
/// straight to <c>LayoutDesc::AccessElementDesc</c> — it is an
|
||||
/// element-desc token WITHIN the popup layout, NOT a "tooltip behavior
|
||||
/// enum" (an earlier, less complete investigation guessed the latter;
|
||||
/// full decomp re-derivation for this port corrects it). Zero when the
|
||||
/// element authors no tooltip.
|
||||
/// </summary>
|
||||
public uint TooltipRootElementId;
|
||||
|
||||
/// <summary>
|
||||
/// #409: the tooltip POPUP LayoutDesc DID — dat property <c>0x48</c>
|
||||
/// (<c>DataIdBaseProperty</c>, read via
|
||||
/// <c>UIElement::GetAttribute_DataID(this, 0x48, ...)</c>). Retail
|
||||
/// authors <c>0x21000041</c> here on every tooltip-bearing element
|
||||
/// (live-DAT-probe-confirmed by the #409 investigation). Retail falls
|
||||
/// back to the HOVERING element's OWN containing <c>m_layout</c> DID
|
||||
/// when <c>P0x48</c> is absent but <c>P0x47</c> is present — an edge
|
||||
/// case this port does not reproduce (every live-DAT element the #409
|
||||
/// sweep found authors <c>P0x48</c> directly alongside <c>P0x47</c>;
|
||||
/// see the divergence register for the honest row). Zero when absent.
|
||||
/// </summary>
|
||||
public uint TooltipLayoutDid;
|
||||
|
||||
/// <summary>
|
||||
/// #409: the tooltip TEXT CHILD's element id — dat property
|
||||
/// <c>0x4A</c> (<c>EnumBaseProperty</c>). Unlike the other five
|
||||
/// tooltip properties, retail reads THIS one off the freshly
|
||||
/// INSTANTIATED popup's own ROOT element (<c>UIElement::
|
||||
/// GetAttribute_Enum(tooltipRootElement, 0x4a, &textChildId)</c>
|
||||
/// inside <c>UIElementManager::StartTooltip @0x0045DE90</c>'s
|
||||
/// fallback branch), not off the hovering trigger element — so this
|
||||
/// field is meaningful only when read from the popup layout's OWN
|
||||
/// resolved root <see cref="ElementInfo"/>/<c>UiElement</c>, which the
|
||||
/// same generic property-read machinery populates uniformly for every
|
||||
/// imported element regardless of role.
|
||||
/// </summary>
|
||||
public uint TooltipTextChildElementId;
|
||||
|
||||
/// <summary>
|
||||
/// #409: a per-element hover-dwell delay OVERRIDE in seconds — dat
|
||||
/// property <c>0x50</c> (<c>FloatBaseProperty</c>). Retail
|
||||
/// <c>UIElementManager::CheckTooltip @0x0045B6E0</c> reads
|
||||
/// <c>UIElement::GetAttribute_Float(hoveredElement, 0x50, &delay)</c>
|
||||
/// and uses it in place of the global <c>m_tooltipDelay</c>
|
||||
/// (<c>Misc.TooltipDelay</c> preference, default 0.25 s) when present.
|
||||
/// Null when the element authors no override.
|
||||
/// </summary>
|
||||
public float? TooltipDelaySeconds;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a property for a state using retail's DirectState-as-base rule. A
|
||||
/// named state's key overrides DirectState by presence, including false/zero.
|
||||
|
|
@ -603,6 +692,32 @@ public static class ElementReader
|
|||
{
|
||||
info.Invisible = invisible;
|
||||
}
|
||||
|
||||
// #409 (client-wide retail tooltip system): the six per-element
|
||||
// tooltip properties. See each ElementInfo field's own doc comment
|
||||
// for the exact decomp anchor. TooltipTextChildElementId (0x4A) is
|
||||
// read the SAME generic way here even though retail only consults
|
||||
// it off a tooltip POPUP's own root element — the read is uniform
|
||||
// and harmless for every other element (it stays 0).
|
||||
if (info.TryGetEffectiveBool(0x4Bu, out bool tooltipOn))
|
||||
{
|
||||
info.TooltipEnabled = tooltipOn;
|
||||
}
|
||||
|
||||
if (info.TryGetEffectiveProperty(0x49u, out var tooltipText)
|
||||
&& tooltipText.Kind == UiPropertyKind.StringInfo)
|
||||
{
|
||||
info.TooltipText = tooltipText.StringInfoValue;
|
||||
}
|
||||
|
||||
info.TooltipRootElementId = ReadReferencedElementId(info, 0x47u);
|
||||
info.TooltipLayoutDid = ReadReferencedElementId(info, 0x48u);
|
||||
info.TooltipTextChildElementId = ReadReferencedElementId(info, 0x4Au);
|
||||
|
||||
if (info.TryGetEffectiveFloat(0x50u, out float tooltipDelay))
|
||||
{
|
||||
info.TooltipDelaySeconds = tooltipDelay;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<UiTabTableEntry> ReadTabTable(ElementInfo info)
|
||||
|
|
|
|||
|
|
@ -121,6 +121,18 @@ public static class LayoutImporter
|
|||
// doc comment for why this does NOT set Visible here.
|
||||
w.AuthoredInvisible = info.Invisible;
|
||||
|
||||
// #409: the six per-element tooltip properties, same pure-data-
|
||||
// passthrough shape as AuthoredInvisible above. TooltipText is the
|
||||
// one property resolved to a display string here (mirrors every
|
||||
// other authored StringInfo caption in this file); the rest stay
|
||||
// raw ids/flags for RetailTooltipPresenter to act on.
|
||||
w.AuthoredTooltipEnabled = info.TooltipEnabled;
|
||||
w.AuthoredTooltipText = DatWidgetFactory.ResolveTooltipText(info, stringResolve);
|
||||
w.AuthoredTooltipRootElementId = info.TooltipRootElementId;
|
||||
w.AuthoredTooltipLayoutDid = info.TooltipLayoutDid;
|
||||
w.AuthoredTooltipTextChildElementId = info.TooltipTextChildElementId;
|
||||
w.AuthoredTooltipDelaySeconds = info.TooltipDelaySeconds;
|
||||
|
||||
if (info.Id != 0) byId[info.Id] = w;
|
||||
|
||||
// Behavioral widgets that draw their full appearance + reproduce their dat
|
||||
|
|
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -528,6 +528,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
MountIndicators();
|
||||
MountJumpPowerbar();
|
||||
MountDialogFactory();
|
||||
MountTooltipPresenter();
|
||||
MountSocialPanel();
|
||||
MountCharacter();
|
||||
MountPlugins();
|
||||
|
|
@ -630,6 +631,9 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public UiNineSlicePanel? InventoryFrame { get; private set; }
|
||||
public InventoryController? InventoryPanelController { get; private set; }
|
||||
public RetailDialogFactory? DialogFactory { get; private set; }
|
||||
/// <summary>#409: client-wide retail hover-tooltip presenter. Constructed
|
||||
/// alongside <see cref="DialogFactory"/> — see <c>ConfigureDialogFactory</c>.</summary>
|
||||
public RetailTooltipPresenter? TooltipPresenter { get; private set; }
|
||||
public ExternalContainerController? ExternalContainerController { get; private set; }
|
||||
public VendorUiController? VendorController { get; private set; }
|
||||
public OptionsPanelController? OptionsPanelController { get; private set; }
|
||||
|
|
@ -813,6 +817,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
CharacterCreationController?.Tick();
|
||||
DialogFactory?.Tick();
|
||||
Host.Tick(deltaSeconds);
|
||||
// #409: MUST tick after both DialogFactory and Host so the tooltip's
|
||||
// own BringToFront re-raise always lands on top of whatever z-order
|
||||
// those two just asserted this frame — see RetailTooltipPresenter.Tick's
|
||||
// own doc comment and the register row filed alongside this class.
|
||||
TooltipPresenter?.Tick();
|
||||
_automation?.Tick(deltaSeconds);
|
||||
}
|
||||
|
||||
|
|
@ -979,6 +988,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
{
|
||||
CharacterManagementController?.ResetSession();
|
||||
DialogFactory?.Reset();
|
||||
TooltipPresenter?.HideCurrent();
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -3314,6 +3324,47 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
$"[UI] retail DialogFactory from LayoutDesc 0x{layoutId:X8}; confirmation root 0x15.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #409: mounts the client-wide retail tooltip presenter. Unlike
|
||||
/// <see cref="MountDialogFactory"/>'s catalog resolve, the tooltip popup
|
||||
/// layout/root are entirely data-driven off each hovered widget's OWN
|
||||
/// authored <c>0x47</c>/<c>0x48</c> properties (<see cref="RetailTooltipPresenter"/>'s
|
||||
/// own class doc) — this method only wires the generic layout-by-DID
|
||||
/// resolver and seeds the client-local <c>Misc.TooltipEnable</c>/
|
||||
/// <c>Misc.TooltipDelay</c> preferences from the shared
|
||||
/// <see cref="SettingsStore"/> (the same instance
|
||||
/// <c>_bindings.Chat.Store</c> already threads to every other client-local
|
||||
/// preference reader in this file).
|
||||
/// </summary>
|
||||
private void MountTooltipPresenter()
|
||||
{
|
||||
if (TooltipPresenter is not null)
|
||||
return;
|
||||
|
||||
ImportedLayout? CreateTooltipLayout(uint layoutDid, uint rootElementId)
|
||||
{
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
return LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
layoutDid,
|
||||
rootElementId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
}
|
||||
|
||||
TooltipPresenter = new RetailTooltipPresenter(Host.Root, CreateTooltipLayout);
|
||||
|
||||
if (_bindings.Chat.Store is { } store)
|
||||
{
|
||||
MiscSettings misc = store.LoadMisc();
|
||||
TooltipPresenter.Enabled = misc.TooltipEnable;
|
||||
Host.Root.TooltipDelayMs = (int)(misc.TooltipDelaySeconds * 1000f);
|
||||
}
|
||||
}
|
||||
|
||||
private UiShortcutDigitGraphics LoadShortcutDigitGraphics()
|
||||
{
|
||||
if (_shortcutDigitGraphics is not null)
|
||||
|
|
@ -4194,7 +4245,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
_characterCreationMount?.Dispose();
|
||||
_gameplayConfirmationController?.Dispose();
|
||||
},
|
||||
() => DialogFactory?.Dispose(),
|
||||
() =>
|
||||
{
|
||||
DialogFactory?.Dispose();
|
||||
TooltipPresenter?.Dispose();
|
||||
},
|
||||
_panelUi.Dispose,
|
||||
Host.Dispose);
|
||||
_shutdown.CompleteOrThrow();
|
||||
|
|
|
|||
|
|
@ -70,6 +70,60 @@ public abstract class UiElement
|
|||
/// </summary>
|
||||
public bool AuthoredInvisible { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409 (client-wide retail tooltip system): mirrors
|
||||
/// <c>ElementInfo.TooltipEnabled</c> (dat property <c>0x4B</c>) — a
|
||||
/// PURE DATA PASSTHROUGH set by <c>LayoutImporter.BuildWidget</c>.
|
||||
/// Gates whether <see cref="RetailTooltipPresenter"/> may show a
|
||||
/// tooltip for this widget at all (retail's per-element
|
||||
/// <c>UIRegion::SetTooltipOn</c> bit, checked by
|
||||
/// <c>UIElement::MouseHover @0x00462520</c> alongside the global
|
||||
/// enable preference). See <see cref="AcDream.App.UI.Layout.ElementInfo.TooltipEnabled"/>.
|
||||
/// </summary>
|
||||
public bool AuthoredTooltipEnabled { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409: mirrors <c>ElementInfo.TooltipText</c> (dat property
|
||||
/// <c>0x49</c>), already resolved to a display string through
|
||||
/// <c>DatStringResolver</c> and escape-normalized at import time —
|
||||
/// the same treatment every other authored <c>StringInfo</c> caption
|
||||
/// gets. Null when the element authors no tooltip text.
|
||||
/// </summary>
|
||||
public string? AuthoredTooltipText { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409: mirrors <c>ElementInfo.TooltipRootElementId</c> (dat property
|
||||
/// <c>0x47</c>) — the element-desc id WITHIN
|
||||
/// <see cref="AuthoredTooltipLayoutDid"/>'s LayoutDesc to instantiate
|
||||
/// as the tooltip popup's root. Zero when absent.
|
||||
/// </summary>
|
||||
public uint AuthoredTooltipRootElementId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409: mirrors <c>ElementInfo.TooltipLayoutDid</c> (dat property
|
||||
/// <c>0x48</c>) — the tooltip popup LayoutDesc DID (retail authors
|
||||
/// <c>0x21000041</c> on every tooltip-bearing element). Zero when
|
||||
/// absent.
|
||||
/// </summary>
|
||||
public uint AuthoredTooltipLayoutDid { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409: mirrors <c>ElementInfo.TooltipTextChildElementId</c> (dat
|
||||
/// property <c>0x4A</c>). Meaningful only when read off a tooltip
|
||||
/// POPUP's own instantiated ROOT widget — see the
|
||||
/// <c>ElementInfo</c> field's own doc for why retail reads this off
|
||||
/// the popup, not the hovering trigger element.
|
||||
/// </summary>
|
||||
public uint AuthoredTooltipTextChildElementId { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// #409: mirrors <c>ElementInfo.TooltipDelaySeconds</c> (dat property
|
||||
/// <c>0x50</c>) — a per-element hover-dwell override in seconds, used
|
||||
/// by <see cref="UiRoot"/>'s tooltip timer in place of the global
|
||||
/// <see cref="UiRoot.TooltipDelayMs"/> when present. Null = no override.
|
||||
/// </summary>
|
||||
public float? AuthoredTooltipDelaySeconds { get; internal set; }
|
||||
|
||||
private readonly Dictionary<string, UiCursorMedia> _stateCursors = new();
|
||||
|
||||
/// <summary>Retail MediaDescCursor entries keyed by UIStateId.ToString(), or "" for DirectState.</summary>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
33
src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
Normal file
33
src/AcDream.UI.Abstractions/Panels/Settings/MiscSettings.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
namespace AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// #409 (client-wide retail tooltip system): the two <c>Misc.*</c>
|
||||
/// <c>UserPreferences.ini</c> keys retail's <c>UIElementManager::Init
|
||||
/// @0x0045EE10</c> registers (<c>UIPreferences::AttachPreference</c>,
|
||||
/// <c>Misc_TooltipDelay</c>/<c>Misc_TooltipEnable</c>) and binds onto
|
||||
/// <c>UIElementManager::m_tooltipDelay</c>/<c>m_tooltipEnable</c>. These are
|
||||
/// CLIENT-LOCAL preferences, not part of the server-synced
|
||||
/// <c>CharacterOptionTable</c> — the #409 investigation confirmed retail's
|
||||
/// own 2013 Config tab does not expose a visible row for either (they exist
|
||||
/// in <c>UserPreferences.ini</c> and are registered engine-side, but
|
||||
/// <c>gmConfigUI::InitOptions</c>'s 27 authored rows never author one), so
|
||||
/// this record follows the <see cref="CameraTurningSettings"/>/
|
||||
/// <see cref="AudioSettings"/> client-local persistence precedent WITHOUT a
|
||||
/// matching options-panel row — persisted, but not (yet) user-editable
|
||||
/// in-client, exactly matching what retail itself ships.
|
||||
/// </summary>
|
||||
public sealed record MiscSettings(
|
||||
bool TooltipEnable,
|
||||
float TooltipDelaySeconds)
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's registered defaults: <c>m_tooltipEnable=1</c>,
|
||||
/// <c>m_tooltipDelay=0.25f</c>
|
||||
/// (<c>UIElementManager::UIElementManager @0x0045F5D0</c>). The
|
||||
/// preference's registered clamp range is <c>[0,10]</c> seconds
|
||||
/// (<c>UIPreferences::SetPreferenceRange(&Misc_TooltipDelay, 0f, 10f)</c>).
|
||||
/// </summary>
|
||||
public static MiscSettings Default { get; } = new(
|
||||
TooltipEnable: true,
|
||||
TooltipDelaySeconds: 0.25f);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ public readonly record struct UiWindowPosition(float X, float Y);
|
|||
|
||||
/// <summary>
|
||||
/// JSON-backed persistence for non-keybind settings (Display / Audio / Chat /
|
||||
/// Character; the OP9-retired <c>Gameplay</c> section is no longer read or
|
||||
/// CameraTurning / Character / Misc; the OP9-retired <c>Gameplay</c> section is no longer read or
|
||||
/// written — see the class's Schema note below for how a leftover
|
||||
/// <c>"gameplay"</c> block from an older settings.json is tolerated). The
|
||||
/// graphical host supplies a canonical portable configuration path. Coexists
|
||||
|
|
@ -280,6 +280,38 @@ public sealed class SettingsStore
|
|||
public void SaveCameraTurning(CameraTurningSettings cameraTurning)
|
||||
=> SaveSection("cameraTurning", BuildCameraTurningObject(cameraTurning));
|
||||
|
||||
/// <summary>
|
||||
/// #409: load the two <c>Misc.*</c> tooltip preferences. Same fall-back
|
||||
/// behaviour as <see cref="LoadDisplay"/>.
|
||||
/// </summary>
|
||||
public MiscSettings LoadMisc()
|
||||
{
|
||||
if (!File.Exists(_path)) return MiscSettings.Default;
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(_path);
|
||||
var doc = JsonDocument.Parse(stream);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("misc", out var misc)
|
||||
|| misc.ValueKind != JsonValueKind.Object)
|
||||
return MiscSettings.Default;
|
||||
|
||||
var d = MiscSettings.Default;
|
||||
return new MiscSettings(
|
||||
TooltipEnable: ReadBool (misc, "tooltipEnable", d.TooltipEnable),
|
||||
TooltipDelaySeconds: ReadFloat(misc, "tooltipDelaySeconds", d.TooltipDelaySeconds));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"settings: failed to load {_path}: {ex.Message} — using defaults");
|
||||
return MiscSettings.Default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Save the tooltip preferences, preserving all other top-level keys.</summary>
|
||||
public void SaveMisc(MiscSettings misc)
|
||||
=> SaveSection("misc", BuildMiscObject(misc));
|
||||
|
||||
/// <summary>
|
||||
/// Load per-character settings keyed by <paramref name="toonKey"/>.
|
||||
/// Missing file or missing toon entry → <see cref="CharacterSettings.Default"/>.
|
||||
|
|
@ -652,6 +684,13 @@ public sealed class SettingsStore
|
|||
["vsync"] = d.VSync,
|
||||
};
|
||||
|
||||
private static SortedDictionary<string, object> BuildMiscObject(MiscSettings m)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
["tooltipDelaySeconds"] = m.TooltipDelaySeconds,
|
||||
["tooltipEnable"] = m.TooltipEnable,
|
||||
};
|
||||
|
||||
private static SortedDictionary<string, object> BuildCameraTurningObject(CameraTurningSettings c)
|
||||
=> new(StringComparer.Ordinal)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue