using System; using System.Collections.Generic; using System.Numerics; namespace AcDream.App.UI.Layout; /// /// Shared multi-segment rich-text composer for the chargen description /// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the /// Summary how-to text). Ports retail's /// UIElement_Text::SetStringInfoWithFont / /// AppendStringInfoWithFont @ 0x00469D70 composition model: a text /// box is built from an ORDERED list of string segments, each carrying its /// OWN font-color palette index /// (UIElement_Text::AppendStringInfoWithFont's /// SetFontColorHelper -> InqProperty(0x1B) array lookup — /// see ). /// /// /// The description pages used to bypass this entirely: they assigned a raw /// LinesProvider lambda returning ONE unwrapped /// per composed string, with no word-wrap. Historical symptom (Batch C): /// for the Town page an unwrapped single line meant the town-specific /// SUFFIX of the composed string rendered far outside the box's clipped /// viewport, so switching towns looked like "the text never changes" even /// though the underlying string genuinely did (only its INVISIBLE tail /// differed). Escape decoding (the DAT's literal two-character "\n", /// Batch C's other symptom) has since moved to the string source /// (, /// the 2026-08-17 systemic round) — segments reach this composer with real /// line breaks already in place. /// /// internal static class DatRichText { /// One composed segment: text plus the color it should render /// in. A null or empty is silently skipped (mirrors /// retail's own null-string-info no-op guards throughout this text /// composition family). public readonly record struct Segment(string? Text, Vector4 Color); /// /// Word-wraps every segment (independently, so /// each segment's wrapped lines keep ITS OWN color), then concatenates /// the results in order. No separator is inserted between segments — /// retail's own composition calls concatenate directly /// (AppendStringInfoWithFont/append_n_chars with no /// interposed literal), so any blank-line spacing between sections /// comes from the authored DAT string content itself, not from code /// here. /// public static IReadOnlyList Compose( UiText target, IReadOnlyList segments) { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(segments); var lines = new List(); // R2-1 (Campaign CC gate round 1 Batch E): the wrap width must shrink // by the SAME left+right inset the draw path now applies (Padding // plus the four retail margins, UiText.MarginLeft's own doc) — the // Batch-C regression's second half: text wasn't just drawing at the // wrong X, it was also wrapping to the FULL box width instead of the // authored interior width, overflowing the visible right edge too. float maximumWidth = MathF.Max( 1f, target.Width - (target.Padding + target.MarginLeft) - (target.Padding + target.MarginRight)); Func measure = target.DatFont is { } font ? font.MeasureWidth : static value => value.Length * 8f; foreach (Segment segment in segments) { if (string.IsNullOrEmpty(segment.Text)) continue; // Escape decoding (the DAT's literal two-character "\n") happens // at the string source (DatStringResolver → RetailStringEscapes, // 2026-08-17 systemic round — retail's own placement), so // segments arrive with real line breaks; WrapWords preserves // them and drops any stray CR itself. foreach (string wrapped in UiText.WrapWords(segment.Text, measure, maximumWidth)) lines.Add(new UiText.Line(wrapped, segment.Color)); } return lines; } /// /// Resolves 's own authored font-color /// palette (dat property 0x1B) entry at , /// falling back to when the palette is /// absent or too short. Mirrors the same fallback shape /// CharacterStatController.BuildSelectedTitleRuns already uses /// for its own palette-indexed colors. /// public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) => index >= 0 && index < target.FontColorPalette.Count ? target.FontColorPalette[index] : fallback; }