using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.UI.Layout;
///
/// Shared word-wrap + retail color-carry-forward transcript line builder.
/// Factored out of
/// (Campaign CH slice CH6b) so
/// reuses the exact same algorithm instead of duplicating it — both the main
/// chat window and the four floating windows are views over the SAME
/// transcript (J4.1 pattern: one canonical log, many
/// filtered presentations).
///
///
/// 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.
///
///
///
/// also lives here (CH6a/b REJECT-review NIT 5 — it
/// used to live on , which made
/// call BACK into its own caller's class, a circular
/// dependency between this "shared" module and one of its two consumers).
///
///
internal static class ChatTranscriptRenderer
{
/// Tail of the shared chat log, formatted with retail metadata.
/// Wrap width in pixels.
/// Glyph-width measurer for the active font.
///
/// Optional per-line filter — retail's ChatInterface::TypeIsActive
/// (or the full ShouldDisplay 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 ChatWindowState) — 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 m_curFontColor only
/// advancing for lines actually appended to THIS window's own scroll
/// (AppendStringInfoWithFont only runs for displayed lines).
///
///
/// The transcript element's own base fill color — retail's
/// m_curFontColor BEFORE any AppendTextWithFont call ever
/// tints it, i.e. the value DoFontReset seeds from the element's
/// authored LayoutDesc property 0x1B (style 0x10000372:
/// ARGB(255,204,204,204) for the main chat transcript). Campaign
/// CH round 4 (docs/research/2026-08-10-retail-ui-text-style.md
/// §5.2 Fix 5): this used to be hardcoded to
/// RetailChatColorTable.TryGetColor(0x00u, ...) (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
/// LogTextType tint (untouched by this parameter — every message
/// with an in-range type still resolves its own table color exactly as
/// before), while 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 m_curFontColor
/// unchanged" rule — see 's own doc).
/// Callers pass their transcript's .
///
///
/// The colour retail gives the timestamp prefix — chat colour index
/// 0x0C, which
/// ChatInterface::BuildChatColorLookupTable @0x004F31C0 fills with
/// colorGrey.
///
///
/// 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 0x1D) and deliberately lives elsewhere.
///
private static Vector4 TimestampColor =>
RetailChatColorTable.TryGetColor(0x0Cu, out Vector4 grey)
? grey
: new Vector4(0.5f, 0.5f, 0.5f, 1f);
///
/// Retail's transcript character budget:
/// ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640
/// truncates once the chat log passes 0x2710 characters.
///
///
///
/// Retail keeps ONE accumulating glyph buffer per window and beheads it
/// back toward 0x1D4C (~7,500) when it passes this, preferring to
/// cut at a newline (ChatInterface::TruncateChatLog @0x004F4290).
/// Its transcript therefore oscillates between roughly 7,500 and 10,000
/// characters.
///
///
/// 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.
///
///
public const int MaxTranscriptCharacters = 0x2710;
///
/// The first index of that fits in retail's
/// character budget, counting back from the newest line.
///
///
/// 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.
///
internal static int FirstLineWithinBudget(
IReadOnlyList detailed,
Func? 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;
}
///
/// 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;
// 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;
}
///
/// 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,
Func measure,
Func? accept,
Vector4 defaultColor,
Vector4? tagColor = null,
List?>? runsPerLine = null,
List?>? tagsPerLine = null)
{
var result = new List(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;
}
///
/// Greedy word-wrap: split into fragments that each fit in
/// pixels (per ), 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).
///
public static IEnumerable WrapText(string text, float maxW, Func 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;
}
}
///
/// Greedy word-wrap for a single, already newline-free line. Split out of
/// (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.
///
private static IEnumerable WrapSingleLine(string text, float maxW, Func 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();
}
}