Repository: AvaloniaUI/Avalonia

Issue:
[Bug] VirtualizingStackPanel (horizontal) issue in ListBox when scrolling using arrow keys [11.0.11 -> now][with repro]
### Describe the bug

I noticed a problem in ListBox when selecting items using arrow keys. 

My ListBox 
I am pressing Right arrow key.
As you can see in the video, once the last items is selected, the listbox scroll by 1 page instead of 1 item.
Moreover, it do not end at the last item, but it wrap to the begining.
In addition, the scroll bar do not reflect the position in the ScrollView.

https://github.com/user-attachments/assets/7466543c-eb0b-4488-951f-3c346176d0e4

And if I use the left arrow, instead of right, it scroll by 1 item at the time, with no wrapping... 

I checked in 11.0.10 and it work perfectly
The video is captured using 11.0.13 => bug
11.0.11 => bug
I tested latest version 11.1.3 and 11.2 beta and same problem.

### To Reproduce

[AvaloniaListBoxBug.zip](https://github.com/user-attachments/files/17134242/AvaloniaListBoxBug.zip)




### Expected behavior

Working perfectly in 11.0.10

https://github.com/user-attachments/assets/1c0488b7-c3b5-46a0-93ca-2db188fa6fce



### Avalonia version

11.0.11

### OS

Windows

### Additional context

My ListBox is horizontal

```
<ListBox ScrollViewer.HorizontalScrollBarVisibility="Visible" ScrollViewer.VerticalScrollBarVisibility="Disabled"
    Height="150"
    DockPanel.Dock="Bottom"
    ItemsSource="{Binding Items}">
    <ListBox.ItemsPanel>
        <ItemsPanelTemplate>
            <VirtualizingStackPanel Orientation="Horizontal" />
        </ItemsPanelTemplate>
    </ListBox.ItemsPanel>
    <ListBox.ItemContainerTheme>
        <ControlTheme BasedOn="{StaticResource {x:Type ListBoxItem}}" TargetType="ListBoxItem">
            <Setter Property="Margin" Value="2" />
            <Setter Property="Width" Value="120" />
            <Setter Property="Height" Value="120" />
        </ControlTheme>
    </ListBox.ItemContainerTheme>
    <ListBox.ItemTemplate>
        <DataTemplate>
            <TextBlock Text="{Binding}" />
        </DataTemplate>
    </ListBox.ItemTemplate>
</ListBox>
```

It look like the issue is in VirtualizingStackPanel when Orientation is Horizontal, not ListBox.

Changes from 11.0.10 to 11.0.11
![image](https://github.com/user-attachments/assets/d5424615-1e26-4626-97f6-43a793746bd6)

EDIT: no problem with StackPanel instead of VirtualizingStackPanel
no problem with vertical direction



Hints:
@grokys 

May be the bug is here? Missing an Orientation check?
![image](https://github.com/user-attachments/assets/475c60ef-06bd-4b83-925e-ca4f6c57e485)

Yeah could be a missing orientation check. Should be easy to add. Can you file a PR for it?

Patch:
diff --git a/src/Avalonia.Controls/VirtualizingStackPanel.cs b/src/Avalonia.Controls/VirtualizingStackPanel.cs
index 7934e0ccc6b..b3d61a9b643 100644
--- a/src/Avalonia.Controls/VirtualizingStackPanel.cs
+++ b/src/Avalonia.Controls/VirtualizingStackPanel.cs
@@ -167,7 +167,7 @@ protected override Size MeasureOverride(Size availableSize)
                 // We handle horizontal and vertical layouts here so X and Y are abstracted to:
                 // - Horizontal layouts: U = horizontal, V = vertical
                 // - Vertical layouts: U = vertical, V = horizontal
-                var viewport = CalculateMeasureViewport(items);
+                var viewport = CalculateMeasureViewport(orientation, items);
 
                 // If the viewport is disjunct then we can recycle everything.
                 if (viewport.viewportIsDisjunct)
@@ -465,15 +465,15 @@ protected internal override int IndexFromContainer(Control container)
             return _realizedElements?.Elements ?? Array.Empty<Control>();
         }
 
-        private MeasureViewport CalculateMeasureViewport(IReadOnlyList<object?> items)
+        private MeasureViewport CalculateMeasureViewport(Orientation orientation, IReadOnlyList<object?> items)
         {
             Debug.Assert(_realizedElements is not null);
 
             var viewport = _viewport;
 
             // Get the viewport in the orientation direction.
-            var viewportStart = Orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
-            var viewportEnd = Orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom;
+            var viewportStart = orientation == Orientation.Horizontal ? viewport.X : viewport.Y;
+            var viewportEnd = orientation == Orientation.Horizontal ? viewport.Right : viewport.Bottom;
 
             // Get or estimate the anchor element from which to start realization. If we are
             // scrolling to an element, use that as the anchor element. Otherwise, estimate the
@@ -484,7 +484,7 @@ private MeasureViewport CalculateMeasureViewport(IReadOnlyList<object?> items)
             if (_scrollToIndex >= 0 && _scrollToElement is not null)
             {
                 anchorIndex = _scrollToIndex;
-                anchorU = _scrollToElement.Bounds.Top;
+                anchorU = orientation == Orientation.Horizontal ? _scrollToElement.Bounds.Left : _scrollToElement.Bounds.Top;
             }
             else
             {


Test patch:
diff --git a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
index d1ee1662f05..ba7765dabbd 100644
--- a/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
+++ b/tests/Avalonia.Controls.UnitTests/VirtualizingStackPanelTests.cs
@@ -30,6 +30,13 @@ public class VirtualizingStackPanelTests : ScopedTestBase
                 [!Layoutable.HeightProperty] = new Binding("Height"),
             });
 
+        private static FuncDataTemplate<ItemWithWidth> CanvasWithWidthTemplate = new((_, _) =>
+            new Canvas
+            {
+                Height = 100,
+                [!Layoutable.WidthProperty] = new Binding("Width"),
+            });
+
         [Fact]
         public void Creates_Initial_Items()
         {
@@ -45,7 +52,7 @@ public void Creates_Initial_Items()
         public void Initializes_Initial_Control_Items()
         {
             using var app = App();
-            var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10});
+            var items = Enumerable.Range(0, 100).Select(x => new Button { Width = 25, Height = 10 });
             var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: null);
 
             Assert.Equal(1000, scroll.Extent.Height);
@@ -492,7 +499,7 @@ public void Resetting_Collection_To_Have_Less_Than_A_Page_Of_Items_When_Scrolled
         public void NthChild_Selector_Works()
         {
             using var app = App();
-            
+
             var style = new Style(x => x.OfType<ContentPresenter>().NthChild(5, 0))
             {
                 Setters = { new Setter(ListBoxItem.BackgroundProperty, Brushes.Red) },
@@ -500,9 +507,9 @@ public void NthChild_Selector_Works()
 
             var (target, _, _) = CreateTarget(styles: new[] { style });
             var realized = target.GetRealizedContainers()!.Cast<ContentPresenter>().ToList();
-            
+
             Assert.Equal(10, realized.Count);
-            
+
             for (var i = 0; i < 10; ++i)
             {
                 var container = realized[i];
@@ -537,7 +544,7 @@ public void NthChild_Selector_Works_For_ItemTemplate_Children()
                 var expectedBackground = (i == 4 || i == 9) ? Brushes.Red : null;
 
                 Assert.Equal(i, index);
-                Assert.Equal(expectedBackground, ((Canvas) container.Child!).Background);
+                Assert.Equal(expectedBackground, ((Canvas)container.Child!).Background);
             }
         }
 
@@ -590,7 +597,7 @@ public void NthLastChild_Selector_Works_For_ItemTemplate_Children()
                 var expectedBackground = (i == 0 || i == 5) ? Brushes.Red : null;
 
                 Assert.Equal(i, index);
-                Assert.Equal(expectedBackground, ((Canvas) container.Child!).Background);
+                Assert.Equal(expectedBackground, ((Canvas)container.Child!).Background);
             }
         }
 
