feat(ui): CT-A1 — multi-line text elements can carry coloured runs

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-21 07:18:55 +02:00
parent ba6c82af93
commit cf41b27c9a
2 changed files with 178 additions and 1 deletions

View file

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