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(); } }