From 71ded23911c2c3565d40cbd424b42784b76bafbc Mon Sep 17 00:00:00 2001 From: Tim <47110241+timunie@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:01:17 +0200 Subject: [PATCH] Fix some issues on SelectingItemsControls when items or control changes visibility (#20798) * Add failing test for #14718 * Fix for AutoScrollToSelectedItemIfNecessary * fix failing CI build and move test to the right location * add failing test for TabItem selection of invisble tab * introduce a helper method to figure out which item to select when nothing was selected beforehand and AlwaysSelected is true * ensure selection works for invisible tabcontrol * simplify conditions * propose: Remove redundant logic from ColorView The TabItem now handles the correct selection of only visible items * fix test: Need to set SelectedIndex after adding items * re-add unused method and make it obsolete Otherwise API-diff will fail. * Address review * Update tests/Avalonia.Controls.UnitTests/TabControlTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update tests/Avalonia.Controls.UnitTests/TabControlTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Adress copilot review * fix duplicate braces * address review * fix for failing tests on CarouselPage and TabbedPage * address feedback - adding more tests to avoid regressions * Fix failing test: add UnitTestApplication.Start() to dedicated thread test Agent-Logs-Url: https://github.com/timunie/Avalonia/sessions/fe2f1190-6d20-4982-8a03-1ae9b52ee701 Co-authored-by: timunie <47110241+timunie@users.noreply.github.com> * Refactor SelectingItemsControl auto-scroll duplicate logic Agent-Logs-Url: https://github.com/timunie/Avalonia/sessions/8895cb60-ef41-473c-972b-bad2483a5a77 Co-authored-by: timunie <47110241+timunie@users.noreply.github.com> * Add new failing tests for AlwaysSelected mode * Fix AlwaysSelected scenarios --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Julien Lebosquain Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../ColorView/ColorView.cs | 128 +------ src/Avalonia.Controls/Page/CarouselPage.cs | 9 +- src/Avalonia.Controls/Page/TabbedPage.cs | 9 +- .../Primitives/SelectingItemsControl.cs | 213 ++++++++++-- .../Primitives/SelectingItemsControlTests.cs | 1 + .../SelectingItemsControlTests_AutoSelect.cs | 311 ++++++++++++++++++ .../TabControlTests.cs | 37 +++ 7 files changed, 557 insertions(+), 151 deletions(-) diff --git a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs index 100430d74f..36d5c6c4ae 100644 --- a/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs +++ b/src/Avalonia.Controls.ColorPicker/ColorView/ColorView.cs @@ -1,10 +1,9 @@ -using System; +using System; using System.Collections.Generic; using Avalonia.Controls.Converters; using Avalonia.Controls.Metadata; using Avalonia.Controls.Primitives; using Avalonia.Media; -using Avalonia.Threading; namespace Avalonia.Controls { @@ -12,7 +11,6 @@ namespace Avalonia.Controls /// Presents a color for user editing using a spectrum, palette and component sliders. /// [TemplatePart("PART_HexTextBox", typeof(TextBox))] - [TemplatePart("PART_TabControl", typeof(TabControl))] public partial class ColorView : TemplatedControl { /// @@ -22,7 +20,6 @@ namespace Avalonia.Controls // XAML template parts private TextBox? _hexTextBox; - private TabControl? _tabControl; protected bool _ignorePropertyChanged = false; @@ -70,106 +67,16 @@ namespace Avalonia.Controls } /// - /// Validates the tab/panel/page selection taking into account the visibility of each item - /// as well as the current selection. + /// Obsolete. No-op. This method is no longer used and will be removed in a future release. /// /// - /// Derived controls may re-implement this based on their default style / control template - /// and any specialized selection needs. + /// This method does nothing and should not be overridden or relied upon. Validation is now handled by TabControl. /// + // TODO-13: Remove this unused method + [Obsolete("The necessary validation is now handled by the TabControl. This method will be removed in the next major release.")] protected virtual void ValidateSelection() { - if (_tabControl != null && - _tabControl.Items != null) - { - // Determine the number of visible tab items - int numVisibleItems = 0; - foreach (var item in _tabControl.Items) - { - if (item is Control control && - control.IsVisible) - { - numVisibleItems++; - } - } - - // Verify the selection - if (numVisibleItems > 0) - { - object? selectedItem = null; - - if (_tabControl.SelectedItem == null && - _tabControl.ItemCount > 0) - { - // As a failsafe, forcefully select the first item - foreach (var item in _tabControl.Items) - { - selectedItem = item; - break; - } - } - else - { - selectedItem = _tabControl.SelectedItem; - } - - if (selectedItem is Control selectedControl && - selectedControl.IsVisible == false) - { - // Select the first visible item instead - foreach (var item in _tabControl.Items) - { - if (item is Control control && - control.IsVisible) - { - selectedItem = item; - break; - } - } - } - - _tabControl.SelectedItem = selectedItem; - _tabControl.IsVisible = true; - } - else - { - // Special case when all items are hidden - // If TabControl ever properly supports no selected item / - // all items hidden this can be removed - _tabControl.SelectedItem = null; - _tabControl.IsVisible = false; - } - - // Hide the "tab strip" if there is only one tab - // This allows, for example, to view only the palette - /* - var itemsPresenter = _tabControl.FindDescendantOfType(); - if (itemsPresenter != null) - { - if (numVisibleItems == 1) - { - itemsPresenter.IsVisible = false; - } - else - { - itemsPresenter.IsVisible = true; - } - } - */ - - // Note that if externally the SelectedIndex is set to 4 or something - // outside the valid range, the TabControl will ignore it and replace it - // with a valid SelectedIndex. This however is not propagated back through - // the TwoWay binding in the control template so the SelectedIndex and - // SelectedIndex become out of sync. - // - // The work-around for this is done here where SelectedIndex is forcefully - // synchronized with whatever the TabControl property value is. This is - // possible since selection validation is already done by this method. - SetCurrentValue(SelectedIndexProperty, _tabControl.SelectedIndex); - } - - return; + // Obsolete: no-op. Will be removed in a future release. } /// @@ -182,7 +89,6 @@ namespace Avalonia.Controls } _hexTextBox = e.NameScope.Find("PART_HexTextBox"); - _tabControl = e.NameScope.Find("PART_TabControl"); SetColorToHexTextBox(); @@ -193,7 +99,6 @@ namespace Avalonia.Controls } base.OnApplyTemplate(e); - ValidateSelection(); } /// @@ -260,27 +165,6 @@ namespace Avalonia.Controls // (Color will be coerced automatically if HsvColor changes) SetCurrentValue(HsvColorProperty, OnCoerceHsvColor(HsvColor)); } - else if (change.Property == IsColorComponentsVisibleProperty || - change.Property == IsColorPaletteVisibleProperty || - change.Property == IsColorSpectrumVisibleProperty) - { - // When the property changed notification is received here the visibility - // of individual tab items has not yet been updated through the bindings. - // Therefore, the validation is delayed until after bindings update. - Dispatcher.UIThread.Post(() => - { - ValidateSelection(); - }, DispatcherPriority.Background); - } - else if (change.Property == SelectedIndexProperty) - { - // Again, it is necessary to wait for the SelectedIndex value to - // be applied to the TabControl through binding before validation occurs. - Dispatcher.UIThread.Post(() => - { - ValidateSelection(); - }, DispatcherPriority.Background); - } base.OnPropertyChanged(change); } diff --git a/src/Avalonia.Controls/Page/CarouselPage.cs b/src/Avalonia.Controls/Page/CarouselPage.cs index 1cf67c603f..67a7651236 100644 --- a/src/Avalonia.Controls/Page/CarouselPage.cs +++ b/src/Avalonia.Controls/Page/CarouselPage.cs @@ -129,6 +129,8 @@ namespace Avalonia.Controls { base.OnApplyTemplate(e); + var requestedIndex = SelectedIndex; + if (_carousel != null) { _carousel.SelectionChanged -= OnCarouselSelectionChanged; @@ -139,7 +141,6 @@ namespace Avalonia.Controls if (_carousel != null) { - _carousel.SelectionChanged += OnCarouselSelectionChanged; _carousel.ContainerPrepared += OnCarouselContainerPrepared; _carousel.PageTransition = PageTransition; _carousel.ItemsPanel = ItemsPanel; @@ -147,11 +148,13 @@ namespace Avalonia.Controls _carousel.IsSwipeEnabled = IsGestureEnabled; _carousel.ItemsSource = (IEnumerable?)ItemsSource ?? Pages; - if (SelectedIndex >= 0) + if (requestedIndex >= 0) { - _carousel.SelectedIndex = SelectedIndex; + _carousel.SelectedIndex = requestedIndex; } + _carousel.SelectionChanged += OnCarouselSelectionChanged; + UpdateActivePage(); } } diff --git a/src/Avalonia.Controls/Page/TabbedPage.cs b/src/Avalonia.Controls/Page/TabbedPage.cs index 81e30819b7..8b0a8f6609 100644 --- a/src/Avalonia.Controls/Page/TabbedPage.cs +++ b/src/Avalonia.Controls/Page/TabbedPage.cs @@ -193,6 +193,8 @@ namespace Avalonia.Controls { base.OnApplyTemplate(e); + var requestedIndex = SelectedIndex; + if (_tabControl != null) { _tabControl.SelectionChanged -= TabControl_SelectionChanged; @@ -206,13 +208,14 @@ namespace Avalonia.Controls if (_tabControl != null) { - _tabControl.SelectionChanged += TabControl_SelectionChanged; _tabControl.ContainerPrepared += OnContainerPrepared; _tabControl.ContainerClearing += OnContainerClearing; _tabControl.ItemsSource = (IEnumerable?)ItemsSource ?? Pages; - if (SelectedIndex >= 0) - _tabControl.SelectedIndex = SelectedIndex; + if (requestedIndex >= 0) + _tabControl.SelectedIndex = requestedIndex; + + _tabControl.SelectionChanged += TabControl_SelectionChanged; if (PageTransition != null) _tabControl.PageTransition = PageTransition; diff --git a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs index 36a14f5b04..8272d1e8b2 100644 --- a/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs +++ b/src/Avalonia.Controls/Primitives/SelectingItemsControl.cs @@ -5,7 +5,6 @@ using System.Collections.Specialized; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; -using Avalonia.Controls.Platform; using Avalonia.Controls.Selection; using Avalonia.Controls.Utils; using Avalonia.Data; @@ -153,6 +152,7 @@ namespace Avalonia.Controls.Primitives private bool _hasScrolledToSelectedItem; private BindingEvaluator? _selectedValueBindingEvaluator; private bool _isSelectionChangeActive; + private int _unverifiedSelectedIndex = -1; public SelectingItemsControl() { @@ -466,7 +466,7 @@ namespace Avalonia.Controls.Primitives if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) { - SelectedIndex = 0; + SelectedIndex = ChooseFirstVisibleAndEnabledIndex(); } } @@ -491,16 +491,13 @@ namespace Avalonia.Controls.Primitives { base.OnApplyTemplate(e); - void ExecuteScrollWhenLayoutUpdated(object? sender, EventArgs e) - { - LayoutUpdated -= ExecuteScrollWhenLayoutUpdated; - - AutoScrollToSelectedItemIfNecessary(GetAnchorIndex()); - } - if (AutoScrollToSelectedItem) { - LayoutUpdated += ExecuteScrollWhenLayoutUpdated; + Dispatcher.UIThread.Post(static state => + { + var control = (SelectingItemsControl)state!; + control.AutoScrollToSelectedItemIfNecessary(control.GetAnchorIndex()); + }, this); } } @@ -547,6 +544,18 @@ namespace Avalonia.Controls.Primitives if (Selection.AnchorIndex == index) KeyboardNavigation.SetTabOnceActiveElement(this, container); + + if (AlwaysSelected) + { + if (SelectedIndex == -1 && container is { IsVisible: true, IsEnabled: true }) + { + SelectedIndex = index; + } + else if (index == SelectedIndex && (!container.IsVisible || !container.IsEnabled)) + { + MoveSelectionToFirstVisibleAndEnabledItem(); + } + } } /// @@ -629,6 +638,13 @@ namespace Avalonia.Controls.Primitives { AutoScrollToSelectedItemIfNecessary(GetAnchorIndex()); } + else if (change.Property == IsVisibleProperty) + { + if (change.GetNewValue()) + { + AutoScrollToSelectedItemIfNecessary(GetAnchorIndex()); + } + } else if (change.Property == SelectionModeProperty && _selection is object) { var newValue = change.GetNewValue(); @@ -1036,6 +1052,8 @@ namespace Avalonia.Controls.Primitives _selectedItemsBeforeReset = null; RaiseSelectionChanged(e.DeselectedItems, e.SelectedItems); + + VerifySelectedIndex(); } /// @@ -1055,7 +1073,7 @@ namespace Avalonia.Controls.Primitives if (AlwaysSelected && ItemsView.Count > 0) { - SelectedIndex = 0; + SelectedIndex = ChooseFirstVisibleAndEnabledIndex(); } } @@ -1183,20 +1201,53 @@ namespace Avalonia.Controls.Primitives } } + private int? _pendingAutoScrollAnchorIndex; + private void AutoScrollToSelectedItemIfNecessary(int anchorIndex) { - if (AutoScrollToSelectedItem && - !_hasScrolledToSelectedItem && - Presenter is object && - anchorIndex >= 0 && - IsAttachedToVisualTree) + if (!(AutoScrollToSelectedItem && !_hasScrolledToSelectedItem && Presenter != null && anchorIndex >= 0 && IsAttachedToVisualTree)) { - Dispatcher.UIThread.Post(state => - { - ScrollIntoView((int)state!); - _hasScrolledToSelectedItem = true; - }, anchorIndex); + ClearPendingAutoScroll(); + return; + } + + if (!IsEffectivelyVisible) + { + // Defer scroll until the control becomes effectively visible. + _pendingAutoScrollAnchorIndex = anchorIndex; + IsEffectivelyVisibleChanged -= OnIsEffectivelyVisibleChangedForAutoScroll; + IsEffectivelyVisibleChanged += OnIsEffectivelyVisibleChangedForAutoScroll; + return; } + + ClearPendingAutoScroll(); + ScrollToAnchorIndex(anchorIndex); + } + + private void OnIsEffectivelyVisibleChangedForAutoScroll(object? sender, EventArgs e) + { + if (!IsEffectivelyVisible || _pendingAutoScrollAnchorIndex is not { } anchorIndex) + { + return; + } + + ClearPendingAutoScroll(); + ScrollToAnchorIndex(anchorIndex); + } + + private void ClearPendingAutoScroll() + { + _pendingAutoScrollAnchorIndex = null; + IsEffectivelyVisibleChanged -= OnIsEffectivelyVisibleChangedForAutoScroll; + } + + private void ScrollToAnchorIndex(int anchorIndex) + { + Dispatcher.UIThread.Post(state => + { + ScrollIntoView((int)state!); + _hasScrolledToSelectedItem = true; + }, anchorIndex); } /// @@ -1243,6 +1294,122 @@ namespace Avalonia.Controls.Primitives } } + /// + /// Finds the first visible and enabled index in the ItemsSource. + /// + /// + /// On return, true if the returned index was checked against a realized container or against + /// an item which is its own container; false if no container was realized for it and the item + /// had to be accepted without knowing its visibility and enabled state. + /// + /// the index of the first visible and enabled item, or -1 if none found + private int GetFirstVisibleAndEnabledIndex(out bool verified) + { + verified = true; + + var count = ItemCount; + if (count == 0) + return -1; + + for (var i = 0; i < count; i++) + { + var container = ContainerFromIndex(i); + if (container is not null) + { + if (container is { IsVisible: true, IsEnabled: true }) + return i; + + continue; + } + + var item = ItemsView[i]; + if (item is Visual v) + { + if (v.IsVisible && (v is not Control c || c.IsEnabled)) + return i; + } + else if (item is not null) + { + // The container isn't realized so its visibility/enabled state is unknown. + verified = false; + return i; + } + } + + return -1; + } + + private int ChooseFirstVisibleAndEnabledIndex() + { + var index = GetFirstVisibleAndEnabledIndex(out var verified); + _unverifiedSelectedIndex = verified ? -1 : index; + return index; + } + + /// + /// Re-checks a selection which had to make before the container + /// of the selected item was realized. + /// + private void VerifySelectedIndex() + { + if (_unverifiedSelectedIndex == -1) + return; + + if (!AlwaysSelected) + { + _unverifiedSelectedIndex = -1; + return; + } + + // The selection has moved elsewhere in the meantime. + if (_unverifiedSelectedIndex != SelectedIndex) + { + _unverifiedSelectedIndex = -1; + return; + } + + // Still not realized: ContainerForItemPreparedOverride will check it. + if (ContainerFromIndex(_unverifiedSelectedIndex) is not { } container) + return; + + _unverifiedSelectedIndex = -1; + + if (!container.IsVisible || !container.IsEnabled) + { + MoveSelectionToFirstVisibleAndEnabledItem(); + } + } + + /// + /// Moves selection to the first visible and enabled item, considering only realized (prepared) + /// containers. If no such container exists, selection is cleared to -1 so that the next + /// valid container prepared in can pick it up. + /// + private void MoveSelectionToFirstVisibleAndEnabledItem() + { + var index = GetFirstRealizedVisibleAndEnabledIndex(); + if (index != SelectedIndex) + { + SelectedIndex = index; + } + } + + /// + /// Finds the first realized (prepared) container that is both visible and enabled. + /// + /// The index of the first qualifying realized container, or -1 if none found. + private int GetFirstRealizedVisibleAndEnabledIndex() + { + var count = ItemCount; + for (var i = 0; i < count; i++) + { + var container = ContainerFromIndex(i); + if (container is { IsVisible: true, IsEnabled: true }) + return i; + } + return -1; + } + private void UpdateContainerSelection() { if (Presenter?.Panel is { } panel) @@ -1291,7 +1458,7 @@ namespace Avalonia.Controls.Primitives if (_updateState is null && AlwaysSelected && model.Count == 0) { - model.SelectedIndex = 0; + model.SelectedIndex = ChooseFirstVisibleAndEnabledIndex(); } UpdateContainerSelection(); @@ -1398,7 +1565,7 @@ namespace Avalonia.Controls.Primitives if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0) { - SelectedIndex = 0; + SelectedIndex = ChooseFirstVisibleAndEnabledIndex(); } } } diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs index 9fc6c93a5f..6c982417f1 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs @@ -2035,6 +2035,7 @@ namespace Avalonia.Controls.UnitTests.Primitives // https://github.com/xunit/xunit/issues/2222 => ThreadRunHelper.RunOnDedicatedThread(() => { + using var _ = UnitTestApplication.Start(); var target = new TestSelector { Template = Template(), diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs index 0b58a5af43..c7863a92fa 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs @@ -1,9 +1,12 @@ +using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; +using System.Linq; using Avalonia.Collections; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; +using Avalonia.Threading; using Avalonia.UnitTests; using Xunit; @@ -118,6 +121,235 @@ namespace Avalonia.Controls.UnitTests.Primitives Assert.Equal("bar", target.SelectedItem); } + [Fact] + public void First_Visible_Item_Should_Be_Selected_When_First_Container_Is_Hidden() + { + // Uses own-container items (Controls) so that IsVisible can be set before preparation. + var target = new TestSelector + { + ItemsSource = new object[] + { + new ListBoxItem { Content = "hidden", IsVisible = false }, + new ListBoxItem { Content = "visible" }, + }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter!.ApplyTemplate(); + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void First_Enabled_Item_Should_Be_Selected_When_First_Container_Is_Disabled() + { + var target = new TestSelector + { + ItemsSource = new object[] + { + new ListBoxItem { Content = "disabled", IsEnabled = false }, + new ListBoxItem { Content = "enabled" }, + }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter!.ApplyTemplate(); + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void First_Visible_Item_Should_Be_Selected_When_Container_Becomes_Hidden_During_Preparation() + { + // Regression test for https://github.com/AvaloniaUI/Avalonia/pull/20798 + // Simulates a MVVM scenario where container visibility is set by a binding applied + // during PrepareContainerForItemOverride (e.g. an ItemContainerTheme). Verifies that + // selection lands on the first truly visible container rather than an unrealized item. + var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 2) + { + ItemsSource = new[] { "item-0", "item-1", "item-2" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter!.ApplyTemplate(); + + Assert.Equal(2, target.SelectedIndex); + } + + [Fact] + public void Selection_Should_Be_Cleared_When_All_Containers_Are_Hidden_During_Preparation() + { + // When all containers are made invisible during preparation (MVVM binding scenario), + // SelectedIndex must be -1 rather than the last container's index. Previously the + // selection would cascade to the last unrealized item and land on an invisible one. + var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 3) + { + ItemsSource = new[] { "item-0", "item-1", "item-2" }, + Template = Template(), + }; + + target.ApplyTemplate(); + target.Presenter!.ApplyTemplate(); + + Assert.Equal(-1, target.SelectedIndex); + } + + [Fact] + public void First_Visible_Item_Should_Be_Selected_When_Items_Are_Added_To_A_Laid_Out_Control() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var items = new AvaloniaList(); + var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 1); + InitWithBeginEndInit(target, items); + + items.Add("item-0"); + items.Add("item-1"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("item-1", target.SelectedItem); + } + + [Fact] + public void Selection_Should_Be_Cleared_When_A_Hidden_Item_Is_Added_To_A_Laid_Out_Control() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var items = new AvaloniaList(); + var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 1); + InitWithBeginEndInit(target, items); + + items.Add("item-0"); + + Assert.Equal(-1, target.SelectedIndex); + Assert.Null(target.SelectedItem); + } + + [Fact] + public void First_Enabled_Item_Should_Be_Selected_When_Items_Are_Added_To_A_Laid_Out_Control() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var items = new AvaloniaList(); + var target = new AlwaysSelectedTestSelectorDisablingFirstContainers(disabledCount: 1); + InitWithBeginEndInit(target, items); + + items.Add("item-0"); + items.Add("item-1"); + + Assert.Equal(1, target.SelectedIndex); + Assert.Equal("item-1", target.SelectedItem); + } + + [Fact] + public void Selection_Should_Settle_On_The_Visible_Item_When_Items_Are_Added_To_A_Laid_Out_Control() + { + using var app = UnitTestApplication.Start(TestServices.MockPlatformRenderInterface); + + var items = new AvaloniaList(); + var target = new AlwaysSelectedTestSelectorHidingFirstContainers(hiddenCount: 1); + InitWithBeginEndInit(target, items); + + var selectionChanges = new List(); + target.SelectionChanged += (_, _) => selectionChanges.Add(target.SelectedItem); + + items.Add("item-0"); + items.Add("item-1"); + + Assert.Equal("item-1", selectionChanges.Last()); + Assert.Equal("item-1", target.SelectedItem); + } + + [Fact] + public void AutoScrollToSelectedItem_Should_Work_When_Becoming_Visible() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var items = Enumerable.Range(0, 100).Select(i => $"Item {i}").ToList(); + + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + ItemsSource = items, + ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), + Height = 100, + ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), + AutoScrollToSelectedItem = true, + IsVisible = false + }; + + target.Width = target.Height = 100; + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + + // Select item 50 + target.SelectedIndex = 50; + + // Make visible + target.IsVisible = true; + target.UpdateLayout(); + + // Wait for dispatcher + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + target.UpdateLayout(); + + var scrollViewer = (ScrollViewer)target.VisualChildren[0]; + var offset = scrollViewer.Offset.Y; + + // Item 50 is at 50 * 50 = 2500. + // ListBox height is 100, so it should be visible if offset is between 2400 and 2500. + Assert.InRange(offset, 2400, 2500); + } + } + + [Fact] + public void AutoScrollToSelectedItem_Should_Work_When_Ancestor_Becomes_Visible() + { + using (UnitTestApplication.Start(TestServices.MockPlatformRenderInterface)) + { + var items = Enumerable.Range(0, 100).Select(i => $"Item {i}").ToList(); + + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + ItemsSource = items, + ItemTemplate = new FuncDataTemplate((_, _) => new TextBlock { Height = 50 }), + Height = 100, + ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel { CacheLength = 0 }), + AutoScrollToSelectedItem = true, + }; + + target.Width = target.Height = 100; + + var host = new StackPanel + { + IsVisible = false, + Children = + { + target, + }, + }; + + var root = new TestRoot(host); + root.LayoutManager.ExecuteInitialLayoutPass(); + + target.SelectedIndex = 50; + Assert.False(target.IsEffectivelyVisible); + + host.IsVisible = true; + root.LayoutManager.ExecuteLayoutPass(); + Dispatcher.UIThread.RunJobs(null, TestContext.Current.CancellationToken); + root.LayoutManager.ExecuteLayoutPass(); + + var scrollViewer = (ScrollViewer)target.VisualChildren[0]; + var offset = scrollViewer.Offset.Y; + Assert.InRange(offset, 2400, 2500); + } + } + private static FuncControlTemplate Template() { return new FuncControlTemplate((control, scope) => @@ -128,6 +360,21 @@ namespace Avalonia.Controls.UnitTests.Primitives }.RegisterInNameScope(scope)); } + /// + /// Initializes and lays out the way it's done in XAML. + /// This is important for some tests, as the selection model isn't committed yet while doing so. + /// + private static void InitWithBeginEndInit(SelectingItemsControl target, IEnumerable items) + { + target.BeginInit(); + target.Template = Template(); + target.ItemsSource = items; + target.EndInit(); + + var root = new TestRoot(target); + root.LayoutManager.ExecuteInitialLayoutPass(); + } + private class TestSelector : SelectingItemsControl { static TestSelector() @@ -148,5 +395,69 @@ namespace Avalonia.Controls.UnitTests.Primitives new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } } + + /// + /// A selector with AlwaysSelected that hides the first N containers during preparation. + /// This simulates a MVVM scenario where an ItemContainerTheme binding sets IsVisible=false on some containers. + /// + private class AlwaysSelectedTestSelectorHidingFirstContainers(int hiddenCount) : SelectingItemsControl + { + static AlwaysSelectedTestSelectorHidingFirstContainers() + { + SelectionModeProperty.OverrideDefaultValue(SelectionMode.AlwaysSelected); + } + + protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + base.PrepareContainerForItemOverride(container, item, index); + if (index < hiddenCount) + container.IsVisible = false; + } + } + + /// + /// A selector with AlwaysSelected that disables the first N containers during preparation. + /// This simulates a MVVM scenario where an ItemContainerTheme binding sets IsEnabled=false on some containers. + /// + private class AlwaysSelectedTestSelectorDisablingFirstContainers(int disabledCount) : SelectingItemsControl + { + static AlwaysSelectedTestSelectorDisablingFirstContainers() + { + SelectionModeProperty.OverrideDefaultValue(SelectionMode.AlwaysSelected); + } + + protected internal override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + base.PrepareContainerForItemOverride(container, item, index); + if (index < disabledCount) + container.IsEnabled = false; + } + } + + private Control CreateListBoxTemplate(TemplatedControl parent, INameScope scope) + { + return new ScrollViewer + { + Name = "PART_ScrollViewer", + Template = new FuncControlTemplate(CreateScrollViewerTemplate), + Content = new ItemsPresenter + { + Name = "PART_ItemsPresenter", + [~ItemsPresenter.ItemsPanelProperty] = + ((ListBox)parent).GetObservable(ItemsControl.ItemsPanelProperty).ToBinding(), + }.RegisterInNameScope(scope) + }.RegisterInNameScope(scope); + } + private Control CreateScrollViewerTemplate(TemplatedControl parent, INameScope scope) + { + return new ScrollContentPresenter + { + Name = "PART_ContentPresenter", + [~ContentPresenter.ContentProperty] = + parent.GetObservable(ContentControl.ContentProperty).ToBinding(), + }.RegisterInNameScope(scope); + } + + } } diff --git a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs index ea14eb5b2f..e10ec3fb64 100644 --- a/tests/Avalonia.Controls.UnitTests/TabControlTests.cs +++ b/tests/Avalonia.Controls.UnitTests/TabControlTests.cs @@ -1838,5 +1838,42 @@ namespace Avalonia.Controls.UnitTests // TabItem without a local value gets the TabControl template Assert.Same(tabControlTemplate, tabItems[1].IndicatorTemplate); } + + [Fact] + public void Only_First_Visible_And_Enabled_Tab_Should_Be_Selected_By_Default() + { + var target = new TabControl + { + Template = TabControlTemplate(), + Items = + { + new TabItem { Header = "hidden", IsVisible = false }, + new TabItem { Header = "visible" }, + } + }; + + ApplyTemplate(target); + + Assert.Equal(1, target.SelectedIndex); + } + + [Fact] + public void Only_First_Enabled_Tab_Should_Be_Selected_By_Default() + { + var target = new TabControl + { + Template = TabControlTemplate(), + Items = + { + new TabItem { Header = "disabled", IsEnabled = false }, + new TabItem { Header = "enabled" }, + } + }; + + ApplyTemplate(target); + + Assert.Equal(1, target.SelectedIndex); + } + } }