diff --git a/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs b/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs index f60a47dc86e3..32e054060ba8 100644 --- a/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs +++ b/src/Controls/src/Core/Platform/Android/Extensions/FormattedStringExtensions.cs @@ -126,6 +126,13 @@ public static void RecalculateSpanPositions(this TextView textView, Label elemen if (layout == null) return; + // Fix for https://github.com/dotnet/maui/issues/35755: skip spans in the + // ellipsized tail to avoid IndexOutOfBoundsException. + var lastLayoutLine = layout.LineCount - 1; + if (lastLayoutLine < 0) + return; + var layoutEndOffset = layout.GetLineEnd(lastLayoutLine); + int next = 0; int count = 0; @@ -163,8 +170,16 @@ public static void RecalculateSpanPositions(this TextView textView, Label elemen var spanStartOffset = spannableString.GetSpanStart(startSpan); var spanEndOffset = spannableString.GetSpanEnd(endSpan); + // Safe for TailTruncation only: both offsets share the same string prefix. + if (spanStartOffset >= layoutEndOffset) + continue; + var spanStartLine = layout.GetLineForOffset(spanStartOffset); - var spanEndLine = layout.GetLineForOffset(spanEndOffset); + var spanEndLine = layout.GetLineForOffset(System.Math.Min(spanEndOffset, layoutEndOffset - 1)); + + // OEM guard: some Layout subclasses don't cap GetLineForOffset at lineCount-1. + // Not dead code — see https://github.com/dotnet/maui/issues/35755 + spanEndLine = System.Math.Min(spanEndLine, lastLayoutLine); // Go through all lines that are affected by the span and calculate a rectangle for each List spanRectangles = new List(); diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue35755.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue35755.cs new file mode 100644 index 000000000000..21fe6fa1dc6d --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue35755.cs @@ -0,0 +1,70 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 35755, "IndexOutOfBoundsException in RecalculateSpanPositions when a Label uses FormattedText, MaxLines, and TailTruncation", PlatformAffected.Android)] +public class Issue35755 : ContentPage +{ + readonly string _paragraphA = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum eleifend, augue nec aliquam interdum, massa nisl viverra orci, non interdum risus arcu id lorem. Curabitur accumsan, urna eu tempor tincidunt, purus neque feugiat tortor, sed tristique nibh nunc et augue."; + readonly string _paragraphB = "Aliquam erat volutpat. Quisque a mi lacus. Integer vitae malesuada sem. Nunc id dui nec lacus feugiat volutpat. Morbi et sollicitudin erat. Sed varius felis id dignissim facilisis. Vivamus vulputate, augue sed finibus laoreet, enim neque tristique odio, id rhoncus elit purus a turpis."; + readonly string _paragraphC = "Praesent in lectus non mauris mattis ultrices. Donec non justo ac nunc porta pellentesque. Integer euismod, velit in posuere iaculis, lorem nunc commodo libero, nec interdum lorem nibh ut turpis. Phasellus gravida tristique tortor, id posuere turpis sodales in."; + + Label _crashTargetLabel; + + public Issue35755() + { + _crashTargetLabel = new Label + { + AutomationId = "CrashTargetLabel", + MaxLines = 4, + LineBreakMode = LineBreakMode.TailTruncation, + FontSize = 14 + }; + + var resultLabel = new Label + { + AutomationId = "ResultLabel", + Text = "Waiting for trigger..." + }; + + var triggerButton = new Button + { + AutomationId = "TriggerButton", + Text = "Trigger FormattedText" + }; + + triggerButton.Clicked += (s, e) => + { + _crashTargetLabel.FormattedText = BuildFormattedText(); + resultLabel.Text = "Success"; + }; + + Content = new ScrollView + { + Content = new VerticalStackLayout + { + Padding = new Thickness(24), + Spacing = 16, + Children = + { + triggerButton, + resultLabel, + _crashTargetLabel + } + } + }; + } + + FormattedString BuildFormattedText() + { + var fs = new FormattedString(); + fs.Spans.Add(new Span + { + Text = "Content: ", + FontAttributes = FontAttributes.Bold, + TextColor = Colors.DarkRed + }); + fs.Spans.Add(new Span { Text = _paragraphA + "\n\n", TextColor = Colors.Black }); + fs.Spans.Add(new Span { Text = _paragraphB + "\n\n", TextColor = Colors.DarkBlue }); + fs.Spans.Add(new Span { Text = _paragraphC, TextColor = Colors.DarkGreen }); + return fs; + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35755.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35755.cs new file mode 100644 index 000000000000..9eeab645230b --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue35755.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue35755 : _IssuesUITest +{ + public Issue35755(TestDevice device) : base(device) + { + } + + public override string Issue => "IndexOutOfBoundsException in RecalculateSpanPositions when a Label uses FormattedText, MaxLines, and TailTruncation"; + + [Test] + [Category(UITestCategories.Label)] + public void FormattedTextWithMaxLinesAndTailTruncationShouldNotCrash() + { + App.WaitForElement("TriggerButton"); + App.Tap("TriggerButton"); + + App.WaitForElement("ResultLabel"); + var resultText = App.FindElement("ResultLabel").GetText(); + Assert.That(resultText, Is.EqualTo("Success"), "Label should display truncated FormattedText without crashing."); + App.WaitForElement("CrashTargetLabel"); + } +}