diff --git a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs index f712e189..9ee09e71 100644 --- a/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs +++ b/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using AcDream.Core.Chat; using AcDream.UI.Abstractions.Panels.Chat; namespace AcDream.App.UI.Layout; @@ -64,14 +65,66 @@ internal static class ChatTranscriptRenderer /// unchanged" rule — see 's own doc). /// Callers pass their transcript's . /// + /// + /// The runs covering one wrapped fragment, or when + /// the fragment is a single colour. + /// + /// + /// + /// Campaign CT slice A4. Wrapping splits a line into fragments, and a tag + /// can straddle a break, so a fragment may hold part of a tagged run, all + /// of it, or none. + /// + /// + /// Returns null unless a tag actually falls inside the window — a + /// single-colour fragment must take the ordinary flat draw path rather + /// than a one-run list that means the same thing. + /// + /// + internal static IReadOnlyList? RunsForFragment( + IReadOnlyList spans, + int fragmentStart, + int fragmentLength, + Vector4 lineColor, + Vector4 tagColor) + { + int fragmentEnd = fragmentStart + fragmentLength; + var runs = new List(); + bool sawTag = false; + int at = 0; + + foreach (ChatTextSpan span in spans) + { + int spanStart = at; + int spanEnd = at + span.Text.Length; + at = spanEnd; + + int from = Math.Max(spanStart, fragmentStart); + int to = Math.Min(spanEnd, fragmentEnd); + if (to <= from) + continue; + + bool tagged = span.Tag is not null; + sawTag |= tagged; + runs.Add(new UiText.TextRun( + span.Text.Substring(from - spanStart, to - from), + tagged ? tagColor : lineColor)); + } + + return sawTag ? runs : null; + } + public static List BuildLines( IReadOnlyList detailed, float maxW, Func measure, Func? accept, - Vector4 defaultColor) + Vector4 defaultColor, + Vector4? tagColor = null, + List?>? runsPerLine = null) { var result = new List(detailed.Count); + runsPerLine?.Clear(); if (detailed.Count == 0) return result; @@ -88,8 +141,40 @@ internal static class ChatTranscriptRenderer continue; if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved)) currentColor = resolved; + // Wrapping can DROP the space it broke on, so a fragment is not + // simply the next N characters — locate each one in the source + // line to keep the span offsets honest. + int searchFrom = 0; foreach (string frag in WrapText(d.Text, maxW, measure)) + { result.Add(new UiText.Line(frag, currentColor)); + + if (runsPerLine is null) + continue; + + if (d.Spans is not { Count: > 0 } spans || frag.Length == 0) + { + runsPerLine.Add(null); + continue; + } + + int at = d.Text.IndexOf(frag, searchFrom, StringComparison.Ordinal); + if (at < 0) + { + // Should not happen; a fragment always comes from the line. + // Fall back to the flat colour rather than mis-colouring. + runsPerLine.Add(null); + continue; + } + searchFrom = at + frag.Length; + + runsPerLine.Add(RunsForFragment( + spans, + at, + frag.Length, + currentColor, + tagColor ?? currentColor)); + } } return result; } diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs index da8e21df..c000034e 100644 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs @@ -128,6 +128,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // metrics that determine wrapping change. This makes an idle chat window // allocation-free instead of snapshotting/formatting/wrapping every frame. private IReadOnlyList _cachedTranscriptLines = Array.Empty(); + + /// + /// Per-cached-line runs, index-aligned with + /// . Null at an index means that line + /// is a single colour and draws through the ordinary flat path. + /// + private readonly List?> _cachedTranscriptRuns = new(); private long _cachedTranscriptRevision = -1; private ulong _cachedFilter; private float _cachedTranscriptWrapWidth = float.NaN; @@ -325,6 +332,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // fallback — a flat overlay here only mismatched it under the wrong // (0x21000006) layout, whose transcript panel lacked one. c.Transcript.LinesProvider = () => c.GetTranscriptLines(vm); + // Index-aligned with the lines the provider above returns, and read + // from the same cache — so a tagged speaker name draws in the + // element's authored tag colour while the rest of its line keeps the + // message colour. + c.Transcript.LineRunsProvider = index => + index >= 0 && index < c._cachedTranscriptRuns.Count + ? c._cachedTranscriptRuns[index] + : null; // ── Input ──────────────────────────────────────────────────────── // Editable/selectable/one-line semantics and state sprites came from the @@ -775,8 +790,18 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta // no more accept:null "no user filter" placeholder. bool Accept(uint logTextType) => _windowFilters.ShouldDisplay( ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType); + // Campaign CT slice A4: the runs come back alongside the flat lines and + // 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(); var result = ChatTranscriptRenderer.BuildLines( - detailed, maxW, measure, Accept, Transcript.DefaultColor); + detailed, + maxW, + measure, + Accept, + Transcript.DefaultColor, + Transcript.TagColor, + _cachedTranscriptRuns); return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont); } diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index 9713c159..bb12f3fb 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -845,6 +845,8 @@ public static class DatWidgetFactory // the build-time default. if (info.FontColor.HasValue) t.DefaultColor = info.FontColor.Value; + if (info.TagFontColor.HasValue) + t.TagColor = info.TagFontColor.Value; // Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the // whole DAT set author a non-black value; when absent, UiText's own ctor default diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 2cf514f1..cd335cc6 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -131,6 +131,22 @@ public sealed class ElementInfo /// public Vector4? FontColor; + /// + /// Authored TAG font colour (dat property 0x1D), the colour retail + /// gives a tagged glyph run — a clickable speaker name — as distinct from + /// (0x1B) for everything else. + /// + /// + /// The two are parallel index-selected arrays refreshed from the same + /// caller index on every append + /// (UIElement_Text::AppendStringInfoWithFont @0x00469DE0), and a + /// glyph takes this one only while a tag is open. Measured on the chat + /// transcript (0x2100006F / 0x10000011) as RGB(0,178,0); it is AUTHORED + /// per element, not built by the runtime chat colour table, so it does not + /// belong in RetailChatColorTable. + /// + public Vector4? TagFontColor; + /// /// Outline flag from dat Properties[0x21] (BoolBaseProperty). Retail /// UIElement_Text::SetOutline @0x0046a81c / m_bitField & 0x10. @@ -576,6 +592,7 @@ public static class ElementReader // FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base. // Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base. FontColor = derived.FontColor ?? base_.FontColor, + TagFontColor = derived.TagFontColor ?? base_.TagFontColor, // Outline: derived wins when true (the dat property 0x21 was present and read as // true); otherwise inherit the base. False-derived never overrides a true base — // matching the FontDid/HJustify "non-default wins" convention. @@ -669,6 +686,25 @@ public static class ElementReader } } + // Tag font colour (0x1D) — same shape as 0x1B above, read the same way. + if (info.TryGetEffectiveProperty(0x1Du, out var tagColor)) + { + UiPropertyValue? tagValue = tagColor.Kind == UiPropertyKind.Color + ? tagColor + : tagColor.Kind == UiPropertyKind.Array + && tagColor.ArrayValue.Count > 0 + && tagColor.ArrayValue[0].Kind == UiPropertyKind.Color + ? tagColor.ArrayValue[0] + : null; + if (tagValue is not null) + { + var t = tagValue.ColorValue; + float alpha = t.Alpha == 0 ? 1f : t.Alpha / 255f; + info.TagFontColor = + new Vector4(t.Red / 255f, t.Green / 255f, t.Blue / 255f, alpha); + } + } + // Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10. if (info.TryGetEffectiveProperty(0x21u, out var outline) && outline.Kind == UiPropertyKind.Bool) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 8c228105..9e7736a4 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -167,6 +167,13 @@ public sealed class UiText : UiElement, IUiDatStateful /// public Vector4 DefaultColor { get; set; } = Vector4.One; + /// + /// Colour for a TAGGED run (dat property 0x1D) — retail's clickable + /// speaker name. Falls back to when the element + /// authors none. + /// + public Vector4? TagColor { get; set; } + /// /// Authored UIElement_Text font-color list from LayoutDesc property /// 0x1B. Retail AppendTextWithFont @ 0x00469D70 selects an diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs new file mode 100644 index 00000000..15fe1d4b --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/ChatTranscriptRunsTests.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions.Panels.Chat; + +namespace AcDream.App.Tests.UI.Layout; + +/// +/// Campaign CT slice A4: a tagged speaker name draws in the element's authored +/// TAG colour while the rest of its line keeps the message colour. +/// +public sealed class ChatTranscriptRunsTests +{ + private static readonly Vector4 LineColor = new(0.8f, 0.8f, 0.8f, 1f); + + /// Measured from the installed dats: chat 0x2100006F / 0x10000011. + private static readonly Vector4 TagGreen = new(0f, 178f / 255f, 0f, 1f); + + /// 10px per character, so wrap points are predictable. + private static float Measure(string text) => text.Length * 10f; + + private static IReadOnlyList TellSpans(string name, string rest) + => new[] + { + new ChatTextSpan(name, new ChatTextTag("Tell", "IIDString", $"1342177290:{name}")), + new ChatTextSpan(rest, null), + }; + + [Fact] + public void TheNameTakesTheTagColourAndTheRestTakesTheLineColour() + { + IReadOnlyList? runs = ChatTranscriptRenderer.RunsForFragment( + TellSpans("Dww", " tells you"), + fragmentStart: 0, + fragmentLength: "Dww tells you".Length, + LineColor, + TagGreen); + + Assert.NotNull(runs); + Assert.Equal(2, runs!.Count); + Assert.Equal(("Dww", TagGreen), (runs[0].Text, runs[0].Color)); + Assert.Equal((" tells you", LineColor), (runs[1].Text, runs[1].Color)); + } + + [Fact] + public void AFragmentWithNoTagInItGetsNoRunsAtAll() + { + // A single-colour fragment must take the ordinary flat draw path + // rather than a one-run list that means the same thing. + IReadOnlyList spans = TellSpans("Dww", " tells you, hello there"); + + Assert.Null(ChatTranscriptRenderer.RunsForFragment( + spans, + fragmentStart: 10, // well past the name + fragmentLength: 5, + LineColor, + TagGreen)); + } + + [Fact] + public void ATagStraddlingAWrapBreakIsSplitAcrossBothFragments() + { + // The name is long enough to be cut in half by the wrap; both halves + // must still be green, or a name would change colour mid-word. + IReadOnlyList spans = TellSpans("Bartholomew", " tells you"); + + IReadOnlyList? first = ChatTranscriptRenderer.RunsForFragment( + spans, fragmentStart: 0, fragmentLength: 5, LineColor, TagGreen); + IReadOnlyList? second = ChatTranscriptRenderer.RunsForFragment( + spans, fragmentStart: 5, fragmentLength: 6, LineColor, TagGreen); + + Assert.Equal("Barth", Assert.Single(first!).Text); + Assert.Equal(TagGreen, first![0].Color); + Assert.Equal("olomew", Assert.Single(second!).Text); + Assert.Equal(TagGreen, second![0].Color); + } + + [Fact] + public void RunsAlwaysReproduceTheFragmentTheyCover() + { + // UiText.RunsMatchLine refuses to draw runs that disagree with the + // line, so a mapping bug here would silently fall back to flat text + // rather than fail loudly. Assert the contract directly. + IReadOnlyList spans = TellSpans("Dww", " tells you, \"hi\""); + string line = string.Concat(spans.Select(s => s.Text)); + + for (int start = 0; start < line.Length; start++) + { + for (int len = 1; len <= line.Length - start; len++) + { + IReadOnlyList? runs = + ChatTranscriptRenderer.RunsForFragment( + spans, start, len, LineColor, TagGreen); + if (runs is null) + continue; + + string fragment = line.Substring(start, len); + Assert.True( + UiText.RunsMatchLine(runs, fragment), + $"runs disagree with fragment [{start}..{start + len})"); + } + } + } + + [Fact] + public void BuildLines_EmitsOneRunEntryPerWrappedFragment() + { + // The run list is index-aligned with the lines the transcript draws; + // if the two ever fall out of step, names colour on the wrong rows. + var detailed = new List + { + new("Dww tells you, hello there friend", ChatKind.Tell, null, 0x03u, + TellSpans("Dww", " tells you, hello there friend")), + new("Welcome.", ChatKind.System, null, 0x05u), + }; + var runs = new List?>(); + + List lines = ChatTranscriptRenderer.BuildLines( + detailed, + maxW: 150f, // forces the first entry to wrap + Measure, + accept: null, + defaultColor: LineColor, + tagColor: TagGreen, + runsPerLine: runs); + + Assert.True(lines.Count > 2, "the first line should have wrapped"); + Assert.Equal(lines.Count, runs.Count); + + // The very first fragment holds the name, so it is the one with runs. + Assert.NotNull(runs[0]); + Assert.Equal(TagGreen, runs[0]![0].Color); + + // The untagged system line never gets runs. + Assert.Null(runs[^1]); + } + + [Fact] + public void BuildLines_WithoutATagColourFallsBackToTheLineColour() + { + // An element that authors no 0x1D must not render the name in a + // colour nobody chose; it renders like the rest of the line. + var detailed = new List + { + new("Dww tells you", ChatKind.Tell, null, 0x03u, + TellSpans("Dww", " tells you")), + }; + var runs = new List?>(); + + ChatTranscriptRenderer.BuildLines( + detailed, maxW: 1000f, Measure, accept: null, + defaultColor: LineColor, tagColor: null, runsPerLine: runs); + + IReadOnlyList line = Assert.Single(runs)!; + Assert.All(line, run => Assert.Equal(line[0].Color, run.Color)); + } +}