diff --git a/docfx/articles/dock-controls-reference.md b/docfx/articles/dock-controls-reference.md index 884351325..9283c56c4 100644 --- a/docfx/articles/dock-controls-reference.md +++ b/docfx/articles/dock-controls-reference.md @@ -83,6 +83,7 @@ Unless noted otherwise, the properties listed are Avalonia styled properties and | `IsActive` | `bool` | Active tab strip state (drives `:active`). | | `EnableWindowDrag` | `bool` | Allows dragging the host window by the tab strip. | | `Orientation` | `Orientation` | Tab strip orientation. | +| `MouseWheelScrollOrientation` | `Orientation` | Mouse-wheel scroll axis for tab overflow (`Horizontal` by default). | | `CreateButtonTheme` | `ControlTheme?` | Theme for the create document button. | ### DocumentTabStripItem @@ -97,6 +98,7 @@ Unless noted otherwise, the properties listed are Avalonia styled properties and | Property | Type | Description | | --- | --- | --- | | `CanCreateItem` | `bool` | `true` when the new-tool button is available. | +| `MouseWheelScrollOrientation` | `Orientation` | Mouse-wheel scroll axis for tab overflow (`Horizontal` by default). | ### ToolTabStripItem diff --git a/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs b/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs index 3427fb14d..37296fa0b 100644 --- a/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs +++ b/src/Dock.Avalonia/Controls/DocumentTabStrip.axaml.cs @@ -19,6 +19,8 @@ public class DocumentTabStrip : TabStrip { private HostWindow? _attachedWindow; private Control? _grip; + private ScrollViewer? _scrollViewer; + private IDisposable? _scrollViewerWheelSubscription; private WindowDragHelper? _windowDragHelper; /// @@ -45,6 +47,14 @@ public class DocumentTabStrip : TabStrip public static readonly StyledProperty OrientationProperty = AvaloniaProperty.Register(nameof(Orientation)); + /// + /// Defines the property. + /// + public static readonly StyledProperty MouseWheelScrollOrientationProperty = + AvaloniaProperty.Register( + nameof(MouseWheelScrollOrientation), + defaultValue: Orientation.Horizontal); + /// /// Define the property. /// @@ -96,6 +106,15 @@ public Orientation Orientation set => SetValue(OrientationProperty, value); } + /// + /// Gets or sets orientation used for mouse wheel scrolling in the tab strip. + /// + public Orientation MouseWheelScrollOrientation + { + get => GetValue(MouseWheelScrollOrientationProperty); + set => SetValue(MouseWheelScrollOrientationProperty, value); + } + /// protected override Type StyleKeyOverride => typeof(DocumentTabStrip); @@ -112,7 +131,13 @@ public DocumentTabStrip() protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); + + DetachScrollViewerWheel(); + _grip = e.NameScope.Find("PART_BorderFill"); + _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); + AttachScrollViewerWheel(); + AttachToWindow(); } @@ -121,6 +146,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); + AttachScrollViewerWheel(); AttachToWindow(); } @@ -128,6 +154,7 @@ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); + DetachScrollViewerWheel(); DetachFromWindow(); } @@ -169,6 +196,11 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang DetachFromWindow(); } } + + if (change.Property == MouseWheelScrollOrientationProperty) + { + AttachScrollViewerWheel(); + } } private void UpdatePseudoClassesCreate(bool canCreate) @@ -237,4 +269,16 @@ private void DetachFromWindow() _windowDragHelper = null; } } + + private void DetachScrollViewerWheel() + { + _scrollViewerWheelSubscription?.Dispose(); + _scrollViewerWheelSubscription = null; + } + + private void AttachScrollViewerWheel() + { + _scrollViewerWheelSubscription?.Dispose(); + _scrollViewerWheelSubscription = ScrollViewerMouseWheelHookHelper.Attach(_scrollViewer, MouseWheelScrollOrientation); + } } diff --git a/src/Dock.Avalonia/Controls/ToolTabStrip.axaml.cs b/src/Dock.Avalonia/Controls/ToolTabStrip.axaml.cs index 87825149b..56d4b8d65 100644 --- a/src/Dock.Avalonia/Controls/ToolTabStrip.axaml.cs +++ b/src/Dock.Avalonia/Controls/ToolTabStrip.axaml.cs @@ -7,8 +7,10 @@ using Avalonia.Controls.Metadata; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives; +using Avalonia.Layout; using Avalonia.Media; using Avalonia.Reactive; +using Dock.Avalonia.Internal; namespace Dock.Avalonia.Controls; @@ -23,6 +25,7 @@ public class ToolTabStrip : TabStrip private Border? _borderRightFill; private ItemsPresenter? _itemsPresenter; private ScrollViewer? _scrollViewer; + private IDisposable? _scrollViewerWheelSubscription; /// /// Defines the property. @@ -30,6 +33,14 @@ public class ToolTabStrip : TabStrip public static readonly StyledProperty CanCreateItemProperty = AvaloniaProperty.Register(nameof(CanCreateItem)); + /// + /// Defines the property. + /// + public static readonly StyledProperty MouseWheelScrollOrientationProperty = + AvaloniaProperty.Register( + nameof(MouseWheelScrollOrientation), + defaultValue: Orientation.Horizontal); + /// /// Gets or sets if tab strop dock can create new items. /// @@ -39,6 +50,15 @@ public bool CanCreateItem set => SetValue(CanCreateItemProperty, value); } + /// + /// Gets or sets orientation used for mouse wheel scrolling in the tab strip. + /// + public Orientation MouseWheelScrollOrientation + { + get => GetValue(MouseWheelScrollOrientationProperty); + set => SetValue(MouseWheelScrollOrientationProperty, value); + } + /// protected override Type StyleKeyOverride => typeof(ToolTabStrip); @@ -55,10 +75,13 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); + AttachScrollViewerWheel(null); + _borderLeftFill = e.NameScope.Find("PART_BorderLeftFill"); _borderRightFill = e.NameScope.Find("PART_BorderRightFill"); _itemsPresenter = e.NameScope.Find("PART_ItemsPresenter"); _scrollViewer = e.NameScope.Find("PART_ScrollViewer"); + AttachScrollViewerWheel(_scrollViewer); _itemsPresenter?.GetObservable(Border.BoundsProperty) .Subscribe(new AnonymousObserver(_ => UpdateBorders())); @@ -75,6 +98,20 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) UpdateBorders(); } + /// + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + AttachScrollViewerWheel(null); + } + + /// + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + AttachScrollViewerWheel(_scrollViewer); + } + private void OnContainerAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { if (sender is ToolTabStripItem tabStripItem) @@ -175,10 +212,21 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang { UpdatePseudoClasses(change.GetNewValue()); } + + if (change.Property == MouseWheelScrollOrientationProperty) + { + AttachScrollViewerWheel(_scrollViewer); + } } private void UpdatePseudoClasses(bool canCreate) { PseudoClasses.Set(":create", canCreate); } + + private void AttachScrollViewerWheel(ScrollViewer? scrollViewer) + { + _scrollViewerWheelSubscription?.Dispose(); + _scrollViewerWheelSubscription = ScrollViewerMouseWheelHookHelper.Attach(scrollViewer, MouseWheelScrollOrientation); + } } diff --git a/src/Dock.Avalonia/Internal/ScrollViewerMouseWheelHookHelper.cs b/src/Dock.Avalonia/Internal/ScrollViewerMouseWheelHookHelper.cs new file mode 100644 index 000000000..96e4811d0 --- /dev/null +++ b/src/Dock.Avalonia/Internal/ScrollViewerMouseWheelHookHelper.cs @@ -0,0 +1,51 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; + +namespace Dock.Avalonia.Internal; + +internal static class ScrollViewerMouseWheelHookHelper +{ + public static IDisposable? Attach(ScrollViewer? scrollViewer, Orientation orientation) + { + if (scrollViewer is null) + { + return null; + } + + void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e) + { + if (e.Handled) + { + return; + } + + if (TabStripMouseWheelScrollHelper.TryHandle(scrollViewer, orientation, e.Delta)) + { + e.Handled = true; + } + } + + scrollViewer.PointerWheelChanged += OnPointerWheelChanged; + return new DelegateDisposable(() => scrollViewer.PointerWheelChanged -= OnPointerWheelChanged); + } + + private sealed class DelegateDisposable : IDisposable + { + private Action? _dispose; + + public DelegateDisposable(Action dispose) + { + _dispose = dispose; + } + + public void Dispose() + { + _dispose?.Invoke(); + _dispose = null; + } + } +} diff --git a/src/Dock.Avalonia/Internal/TabStripMouseWheelScrollHelper.cs b/src/Dock.Avalonia/Internal/TabStripMouseWheelScrollHelper.cs new file mode 100644 index 000000000..9025d18c0 --- /dev/null +++ b/src/Dock.Avalonia/Internal/TabStripMouseWheelScrollHelper.cs @@ -0,0 +1,73 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace Dock.Avalonia.Internal; + +internal static class TabStripMouseWheelScrollHelper +{ + public static bool TryHandle(ScrollViewer? scrollViewer, Orientation orientation, Vector delta) + { + if (scrollViewer is null) + { + return false; + } + + var deltaY = delta.Y; + if (Math.Abs(deltaY) <= double.Epsilon) + { + return false; + } + + var steps = Math.Max(1, (int)Math.Ceiling(Math.Abs(deltaY))); + + switch (orientation) + { + case Orientation.Horizontal: + if (scrollViewer.Extent.Width <= scrollViewer.Viewport.Width) + { + return false; + } + + var initialHorizontalOffset = scrollViewer.Offset; + for (var i = 0; i < steps; i++) + { + if (deltaY > 0) + { + scrollViewer.LineLeft(); + } + else + { + scrollViewer.LineRight(); + } + } + + return !scrollViewer.Offset.Equals(initialHorizontalOffset); + case Orientation.Vertical: + if (scrollViewer.Extent.Height <= scrollViewer.Viewport.Height) + { + return false; + } + + var initialVerticalOffset = scrollViewer.Offset; + for (var i = 0; i < steps; i++) + { + if (deltaY > 0) + { + scrollViewer.LineUp(); + } + else + { + scrollViewer.LineDown(); + } + } + + return !scrollViewer.Offset.Equals(initialVerticalOffset); + default: + return false; + } + } +} diff --git a/tests/Dock.Avalonia.Diagnostics.UnitTests/DockControlInstantiationTests.cs b/tests/Dock.Avalonia.Diagnostics.UnitTests/DockControlInstantiationTests.cs index daff565c8..55bdd6ff7 100644 --- a/tests/Dock.Avalonia.Diagnostics.UnitTests/DockControlInstantiationTests.cs +++ b/tests/Dock.Avalonia.Diagnostics.UnitTests/DockControlInstantiationTests.cs @@ -23,6 +23,13 @@ public void DocumentTabStrip_Default_Orientation_Horizontal() Assert.Equal(LayoutOrientation.Horizontal, control.Orientation); } + [AvaloniaFact] + public void DocumentTabStrip_Default_MouseWheelScrollOrientation_Horizontal() + { + var control = new DocumentTabStrip(); + Assert.Equal(LayoutOrientation.Horizontal, control.MouseWheelScrollOrientation); + } + [AvaloniaFact] public void DockableControl_Default_TrackingMode_Visible() { @@ -197,6 +204,13 @@ public void ToolTabStrip_Can_Instantiate() Assert.NotNull(control); } + [AvaloniaFact] + public void ToolTabStrip_Default_MouseWheelScrollOrientation_Horizontal() + { + var control = new ToolTabStrip(); + Assert.Equal(LayoutOrientation.Horizontal, control.MouseWheelScrollOrientation); + } + [AvaloniaFact] public void ToolTabStripItem_Can_Instantiate() { diff --git a/tests/Dock.Avalonia.HeadlessTests/TabStripMouseWheelScrollTests.cs b/tests/Dock.Avalonia.HeadlessTests/TabStripMouseWheelScrollTests.cs new file mode 100644 index 000000000..b5f1404af --- /dev/null +++ b/tests/Dock.Avalonia.HeadlessTests/TabStripMouseWheelScrollTests.cs @@ -0,0 +1,267 @@ +using System.Linq; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless.XUnit; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.VisualTree; +using Dock.Avalonia.Controls; +using Xunit; + +namespace Dock.Avalonia.HeadlessTests; + +public class TabStripMouseWheelScrollTests +{ + [AvaloniaFact] + public void DocumentTabStrip_MouseWheelScrollsHorizontally_ByDefault() + { + var tabStrip = new DocumentTabStrip + { + Width = 180, + Height = 32, + ItemsSource = CreateItems(30, "Document") + }; + + var window = ShowInWindow(tabStrip, 180, 100); + try + { + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Width > scrollViewer.Viewport.Width); + + RaisePointerWheel(tabStrip, new Vector(0, -1)); + + Assert.True(scrollViewer.Offset.X > 0); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolTabStrip_MouseWheelScrollsHorizontally_ByDefault() + { + var tabStrip = new ToolTabStrip + { + Width = 180, + Height = 32, + ItemsSource = CreateItems(30, "Tool") + }; + + var window = ShowInWindow(tabStrip, 180, 100); + try + { + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Width > scrollViewer.Viewport.Width); + + RaisePointerWheel(tabStrip, new Vector(0, -1)); + + Assert.True(scrollViewer.Offset.X > 0); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DocumentTabStrip_CanScrollVertically_WhenConfigured() + { + var tabStrip = new DocumentTabStrip + { + Width = 200, + Height = 120, + Orientation = Orientation.Vertical, + MouseWheelScrollOrientation = Orientation.Vertical, + ItemsSource = CreateItems(30, "Document") + }; + + var window = ShowInWindow(tabStrip, 240, 140); + try + { + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Height > scrollViewer.Viewport.Height); + + RaisePointerWheel(tabStrip, new Vector(0, -1)); + + Assert.True(scrollViewer.Offset.Y > 0); + Assert.Equal(0, scrollViewer.Offset.X); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DocumentTabStrip_MouseWheelScrollOrientation_ChangesAtRuntime() + { + var tabStrip = new DocumentTabStrip + { + Width = 180, + Height = 32, + ItemsSource = CreateItems(30, "Document") + }; + + var window = ShowInWindow(tabStrip, 180, 100); + try + { + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Width > scrollViewer.Viewport.Width); + + tabStrip.MouseWheelScrollOrientation = Orientation.Vertical; + RaisePointerWheel(tabStrip, new Vector(0, -1)); + Assert.Equal(0, scrollViewer.Offset.X); + + tabStrip.MouseWheelScrollOrientation = Orientation.Horizontal; + RaisePointerWheel(tabStrip, new Vector(0, -1)); + Assert.True(scrollViewer.Offset.X > 0); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolTabStrip_MouseWheelScrollOrientation_ChangesAtRuntime() + { + var tabStrip = new ToolTabStrip + { + Width = 180, + Height = 32, + ItemsSource = CreateItems(30, "Tool") + }; + + var window = ShowInWindow(tabStrip, 180, 100); + try + { + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Width > scrollViewer.Viewport.Width); + + tabStrip.MouseWheelScrollOrientation = Orientation.Vertical; + RaisePointerWheel(tabStrip, new Vector(0, -1)); + Assert.Equal(0, scrollViewer.Offset.X); + + tabStrip.MouseWheelScrollOrientation = Orientation.Horizontal; + RaisePointerWheel(tabStrip, new Vector(0, -1)); + Assert.True(scrollViewer.Offset.X > 0); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void DocumentTabStrip_MouseWheelScrolls_AfterDetachAndReattach() + { + var tabStrip = new DocumentTabStrip + { + Width = 180, + Height = 32, + ItemsSource = CreateItems(30, "Document") + }; + + var window = ShowInWindow(tabStrip, 180, 100); + try + { + window.Content = null; + window.UpdateLayout(); + window.Content = tabStrip; + window.UpdateLayout(); + tabStrip.UpdateLayout(); + + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Width > scrollViewer.Viewport.Width); + + RaisePointerWheel(tabStrip, new Vector(0, -1)); + + Assert.True(scrollViewer.Offset.X > 0); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void ToolTabStrip_MouseWheelScrolls_AfterDetachAndReattach() + { + var tabStrip = new ToolTabStrip + { + Width = 180, + Height = 32, + ItemsSource = CreateItems(30, "Tool") + }; + + var window = ShowInWindow(tabStrip, 180, 100); + try + { + window.Content = null; + window.UpdateLayout(); + window.Content = tabStrip; + window.UpdateLayout(); + tabStrip.UpdateLayout(); + + var scrollViewer = GetScrollViewer(tabStrip); + Assert.True(scrollViewer.Extent.Width > scrollViewer.Viewport.Width); + + RaisePointerWheel(tabStrip, new Vector(0, -1)); + + Assert.True(scrollViewer.Offset.X > 0); + } + finally + { + window.Close(); + } + } + + private static AvaloniaList CreateItems(int count, string prefix) + { + var items = new AvaloniaList(); + for (var i = 0; i < count; i++) + { + items.Add($"{prefix} {i:00} - Very Long Tab Header"); + } + + return items; + } + + private static Window ShowInWindow(Control control, double width, double height) + { + var window = new Window + { + Width = width, + Height = height, + Content = control + }; + + window.Show(); + control.ApplyTemplate(); + window.UpdateLayout(); + control.UpdateLayout(); + + return window; + } + + private static ScrollViewer GetScrollViewer(Control control) + { + var scrollViewer = control.GetVisualDescendants().OfType().FirstOrDefault(); + Assert.NotNull(scrollViewer); + return scrollViewer!; + } + + private static void RaisePointerWheel(Control control, Vector delta) + { + var source = control.GetVisualDescendants().OfType().OfType().FirstOrDefault() ?? control; + var pointer = new Pointer(1, PointerType.Mouse, true); + var x = source.Bounds.Width > 1 ? source.Bounds.Width / 2 : 1; + var y = source.Bounds.Height > 1 ? source.Bounds.Height / 2 : 1; + var properties = new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.Other); + var args = new PointerWheelEventArgs(source, pointer, control, new Point(x, y), 0, properties, KeyModifiers.None, delta); + source.RaiseEvent(args); + } +}