@@ -762,7 +769,7 @@ public void Scrolling_Down_With_Larger_Element_Does_Not_Cause_Jump_And_Arrives_A
                 Layout(target);
 
                 Assert.True(
-                    target.FirstRealizedIndex >= index, 
+                    target.FirstRealizedIndex >= index,
                     $"{target.FirstRealizedIndex} is not greater or equal to {index}");
 
                 if (scroll.Offset.Y + scroll.Viewport.Height == scroll.Extent.Height)
@@ -801,7 +808,7 @@ public void Scrolling_Up_To_Larger_Element_Does_Not_Cause_Jump()
                 index = target.FirstRealizedIndex;
             }
         }
-        
+
         [Fact]
         public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump()
         {
@@ -828,12 +835,12 @@ public void Scrolling_Up_To_Smaller_Element_Does_Not_Cause_Jump()
                 Layout(target);
 
                 Assert.True(
-                    target.FirstRealizedIndex <= index, 
+                    target.FirstRealizedIndex <= index,
                     $"{target.FirstRealizedIndex} is not less than {index}");
                 Assert.True(
                     index - target.FirstRealizedIndex <= 1,
                     $"FirstIndex changed from {index} to {target.FirstRealizedIndex}");
-                
+
                 index = target.FirstRealizedIndex;
             }
         }
