acdream/src/AcDream.App/UI/Layout/ChatTranscriptRenderer.cs
Erik f6fe0f2a4f
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s
fix(client): restore retail interaction parity
Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
2026-08-26 20:45:11 +02:00

456 lines
19 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. Most entries are already
/// one line; an oversized server entry with embedded newlines is clipped
/// at the first complete line inside the retained suffix, matching
/// retail's newline preference.
/// </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)
=> FindBudgetStart(detailed, accept, budget).LineIndex;
private readonly record struct BudgetStart(int LineIndex, int CharacterOffset);
private static BudgetStart FindBudgetStart(
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.
long cost = detailed[i].Text.Length + 1L;
if (used + cost <= budget)
{
used += cost;
continue;
}
int available = (int)Math.Max(0L, budget - used - 1L);
if (available > 0)
{
string text = detailed[i].Text;
int minimumOffset = Math.Max(0, text.Length - available);
int offset = FirstCharacterAfterLineBreak(text, minimumOffset);
if (offset < text.Length)
return new BudgetStart(i, offset);
// A single newest unbroken message must still remain visible;
// dropping it wholesale is what made large @acecommands
// replies render as an empty transcript.
if (used == 0 && text.Length > 0)
return new BudgetStart(i, minimumOffset);
}
return new BudgetStart(i + 1, 0);
}
return new BudgetStart(0, 0);
}
private static int FirstCharacterAfterLineBreak(string text, int start)
{
for (int i = Math.Clamp(start, 0, text.Length); i < text.Length; i++)
{
if (text[i] is not ('\r' or '\n'))
continue;
if (text[i] == '\r' && i + 1 < text.Length && text[i + 1] == '\n')
i++;
return i + 1;
}
return text.Length;
}
private static FormattedLine SliceLine(FormattedLine line, int offset)
{
if (offset <= 0)
return line;
string text = line.Text[offset..];
if (line.Spans is not { Count: > 0 } spans)
return line with { Text = text };
var sliced = new List<ChatTextSpan>();
int at = 0;
foreach (ChatTextSpan span in spans)
{
int end = at + span.Text.Length;
if (end > offset)
{
int from = Math.Max(offset, at) - at;
sliced.Add(span with { Text = span.Text[from..] });
}
at = end;
}
return line with { Text = text, Spans = sliced };
}
/// <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;
BudgetStart start = FindBudgetStart(detailed, accept);
for (int lineIndex = start.LineIndex; lineIndex < detailed.Count; lineIndex++)
{
FormattedLine d = detailed[lineIndex];
if (accept is not null && !accept(d.LogTextType))
continue;
if (lineIndex == start.LineIndex && start.CharacterOffset > 0)
d = SliceLine(d, start.CharacterOffset);
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();
}
}