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