diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b8f6e9a890a0..6b39e8adc893 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -138,6 +138,26 @@ When working with public API changes: - **Use `dotnet format analyzers`** if having trouble - **If files are incorrect**: Revert all changes, then add only the necessary new API entries +**🚨 CRITICAL: `#nullable enable` must be line 1** + +Every `PublicAPI.Unshipped.txt` file starts with `#nullable enable` (often BOM-prefixed: `#nullable enable`) on the **first line**. If this line is moved or removed, the analyzer treats it as a declared API symbol and emits **RS0017** errors. + +**Never sort these files with plain `sort`** — the BOM bytes (`0xEF 0xBB 0xBF`) sort after ASCII characters under `LC_ALL=C`, pushing `#nullable enable` to the bottom of the file. + +When resolving merge conflicts or adding entries, use this safe pattern that preserves line 1: +```bash +for f in $(git diff --name-only --diff-filter=U | grep "PublicAPI.Unshipped.txt"); do + # Extract and preserve the #nullable enable line (with or without BOM) + HEADER=$(head -1 "$f" | grep -o '.*#nullable enable' || echo '#nullable enable') + # Strip conflict markers, remove all #nullable lines, sort+dedup the API entries + grep -v '^<<<<<<\|^======\|^>>>>>>\|#nullable enable' "$f" | LC_ALL=C sort -u | sed '/^$/d' > /tmp/api_fix.txt + # Reassemble: header first, then sorted entries + printf '%s\n' "$HEADER" > "$f" + cat /tmp/api_fix.txt >> "$f" + git add "$f" +done +``` + ### Branching - `main` - For bug fixes without API changes - `net10.0` - For new features and API changes diff --git a/eng/pipelines/ci-device-tests.yml b/eng/pipelines/ci-device-tests.yml index 1547831fb642..d33cf5b4e7e4 100644 --- a/eng/pipelines/ci-device-tests.yml +++ b/eng/pipelines/ci-device-tests.yml @@ -5,6 +5,7 @@ trigger: - release/* - net*.0 - inflight/* + - darc-* tags: include: - '*' diff --git a/eng/pipelines/ci-uitests.yml b/eng/pipelines/ci-uitests.yml index c3b6ace38f43..7e16e1733d75 100644 --- a/eng/pipelines/ci-uitests.yml +++ b/eng/pipelines/ci-uitests.yml @@ -5,6 +5,7 @@ trigger: - release/* - net*.0 - inflight/* + - darc-* tags: include: - '*' diff --git a/src/Controls/src/Core/Handlers/Items/Android/Adapters/SelectableItemsViewAdapter.cs b/src/Controls/src/Core/Handlers/Items/Android/Adapters/SelectableItemsViewAdapter.cs index 41f5619c3ea7..55579ae57324 100644 --- a/src/Controls/src/Core/Handlers/Items/Android/Adapters/SelectableItemsViewAdapter.cs +++ b/src/Controls/src/Core/Handlers/Items/Android/Adapters/SelectableItemsViewAdapter.cs @@ -28,6 +28,14 @@ public override void OnBindViewHolder(RecyclerView.ViewHolder holder, int positi return; } + // Header and footer view holders should not participate in selection tracking. + // They are not data items and calling GetItem() on their positions would cause + // an ArgumentOutOfRangeException due to the header index adjustment. + if (ItemsSource.IsHeader(position) || ItemsSource.IsFooter(position)) + { + return; + } + // Watch for clicks so the user can select the item held by this ViewHolder selectable.Clicked += SelectableClicked; diff --git a/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LabelNotTruncatedWithMaxLines.png new file mode 100644 index 000000000000..dbeca9afe4c7 Binary files /dev/null and b/src/Controls/tests/TestCases.Android.Tests/snapshots/android/LabelNotTruncatedWithMaxLines.png differ diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue34120.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34120.cs new file mode 100644 index 000000000000..5b083542ab64 --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34120.cs @@ -0,0 +1,81 @@ +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 34120, "Label text truncated in ScrollView when MaxLines is set", PlatformAffected.Android)] +public class Issue34120 : ContentPage +{ + // Reproduces the N3_Navigation layout: horizontal ScrollView with BindableLayout, + // 200×200 Border cards, Image (HeightRequest=120), and a Label with MaxLines=2. + record Issue34120MonkeyItem(string Name, string ImageUrl); + + public Issue34120() + { + // Long names ("Golden Snub-nosed Monkey", "Tonkin Snub-nosed Monkey") are the ones + // that triggered truncation; "Baboon" is a short-name reference card. + var monkeys = new List + { + new("Golden Snub-nosed Monkey", "golden.jpg"), + new("Baboon", "papio.jpg"), + new("Tonkin Snub-nosed Monkey", "bluemonkey.jpg"), + new("Howler Monkey", "alouatta.jpg"), + new("Squirrel Monkey", "saimiri.jpg"), + }; + + var itemTemplate = new DataTemplate(() => + { + var image = new Image + { + Aspect = Aspect.AspectFit, + HeightRequest = 120, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + }; + image.SetBinding(Image.SourceProperty, "ImageUrl"); + + var nameLabel = new Label + { + FontSize = 14, + FontAttributes = FontAttributes.Bold, + BackgroundColor = Color.FromArgb("#AAFFFFFF"), + TextColor = Colors.Black, + Padding = new Thickness(4, 2), + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + HorizontalTextAlignment = TextAlignment.Center, + LineBreakMode = LineBreakMode.WordWrap, + MaxLines = 2, + }; + nameLabel.SetBinding(Label.TextProperty, "Name"); + nameLabel.SetBinding(Label.AutomationIdProperty, "Name"); + + var card = new Border + { + Padding = new Thickness(10), + Stroke = Colors.LightGray, + StrokeThickness = 1, + WidthRequest = 200, + HeightRequest = 200, + BackgroundColor = Colors.White, + Content = new VerticalStackLayout + { + Spacing = 10, + Children = { image, nameLabel } + } + }; + return card; + }); + + var horizontalStack = new HorizontalStackLayout + { + Spacing = 15, + Padding = new Thickness(5), + }; + BindableLayout.SetItemTemplate(horizontalStack, itemTemplate); + BindableLayout.SetItemsSource(horizontalStack, monkeys); + + Content = new ScrollView + { + Orientation = ScrollOrientation.Horizontal, + Content = horizontalStack, + }; + } +} diff --git a/src/Controls/tests/TestCases.HostApp/Issues/Issue34247.cs b/src/Controls/tests/TestCases.HostApp/Issues/Issue34247.cs new file mode 100644 index 000000000000..184755af5a0a --- /dev/null +++ b/src/Controls/tests/TestCases.HostApp/Issues/Issue34247.cs @@ -0,0 +1,69 @@ +using System.Collections.ObjectModel; + +namespace Maui.Controls.Sample.Issues; + +[Issue(IssueTracker.Github, 34247, "CollectionView with HeaderTemplate and SelectionMode.Single crashes on selection", PlatformAffected.Android)] +public class Issue34247 : ContentPage +{ + public Issue34247() + { + var layout = new StackLayout(); + + var resultLabel = new Label() + { + Text = "Select an item to test", + AutomationId = "ResultLabel" + }; + + var items = new ObservableCollection { "Item 1", "Item 2", "Item 3" }; + + var collectionView = new CollectionView() + { + SelectionMode = SelectionMode.Single, + AutomationId = "TestCollectionView", + HeightRequest = 300 + }; + + collectionView.HeaderTemplate = new DataTemplate(() => + { + return new Label + { + Text = "Header", + FontSize = 18, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(10) + }; + }); + + collectionView.FooterTemplate = new DataTemplate(() => + { + return new Label + { + Text = "Footer", + FontSize = 18, + FontAttributes = FontAttributes.Bold, + Margin = new Thickness(10) + }; + }); + + collectionView.ItemTemplate = new DataTemplate(() => + { + var label = new Label(); + label.SetBinding(Label.TextProperty, "."); + label.Margin = new Thickness(10); + label.FontSize = 16; + return label; + }); + + collectionView.ItemsSource = items; + collectionView.SelectionChanged += (s, e) => + { + resultLabel.Text = "Success"; + }; + + layout.Children.Add(resultLabel); + layout.Children.Add(collectionView); + + Content = layout; + } +} diff --git a/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/LabelNotTruncatedWithMaxLines.png new file mode 100644 index 000000000000..b87fca4e362c Binary files /dev/null and b/src/Controls/tests/TestCases.Mac.Tests/snapshots/mac/LabelNotTruncatedWithMaxLines.png differ diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34120.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34120.cs new file mode 100644 index 000000000000..3635c562e89d --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34120.cs @@ -0,0 +1,21 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues; + +public class Issue34120 : _IssuesUITest +{ + public override string Issue => "Label text truncated in ScrollView when MaxLines is set"; + + public Issue34120(TestDevice device) : base(device) { } + + [Test] + [Category(UITestCategories.Label)] + public void LabelNotTruncatedWithMaxLines() + { + // Wait for the page to load, then verify labels are not truncated. + App.WaitForElement("Golden Snub-nosed Monkey"); + VerifyScreenshot(); + } +} diff --git a/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34247.cs b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34247.cs new file mode 100644 index 000000000000..c9799d08cda6 --- /dev/null +++ b/src/Controls/tests/TestCases.Shared.Tests/Tests/Issues/Issue34247.cs @@ -0,0 +1,26 @@ +using NUnit.Framework; +using UITest.Appium; +using UITest.Core; + +namespace Microsoft.Maui.TestCases.Tests.Issues +{ + public class Issue34247 : _IssuesUITest + { + public Issue34247(TestDevice testDevice) : base(testDevice) + { + } + + public override string Issue => "CollectionView with HeaderTemplate and SelectionMode.Single crashes on selection"; + + [Test] + [Category(UITestCategories.CollectionView)] + public void SelectingItemInCollectionViewWithHeaderTemplateDoesNotCrash() + { + App.WaitForElement("TestCollectionView"); + App.WaitForElement("Item 1"); + App.Tap("Item 1"); + var result = App.WaitForElement("ResultLabel").GetText(); + Assert.That(result, Is.EqualTo("Success")); + } + } +} diff --git a/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/LabelNotTruncatedWithMaxLines.png new file mode 100644 index 000000000000..845d3f890d05 Binary files /dev/null and b/src/Controls/tests/TestCases.WinUI.Tests/snapshots/windows/LabelNotTruncatedWithMaxLines.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LabelNotTruncatedWithMaxLines.png new file mode 100644 index 000000000000..b70efeacc5f7 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios-26/LabelNotTruncatedWithMaxLines.png differ diff --git a/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/LabelNotTruncatedWithMaxLines.png b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/LabelNotTruncatedWithMaxLines.png new file mode 100644 index 000000000000..85976d230f66 Binary files /dev/null and b/src/Controls/tests/TestCases.iOS.Tests/snapshots/ios/LabelNotTruncatedWithMaxLines.png differ diff --git a/src/Core/src/Handlers/Label/LabelHandler.Android.cs b/src/Core/src/Handlers/Label/LabelHandler.Android.cs index d70b114984d7..e60e9017d408 100644 --- a/src/Core/src/Handlers/Label/LabelHandler.Android.cs +++ b/src/Core/src/Handlers/Label/LabelHandler.Android.cs @@ -22,9 +22,12 @@ public override Size GetDesiredSize(double widthConstraint, double heightConstra // Android TextView reports full available width instead of actual text width when // text wraps to multiple lines, causing incorrect positioning for non-Fill alignments. + // We narrow the desired width to the widest rendered line, but only when that narrowing + // won't cause re-wrapping that exceeds MaxLines and truncates visible text. if (VirtualView.HorizontalLayoutAlignment != Primitives.LayoutAlignment.Fill && PlatformView?.Layout is Layout layout && - layout.LineCount > 1) + layout.LineCount > 1 && + PlatformView.Ellipsize == null) { float maxLineWidth = 0; for (int i = 0; i < layout.LineCount; i++) @@ -38,7 +41,33 @@ public override Size GetDesiredSize(double widthConstraint, double heightConstra { var actualWidth = Context.FromPixels(maxLineWidth + PlatformView.PaddingLeft + PlatformView.PaddingRight); if (actualWidth < size.Width) + { + // When MaxLines is constrained, verify that narrowing doesn't cause the text + // to re-wrap into more lines than MaxLines allows (which would truncate text). + // Re-measure at exactly the pixel width the view will be arranged at. + if (PlatformView.MaxLines != int.MaxValue) + { + var narrowedPx = (int)Context.ToPixels(actualWidth); + + // AtMost mirrors how the layout pass constrains width, ensuring the + // re-measurement reflects the same wrapping behaviour the view will + // experience when arranged at actualWidth. + PlatformView.Measure( + MeasureSpecMode.AtMost.MakeMeasureSpec(narrowedPx), + MeasureSpecMode.Unspecified.MakeMeasureSpec(0)); + + // Fail-safe: if Layout is null after re-measurement we cannot verify + // that truncation won't occur, so return the original size. + var measuredLayout = PlatformView.Layout; + + if (measuredLayout is null || measuredLayout.LineCount > PlatformView.MaxLines) + { + return size; // Narrowing causes truncation (or unverifiable); return original size + } + } + return new Size(actualWidth, size.Height); + } } } diff --git a/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs b/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs index 04e5e5e30703..16744f1a48d6 100644 --- a/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs +++ b/src/Core/src/Platform/Android/PlatformTouchGraphicsView.cs @@ -89,7 +89,7 @@ public void TouchesMoved(PointF[] points) { if (!_dragStarted) { - if (points.Length == 1) + if (points.Length == 1 && _lastMovedViewPoints.Length > 0) { float deltaX = _lastMovedViewPoints[0].X - points[0].X; float deltaY = _lastMovedViewPoints[0].Y - points[0].Y;