using System;
using System.Collections.Generic;
using System.Numerics;
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 .
///
public static List BuildLines(
IReadOnlyList detailed,
float maxW,
Func measure,
Func? accept,
Vector4 defaultColor)
{
var result = new List(detailed.Count);
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;
foreach (FormattedLine d in detailed)
{
if (accept is not null && !accept(d.LogTextType))
continue;
if (RetailChatColorTable.TryGetColor(d.LogTextType, out Vector4 resolved))
currentColor = resolved;
foreach (string frag in WrapText(d.Text, maxW, measure))
result.Add(new UiText.Line(frag, currentColor));
}
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();
}
}