Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions Runtimes/RaylibGum/Renderables/CustomSetPropertyOnRenderable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -774,10 +774,13 @@ private static void SetBbCodeText(Text asText, GraphicalUiElement graphicalUiEle
}
}

// #3481: RawText was assigned above (which measured the text) before any InlineVariables
// existed, so that first measurement was blind to inline [FontScale=N] runs. Re-measure now
// that the runs are populated so the reported size accounts for per-line scale (otherwise a
// tall run overflows its slot and overlaps the next stacked sibling).
// #3481/#3532: RawText was assigned above (which wrapped + measured the text) before any
// InlineVariables existed, so that first pass was blind to inline [FontScale=N]/[FontSize=N]
// runs. Re-wrap first so line breaks account for an enlarged run's real size (#3532), then
// re-measure so the reported size accounts for per-line scale (#3481) — otherwise a tall run
// overflows its slot and overlaps the next stacked sibling, or a wide run spills past a fixed
// wrap width. Mirrors the MonoGame Gum/Wireframe/CustomSetPropertyOnRenderable.cs.
asText.UpdateWrappedText();
asText.UpdatePreRenderDimensions();
}

Expand Down
138 changes: 120 additions & 18 deletions Runtimes/RaylibGum/Renderables/Text.cs
Original file line number Diff line number Diff line change
Expand Up @@ -675,7 +675,13 @@ public Text(ISystemManagers? managers)
Visible = true;
}

private void UpdateWrappedText()
/// <summary>
/// Re-runs the shared wrap loop (<see cref="IWrappedTextExtensions.UpdateLines"/>) to rebuild
/// <see cref="WrappedText"/>. Public so the BBCode dispatch (CustomSetPropertyOnRenderable) can
/// re-wrap once <see cref="InlineVariables"/> populate — the first wrap runs when RawText is set,
/// before the runs exist, so it is blind to any enlarged [FontSize]/[FontScale] run (#3532).
/// </summary>
public void UpdateWrappedText()
{
mWrappedText.Clear();
this.UpdateLines(mWrappedText);
Expand Down Expand Up @@ -775,6 +781,11 @@ public void Render(ISystemManagers managers)
global::RenderingLibrary.Graphics.Renderer.Self.BatchDrawCallCounter.BeginBlendMode(Blend.Value);
}

// Per-line vertical offsets. Plain text (no inline runs) keeps the allocation-free uniform
// advance; when inline [FontSize]/[FontScale] runs can enlarge a line, each line advances by its
// own height so a line after an enlarged run is not drawn on top of it (#3532).
IReadOnlyList<float>? lineTopOffsets = InlineVariables.Count > 0 ? GetLineTopOffsets() : null;

for(int i = 0; i < WrappedText.Count; i++)
{
var line = WrappedText[i];
Expand All @@ -794,7 +805,9 @@ public void Render(ISystemManagers managers)
}

var linePosition = position;
linePosition.Y += i * LineHeightInPixels * LineHeightMultiplier;
linePosition.Y += lineTopOffsets != null
? lineTopOffsets[i]
: i * LineHeightInPixels * LineHeightMultiplier;

var substrings = InlineVariables.Count > 0
? GetStyledSubstrings(startOfLineIndex, line)
Expand All @@ -818,6 +831,53 @@ public void Render(ISystemManagers managers)
}
}

/// <summary>
/// The Y offset (in pixels from this Text's top) at which each wrapped line is drawn, growing a
/// line's advance by the tallest inline [FontSize]/[FontScale] run on it so a line following an
/// enlarged run is placed below that run's full height rather than one base line-height down — which
/// overlapped it (issue #3532). Matches the per-line height the size pass reports
/// (<see cref="GetInlineVariableAwareWidthAndHeight"/>). Consumed by <see cref="Render"/>; public so
/// the vertical stacking can be unit-tested without rendering.
/// </summary>
public IReadOnlyList<float> GetLineTopOffsets()
{
var offsets = new float[WrappedText.Count];
float runningY = 0;
int startOfLineIndex = 0;
for (int i = 0; i < WrappedText.Count; i++)
{
offsets[i] = runningY;
var line = WrappedText[i];
float lineScale = GetLineLayoutScale(startOfLineIndex, line);
float lineHeightFactor = FontScale > 0 ? lineScale / FontScale : 1;
runningY += LineHeightInPixels * lineHeightFactor * LineHeightMultiplier;
startOfLineIndex += line.Length;
}
return offsets;
}

