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

@ -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);
}
}

View file

@ -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>