diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
index 9ee09e71..2b4a1a5d 100644
--- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
+++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
@@ -114,6 +114,45 @@ internal static class ChatTranscriptRenderer
return sawTag ? runs : null;
}
+ ///
+ /// The tagged column ranges inside one wrapped fragment, or
+ /// when it holds none.
+ ///
+ ///
+ /// Columns are relative to the FRAGMENT, because that is what a click
+ /// resolves to: UiText.HitChar returns a line index into the
+ /// wrapped list plus a column within that line.
+ ///
+ internal static IReadOnlyList<(int Start, int Length, ChatTextTag Tag)>?
+ TaggedRangesForFragment(
+ IReadOnlyList 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 BuildLines(
IReadOnlyList detailed,
float maxW,
@@ -121,10 +160,12 @@ internal static class ChatTranscriptRenderer
Func? accept,
Vector4 defaultColor,
Vector4? tagColor = null,
- List?>? runsPerLine = null)
+ List?>? runsPerLine = null,
+ List?>? tagsPerLine = null)
{
var result = new List(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;
diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index c000034e..657a6353 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -135,6 +135,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
/// is a single colour and draws through the ordinary flat path.
///
private readonly List?> _cachedTranscriptRuns = new();
+
+ ///
+ /// Per-cached-line tagged column ranges, index-aligned with
+ /// . This is what turns a click into a
+ /// tag; the runs alongside only decide colour.
+ ///
+ private readonly List?>
+ _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);
}
+ ///
+ /// Opens a tell to the player whose name was clicked, if one was.
+ ///
+ ///
+ /// Retail writes "@tell {Name}, " into the chat entry, takes keyboard
+ /// focus and shows the entry bar (ChatInterface::StartTell
+ /// @0x004F41F0). 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.
+ ///
+ 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;
+ }
+
+ /// Aims the chat entry at and focuses it.
+ 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 StoreTranscriptLayout(
IReadOnlyList lines,
long revision,
diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs
index 9e7736a4..0c8ce38c 100644
--- a/src/AcDream.App/UI/UiText.cs
+++ b/src/AcDream.App/UI/UiText.cs
@@ -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;
/// Dat element id for imported UIElement_Text widgets. 0 for synthesized text.
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).
public readonly record struct Pos(int Line, int Col);
+ ///
+ /// Offered the character under a left click before .
+ /// Return to consume the click.
+ ///
+ ///
+ /// The seam for retail's clickable tagged runs. Kept separate from
+ /// 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.
+ ///
+ public Func? OnCharClick { get; set; }
+
/// Provider of the lines to show, oldest-first. Polled each frame.
public Func> LinesProvider { get; set; } = static () => Array.Empty();
@@ -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();
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs
new file mode 100644
index 00000000..ea3679e4
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatTagClickTests.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Chat;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Campaign CT slice A5: clicking a speaker's name opens a tell to them.
+///
+public sealed class ChatTagClickTests
+{
+ private static ChatTextTag Tell(string name)
+ => new("Tell", "IIDString", $"1342177290:{name}");
+
+ private static IReadOnlyList 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 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);
+ }
+}
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
index d2c1365c..05d43166 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
@@ -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" ─────────────
///