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() {