diff --git a/maui/src/Button/SfButton.Methods.cs b/maui/src/Button/SfButton.Methods.cs index 32d48a4c..4c4e2c2a 100644 --- a/maui/src/Button/SfButton.Methods.cs +++ b/maui/src/Button/SfButton.Methods.cs @@ -538,20 +538,65 @@ void DrawButtonOutline(ICanvas canvas, RectF dirtyRect) /// Calculated width. double CalculateWidth(double widthConstraint) { - if (widthConstraint == double.PositiveInfinity || widthConstraint < 0 || WidthRequest < 0) + // If WidthRequest is explicitly set, use it + if (WidthRequest > 0) { - if (ShowIcon && ImageSource != null) - { - return ImageAlignment == Alignment.Top || ImageAlignment == Alignment.Bottom - ? Math.Max(ImageSize, TextSize.Width) + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2) - : ImageSize + TextSize.Width + StrokeThickness + Padding.Left + Padding.Right + (_leftPadding * 2) + (_rightPadding * 2); - } - else - { - return TextSize.Width + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2); - } + return WidthRequest; + } + + // If HorizontalOptions is Fill, use the constraint width when available + if (HorizontalOptions.Alignment == LayoutAlignment.Fill && + widthConstraint != double.PositiveInfinity && widthConstraint > 0) + { + return widthConstraint; + } + + // For HorizontalOptions Start, Center, End, calculate natural width based on content + // but ensure it doesn't exceed available width constraint to prevent overflow + double naturalWidth; + if (ShowIcon && ImageSource != null) + { + naturalWidth = ImageAlignment == Alignment.Top || ImageAlignment == Alignment.Bottom + ? Math.Max(ImageSize, TextSize.Width) + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2) + : ImageSize + TextSize.Width + StrokeThickness + Padding.Left + Padding.Right + (_leftPadding * 2) + (_rightPadding * 2); + } + else + { + naturalWidth = TextSize.Width + Padding.Left + Padding.Right + StrokeThickness + (_leftPadding * 2) + (_rightPadding * 2); } - return widthConstraint; + + // If we have a finite width constraint and the natural width would exceed it, + // constrain the width to prevent overflow (especially important on Android) + if (widthConstraint != double.PositiveInfinity && widthConstraint > 0 && naturalWidth > widthConstraint) + { + return widthConstraint; + } + + return naturalWidth; + } + + /// + /// Calculates the available text width considering padding, stroke thickness, and icon positioning. + /// + /// Total width of the button. + /// Available width for text. + double CalculateAvailableTextWidth(double totalWidth) + { + // Start with total width and subtract padding and stroke thickness + double availableWidth = totalWidth - Padding.Left - Padding.Right - StrokeThickness - (_textAreaPadding * 2); + + // If icon is positioned left or right (not top/bottom), subtract icon size + if (ShowIcon && ImageSource != null && ImageAlignment != Alignment.Top && ImageAlignment != Alignment.Bottom) + { + availableWidth -= ImageSize + (_leftIconPadding * 2); + } + +#if ANDROID + // Account for Android-specific text margin + availableWidth -= AndroidTextMargin; +#endif + + return Math.Max(0, availableWidth); } /// @@ -566,10 +611,13 @@ double CalculateHeight(double heightConstraint, double width) { if (LineBreakMode == LineBreakMode.WordWrap || LineBreakMode == LineBreakMode.CharacterWrap) { - _numberOfLines = StringExtensions.GetLinesCount(Text, (float)width, this, LineBreakMode, out _); + // Calculate available text width considering padding, stroke thickness, and icon + double availableTextWidth = CalculateAvailableTextWidth(width); + _numberOfLines = StringExtensions.GetLinesCount(Text, (float)availableTextWidth, this, LineBreakMode, out _); } else { + // For truncation modes (Head, Middle, Tail) and NoWrap, text should always be on a single line _numberOfLines = 1; } if (ShowIcon && ImageSource != null) @@ -690,7 +738,7 @@ protected override Size MeasureContent(double widthConstraint, double heightCons base.MeasureContent(widthConstraint, heightConstraint); double width = CalculateWidth(widthConstraint); - double height = CalculateHeight(heightConstraint, WidthRequest > 0 ? WidthRequest : width); + double height = CalculateHeight(heightConstraint, width); if (Children.Count > 0 && IsItemTemplate) { @@ -733,14 +781,35 @@ internal override void DrawText(ICanvas canvas, RectF dirtyRect) : TextAlignment.Center; UpdateTextRect(dirtyRect); canvas.SaveState(); + + // Calculate available width consistently with height calculation float availableWidth = _textRectF.Width; #if ANDROID - availableWidth-=AndroidTextMargin; + availableWidth -= AndroidTextMargin; #endif - var trimmedText = _isFontIconText ? Text : StringExtensions.GetTextBasedOnLineBreakMode(ApplyTextTransform(Text), this, availableWidth, _textRectF.Height, LineBreakMode); + + // For truncation modes, ensure we have adequate width and avoid wrapping + string textToRender; + if (_isFontIconText) + { + textToRender = Text; + } + else + { + // Apply text transformation first + string transformedText = ApplyTextTransform(Text); + + // For truncation modes, make sure we don't allow wrapping by using single line height + double effectiveHeight = LineBreakMode == LineBreakMode.WordWrap || LineBreakMode == LineBreakMode.CharacterWrap + ? _textRectF.Height + : TextSize.Height; // Use single line height for truncation modes + + textToRender = StringExtensions.GetTextBasedOnLineBreakMode(transformedText, this, availableWidth, effectiveHeight, LineBreakMode); + } + if (_textRectF.Width > 0 && _textRectF.Height > 0) { - canvas.DrawText(trimmedText, _textRectF, _isRightToLeft ? (HorizontalAlignment)horizontalTextAlignment : (HorizontalAlignment)HorizontalTextAlignment, (VerticalAlignment)VerticalTextAlignment, this); + canvas.DrawText(textToRender, _textRectF, _isRightToLeft ? (HorizontalAlignment)horizontalTextAlignment : (HorizontalAlignment)HorizontalTextAlignment, (VerticalAlignment)VerticalTextAlignment, this); } canvas.RestoreState(); } diff --git a/maui/src/Core/Extensions/StringExtensions.cs b/maui/src/Core/Extensions/StringExtensions.cs index 3f13a729..53a7e174 100644 --- a/maui/src/Core/Extensions/StringExtensions.cs +++ b/maui/src/Core/Extensions/StringExtensions.cs @@ -166,20 +166,52 @@ public static string GetTextBasedOnLineBreakMode(this string text, ITextElement return text.TrimTextToFit(textElement, availableWidth); case LineBreakMode.MiddleTruncation: - int charsToKeep = (int)((availableWidth - ("...").Measure(textElement).Width) / 2); - string leftTrimmedText = text; - var leftTrimmedTextSize = leftTrimmedText.Measure((ITextElement)textElement); - int leftLength = 0; - - while (leftTrimmedTextSize.Width > charsToKeep && leftTrimmedText.Length > 0) + // Calculate available width for each half after subtracting ellipsis width + double ellipsisWidth = ("...").Measure(textElement).Width; + double halfAvailableWidth = (availableWidth - ellipsisWidth) / 2; + + // Trim from the left side + string leftPart = text; + while (leftPart.Length > 0 && leftPart.Measure(textElement).Width > halfAvailableWidth) + { + leftPart = leftPart.Substring(0, leftPart.Length - 1); + } + + // Trim from the right side + string rightPart = text; + while (rightPart.Length > 0 && rightPart.Measure(textElement).Width > halfAvailableWidth) + { + rightPart = rightPart.Substring(1); + } + + // Ensure we don't duplicate characters from the middle + int leftLength = leftPart.Length; + int rightStartIndex = text.Length - rightPart.Length; + + // If there's overlap, adjust the split point + if (leftLength >= rightStartIndex) { - leftTrimmedText = leftTrimmedText.Substring(0, leftTrimmedText.Length - 1); - leftTrimmedTextSize = leftTrimmedText.Measure((ITextElement)textElement); - leftLength++; + int midPoint = text.Length / 2; + leftPart = text.Substring(0, Math.Min(leftLength, midPoint)); + rightPart = text.Substring(Math.Max(rightStartIndex, midPoint)); + + // Final trim to ensure we fit in available width + string candidateText = leftPart + "..." + rightPart; + while (candidateText.Measure(textElement).Width > availableWidth && (leftPart.Length > 0 || rightPart.Length > 0)) + { + if (leftPart.Length > rightPart.Length && leftPart.Length > 0) + { + leftPart = leftPart.Substring(0, leftPart.Length - 1); + } + else if (rightPart.Length > 0) + { + rightPart = rightPart.Substring(1); + } + candidateText = leftPart + "..." + rightPart; + } } - string rightText = text.Substring(leftLength); - string trimmedText = leftTrimmedText + "..." + rightText; - return trimmedText; + + return leftPart + "..." + rightPart; case LineBreakMode.HeadTruncation: diff --git a/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs b/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs index ef4a8e03..6897d243 100644 --- a/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs +++ b/maui/tests/Syncfusion.Maui.Toolkit.UnitTest/Buttons/SfButtonUnitTests.cs @@ -776,6 +776,281 @@ private void AttachVisualStates(SfButton button) #endregion + #region HorizontalOptions Width Tests + + [Theory] + [InlineData(LayoutAlignment.Start)] + [InlineData(LayoutAlignment.Center)] + [InlineData(LayoutAlignment.End)] + public void CalculateWidth_WithNonFillHorizontalOptions_ShouldUseContentWidth(LayoutAlignment alignment) + { + var button = new SfButton(); + button.Text = "Sample Text"; + button.HorizontalOptions = new LayoutOptions(alignment, false); + + // Test with available width constraint larger than content + double widthConstraint = 300; + var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint); + + // Should not use the full constraint width, but rather content-based width + Assert.True(actualWidth < widthConstraint, + $"Button with HorizontalOptions.{alignment} should not fill constraint width {widthConstraint}, but got {actualWidth}"); + } + + [Fact] + public void CalculateWidth_WithFillHorizontalOptions_ShouldUseConstraintWidth() + { + var button = new SfButton(); + button.Text = "Sample Text"; + button.HorizontalOptions = LayoutOptions.Fill; + + // Test with available width constraint + double widthConstraint = 300; + var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint); + + // Should use the constraint width when HorizontalOptions is Fill + Assert.Equal(widthConstraint, actualWidth); + } + + [Fact] + public void CalculateWidth_WithWidthRequest_ShouldAlwaysUseWidthRequest() + { + var button = new SfButton(); + button.Text = "Sample Text"; + button.WidthRequest = 150; + button.HorizontalOptions = LayoutOptions.Fill; + + // Test with larger width constraint + double widthConstraint = 300; + var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint); + + // Should use WidthRequest regardless of HorizontalOptions + Assert.Equal(150, actualWidth); + } + + [Fact] + public void CalculateWidth_WithInfiniteConstraint_ShouldUseContentWidth() + { + var button = new SfButton(); + button.Text = "Sample Text"; + button.HorizontalOptions = LayoutOptions.Fill; + + // Test with infinite width constraint + double widthConstraint = double.PositiveInfinity; + var actualWidth = (double)InvokePrivateMethod(button, "CalculateWidth", widthConstraint); + + // Should fall back to content width even with Fill when constraint is infinite + Assert.True(actualWidth > 0 && actualWidth != double.PositiveInfinity, + $"Button should calculate content width when constraint is infinite, but got {actualWidth}"); + } + + #endregion + + #region Text Wrapping Tests + + [Fact] + public void TextWrapping_ShouldWrapWithoutWidthRequest() + { + var button = new SfButton(); + button.Text = "This is a very long text that should automatically wrap into multiple lines and resize the button height accordingly"; + button.LineBreakMode = LineBreakMode.WordWrap; + button.HorizontalOptions = LayoutOptions.Start; + button.VerticalOptions = LayoutOptions.Start; + + // Measure with width constraint but no WidthRequest + var size = button.MeasureContent(200, double.PositiveInfinity); + + // Calculate expected single line height for comparison + var singleLineButton = new SfButton(); + singleLineButton.Text = "Short text"; + singleLineButton.LineBreakMode = LineBreakMode.NoWrap; + var singleLineSize = singleLineButton.MeasureContent(200, double.PositiveInfinity); + + // Height should be greater than single line due to text wrapping + Assert.True(size.Height > singleLineSize.Height, + $"Button height {size.Height} should be greater than single line height {singleLineSize.Height} when text wraps"); + + // Width should not exceed the constraint + Assert.True(size.Width <= 200, + $"Button width {size.Width} should not exceed width constraint of 200"); + } + + [Fact] + public void TextWrapping_ShouldRespectWidthRequest() + { + var button = new SfButton(); + button.Text = "This is a very long text that should automatically wrap into multiple lines and resize the button height accordingly"; + button.LineBreakMode = LineBreakMode.WordWrap; + button.WidthRequest = 150; + + // Measure with larger width constraint, but WidthRequest should take precedence + var size = button.MeasureContent(300, double.PositiveInfinity); + + // Width should be close to WidthRequest (accounting for padding) + Assert.True(size.Width >= 150, + $"Button width {size.Width} should respect WidthRequest of 150"); + } + + [Fact] + public void TextWrapping_WithIcon_ShouldAccountForIconSpace() + { + var button = new SfButton(); + button.Text = "This is a very long text that should automatically wrap into multiple lines"; + button.LineBreakMode = LineBreakMode.WordWrap; + button.ShowIcon = true; + button.ImageAlignment = Alignment.Start; // Icon on left side + button.ImageSize = 20; + + // Measure with width constraint + var sizeWithIcon = button.MeasureContent(200, double.PositiveInfinity); + + // Compare with button without icon + var buttonNoIcon = new SfButton(); + buttonNoIcon.Text = button.Text; + buttonNoIcon.LineBreakMode = LineBreakMode.WordWrap; + var sizeNoIcon = buttonNoIcon.MeasureContent(200, double.PositiveInfinity); + + // Button with icon should potentially wrap more (higher height) due to less available text width + Assert.True(sizeWithIcon.Height >= sizeNoIcon.Height, + $"Button with icon height {sizeWithIcon.Height} should be >= button without icon height {sizeNoIcon.Height}"); + } + + [Fact] + public void TextWrapping_AndroidOverflowPrevention_ShouldConstrainWidth() + { + var button = new SfButton(); + button.Text = "This is a very long text that should automatically wrap into multiple lines and resize the button height accordingly without overflowing the screen bounds on Android"; + button.LineBreakMode = LineBreakMode.WordWrap; + button.HorizontalOptions = LayoutOptions.Start; // Non-Fill alignment + button.VerticalOptions = LayoutOptions.Start; + + // Simulate Android screen constraint (smaller width) + var size = button.MeasureContent(250, double.PositiveInfinity); + + // Button width should be constrained to prevent overflow + Assert.True(size.Width <= 250, + $"Button width {size.Width} should be constrained to prevent overflow on Android (max 250)"); + + // Height should be greater than single line height due to wrapping + var singleLineHeight = button.MeasureContent(double.PositiveInfinity, double.PositiveInfinity).Height; + Assert.True(size.Height >= singleLineHeight, + $"Button height {size.Height} should accommodate wrapped text (>= {singleLineHeight})"); + } + + #endregion + + #region Text Truncation Tests + + [Theory] + [InlineData(LineBreakMode.TailTruncation)] + [InlineData(LineBreakMode.HeadTruncation)] + [InlineData(LineBreakMode.MiddleTruncation)] + [InlineData(LineBreakMode.NoWrap)] + public void TextTruncation_ShouldAlwaysUseSingleLine(LineBreakMode lineBreakMode) + { + var button = new SfButton(); + button.Text = "This is a very long text that should be truncated instead of wrapping to multiple lines"; + button.LineBreakMode = lineBreakMode; + + // Test with constrained width that would normally cause wrapping + var size = button.MeasureContent(150, double.PositiveInfinity); + + // Calculate single line height for comparison + var singleLineButton = new SfButton(); + singleLineButton.Text = "Short"; + singleLineButton.LineBreakMode = LineBreakMode.NoWrap; + var singleLineHeight = singleLineButton.MeasureContent(double.PositiveInfinity, double.PositiveInfinity).Height; + + // For truncation modes, height should be approximately single line height + var heightDifference = Math.Abs(size.Height - singleLineHeight); + Assert.True(heightDifference < 5, // Allow small padding differences + $"Truncation mode {lineBreakMode} should use single line height. Expected ~{singleLineHeight}, got {size.Height}"); + } + + [Fact] + public void TextTruncation_MiddleMode_ShouldTrimBothEnds() + { + var longText = "This is a very long text that should be truncated in the middle with ellipsis"; + + // Use StringExtensions directly to test the truncation logic + var result = StringExtensions.GetTextBasedOnLineBreakMode(longText, new TestTextElement(), 100, 20, LineBreakMode.MiddleTruncation); + + // Should contain ellipsis + Assert.Contains("...", result); + + // Should be shorter than original + Assert.True(result.Length < longText.Length, + $"Truncated text '{result}' should be shorter than original '{longText}'"); + + // Should start with beginning of original text and end with end of original text + Assert.True(result.StartsWith(longText.Substring(0, Math.Min(10, longText.Length))), + $"Truncated text '{result}' should start with beginning of original text"); + } + + [Fact] + public void TextTruncation_TailMode_ShouldAddEllipsisAtEnd() + { + var longText = "This is a very long text that should be truncated at the end"; + + var result = StringExtensions.GetTextBasedOnLineBreakMode(longText, new TestTextElement(), 100, 20, LineBreakMode.TailTruncation); + + // Should end with ellipsis + Assert.True(result.EndsWith("..."), + $"Tail truncated text '{result}' should end with ellipsis"); + + // Should be shorter than original + Assert.True(result.Length < longText.Length, + $"Truncated text '{result}' should be shorter than original '{longText}'"); + } + + [Fact] + public void TextTruncation_HeadMode_ShouldAddEllipsisAtStart() + { + var longText = "This is a very long text that should be truncated at the beginning"; + + var result = StringExtensions.GetTextBasedOnLineBreakMode(longText, new TestTextElement(), 100, 20, LineBreakMode.HeadTruncation); + + // Should start with ellipsis + Assert.True(result.StartsWith("..."), + $"Head truncated text '{result}' should start with ellipsis"); + + // Should be shorter than original + Assert.True(result.Length < longText.Length, + $"Truncated text '{result}' should be shorter than original '{longText}'"); + } + + [Fact] + public void TextTruncation_ShouldHandleSpacesCorrectly() + { + // This tests the scenario mentioned in the comment where spaces cause wrapping instead of truncation + var textWithSpaces = "Word1 Word2 Word3 Word4 Word5 Word6 Word7 Word8 Word9 Word10"; + + var result = StringExtensions.GetTextBasedOnLineBreakMode(textWithSpaces, new TestTextElement(), 80, 20, LineBreakMode.TailTruncation); + + // Should truncate, not wrap + Assert.Contains("...", result); + Assert.True(result.Length < textWithSpaces.Length); + + // Should not contain line breaks or be multi-line + Assert.False(result.Contains('\n')); + Assert.False(result.Contains('\r')); + } + + // Helper class for testing text measurements + private class TestTextElement : ITextElement + { + public FontAttributes FontAttributes => FontAttributes.None; + public string FontFamily => null; + public double FontSize => 14; + public bool FontAutoScalingEnabled => false; + public Color TextColor => Colors.Black; + + // Simple width calculation for testing (assumes each char is ~8 pixels wide) + public Size Measure(string text) => new Size(text.Length * 8, 20); + } + + #endregion + #region AutomationScenario [Theory]