@@ -846,7 +853,7 @@ public void Does_Not_Throw_When_Estimating_Viewport_With_Ancestor_Margin()
             var (_, _, itemsControl) = CreateUnrootedTarget<ItemsControl>();
             var container = new Decorator { Margin = new Thickness(100) };
             var root = new TestRoot(true, container);
-            
+
             root.LayoutManager.ExecuteInitialLayoutPass();
 
             container.Child = itemsControl;
@@ -889,7 +896,7 @@ public void Supports_Null_Recycle_Key_When_Clearing_Items()
 
             Assert.Null(firstItem.Parent);
             Assert.Null(firstItem.VisualParent);
-            Assert.Empty(itemsControl.ItemsPanelRoot!.Children);            
+            Assert.Empty(itemsControl.ItemsPanelRoot!.Children);
         }
 
         [Fact]
@@ -1038,7 +1045,7 @@ public void Inserting_Item_Before_Viewport_Preserves_FirstRealizedIndex()
         public void Can_Bind_Item_IsVisible()
         {
             using var app = App();
-            var style = CreateIsVisibleBindingStyle();            
+            var style = CreateIsVisibleBindingStyle();
             var items = Enumerable.Range(0, 100).Select(x => new ItemWithIsVisible(x)).ToList();
             var (target, scroll, itemsControl) = CreateTarget(items: items, styles: new[] { style });
             var container = target.ContainerFromIndex(2)!;
@@ -1192,6 +1199,58 @@ public void ScrollIntoView_Correctly_Scrolls_Down_To_A_Page_Of_Larger_Items()
             AssertRealizedItems(target, itemsControl, 15, 5);
         }
 