/// <summary>
/// The layout scale of the wrapped line beginning at <paramref name="startOfLineIndex"/> in the
/// stripped text: <see cref="FontScale"/> for a plain line, or the tallest inline
/// [FontSize]/[FontScale] run's scale for a styled line (the same per-run resolution
/// <see cref="DrawStyledLine"/> lays out with, so drawn and vertically-advanced sizes cannot drift).
/// </summary>
private float GetLineLayoutScale(int startOfLineIndex, string line)
{
if (InlineVariables.Count == 0)
{
return FontScale;
}

var substrings = GetStyledSubstrings(startOfLineIndex, line);
float maxScale = FontScale;
for (int substringIndex = 0; substringIndex < substrings.Count; substringIndex++)
{
maxScale = System.Math.Max(maxScale, ResolveRunFont(substrings[substringIndex].Variables).LayoutScale);
}
return maxScale;
}

/// <summary>
/// Draws a single line with no inline styling, honoring horizontal alignment and pixel snapping.
/// </summary>
Expand Down Expand Up @@ -1062,8 +1122,6 @@ public void UpdatePreRenderDimensions()
/// </remarks>
private void GetInlineVariableAwareWidthAndHeight(out int requiredWidth, out int requiredHeight)
{
float baseScale = FontScale;

float maxWidth = 0;
float totalHeight = 0;

Expand All @@ -1083,20 +1141,7 @@ private void GetInlineVariableAwareWidthAndHeight(out int requiredWidth, out int
}
else
{
float maxRunScale = baseScale;
float lineWidthAtScale = 0;
for (int substringIndex = 0; substringIndex < substrings.Count; substringIndex++)
{
var substring = substrings[substringIndex];
// Same resolver the draw loop uses, measured with the same per-run font at the same
// size, so a run is measured at exactly the size it is drawn - draw/measure drift is
// what reproduced the RelativeToChildren spill (#3524).
var resolvedFont = ResolveRunFont(substring.Variables);
lineWidthAtScale += MeasureTextEx(resolvedFont.Font, substring.Substring, resolvedFont.DrawSize, 0).X;
maxRunScale = System.Math.Max(maxRunScale, resolvedFont.LayoutScale);
}
lineHeightFactor = maxRunScale / baseScale;
lineWidthInBaseUnits = lineWidthAtScale / baseScale;
lineWidthInBaseUnits = MeasureStyledLineInBaseUnits(substrings, out lineHeightFactor);
}

maxWidth = System.Math.Max(maxWidth, lineWidthInBaseUnits);
Expand All @@ -1110,6 +1155,63 @@ private void GetInlineVariableAwareWidthAndHeight(out int requiredWidth, out int
requiredHeight = System.Math.Min((int)(totalHeight + .5f), MaxWidthAndHeight);
}

/// <summary>
/// Sums the base-unit width of a single line already split into styled runs, measuring each run with
/// the same per-run font/size <see cref="DrawStyledLine"/> draws it with (a [FontSize] crisp-swap or
/// scaled-atlas run, or a [FontScale] multiplier run), and reports the tallest run's height factor
/// relative to the base line height.
/// </summary>
/// <remarks>
/// Shared by the size pass (<see cref="GetInlineVariableAwareWidthAndHeight"/>, #3481/#3524) and the
/// font-aware wrap seam (<see cref="MeasureString(string, int)"/>, #3532) so the measured, drawn, and
/// wrapped size of a run cannot drift. Widths are returned in BASE units (<see cref="FontScale"/>
/// factored out), matching <see cref="MeasureString(string)"/>; <see cref="FontScale"/> is the floor,
/// so an inline run smaller than the base does not shrink the line. Guards <see cref="FontScale"/> == 0
/// because the wrap seam calls this regardless of scale (the size pass is already gated on scale &gt; 0).
/// </remarks>
private float MeasureStyledLineInBaseUnits(List<StyledSubstring> substrings, out float lineHeightFactor)
{
float baseScale = FontScale;
float maxRunScale = baseScale;
float lineWidthAtScale = 0;
for (int substringIndex = 0; substringIndex < substrings.Count; substringIndex++)
{
var substring = substrings[substringIndex];
// Same resolver the draw loop uses, measured with the same per-run font at the same
// size, so a run is measured at exactly the size it is drawn - draw/measure drift is
// what reproduced the RelativeToChildren spill (#3524).
var resolvedFont = ResolveRunFont(substring.Variables);
lineWidthAtScale += MeasureTextEx(resolvedFont.Font, substring.Substring, resolvedFont.DrawSize, 0).X;
maxRunScale = System.Math.Max(maxRunScale, resolvedFont.LayoutScale);
}
lineHeightFactor = baseScale > 0 ? maxRunScale / baseScale : 1;
return baseScale > 0 ? lineWidthAtScale / baseScale : 0;
}

/// <summary>
/// Font-aware measurement used by line wrapping (issue #3532): measures <paramref name="whatToMeasure"/>
/// (which begins at <paramref name="absoluteStartIndexInStrippedText"/> in the stripped text) honoring
/// the inline [FontSize]/[FontScale] runs active over that range, so a line containing an enlarged run
/// wraps at the run's real size instead of the base size. Falls back to the base
/// <see cref="MeasureString(string)"/> when no inline runs cover the range, keeping plain-text wrapping
/// byte-identical.
/// </summary>
public float MeasureString(string whatToMeasure, int absoluteStartIndexInStrippedText)
{
if (InlineVariables.Count == 0)
{
return MeasureString(whatToMeasure);
}

var substrings = GetStyledSubstrings(absoluteStartIndexInStrippedText, whatToMeasure);
if (substrings.Count == 0)
{
return MeasureString(whatToMeasure);
}

return MeasureStyledLineInBaseUnits(substrings, out _);
}

public void GetRequiredWidthAndHeight(IEnumerable<string> lines, out int requiredWidth, out int requiredHeight, List<float>? widths)
{

Expand Down
165 changes: 165 additions & 0 deletions Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,25 @@ namespace RaylibGum.Tests.Renderables;
/// </summary>
public class TextMarkupTests
{
// Issue #3532: the font-aware wrap seam MeasureString(string, int) must fall back to the base
// MeasureString when the line carries no inline runs, so plain-text wrapping stays byte-identical.
// Pins the fallback directly (independent of any font metrics) so the override can't regress plain
// wrapping.
[Fact]
public void MeasureString_WithStartIndex_ShouldMatchBaseMeasure_WhenNoInlineVariables()
{
TextRuntime textRuntime = new();
textRuntime.Text = "Hello World";

Text internalText = (Text)textRuntime.RenderableComponent;
internalText.InlineVariables.ShouldBeEmpty();

float baseMeasure = internalText.MeasureString("Hello World");
float indexedMeasure = internalText.MeasureString("Hello World", 0);

indexedMeasure.ShouldBe(baseMeasure);
}

[Fact]
public void Text_WithColorMarkup_PopulatesInlineVariablesAndStripsTags()
{
Expand Down Expand Up @@ -282,4 +301,150 @@ public void WrappedTextHeight_ShouldGrow_WhenLineHasFontSizeLargerThanBase()
sizedHeight.ShouldBe(plainHeight * 2, plainHeight * 0.05,
"Because a [FontSize=2xBase] run is twice the base pixel size, so the measured line height doubles");
}

// Issue #3532 (vertical stacking): a wrapped line that follows an enlarged inline run must be drawn
// below that run's full height, not one base line-height down (which overlapped it). raylib advanced
// every line by a uniform base line height; XNA already grows the advance per line. An explicit
// newline makes the two-line split deterministic (independent of wrap width / font metrics). The
// plain control derives the base per-line advance; the scaled version's second line must sit twice
// as far down because its first line carries a [FontScale=2] run.
[Fact]
public void GetLineTopOffsets_ShouldPushLineBelowEnlargedRun_NotOverlapIt()
{
TextRuntime plain = new();
plain.Text = "BIG\nsmall";
Text plainText = (Text)plain.RenderableComponent;
IReadOnlyList<float> plainOffsets = plainText.GetLineTopOffsets();
plainOffsets.Count.ShouldBe(2);
plainOffsets[0].ShouldBe(0);
float baseLineAdvance = plainOffsets[1];
baseLineAdvance.ShouldBeGreaterThan(0);

TextRuntime scaled = new();
scaled.Text = "[FontScale=2]BIG[/FontScale]\nsmall";
Text scaledText = (Text)scaled.RenderableComponent;
IReadOnlyList<float> scaledOffsets = scaledText.GetLineTopOffsets();
scaledOffsets.Count.ShouldBe(2);
scaledOffsets[0].ShouldBe(0);
scaledOffsets[1].ShouldBe(baseLineAdvance * 2, 0.01,
"because the first line's [FontScale=2] run doubles its height, so the second line sits twice as far down");
}

// Issue #3532 (the wrapping half, end-to-end): a fixed-width Text that fits "AA BB CC" at the base
// size must re-wrap once the inline [FontScale=2] run enlarges "BB" past the wrap width. The BBCode
// path assigns RawText (which wraps) BEFORE the InlineVariables populate, so the first wrap is blind
// to the runs; the fix re-wraps font-aware afterward. raylib measures with a real font atlas, so the
// wrap width can't be hardcoded — it's derived to sit strictly between the plain and enlarged
// single-line widths (fits the plain line, forces the enlarged line to break). Without the fix the
// enlarged text stays one line and spills past the width.
[Fact]
public void WrappedText_ShouldRewrapFontAware_WhenBbCodeFontScaleRunWidensLine()
{
// Plain single-line width (huge Absolute width -> no wrap).
TextRuntime plainUnwrapped = new();
plainUnwrapped.WidthUnits = DimensionUnitType.Absolute;
plainUnwrapped.Width = 100000;
plainUnwrapped.Text = "AA BB CC";
Text plainUnwrappedText = (Text)plainUnwrapped.RenderableComponent;
plainUnwrappedText.WrappedText.Count.ShouldBe(1);
float plainWidth = plainUnwrappedText.WrappedTextWidth;

// Enlarged single-line width: the [FontScale=2] run widens the line (#3524 size reporting).
TextRuntime scaledUnwrapped = new();
scaledUnwrapped.WidthUnits = DimensionUnitType.Absolute;
scaledUnwrapped.Width = 100000;
scaledUnwrapped.Text = "AA [FontScale=2]BB[/FontScale] CC";
Text scaledUnwrappedText = (Text)scaledUnwrapped.RenderableComponent;
scaledUnwrappedText.WrappedText.Count.ShouldBe(1);
float scaledWidth = scaledUnwrappedText.WrappedTextWidth;

scaledWidth.ShouldBeGreaterThan(plainWidth);

// A width that fits the plain line but not the enlarged line.
float wrapWidth = (plainWidth + scaledWidth) / 2f;

// Control: plain text at this width still fits on one line, proving the width is not itself the
// cause of the extra break.
TextRuntime plainWrapped = new();
plainWrapped.WidthUnits = DimensionUnitType.Absolute;
plainWrapped.Width = wrapWidth;
plainWrapped.Text = "AA BB CC";
((Text)plainWrapped.RenderableComponent).WrappedText.Count.ShouldBe(1);

// Subject: width set BEFORE text so RawText wraps blind, then the inline run populates. The
// font-aware re-wrap must break the enlarged line.
TextRuntime scaled = new();
scaled.WidthUnits = DimensionUnitType.Absolute;
scaled.Width = wrapWidth;
scaled.Text = "AA [FontScale=2]BB[/FontScale] CC";
((Text)scaled.RenderableComponent).WrappedText.Count.ShouldBeGreaterThan(1,
"because after the inline [FontScale] run populates, the text must re-wrap measuring the enlarged run at its own size");
}

// Issue #3532: the FontSize-swap half of the wrap fix. With a font creator wired, [FontSize=N] swaps
// in a crisp font rasterized at N px whose glyphs are wider than the base, so a fixed-width Text must
// re-wrap around it. Mirrors WrappedText_ShouldRewrapFontAware_WhenBbCodeFontScaleRunWidensLine but
// exercises the absolute-swap-font run path (ResolveRunFont's swap-font branch) instead of the
// [FontScale] multiplier path.
[Fact]
public void WrappedText_ShouldRewrapFontAware_WhenBbCodeFontSizeRunWidensLine()
{
IRaylibFontCreator? savedCreator = CustomSetPropertyOnRenderable.InMemoryFontCreator;
try
{
CustomSetPropertyOnRenderable.InMemoryFontCreator = new KernSmithRaylibFontCreator();

// Plain single-line width (huge Absolute width -> no wrap).
TextRuntime plainUnwrapped = new();
plainUnwrapped.Font = "Arial";
plainUnwrapped.FontSize = 20;
plainUnwrapped.WidthUnits = DimensionUnitType.Absolute;
plainUnwrapped.Width = 100000;
plainUnwrapped.Text = "AA BB CC";
Text plainUnwrappedText = (Text)plainUnwrapped.RenderableComponent;
plainUnwrappedText.WrappedText.Count.ShouldBe(1);
float plainWidth = plainUnwrappedText.WrappedTextWidth;
int targetFontSize = plainUnwrappedText.Font.BaseSize * 2;

// Enlarged single-line width via a size-2xBase crisp swap font (#3524 size reporting).
TextRuntime swappedUnwrapped = new();
swappedUnwrapped.Font = "Arial";
swappedUnwrapped.FontSize = 20;
swappedUnwrapped.WidthUnits = DimensionUnitType.Absolute;
swappedUnwrapped.Width = 100000;
swappedUnwrapped.Text = $"AA [FontSize={targetFontSize}]BB[/FontSize] CC";
Text swappedUnwrappedText = (Text)swappedUnwrapped.RenderableComponent;
swappedUnwrappedText.WrappedText.Count.ShouldBe(1);
float swappedWidth = swappedUnwrappedText.WrappedTextWidth;

swappedWidth.ShouldBeGreaterThan(plainWidth);

// A width that fits the plain line but not the enlarged line.
float wrapWidth = (plainWidth + swappedWidth) / 2f;

// Control: plain text at this width still fits on one line.
TextRuntime plainWrapped = new();
plainWrapped.Font = "Arial";
plainWrapped.FontSize = 20;
plainWrapped.WidthUnits = DimensionUnitType.Absolute;
plainWrapped.Width = wrapWidth;
plainWrapped.Text = "AA BB CC";
((Text)plainWrapped.RenderableComponent).WrappedText.Count.ShouldBe(1);

// Subject: width set BEFORE text so RawText wraps blind, then the swap run populates. The
// font-aware re-wrap must break the enlarged line.
TextRuntime swapped = new();
swapped.Font = "Arial";
swapped.FontSize = 20;
swapped.WidthUnits = DimensionUnitType.Absolute;
swapped.Width = wrapWidth;
swapped.Text = $"AA [FontSize={targetFontSize}]BB[/FontSize] CC";
((Text)swapped.RenderableComponent).WrappedText.Count.ShouldBeGreaterThan(1,
"because after the inline [FontSize] swap run populates, the text must re-wrap measuring the enlarged run at its own size");
}
finally
{
CustomSetPropertyOnRenderable.InMemoryFontCreator = savedCreator;
}
}
}
Loading