From e77034f84776237e14b47ba14c9695dbb78bafb1 Mon Sep 17 00:00:00 2001 From: Victor Chelaru Date: Tue, 7 Jul 2026 12:58:19 -0600 Subject: [PATCH 1/2] Fix raylib Text wrapping to measure inline BBCode run sizes (#3532) raylib's Text wrapped inline BBCode runs at the base font size: a fixed-width Text with an enlarged [FontSize=N]/[FontScale=N] run packed words onto a base-measured line, so the enlarged run spilled past the wrap width. This is the raylib mirror of the XNA-family fix #3529. Two-part fix (mirroring #3529): (1) override IWrappedText.MeasureString(string, int absoluteStartIndexInStrippedText) on the raylib Text so the shared wrap loop measures each styled run at its own resolved size, extracting the shared MeasureStyledLineInBaseUnits helper from GetInlineVariableAwareWidthAndHeight so draw/size/wrap use one per-run resolution; (2) re-wrap after InlineVariables populate in raylib's CustomSetPropertyOnRenderable (make Text.UpdateWrappedText public), since the first wrap runs when RawText is set, before the runs exist. Pins in RaylibGum.Tests: red-first end-to-end wrap tests for a [FontScale=2] run and a [FontSize=N] crisp-swap run (real font measurement, wrap width derived between plain and enlarged single-line widths), plus a fallback pin that MeasureString(string,int) equals the base measure when no inline runs cover the range (plain-text wrapping unchanged). Closes #3532 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../CustomSetPropertyOnRenderable.cs | 11 +- Runtimes/RaylibGum/Renderables/Text.cs | 82 ++++++++--- .../Renderables/TextMarkupTests.cs | 137 ++++++++++++++++++ 3 files changed, 209 insertions(+), 21 deletions(-) diff --git a/Runtimes/RaylibGum/Renderables/CustomSetPropertyOnRenderable.cs b/Runtimes/RaylibGum/Renderables/CustomSetPropertyOnRenderable.cs index d7e0c7000..0c24b792b 100644 --- a/Runtimes/RaylibGum/Renderables/CustomSetPropertyOnRenderable.cs +++ b/Runtimes/RaylibGum/Renderables/CustomSetPropertyOnRenderable.cs @@ -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(); } diff --git a/Runtimes/RaylibGum/Renderables/Text.cs b/Runtimes/RaylibGum/Renderables/Text.cs index c536b69d5..9cf5f359d 100644 --- a/Runtimes/RaylibGum/Renderables/Text.cs +++ b/Runtimes/RaylibGum/Renderables/Text.cs @@ -675,7 +675,13 @@ public Text(ISystemManagers? managers) Visible = true; } - private void UpdateWrappedText() + /// + /// Re-runs the shared wrap loop () to rebuild + /// . Public so the BBCode dispatch (CustomSetPropertyOnRenderable) can + /// re-wrap once populate — the first wrap runs when RawText is set, + /// before the runs exist, so it is blind to any enlarged [FontSize]/[FontScale] run (#3532). + /// + public void UpdateWrappedText() { mWrappedText.Clear(); this.UpdateLines(mWrappedText); @@ -1062,8 +1068,6 @@ public void UpdatePreRenderDimensions() /// private void GetInlineVariableAwareWidthAndHeight(out int requiredWidth, out int requiredHeight) { - float baseScale = FontScale; - float maxWidth = 0; float totalHeight = 0; @@ -1083,20 +1087,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); @@ -1110,6 +1101,63 @@ private void GetInlineVariableAwareWidthAndHeight(out int requiredWidth, out int requiredHeight = System.Math.Min((int)(totalHeight + .5f), MaxWidthAndHeight); } + /// + /// Sums the base-unit width of a single line already split into styled runs, measuring each run with + /// the same per-run font/size 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. + /// + /// + /// Shared by the size pass (, #3481/#3524) and the + /// font-aware wrap seam (, #3532) so the measured, drawn, and + /// wrapped size of a run cannot drift. Widths are returned in BASE units ( + /// factored out), matching ; is the floor, + /// so an inline run smaller than the base does not shrink the line. Guards == 0 + /// because the wrap seam calls this regardless of scale (the size pass is already gated on scale > 0). + /// + private float MeasureStyledLineInBaseUnits(List 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; + } + + /// + /// Font-aware measurement used by line wrapping (issue #3532): measures + /// (which begins at 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 + /// when no inline runs cover the range, keeping plain-text wrapping + /// byte-identical. + /// + 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 lines, out int requiredWidth, out int requiredHeight, List? widths) { diff --git a/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs b/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs index e751fbd2d..6f07eb6c8 100644 --- a/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs +++ b/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs @@ -16,6 +16,25 @@ namespace RaylibGum.Tests.Renderables; /// 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() { @@ -282,4 +301,122 @@ 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 (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; + } + } } From e0447d0243b66df4dfef517c5da4be9b595b9873 Mon Sep 17 00:00:00 2001 From: Victor Chelaru Date: Tue, 7 Jul 2026 13:24:00 -0600 Subject: [PATCH 2/2] raylib Text: advance wrapped lines by per-line height so an enlarged run doesn't overlap the next line (#3532) The wrap-width fix made enlarged inline runs wrap, which exposed a pre-existing raylib draw bug: Render advanced every wrapped line by a uniform base line height (i * LineHeightInPixels), so a line following an enlarged [FontSize]/[FontScale] run was drawn on top of it instead of below. XNA already grows the per-line advance (RenderingLibrary/Graphics/Text.cs); this brings raylib to parity. Extracted GetLineTopOffsets() (public, testable) + GetLineLayoutScale() and had Render consume them only when InlineVariables are present, so the plain-text draw path stays byte-identical and allocation-free. GetLineLayoutScale reuses the same ResolveRunFont resolution DrawStyledLine lays out with (no MeasureTextEx), and the per-line factor matches what GetInlineVariableAwareWidthAndHeight already reports for size. Pin: GetLineTopOffsets_ShouldPushLineBelowEnlargedRun_NotOverlapIt (red-first via a faithful uniform extraction) asserts the line after a [FontScale=2] line sits at twice the base advance. Co-Authored-By: Claude Opus 4.8 (1M context) --- Runtimes/RaylibGum/Renderables/Text.cs | 56 ++++++++++++++++++- .../Renderables/TextMarkupTests.cs | 28 ++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/Runtimes/RaylibGum/Renderables/Text.cs b/Runtimes/RaylibGum/Renderables/Text.cs index 9cf5f359d..0b979eaa7 100644 --- a/Runtimes/RaylibGum/Renderables/Text.cs +++ b/Runtimes/RaylibGum/Renderables/Text.cs @@ -781,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? lineTopOffsets = InlineVariables.Count > 0 ? GetLineTopOffsets() : null; + for(int i = 0; i < WrappedText.Count; i++) { var line = WrappedText[i]; @@ -800,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) @@ -824,6 +831,53 @@ public void Render(ISystemManagers managers) } } + /// + /// 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 + /// (). Consumed by ; public so + /// the vertical stacking can be unit-tested without rendering. + /// + public IReadOnlyList 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; + } + + /// + /// The layout scale of the wrapped line beginning at in the + /// stripped text: for a plain line, or the tallest inline + /// [FontSize]/[FontScale] run's scale for a styled line (the same per-run resolution + /// lays out with, so drawn and vertically-advanced sizes cannot drift). + /// + 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; + } + /// /// Draws a single line with no inline styling, honoring horizontal alignment and pixel snapping. /// diff --git a/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs b/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs index 6f07eb6c8..f5fb3f7bd 100644 --- a/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs +++ b/Tests/RaylibGum.Tests/Renderables/TextMarkupTests.cs @@ -302,6 +302,34 @@ public void WrappedTextHeight_ShouldGrow_WhenLineHasFontSizeLargerThanBase() "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 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 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