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;
|
||||
|
|
|
|||
|
|
@ -135,6 +135,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
/// is a single colour and draws through the ordinary flat path.
|
||||
/// </summary>
|
||||
private readonly List<IReadOnlyList<UiText.TextRun>?> _cachedTranscriptRuns = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-cached-line tagged column ranges, index-aligned with
|
||||
/// <see cref="_cachedTranscriptLines"/>. This is what turns a click into a
|
||||
/// tag; the runs alongside only decide colour.
|
||||
/// </summary>
|
||||
private readonly List<IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?>
|
||||
_cachedTranscriptTags = new();
|
||||
private long _cachedTranscriptRevision = -1;
|
||||
private ulong _cachedFilter;
|
||||
private float _cachedTranscriptWrapWidth = float.NaN;
|
||||
|
|
@ -340,6 +348,10 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
index >= 0 && index < c._cachedTranscriptRuns.Count
|
||||
? c._cachedTranscriptRuns[index]
|
||||
: null;
|
||||
// Clicking a tagged name opens a tell to that player, exactly as
|
||||
// retail does — gmMainChatUI::RecvNotice_TextTag_IIDStringClick
|
||||
// @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0.
|
||||
c.Transcript.OnCharClick = pos => c.TryStartTellFromTag(pos);
|
||||
|
||||
// ── Input ────────────────────────────────────────────────────────
|
||||
// Editable/selectable/one-line semantics and state sprites came from the
|
||||
|
|
@ -794,6 +806,7 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
// are cached with them, so the transcript's per-line run lookup costs
|
||||
// nothing per frame — it reads the same cache the lines do.
|
||||
_cachedTranscriptRuns.Clear();
|
||||
_cachedTranscriptTags.Clear();
|
||||
var result = ChatTranscriptRenderer.BuildLines(
|
||||
detailed,
|
||||
maxW,
|
||||
|
|
@ -801,10 +814,60 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
|
|||
Accept,
|
||||
Transcript.DefaultColor,
|
||||
Transcript.TagColor,
|
||||
_cachedTranscriptRuns);
|
||||
_cachedTranscriptRuns,
|
||||
_cachedTranscriptTags);
|
||||
return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a tell to the player whose name was clicked, if one was.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail writes "@tell {Name}, " into the chat entry, takes keyboard
|
||||
/// focus and shows the entry bar (<c>ChatInterface::StartTell
|
||||
/// @0x004F41F0</c>). It uses the tag's NAME rather than its object id: the
|
||||
/// id is carried but this handler never reads it, so a tell still
|
||||
/// addresses correctly for a player who has since moved out of range.
|
||||
/// </remarks>
|
||||
internal bool TryStartTellFromTag(UiText.Pos position)
|
||||
{
|
||||
if (position.Line < 0 || position.Line >= _cachedTranscriptTags.Count)
|
||||
return false;
|
||||
if (_cachedTranscriptTags[position.Line] is not { } ranges)
|
||||
return false;
|
||||
|
||||
foreach ((int start, int length, ChatTextTag tag) in ranges)
|
||||
{
|
||||
// A caret slot sits BETWEEN glyphs, so the range is half-open:
|
||||
// clicking just past the last letter of a name is not that name.
|
||||
if (position.Col < start || position.Col >= start + length)
|
||||
continue;
|
||||
if (!tag.TryGetIidString(out _, out string name) || name.Length == 0)
|
||||
continue;
|
||||
|
||||
StartTell(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Aims the chat entry at <paramref name="name"/> and focuses it.</summary>
|
||||
internal void StartTell(string name)
|
||||
{
|
||||
Input.SetText($"@tell {name}, ");
|
||||
Input.MoveCaret(int.MaxValue);
|
||||
FindRootOf(Input)?.SetKeyboardFocus(Input);
|
||||
}
|
||||
|
||||
private static UiRoot? FindRootOf(UiElement element)
|
||||
{
|
||||
for (UiElement? at = element; at is not null; at = at.Parent)
|
||||
if (at is UiRoot root)
|
||||
return root;
|
||||
return null;
|
||||
}
|
||||
|
||||
private IReadOnlyList<UiText.Line> StoreTranscriptLayout(
|
||||
IReadOnlyList<UiText.Line> lines,
|
||||
long revision,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
85
tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs
Normal file
85
tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.Core.Chat;
|
||||
|
||||
namespace AcDream.App.Tests.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CT slice A5: clicking a speaker's name opens a tell to them.
|
||||
/// </summary>
|
||||
public sealed class ChatTagClickTests
|
||||
{
|
||||
private static ChatTextTag Tell(string name)
|
||||
=> new("Tell", "IIDString", $"1342177290:{name}");
|
||||
|
||||
private static IReadOnlyList<ChatTextSpan> TellSpans(string name, string rest)
|
||||
=> new[] { new ChatTextSpan(name, Tell(name)), new ChatTextSpan(rest, null) };
|
||||
|
||||
[Fact]
|
||||
public void TheNamesColumnsAreTaggedAndTheRestIsNot()
|
||||
{
|
||||
IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>? ranges =
|
||||
ChatTranscriptRenderer.TaggedRangesForFragment(
|
||||
TellSpans("Dww", " tells you"),
|
||||
fragmentStart: 0,
|
||||
fragmentLength: "Dww tells you".Length);
|
||||
|
||||
(int start, int length, ChatTextTag tag) = Assert.Single(ranges!);
|
||||
Assert.Equal(0, start);
|
||||
Assert.Equal(3, length);
|
||||
Assert.True(tag.TryGetIidString(out _, out string name));
|
||||
Assert.Equal("Dww", name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ColumnsAreRelativeToTheFragmentNotTheWholeLine()
|
||||
{
|
||||
// A click resolves to a line index in the WRAPPED list plus a column
|
||||
// within that line, so ranges must be fragment-relative or every click
|
||||
// on a wrapped line lands on the wrong characters.
|
||||
IReadOnlyList<ChatTextSpan> spans = TellSpans("Dww", " tells you now");
|
||||
|
||||
// A window starting mid-name: the tagged range starts at column 0 of
|
||||
// the fragment, not at column 1 of the line.
|
||||
(int start, int length, _) = Assert.Single(
|
||||
ChatTranscriptRenderer.TaggedRangesForFragment(spans, 1, 5)!);
|
||||
|
||||
Assert.Equal(0, start);
|
||||
Assert.Equal(2, length); // "ww" — the part of the name inside the window
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AFragmentPastTheNameHasNoTaggedRanges()
|
||||
{
|
||||
Assert.Null(ChatTranscriptRenderer.TaggedRangesForFragment(
|
||||
TellSpans("Dww", " tells you"),
|
||||
fragmentStart: 6,
|
||||
fragmentLength: 4));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUntaggedLineHasNoTaggedRanges()
|
||||
{
|
||||
Assert.Null(ChatTranscriptRenderer.TaggedRangesForFragment(
|
||||
new[] { new ChatTextSpan("Welcome.", null) },
|
||||
fragmentStart: 0,
|
||||
fragmentLength: 8));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// A caret slot sits BETWEEN glyphs, so the range is half-open: the column
|
||||
// just past the name's last letter belongs to the space after it.
|
||||
[InlineData(0, true)]
|
||||
[InlineData(2, true)]
|
||||
[InlineData(3, false)]
|
||||
[InlineData(9, false)]
|
||||
public void OnlyColumnsInsideTheNameCount(int column, bool inside)
|
||||
{
|
||||
IReadOnlyList<(int Start, int Length, ChatTextTag Tag)> ranges =
|
||||
ChatTranscriptRenderer.TaggedRangesForFragment(
|
||||
TellSpans("Dww", " tells you"), 0, "Dww tells you".Length)!;
|
||||
|
||||
(int start, int length, _) = ranges[0];
|
||||
Assert.Equal(inside, column >= start && column < start + length);
|
||||
}
|
||||
}
|
||||
|
|
@ -158,6 +158,25 @@ public class ChatWindowControllerTests
|
|||
Assert.NotNull(ctrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd()
|
||||
{
|
||||
// Retail's ChatInterface::StartTell @0x004F41F0 writes "@tell {Name}, "
|
||||
// into the chat entry and takes focus, so the player can type straight
|
||||
// into a reply. The trailing space matters: without it the first thing
|
||||
// they type joins the comma.
|
||||
var (rootInfo, layout, vm) = BuildTestTree();
|
||||
var bus = new CaptureBus();
|
||||
|
||||
ChatWindowController? ctrl = ChatWindowController.Bind(
|
||||
rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
|
||||
Assert.NotNull(ctrl);
|
||||
|
||||
ctrl!.StartTell("Dww");
|
||||
|
||||
Assert.Equal("@tell Dww, ", ctrl.Input.Text);
|
||||
}
|
||||
|
||||
// ── Talk-focus specials: "Tell to X" / "Squelch (ignore) X" ─────────────
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue