feat(chat): CT-A5 — clicking a speaker's name opens a tell

Campaign CT slice A5, closing Group A. Retail's
gmMainChatUI::RecvNotice_TextTag_IIDStringClick @0x004CCE10 ->
ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, " into the chat
entry and takes keyboard focus; clicking a green name here now does the same.

The trailing space is deliberate — without it the first character the player
types joins the comma.

Three seams, each narrow on purpose:

  - UiText.OnCharClick is offered the character under a left click before the
    element-wide OnClick, and consuming it suppresses that. Kept separate
    because a tag click is POSITIONAL and an element click is not; folding
    them together would make every text element with an OnClick swallow tag
    clicks.
  - TaggedRangesForFragment returns tagged column ranges relative to the
    FRAGMENT, because that is what a click resolves to — UiText.HitChar gives
    a line index into the WRAPPED list plus a column within it. Line-relative
    ranges would land every click on a wrapped line at the wrong characters.
  - The controller caches those ranges alongside the runs it already caches,
    so the per-click lookup reads the same cache the draw does.

The hit test is half-open: a caret slot sits BETWEEN glyphs, so clicking just
past a name's last letter belongs to the space after it, not the name. Pinned
by theory rather than left to chance, since off-by-one here means clicking a
name sometimes does nothing.

StartTell uses the tag's NAME, not its object id — retail carries the id but
this handler never reads it, so the tell still addresses correctly for someone
who has since moved out of range.

Group A is complete: names are green (A4) and clickable (A5). Ready for the
user's visual gate.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 07:34:58 +02:00
parent 53395e4de4
commit d32ef388f0
5 changed files with 245 additions and 7 deletions

View file

@ -42,7 +42,10 @@ public sealed class UiText : UiElement, IUiDatStateful
private Action? _onClick;
public override bool HandlesClick
=> OnClick is not null || WheelScrollEnabled || base.HandlesClick;
=> OnClick is not null
|| OnCharClick is not null
|| WheelScrollEnabled
|| base.HandlesClick;
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
public uint ElementId { get; set; }
@ -58,6 +61,18 @@ public sealed class UiText : UiElement, IUiDatStateful
/// character index (0..line.Text.Length, i.e. a caret slot between glyphs).</summary>
public readonly record struct Pos(int Line, int Col);
/// <summary>
/// Offered the character under a left click before <see cref="OnClick"/>.
/// Return <see langword="true"/> to consume the click.
/// </summary>
/// <remarks>
/// The seam for retail's clickable tagged runs. Kept separate from
/// <see cref="OnClick"/> because a tag click is positional and an element
/// click is not; folding them together would make every text element with
/// an OnClick swallow tag clicks.
/// </remarks>
public Func<Pos, bool>? OnCharClick { get; set; }
/// <summary>Provider of the lines to show, oldest-first. Polled each frame.</summary>
public Func<IReadOnlyList<Line>> LinesProvider { get; set; } = static () => Array.Empty<Line>();
@ -909,6 +924,18 @@ public sealed class UiText : UiElement, IUiDatStateful
public override bool OnEvent(in UiEvent e)
{
// Campaign CT slice A5: a click inside the text may land on a TAGGED
// run (a speaker's name), which retail treats as its own affordance —
// UIElement_Text::MouseUp @0x004694F0 resolves the xy to a glyph and
// dispatches through that glyph's tag. Offered the character first;
// a handled tag click does not also fire the element-wide OnClick.
if (e.Type == UiEventType.Click && OnCharClick is not null)
{
// Data1/Data2 = local-to-target coords (UiRoot's Click event).
if (OnCharClick(HitChar(e.Data1, e.Data2)))
return true;
}
if (e.Type == UiEventType.Click && OnClick is not null)
{
OnClick();