+        [Fact]
+        public void ScrollIntoView_Correctly_Scrolls_Right_To_A_Page_Of_Smaller_Items()
+        {
+            using var app = App();
+
+            // First 10 items have width of 20, next 10 have width of 10.
+            var items = Enumerable.Range(0, 20).Select(x => new ItemWithWidth(x, ((29 - x) / 10) * 10));
+            var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithWidthTemplate, orientation: Orientation.Horizontal);
+
+            // Scroll the last item into view.
+            target.ScrollIntoView(19);
+
+            // At the time of the scroll, the average item width is 20, so the requested item
+            // should be placed at 380 (19 * 20) which therefore results in an extent of 390 to
+            // accommodate the item width of 10. This is obviously not a perfect answer, but
+            // it's the best we can do without knowing the actual item widths.
+            var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(19));
+            Assert.Equal(new Rect(380, 0, 10, 100), container.Bounds);
+            Assert.Equal(new Size(100, 100), scroll.Viewport);
+            Assert.Equal(new Size(390, 100), scroll.Extent);
+            Assert.Equal(new Vector(290, 0), scroll.Offset);
+
+            // Items 10-19 should be visible.
+            AssertRealizedItems(target, itemsControl, 10, 10);
+        }
+
+        [Fact]
+        public void ScrollIntoView_Correctly_Scrolls_Right_To_A_Page_Of_Larger_Items()
+        {
+            using var app = App();
+
+            // First 10 items have width of 10, next 10 have width of 20.
+            var items = Enumerable.Range(0, 20).Select(x => new ItemWithWidth(x, ((x / 10) + 1) * 10));
+            var (target, scroll, itemsControl) = CreateTarget(items: items, itemTemplate: CanvasWithWidthTemplate, orientation: Orientation.Horizontal);
+
+            // Scroll the last item into view.
+            target.ScrollIntoView(19);
+
+            // At the time of the scroll, the average item width is 10, so the requested item
+            // should be placed at 190 (19 * 10) which therefore results in an extent of 210 to
+            // accommodate the item width of 20. This is obviously not a perfect answer, but
+            // it's the best we can do without knowing the actual item widths.
+            var container = Assert.IsType<ContentPresenter>(target.ContainerFromIndex(19));
+            Assert.Equal(new Rect(190, 0, 20, 100), container.Bounds);
+            Assert.Equal(new Size(100, 100), scroll.Viewport);
+            Assert.Equal(new Size(210, 100), scroll.Extent);
+            Assert.Equal(new Vector(110, 0), scroll.Offset);
+
+            // Items 15-19 should be visible.
+            AssertRealizedItems(target, itemsControl, 15, 5);
+        }
+
         [Fact]
         public void Extent_And_Offset_Should_Be_Updated_When_Containers_Resize()
         {
@@ -1348,21 +1407,24 @@ private static void AssertRealizedControlItems<TContainer>(
         private static (VirtualizingStackPanel, ScrollViewer, ItemsControl) CreateTarget(
             IEnumerable<object>? items = null,
             Optional<IDataTemplate?> itemTemplate = default,
-            IEnumerable<Style>? styles = null)
+            IEnumerable<Style>? styles = null,
+            Orientation orientation = Orientation.Vertical)
         {
             return CreateTarget<ItemsControl>(
-                items: items, 
-                itemTemplate: itemTemplate, 
-                styles: styles);
+                items: items,
+                itemTemplate: itemTemplate,
+                styles: styles,
+                orientation: orientation);
         }
 
         private static (VirtualizingStackPanel, ScrollViewer, T) CreateTarget<T>(
             IEnumerable<object>? items = null,
             Optional<IDataTemplate?> itemTemplate = default,
-            IEnumerable<Style>? styles = null)
+            IEnumerable<Style>? styles = null,
+            Orientation orientation = Orientation.Vertical)
                 where T : ItemsControl, new()
         {
-            var (target, scroll, itemsControl) = CreateUnrootedTarget<T>(items, itemTemplate);
+            var (target, scroll, itemsControl) = CreateUnrootedTarget<T>(items, itemTemplate, orientation);
             var root = CreateRoot(itemsControl, styles: styles);
 
             root.LayoutManager.ExecuteInitialLayoutPass();
@@ -1372,10 +1434,14 @@ private static (VirtualizingStackPanel, ScrollViewer, T) CreateTarget<T>(
 
         private static (VirtualizingStackPanel, ScrollViewer, T) CreateUnrootedTarget<T>(
             IEnumerable<object>? items = null,
-            Optional<IDataTemplate?> itemTemplate = default)
+            Optional<IDataTemplate?> itemTemplate = default,
+            Orientation orientation = Orientation.Vertical)
                 where T : ItemsControl, new()
         {
-            var target = new VirtualizingStackPanel();
+            var target = new VirtualizingStackPanel
+            {
+                Orientation = orientation,
+            };
 
             items ??= new ObservableCollection<string>(Enumerable.Range(0, 100).Select(x => $"Item {x}"));
 
@@ -1388,9 +1454,16 @@ private static (VirtualizingStackPanel, ScrollViewer, T) CreateUnrootedTarget<T>
             {
                 Name = "PART_ScrollViewer",
                 Content = presenter,
-                Template = ScrollViewerTemplate(),
             };
 
+            if (orientation == Orientation.Horizontal)
+            {
+                scroll.HorizontalScrollBarVisibility = ScrollBarVisibility.Auto;
+                scroll.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled;
+            }
+
+            scroll.Template = ScrollViewerTemplate();
+
             var itemsControl = new T
             {
                 ItemsSource = items,
@@ -1403,7 +1476,7 @@ private static (VirtualizingStackPanel, ScrollViewer, T) CreateUnrootedTarget<T>
         }
 
         private static TestRoot CreateRoot(
-            Control? child, 
+            Control? child,
             Size? clientSize = null,
             IEnumerable<Style>? styles = null)
         {
@@ -1469,16 +1542,35 @@ public ItemWithHeight(int index, double height = 10)
                 Caption = $"Item {index}";
                 Height = height;
             }
-            
+
             public string Caption { get; set; }
-            
-            public double Height 
+
+            public double Height
             {
                 get => _height;
                 set => SetField(ref _height, value);
             }
         }
 
+        private class ItemWithWidth : NotifyingBase
+        {
+            private double _width;
+
+            public ItemWithWidth(int index, double width = 10)
+            {
+                Caption = $"Item {index}";
+                Width = width;
+            }
+
+            public string Caption { get; set; }
+
+            public double Width
+            {
+                get => _width;
+                set => SetField(ref _width, value);
+            }
+        }
+
         private class ItemWithIsVisible : NotifyingBase
         {
             private bool _isVisible = true;