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:
parent
53395e4de4
commit
d32ef388f0
5 changed files with 245 additions and 7 deletions
|
|
@ -114,6 +114,45 @@ internal static class ChatTranscriptRenderer
|
|||
return sawTag ? runs : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tagged column ranges inside one wrapped fragment, or
|
||||
/// <see langword="null"/> when it holds none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Columns are relative to the FRAGMENT, because that is what a click
|
||||
/// resolves to: <c>UiText.HitChar</c> returns a line index into the
|
||||
/// wrapped list plus a column within that line.
|
||||
/// </remarks>
|
||||
internal static IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?
|
||||
TaggedRangesForFragment(
|
||||
IReadOnlyList<ChatTextSpan> spans,
|
||||
int fragmentStart,
|
||||
int fragmentLength)
|
||||
{
|
||||
int fragmentEnd = fragmentStart + fragmentLength;
|
||||
List<(int Start, int Length, ChatTextTag Tag)>? ranges = null;
|
||||
int at = 0;
|
||||
|
||||
foreach (ChatTextSpan span in spans)
|
||||
{
|
||||
int spanStart = at;
|
||||
int spanEnd = at + span.Text.Length;
|
||||
at = spanEnd;
|
||||
|
||||
if (span.Tag is not { } tag)
|
||||
continue;
|
||||
|
||||
int from = Math.Max(spanStart, fragmentStart);
|
||||
int to = Math.Min(spanEnd, fragmentEnd);
|
||||
if (to <= from)
|
||||
continue;
|
||||
|
||||
(ranges ??= new()).Add((from - fragmentStart, to - from, tag));
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
public static List<UiText.Line> BuildLines(
|
||||
IReadOnlyList<FormattedLine> detailed,
|
||||
float maxW,
|
||||
|
|
@ -121,10 +160,12 @@ internal static class ChatTranscriptRenderer
|
|||
Func<uint, bool>? accept,
|
||||
Vector4 defaultColor,
|
||||
Vector4? tagColor = null,
|
||||
List<IReadOnlyList<UiText.TextRun>?>? runsPerLine = null)
|
||||
List<IReadOnlyList<UiText.TextRun>?>? runsPerLine = null,
|
||||
List<IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?>? tagsPerLine = null)
|
||||
{
|
||||
var result = new List<UiText.Line>(detailed.Count);
|
||||
runsPerLine?.Clear();
|
||||
tagsPerLine?.Clear();
|
||||
if (detailed.Count == 0)
|
||||
return result;
|
||||
|
||||
|
|
@ -149,12 +190,13 @@ internal static class ChatTranscriptRenderer
|
|||
{
|
||||
result.Add(new UiText.Line(frag, currentColor));
|
||||
|
||||
if (runsPerLine is null)
|
||||
if (runsPerLine is null && tagsPerLine is null)
|
||||
continue;
|
||||
|
||||
if (d.Spans is not { Count: > 0 } spans || frag.Length == 0)
|
||||
{
|
||||
runsPerLine.Add(null);
|
||||
runsPerLine?.Add(null);
|
||||
tagsPerLine?.Add(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -163,17 +205,19 @@ internal static class ChatTranscriptRenderer
|
|||
{
|
||||
// Should not happen; a fragment always comes from the line.
|
||||
// Fall back to the flat colour rather than mis-colouring.
|
||||
runsPerLine.Add(null);
|
||||
runsPerLine?.Add(null);
|
||||
tagsPerLine?.Add(null);
|
||||
continue;
|
||||
}
|
||||
searchFrom = at + frag.Length;
|
||||
|
||||
runsPerLine.Add(RunsForFragment(
|
||||
runsPerLine?.Add(RunsForFragment(
|
||||
spans,
|
||||
at,
|
||||
frag.Length,
|
||||
currentColor,
|
||||
tagColor ?? currentColor));
|
||||
tagsPerLine?.Add(TaggedRangesForFragment(spans, at, frag.Length));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue