acdream/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
Erik 9f6d79b7e0 feat(chat): CT-C2 Escape leaves the entry; CT-C3 the timestamp is grey
Campaign CT slices C2 and C3.

**C2 — Escape in the chat input did nothing at all.** Not "did the wrong
thing": nothing. Two independent facts had to hold for that. UiField has no
Escape case, AND a focused field reports IsEditControl, which makes UiRoot skip
its own fallback and the input dispatcher withhold game actions — so the player
had no way out of the bar except the mouse.

Retail maps Escape to input action 0x0B, which runs
ChatInterface::DeactivateChatEntry @0x004F2FC0: RelinquishFocus, then
Deactivate. It does NOT clear the field. That is worth stating because the
obvious guess — "Escape clears the input" — is wrong and would have looked
perfectly reasonable; a half-written message survives stepping away from the
bar, and the test pins that rather than just pinning "handled".

**C3 — the timestamp took the message's colour.** Retail appends it as its own
run at a FIXED colour index (0x0C, which BuildChatColorLookupTable @0x004F31C0
fills with colorGrey) rather than the line's, so it stays grey whether the
message is red combat text or white speech.

Most of C3 was already done and stayed untouched: the DisplayTimeStamps option
is polled, and FormatTimestampPrefix already matches retail's "%#H:%M:%S ".
Only the colour was wrong, and it was only fixable now because A1/A4 made a
line able to carry more than one colour.

The stamp is a span ROLE rather than a second tag type: it is not clickable and
carries no payload, so modelling it as a tag would have made it hit-testable
for no reason. Its colour comes from the same runtime table every message
colour comes from, unlike the tagged-name colour, which is authored per element
(0x1D) and deliberately lives elsewhere.

One consequence worth naming: a timestamped line now needs runs even when its
sender is not tagged, because the stamp alone is reason enough. Before this,
only tagged lines got runs.

Also verified and NOT changed, having checked rather than assumed: C1's
auto-scroll half is already retail-faithful — UiScrollable.SetExtents samples
"was at the end" BEFORE applying new extents and only re-sticks if so, which is
exactly retail's IsAtVerticalEnd rule, and chat gets it by default. C1 reduces
to the unread indicator, which does not exist yet.

Solution builds clean; full hermetic gate green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:28:45 +02:00

388 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared word-wrap + retail color-carry-forward transcript line builder.
/// Factored out of <see cref="ChatWindowController.GetTranscriptLines"/>
/// (Campaign CH slice CH6b) so <see cref="FloatingChatWindowController"/>
/// reuses the exact same algorithm instead of duplicating it — both the main
/// chat window and the four floating windows are views over the SAME
/// <see cref="ChatVM"/> transcript (J4.1 pattern: one canonical log, many
/// filtered presentations).
///
/// <para>
/// Pure function — callers own their own per-controller layout cache
/// (revision/wrap-width/font keyed), matching the caching each controller
/// already had before this extraction.
/// </para>
///
/// <para>
/// <see cref="WrapText"/> also lives here (CH6a/b REJECT-review NIT 5 — it
/// used to live on <see cref="ChatWindowController"/>, which made
/// <see cref="BuildLines"/> call BACK into its own caller's class, a circular
/// dependency between this "shared" module and one of its two consumers).
/// </para>
/// </summary>
internal static class ChatTranscriptRenderer
{
/// <param name="detailed">Tail of the shared chat log, formatted with retail metadata.</param>
/// <param name="maxW">Wrap width in pixels.</param>
/// <param name="measure">Glyph-width measurer for the active font.</param>
/// <param name="accept">
/// Optional per-line filter — retail's <c>ChatInterface::TypeIsActive</c>
/// (or the full <c>ShouldDisplay</c> predicate) for THIS window. Null
/// accepts every line. CH6a/b REJECT-review SHOULD-FIX 2: the main
/// window now ALWAYS passes a real predicate too (its own retail default
/// 0xFBFFFFFF filter via <c>ChatWindowState</c>) — null remains supported
/// for callers with no filter concept at all (there are none in
/// production today, but the shape stays general). A line that fails the
/// filter is dropped from this window's view WITHOUT advancing the
/// carried-forward color, matching retail's <c>m_curFontColor</c> only
/// advancing for lines actually appended to THIS window's own scroll
/// (<c>AppendStringInfoWithFont</c> only runs for displayed lines).
/// </param>
/// <param name="defaultColor">
/// The transcript element's own base fill color — retail's
/// <c>m_curFontColor</c> BEFORE any <c>AppendTextWithFont</c> call ever
/// tints it, i.e. the value <c>DoFontReset</c> seeds from the element's
/// authored LayoutDesc property <c>0x1B</c> (style <c>0x10000372</c>:
/// <c>ARGB(255,204,204,204)</c> for the main chat transcript). Campaign
/// CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>
/// §5.2 Fix 5): this used to be hardcoded to
/// <c>RetailChatColorTable.TryGetColor(0x00u, ...)</c> (colorGreen) —
/// the color table's OWN default-slot color, not the ELEMENT's authored
/// default. The two are unrelated: the table governs the per-message
/// <c>LogTextType</c> tint (untouched by this parameter — every message
/// with an in-range type still resolves its own table color exactly as
/// before), while <paramref name="defaultColor"/> is only the carried-
/// forward seed for a line whose type falls OUTSIDE the table's 34
/// entries (matching retail's out-of-range "leave <c>m_curFontColor</c>
/// unchanged" rule — see <see cref="RetailChatColorTable"/>'s own doc).
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
/// </param>
/// <summary>
/// The colour retail gives the timestamp prefix — chat colour index
/// <c>0x0C</c>, which
/// <c>ChatInterface::BuildChatColorLookupTable @0x004F31C0</c> fills with
/// <c>colorGrey</c>.
/// </summary>
/// <remarks>
/// Read from the same table every message colour comes from rather than
/// hard-coded, so it cannot drift from the rest of the palette. This one
/// IS a table index, unlike the tagged-name colour, which is authored per
/// element (property <c>0x1D</c>) and deliberately lives elsewhere.
/// </remarks>
private static Vector4 TimestampColor =>
RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey)
? grey
: new Vector4(0.5f, 0.5f, 0.5f, 1f);
/// <summary>
/// Retail's transcript character budget:
/// <c>ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640</c>
/// truncates once the chat log passes <c>0x2710</c> characters.
/// </summary>
/// <remarks>
/// <para>
/// Retail keeps ONE accumulating glyph buffer per window and beheads it
/// back toward <c>0x1D4C</c> (~7,500) when it passes this, preferring to
/// cut at a newline (<c>ChatInterface::TruncateChatLog @0x004F4290</c>).
/// Its transcript therefore oscillates between roughly 7,500 and 10,000
/// characters.
/// </para>
/// <para>
/// We rebuild the visible list from the log each time instead of
/// accumulating, so the two-threshold hysteresis has nothing to damp — it
/// exists to stop retail trimming on every single append. A single cap
/// gives a STABLE window here; oscillating one would make the oldest
/// visible line jump around as messages arrive. Cutting at whole lines is
/// automatic for the same reason: our unit already is the line, which is
/// what retail's newline preference is trying to achieve.
/// </para>
/// </remarks>
public const int MaxTranscriptCharacters = 0x2710;
/// <summary>
/// The first index of <paramref name="detailed"/> that fits in retail's
/// character budget, counting back from the newest line.
/// </summary>
/// <remarks>
/// Only ACCEPTED lines consume budget — a line this window filters out is
/// not in its buffer at all, so it cannot push older lines off the top.
/// </remarks>
internal static int FirstLineWithinBudget(
IReadOnlyList<FormattedLine> detailed,
Func<uint, bool>? accept,
int budget = MaxTranscriptCharacters)
{
long used = 0;
for (int i = detailed.Count - 1; i >= 0; i--)
{
if (accept is not null && !accept(detailed[i].LogTextType))
continue;
// +1 for the newline retail stores between lines.
used += detailed[i].Text.Length + 1;
if (used > budget)
return i + 1;
}
return 0;
}
/// <summary>
/// The runs covering one wrapped fragment, or <see langword="null"/> when
/// the fragment is a single colour.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static IReadOnlyList<UiText.TextRun>? RunsForFragment(
IReadOnlyList<ChatTextSpan> spans,
int fragmentStart,
int fragmentLength,
Vector4 lineColor,
Vector4 tagColor)
{
int fragmentEnd = fragmentStart + fragmentLength;
var runs = new List<UiText.TextRun>();
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;
// Retail appends the timestamp at a FIXED colour index (0x0C)
// rather than the message's, so it stays grey whatever colour the
// line is — RecvNotice_DisplayFinalStringInfo @0x004F4640 passes
// 0xc for that run and the line's own type for the body.
bool tagged = span.Tag is not null;
bool stamped = span.Role == ChatSpanRole.Timestamp;
sawTag |= tagged || stamped;
Vector4 color = tagged
? tagColor
: stamped
? TimestampColor
: lineColor;
runs.Add(new UiText.TextRun(
span.Text.Substring(from - spanStart, to - from),
color));
}
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,
Func<string, float> measure,
Func<uint, bool>? accept,
Vector4 defaultColor,
Vector4? tagColor = 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;
// Retail's font-color state (m_curFontColor) persists across every line
// actually appended to this window — an out-of-range LogTextType leaves it
// unchanged rather than reverting to a color-table default (color-table doc
// §3.2). Seed the carry with the ELEMENT's own authored default fill
// (defaultColor), matching retail's DoFontReset — not the color table's
// unrelated index-0x00 slot.
Vector4 currentColor = defaultColor;
int firstLine = FirstLineWithinBudget(detailed, accept);
for (int lineIndex = firstLine; lineIndex < detailed.Count; lineIndex++)
{
FormattedLine d = detailed[lineIndex];
if (accept is not null && !accept(d.LogTextType))
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 && tagsPerLine is null)
continue;
if (d.Spans is not { Count: > 0 } spans || frag.Length == 0)
{
runsPerLine?.Add(null);
tagsPerLine?.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);
tagsPerLine?.Add(null);
continue;
}
searchFrom = at + frag.Length;
runsPerLine?.Add(RunsForFragment(
spans,
at,
frag.Length,
currentColor,
tagColor ?? currentColor));
tagsPerLine?.Add(TaggedRangesForFragment(spans, at, frag.Length));
}
}
return result;
}
/// <summary>
/// Greedy word-wrap: split <paramref name="text"/> into fragments that each fit in
/// <paramref name="maxW"/> pixels (per <paramref name="measure"/>), breaking at spaces.
/// A word that is itself wider than the line is broken at CHARACTER boundaries (no
/// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL
/// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same
/// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine
/// emission (which breaks mid-glyph-run when a run exceeds the wrap width).
/// </summary>
public static IEnumerable<string> WrapText(string text, float maxW, Func<string, float> measure)
{
if (string.IsNullOrEmpty(text))
{
yield return string.Empty;
yield break;
}
// Campaign CH user-gate round 1 (item F): server text (e.g. /help's
// reply) carries embedded '\n's. This function used to hand the
// WHOLE blob — newlines and all — to the single early-out below,
// rendering multi-line text as one UiText.Line with literal newline
// characters in it instead of one rendered line per segment. Split
// on '\n' FIRST (normalizing "\r\n"/bare "\r" the same way), then
// word-wrap each segment independently; the early-out is now scoped
// to one already-newline-free segment, so it only ever collapses a
// single-segment text to one line, never a multi-line one.
string normalized = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (string segment in normalized.Split('\n'))
{
foreach (string frag in WrapSingleLine(segment, maxW, measure))
yield return frag;
}
}
/// <summary>
/// Greedy word-wrap for a single, already newline-free line. Split out of
/// <see cref="WrapText"/> (Campaign CH user-gate round 1, item F) so the
/// multi-segment split there can call this once per '\n'-delimited
/// segment without re-deriving the per-line wrap algorithm.
/// </summary>
private static IEnumerable<string> WrapSingleLine(string text, float maxW, Func<string, float> measure)
{
if (text.Length == 0 || maxW <= 0f || measure(text) <= maxW)
{
yield return text;
yield break;
}
var line = new System.Text.StringBuilder();
foreach (var word in text.Split(' '))
{
string sep = line.Length > 0 ? " " : string.Empty;
if (measure(line.ToString() + sep + word) <= maxW)
{
line.Append(sep).Append(word); // fits on the current line
continue;
}
if (line.Length > 0 && measure(word) <= maxW)
{
yield return line.ToString(); // word fits alone → push to a new line
line.Clear();
line.Append(word);
continue;
}
// Word too long for any single line: char-wrap it, packing onto the current
// line's remaining space first (keeps the prefix with the message start).
if (line.Length > 0) line.Append(' ');
foreach (char ch in word)
{
if (line.Length > 0 && measure(line.ToString() + ch) > maxW)
{
yield return line.ToString();
line.Clear();
}
line.Append(ch);
}
}
if (line.Length > 0) yield return line.ToString();
}
}