From cf41b27c9a15ed2f125f68e59b2fbf94b5918227 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 21 Aug 2026 07:18:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20CT-A1=20=E2=80=94=20multi-line=20te?= =?UTF-8?q?xt=20elements=20can=20carry=20coloured=20runs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign CT slice A1. No behaviour change: this is the capability the rest of Group A needs. UiText already drew several differently-coloured runs on one line (TextRun/RunsProvider, used by the character stat panel) but the path was gated to OneLine == true, and the chat transcript is multi-line — so a chat line could only ever be one colour. Retail's is not: a tagged glyph run takes the element's TAG colour (property 0x1D) while the rest of the line takes the ordinary one (0x1B), per UIElement_Text::InqGlyphs @0x00468EA0. LineRunsProvider is a SIDECAR keyed by line index rather than a field on Line. Roughly fifty files construct Line, and widening its shape would put every one of them in the blast radius of a chat feature; a line with no runs draws exactly as before. The runs fold into the existing datLines list as extra entries at advancing pen-X, so the S1 outline-then-fill batching is untouched — a multi-colour line still submits its whole outline pass before any fill, and cannot notch the descender of the line above. Two things are deliberately load-bearing: - RunsMatchLine. Selection, hit-testing and the caret all index into the FLAT line text, so a run list that disagrees with it would draw one thing and select another. The draw path verifies the runs say exactly the same characters and falls back to the flat line if not, rather than trusting the caller. - LayoutRuns is pure. The pen-advance is the part that silently mis-renders if it drifts, so it is testable without a font atlas or a GPU — which also keeps its tests in the ordinary gate rather than the SystemFont lane. Solution builds clean; full hermetic gate green, 0 failures. Co-Authored-By: Claude Opus 5 --- src/AcDream.App/UI/UiText.cs | 98 ++++++++++++++++++++++- tests/AcDream.App.Tests/UI/UiTextTests.cs | 81 +++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index 1a9f64e3..8c228105 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -68,6 +68,80 @@ public sealed class UiText : UiElement, IUiDatStateful /// public Func>? RunsProvider { get; set; } + /// + /// Optional per-line inline fragments for the MULTI-line path, by line + /// index. Return for a line that is a single colour. + /// + /// + /// + /// Campaign CT slice A1. Retail's chat transcript is not uniformly + /// coloured per line: a tagged glyph run (a speaker's name) takes the + /// element's TAG colour while the rest of the line takes the ordinary one + /// (UIElement_Text::InqGlyphs @0x00468EA0, properties 0x1D vs 0x1B). + /// This is the seam that lets a line carry more than one colour. + /// + /// + /// Deliberately a SIDECAR rather than a field on : + /// roughly fifty files construct Line, and changing its shape would + /// put every one of them in the blast radius of a chat feature. + /// + /// + /// Invariant: the concatenated run text MUST equal the line's own + /// . Selection, hit-testing and the caret all index + /// into that flat string, so a run list that disagrees with it would draw + /// one thing and select another. states the + /// check; the draw path falls back to the flat line rather than trusting a + /// mismatched list. + /// + /// + public Func?>? LineRunsProvider { get; set; } + + /// + /// Places a line's runs left-to-right from , + /// advancing the pen by each run's measured width. + /// + /// + /// Empty runs contribute no geometry but still advance the pen by their + /// (zero) width, so they cannot shift what follows them. Pure, so the + /// placement is testable without a font atlas or a GPU. + /// + internal static List<(string Text, float X, Vector4 Color)> LayoutRuns( + IReadOnlyList runs, + float startX, + Func measure) + { + var placed = new List<(string Text, float X, Vector4 Color)>(runs.Count); + float penX = startX; + for (int i = 0; i < runs.Count; i++) + { + TextRun run = runs[i]; + if (run.Text.Length != 0) + placed.Add((run.Text, penX, run.Color)); + penX += measure(run.Text); + } + return placed; + } + + /// + /// Whether a run list may be drawn in place of — + /// i.e. whether it says exactly the same characters. + /// + internal static bool RunsMatchLine(IReadOnlyList runs, string line) + { + int at = 0; + for (int i = 0; i < runs.Count; i++) + { + string text = runs[i].Text; + if (at + text.Length > line.Length + || string.CompareOrdinal(line, at, text, 0, text.Length) != 0) + { + return false; + } + at += text.Length; + } + return at == line.Length; + } + /// Font for the transcript; falls back to the context default. public BitmapFont? Font { get; set; } @@ -668,9 +742,31 @@ public sealed class UiText : UiElement, IUiDatStateful } } + IReadOnlyList? runs = LineRunsProvider?.Invoke(i); + if (runs is { Count: > 0 } && !RunsMatchLine(runs, text)) + runs = null; // never draw text that disagrees with what we select + if (datFont is not null) { - (datLines ??= new()).Add((text, lineX, y, lines[i].Color)); + datLines ??= new(); + if (runs is { Count: > 0 }) + { + // Several entries at advancing pen-X instead of one. The + // outline/fill batching below is untouched by this: it + // walks whatever entries are here, so a multi-colour line + // still gets its whole outline pass before any fill. + foreach (var placed in LayoutRuns(runs, lineX, datFont.MeasureWidth)) + datLines.Add((placed.Text, placed.X, y, placed.Color)); + } + else + { + datLines.Add((text, lineX, y, lines[i].Color)); + } + } + else if (runs is { Count: > 0 }) + { + foreach (var placed in LayoutRuns(runs, lineX, bitmapFont!.MeasureWidth)) + ctx.DrawString(placed.Text, placed.X, y, placed.Color, bitmapFont); } else { diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs index 1ddf4feb..a5b4f5f2 100644 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ b/tests/AcDream.App.Tests/UI/UiTextTests.cs @@ -8,6 +8,87 @@ namespace AcDream.App.Tests.UI; public class UiTextTests { + // ── Campaign CT slice A1: per-line coloured runs ──────────────────── + + private static UiText.TextRun Run(string text) + => new(text, Vector4.One); + + [Fact] + public void LayoutRuns_AdvancesThePenAcrossRuns() + { + // 10px per character, so the offsets are checkable by hand. + var placed = UiText.LayoutRuns( + [ + new UiText.TextRun("Dww", new Vector4(0f, 0.698f, 0f, 1f)), + new UiText.TextRun(" tells you", Vector4.One), + ], + startX: 4f, + measure: text => text.Length * 10f); + + Assert.Equal(2, placed.Count); + Assert.Equal(4f, placed[0].X); // first run starts at the line origin + Assert.Equal(34f, placed[1].X); // 4 + 3 chars * 10 + Assert.Equal(new Vector4(0f, 0.698f, 0f, 1f), placed[0].Color); + Assert.Equal(Vector4.One, placed[1].Color); + } + + [Fact] + public void LayoutRuns_EmptyRunsDrawNothingAndShiftNothing() + { + var placed = UiText.LayoutRuns( + [ + new UiText.TextRun(string.Empty, Vector4.One), + new UiText.TextRun("ab", Vector4.One), + new UiText.TextRun(string.Empty, Vector4.One), + new UiText.TextRun("cd", Vector4.One), + ], + startX: 0f, + measure: text => text.Length * 10f); + + // The two empties contribute no geometry, and because they measure zero + // they cannot displace what follows. + Assert.Equal(2, placed.Count); + Assert.Equal(0f, placed[0].X); + Assert.Equal(20f, placed[1].X); + } + + [Fact] + public void RunsMatchLine_AcceptsAnExactSplitOfTheLine() + { + Assert.True(UiText.RunsMatchLine( + [Run("Dww"), Run(" tells you, \"hi\"")], + "Dww tells you, \"hi\"")); + + // Order matters: the same pieces rearranged are a different line. + Assert.False(UiText.RunsMatchLine( + [Run(" tells you, \"hi\""), Run("Dww")], + "Dww tells you, \"hi\"")); + } + + [Fact] + public void RunsMatchLine_RejectsAnythingThatWouldDesyncSelection() + { + // Selection, hit-testing and the caret all index into the FLAT line, so + // a run list that says something else would draw one thing and select + // another. Every one of these must be refused. + const string line = "Dww tells you"; + + Assert.False(UiText.RunsMatchLine([Run("Dww")], line)); // short + Assert.False(UiText.RunsMatchLine([Run(line), Run("!")], line)); // long + Assert.False(UiText.RunsMatchLine([Run("Dwx"), Run(" tells you")], line)); // altered + Assert.False(UiText.RunsMatchLine([], line)); // empty vs text + } + + [Fact] + public void RunsMatchLine_HandlesTheDegenerateCases() + { + Assert.True(UiText.RunsMatchLine([], string.Empty)); + Assert.True(UiText.RunsMatchLine([Run(string.Empty)], string.Empty)); + // An empty run between real ones is harmless — it contributes nothing + // to the text and advances the pen by nothing. + Assert.True(UiText.RunsMatchLine([Run("ab"), Run(string.Empty), Run("cd")], "abcd")); + } + [Fact] public void WrapWords_UsesMeasuredWidthAndPreservesParagraphs() {