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" ───────────── ///