From 2bdb914dd1cd58d5aca75bb08d6e4ea96b29692b Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 9 Jul 2020 12:20:46 +0200 Subject: [PATCH 01/26] Show overlay layer when popup StaysOpen = false. We need to stop events getting to parent window. Pointer capture won't work because this now needs an input event in order to take pointer capture, and filtering events on the parent window causes tooltips to continue tooltips to get stuck. Best solution would seem to be using an overlay layer. --- .../Primitives/LightDismissOverlayLayer.cs | 10 ++++++++ src/Avalonia.Controls/Primitives/Popup.cs | 21 +++++++++++++--- .../Primitives/VisualLayerManager.cs | 25 ++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs diff --git a/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs new file mode 100644 index 0000000000..65a21a563a --- /dev/null +++ b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs @@ -0,0 +1,10 @@ +using System.Linq; +using Avalonia.Rendering; +using Avalonia.VisualTree; + +namespace Avalonia.Controls.Primitives +{ + public class LightDismissOverlayLayer : Border + { + } +} diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 1fcf8d61bc..01ae6fbf43 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -369,8 +369,6 @@ namespace Avalonia.Controls.Primitives } } - DeferCleanup(topLevel.AddDisposableHandler(PointerPressedEvent, PointerPressedOutside, RoutingStrategies.Tunnel)); - DeferCleanup(InputManager.Instance?.Process.Subscribe(ListenForNonClientClick)); var cleanupPopup = Disposable.Create((popupHost, handlerCleanup), state => @@ -384,6 +382,23 @@ namespace Avalonia.Controls.Primitives state.popupHost.Dispose(); }); + if (!StaysOpen) + { + var layerManager = placementTarget.FindAncestorOfType(); + var dismissLayer = layerManager?.LightDismissOverlayLayer; + + if (dismissLayer != null) + { + dismissLayer.IsVisible = true; + DeferCleanup(Disposable.Create(() => dismissLayer.IsVisible = false)); + DeferCleanup(SubscribeToEventHandler>( + dismissLayer, + PointerPressedDismissOverlay, + (x, handler) => x.PointerPressed += handler, + (x, handler) => x.PointerPressed -= handler)); + } + } + _openState = new PopupOpenState(topLevel, popupHost, cleanupPopup); WindowManagerAddShadowHintChanged(popupHost, WindowManagerAddShadowHint); @@ -504,7 +519,7 @@ namespace Avalonia.Controls.Primitives } } - private void PointerPressedOutside(object sender, PointerPressedEventArgs e) + private void PointerPressedDismissOverlay(object sender, PointerPressedEventArgs e) { if (!StaysOpen && e.Source is IVisual v && !IsChildOrThis(v)) { diff --git a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs index 3084d7fa72..a4e230e2f4 100644 --- a/src/Avalonia.Controls/Primitives/VisualLayerManager.cs +++ b/src/Avalonia.Controls/Primitives/VisualLayerManager.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Avalonia.LogicalTree; +using Avalonia.Media; namespace Avalonia.Controls.Primitives { @@ -7,7 +8,8 @@ namespace Avalonia.Controls.Primitives { private const int AdornerZIndex = int.MaxValue - 100; private const int ChromeZIndex = int.MaxValue - 99; - private const int OverlayZIndex = int.MaxValue - 98; + private const int LightDismissOverlayZIndex = int.MaxValue - 98; + private const int OverlayZIndex = int.MaxValue - 97; private ILogicalRoot _logicalRoot; private readonly List _layers = new List(); @@ -50,6 +52,27 @@ namespace Avalonia.Controls.Primitives } } + public LightDismissOverlayLayer LightDismissOverlayLayer + { + get + { + if (IsPopup) + return null; + var rv = FindLayer(); + if (rv == null) + { + rv = new LightDismissOverlayLayer + { + Background = Brushes.Transparent, + IsVisible = false + }; + + AddLayer(rv, LightDismissOverlayZIndex); + } + return rv; + } + } + T FindLayer() where T : class { foreach (var layer in _layers) From 529b279549f6570745565b0c2b3d4eabf4dad5e8 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 9 Jul 2020 22:02:37 +0200 Subject: [PATCH 02/26] Fix failing popup tests. --- .../Primitives/LightDismissOverlayLayer.cs | 33 ++++++++++++++++++- src/Avalonia.Controls/Primitives/Popup.cs | 3 +- .../Primitives/PopupTests.cs | 3 +- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs index 65a21a563a..ede9a9b635 100644 --- a/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs +++ b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs @@ -1,10 +1,41 @@ +using System; using System.Linq; -using Avalonia.Rendering; +using Avalonia.Controls.Templates; +using Avalonia.Styling; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Controls.Primitives { + /// + /// A layer that is used to dismiss a when the user clicks outside. + /// public class LightDismissOverlayLayer : Border { + /// + /// Returns the light dismiss overlay for a specified visual. + /// + /// The visual. + /// The light dismiss overlay, or null if none found. + public static LightDismissOverlayLayer? GetLightDismissOverlayLayer(IVisual visual) + { + visual = visual ?? throw new ArgumentNullException(nameof(visual)); + + VisualLayerManager? manager; + + if (visual is TopLevel topLevel) + { + manager = topLevel.GetTemplateChildren() + .OfType() + .FirstOrDefault(); + } + else + { + manager = visual.FindAncestorOfType(); + } + + return manager?.LightDismissOverlayLayer; + } } } diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 01ae6fbf43..00bb026d0f 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -384,8 +384,7 @@ namespace Avalonia.Controls.Primitives if (!StaysOpen) { - var layerManager = placementTarget.FindAncestorOfType(); - var dismissLayer = layerManager?.LightDismissOverlayLayer; + var dismissLayer = LightDismissOverlayLayer.GetLightDismissOverlayLayer(placementTarget); if (dismissLayer != null) { diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index fd06ba295b..5519c11582 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -392,7 +392,8 @@ namespace Avalonia.Controls.UnitTests.Primitives ++raised; }; - window.RaiseEvent(press); + var lightDismissLayer = window.FindDescendantOfType().LightDismissOverlayLayer; + lightDismissLayer.RaiseEvent(press); Assert.Equal(1, raised); } From b2a7339b4bc6246467b3b55f0eb5ff98e23604dd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jul 2020 00:10:18 +0200 Subject: [PATCH 03/26] Added Popup.IsLightDismissEnabled property. And deprecate `StaysOpen` in favor of this property. --- src/Avalonia.Controls/ContextMenu.cs | 2 +- src/Avalonia.Controls/Primitives/Popup.cs | 41 ++++++++++++++----- .../Primitives/PopupClosedEventArgs.cs | 2 +- .../AutoCompleteBox.xaml | 2 +- src/Avalonia.Themes.Default/ComboBox.xaml | 2 +- src/Avalonia.Themes.Default/MenuItem.xaml | 4 +- .../AutoCompleteBox.xaml | 2 +- .../CalendarDatePicker.xaml | 2 +- src/Avalonia.Themes.Fluent/ComboBox.xaml | 2 +- src/Avalonia.Themes.Fluent/DatePicker.xaml | 2 +- src/Avalonia.Themes.Fluent/MenuItem.xaml | 5 +-- src/Avalonia.Themes.Fluent/TimePicker.xaml | 2 +- .../Primitives/PopupTests.cs | 6 +-- 13 files changed, 45 insertions(+), 29 deletions(-) diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 5929dd39d4..7720011d5e 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -265,7 +265,7 @@ namespace Avalonia.Controls PlacementMode = PlacementMode, PlacementRect = PlacementRect, PlacementTarget = PlacementTarget ?? control, - StaysOpen = false + IsLightDismissEnabled = true, }; _popup.Opened += PopupOpened; diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 00bb026d0f..7272a565d9 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -92,6 +92,12 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty HorizontalOffsetProperty = AvaloniaProperty.Register(nameof(HorizontalOffset)); + /// + /// Defines the property. + /// + public static readonly StyledProperty IsLightDismissEnabledProperty = + AvaloniaProperty.Register(nameof(IsLightDismissEnabled)); + /// /// Defines the property. /// @@ -101,8 +107,13 @@ namespace Avalonia.Controls.Primitives /// /// Defines the property. /// - public static readonly StyledProperty StaysOpenProperty = - AvaloniaProperty.Register(nameof(StaysOpen), true); + [Obsolete("Use IsLightDismissEnabledProperty")] + public static readonly DirectProperty StaysOpenProperty = + AvaloniaProperty.RegisterDirect( + nameof(StaysOpen), + o => o.StaysOpen, + (o, v) => o.StaysOpen = v, + true); /// /// Defines the property. @@ -165,6 +176,15 @@ namespace Avalonia.Controls.Primitives set; } + /// + /// Gets or sets a value that determines how the can be dismissed. + /// + public bool IsLightDismissEnabled + { + get => GetValue(IsLightDismissEnabledProperty); + set => SetValue(IsLightDismissEnabledProperty, value); + } + /// /// Gets or sets a value indicating whether the popup is currently open. /// @@ -268,10 +288,11 @@ namespace Avalonia.Controls.Primitives /// Gets or sets a value indicating whether the popup should stay open when the popup is /// pressed or loses focus. /// + [Obsolete("Use IsLightDismissEnabled")] public bool StaysOpen { - get { return GetValue(StaysOpenProperty); } - set { SetValue(StaysOpenProperty, value); } + get => !IsLightDismissEnabled; + set => IsLightDismissEnabled = !value; } /// @@ -382,7 +403,7 @@ namespace Avalonia.Controls.Primitives state.popupHost.Dispose(); }); - if (!StaysOpen) + if (IsLightDismissEnabled) { var dismissLayer = LightDismissOverlayLayer.GetLightDismissOverlayLayer(placementTarget); @@ -512,7 +533,7 @@ namespace Avalonia.Controls.Primitives { var mouse = e as RawPointerEventArgs; - if (!StaysOpen && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown) + if (IsLightDismissEnabled && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown) { CloseCore(e); } @@ -520,7 +541,7 @@ namespace Avalonia.Controls.Primitives private void PointerPressedDismissOverlay(object sender, PointerPressedEventArgs e) { - if (!StaysOpen && e.Source is IVisual v && !IsChildOrThis(v)) + if (IsLightDismissEnabled && e.Source is IVisual v && !IsChildOrThis(v)) { CloseCore(e); } @@ -616,7 +637,7 @@ namespace Avalonia.Controls.Primitives private void WindowDeactivated(object sender, EventArgs e) { - if (!StaysOpen) + if (IsLightDismissEnabled) { Close(); } @@ -624,7 +645,7 @@ namespace Avalonia.Controls.Primitives private void ParentClosed(object sender, EventArgs e) { - if (!StaysOpen) + if (IsLightDismissEnabled) { Close(); } @@ -632,7 +653,7 @@ namespace Avalonia.Controls.Primitives private void WindowLostFocus() { - if(!StaysOpen) + if(IsLightDismissEnabled) Close(); } diff --git a/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs b/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs index c51543438c..db554b3c82 100644 --- a/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs +++ b/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs @@ -23,7 +23,7 @@ namespace Avalonia.Controls.Primitives /// Gets the event that closed the popup, if any. /// /// - /// If is false, then this property will hold details of the + /// If is true, then this property will hold details of the /// interaction that caused the popup to close if the close was caused by e.g. a pointer press /// outside the popup. It can be used to mark the event as handled if the event should not /// be propagated. diff --git a/src/Avalonia.Themes.Default/AutoCompleteBox.xaml b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml index 788b60892b..66d0f17ede 100644 --- a/src/Avalonia.Themes.Default/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Default/AutoCompleteBox.xaml @@ -19,7 +19,7 @@ MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" PlacementTarget="{TemplateBinding}" - StaysOpen="False"> + IsLightDismissEnabled="True"> + IsLightDismissEnabled="True"> diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index d7f367c591..0eb87166b2 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -59,7 +59,6 @@ Grid.Column="4"/> + IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}"> diff --git a/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml b/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml index 0d5d733cd9..6d83674c43 100644 --- a/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml +++ b/src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml @@ -47,7 +47,7 @@ WindowManagerAddShadowHint="False" MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" - StaysOpen="False" + IsLightDismissEnabled="True" PlacementTarget="{TemplateBinding}"> + IsLightDismissEnabled="True"> diff --git a/src/Avalonia.Themes.Fluent/ComboBox.xaml b/src/Avalonia.Themes.Fluent/ComboBox.xaml index 2788344842..2d43a2afe5 100644 --- a/src/Avalonia.Themes.Fluent/ComboBox.xaml +++ b/src/Avalonia.Themes.Fluent/ComboBox.xaml @@ -123,7 +123,7 @@ MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}" MaxHeight="{TemplateBinding MaxDropDownHeight}" PlacementTarget="{TemplateBinding}" - StaysOpen="False"> + IsLightDismissEnabled="True"> diff --git a/src/Avalonia.Themes.Fluent/MenuItem.xaml b/src/Avalonia.Themes.Fluent/MenuItem.xaml index fbb994e90c..f861c456e9 100644 --- a/src/Avalonia.Themes.Fluent/MenuItem.xaml +++ b/src/Avalonia.Themes.Fluent/MenuItem.xaml @@ -108,7 +108,6 @@ + IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}"> diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 5519c11582..1d5b39cc14 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -349,7 +349,7 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void StaysOpen_False_Should_Not_Handle_Closing_Click() + public void LightDismiss_Should_Not_Handle_Closing_Click() { using (CreateServices()) { @@ -357,7 +357,7 @@ namespace Avalonia.Controls.UnitTests.Primitives var target = new Popup() { PlacementTarget = window , - StaysOpen = false, + IsLightDismissEnabled = true, }; target.Open(); @@ -378,7 +378,7 @@ namespace Avalonia.Controls.UnitTests.Primitives var target = new Popup() { PlacementTarget = window, - StaysOpen = false, + IsLightDismissEnabled = true, }; target.Open(); From c45436dff124117a01b591262e9b80441a5b1a91 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jul 2020 09:49:59 +0200 Subject: [PATCH 04/26] Added Popup.OverlayDismissEventPassThrough. To control whether dismiss events are passed through from the overlay layer to the underlying window content. --- src/Avalonia.Controls/AutoCompleteBox.cs | 7 +-- .../Calendar/CalendarDatePicker.cs | 7 +-- src/Avalonia.Controls/ComboBox.cs | 22 +------ src/Avalonia.Controls/ContextMenu.cs | 1 + src/Avalonia.Controls/Primitives/Popup.cs | 59 ++++++++++++++++--- .../Primitives/PopupClosedEventArgs.cs | 33 ----------- src/Avalonia.Input/InputExtensions.cs | 29 ++++++++- .../Primitives/PopupTests.cs | 49 ++++++--------- .../MockWindowingPlatform.cs | 4 ++ 9 files changed, 103 insertions(+), 108 deletions(-) delete mode 100644 src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs diff --git a/src/Avalonia.Controls/AutoCompleteBox.cs b/src/Avalonia.Controls/AutoCompleteBox.cs index 31101dc0f1..c164f282e8 100644 --- a/src/Avalonia.Controls/AutoCompleteBox.cs +++ b/src/Avalonia.Controls/AutoCompleteBox.cs @@ -1647,7 +1647,7 @@ namespace Avalonia.Controls /// /// The source object. /// The event data. - private void DropDownPopup_Closed(object sender, PopupClosedEventArgs e) + private void DropDownPopup_Closed(object sender, EventArgs e) { // Force the drop down dependency property to be false. if (IsDropDownOpen) @@ -1655,11 +1655,6 @@ namespace Avalonia.Controls IsDropDownOpen = false; } - if (e.CloseEvent is PointerEventArgs pointerEvent) - { - pointerEvent.Handled = true; - } - // Fire the DropDownClosed event if (_popupHasOpened) { diff --git a/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs b/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs index b987f065be..046b55d49a 100644 --- a/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs +++ b/src/Avalonia.Controls/Calendar/CalendarDatePicker.cs @@ -889,17 +889,12 @@ namespace Avalonia.Controls _ignoreButtonClick = false; } } - private void PopUp_Closed(object sender, PopupClosedEventArgs e) + private void PopUp_Closed(object sender, EventArgs e) { IsDropDownOpen = false; if(!_isPopupClosing) { - if (e.CloseEvent is PointerEventArgs pointerEvent) - { - pointerEvent.Handled = true; - } - _isPopupClosing = true; Threading.Dispatcher.UIThread.InvokeAsync(() => _isPopupClosing = false); } diff --git a/src/Avalonia.Controls/ComboBox.cs b/src/Avalonia.Controls/ComboBox.cs index 75a0e41e7b..27313b0b4c 100644 --- a/src/Avalonia.Controls/ComboBox.cs +++ b/src/Avalonia.Controls/ComboBox.cs @@ -290,24 +290,6 @@ namespace Avalonia.Controls _popup = e.NameScope.Get("PART_Popup"); _popup.Opened += PopupOpened; - _popup.Closed += PopupClosed; - } - - /// - /// Called when the ComboBox popup is closed, with the - /// that caused the popup to close. - /// - /// The event args. - /// - /// This method can be overridden to control whether the event that caused the popup to close - /// is swallowed or passed through. - /// - protected virtual void PopupClosedOverride(PopupClosedEventArgs e) - { - if (e.CloseEvent is PointerEventArgs pointerEvent) - { - pointerEvent.Handled = true; - } } internal void ItemFocused(ComboBoxItem dropDownItem) @@ -318,13 +300,11 @@ namespace Avalonia.Controls } } - private void PopupClosed(object sender, PopupClosedEventArgs e) + private void PopupClosed(object sender, EventArgs e) { _subscriptionsOnOpen?.Dispose(); _subscriptionsOnOpen = null; - PopupClosedOverride(e); - if (CanFocus(this)) { Focus(); diff --git a/src/Avalonia.Controls/ContextMenu.cs b/src/Avalonia.Controls/ContextMenu.cs index 7720011d5e..b4e4dd5071 100644 --- a/src/Avalonia.Controls/ContextMenu.cs +++ b/src/Avalonia.Controls/ContextMenu.cs @@ -266,6 +266,7 @@ namespace Avalonia.Controls PlacementRect = PlacementRect, PlacementTarget = PlacementTarget ?? control, IsLightDismissEnabled = true, + OverlayDismissEventPassThrough = true, }; _popup.Opened += PopupOpened; diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index 7272a565d9..c97d90baed 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -1,12 +1,10 @@ using System; -using System.Diagnostics; using System.Linq; using System.Reactive.Disposables; using Avalonia.Controls.Presenters; using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Input; using Avalonia.Input.Raw; -using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.Metadata; using Avalonia.Platform; @@ -86,6 +84,9 @@ namespace Avalonia.Controls.Primitives AvaloniaProperty.Register(nameof(ObeyScreenEdges), true); #pragma warning restore 618 + public static readonly StyledProperty OverlayDismissEventPassThroughProperty = + AvaloniaProperty.Register(nameof(OverlayDismissEventPassThrough)); + /// /// Defines the property. /// @@ -138,7 +139,7 @@ namespace Avalonia.Controls.Primitives /// /// Raised when the popup closes. /// - public event EventHandler? Closed; + public event EventHandler? Closed; /// /// Raised when the popup opens. @@ -179,6 +180,9 @@ namespace Avalonia.Controls.Primitives /// /// Gets or sets a value that determines how the can be dismissed. /// + /// + /// Light dismiss is when the user taps on any area other than the popup. + /// public bool IsLightDismissEnabled { get => GetValue(IsLightDismissEnabledProperty); @@ -266,6 +270,22 @@ namespace Avalonia.Controls.Primitives set => SetValue(ObeyScreenEdgesProperty, value); } + /// + /// Gets or sets a value indicating whether the event that closes the popup is passed + /// through to the parent window. + /// + /// + /// When is set to true, clicks outside the the popup + /// cause the popup to close. When is set to + /// false, these clicks will be handled by the popup and not be registered by the parent + /// window. When set to true, the events will be passed through to the parent window. + /// + public bool OverlayDismissEventPassThrough + { + get => GetValue(OverlayDismissEventPassThroughProperty); + set => SetValue(OverlayDismissEventPassThroughProperty, value); + } + /// /// Gets or sets the Horizontal offset of the popup in relation to the . /// @@ -384,7 +404,7 @@ namespace Avalonia.Controls.Primitives if (parentPopupRoot?.Parent is Popup popup) { - DeferCleanup(SubscribeToEventHandler>(popup, ParentClosed, + DeferCleanup(SubscribeToEventHandler>(popup, ParentClosed, (x, handler) => x.Closed += handler, (x, handler) => x.Closed -= handler)); } @@ -436,7 +456,7 @@ namespace Avalonia.Controls.Primitives /// /// Closes the popup. /// - public void Close() => CloseCore(null); + public void Close() => CloseCore(); /// /// Measures the control. @@ -506,7 +526,7 @@ namespace Avalonia.Controls.Primitives } } - private void CloseCore(EventArgs? closeEvent) + private void CloseCore() { if (_openState is null) { @@ -526,7 +546,7 @@ namespace Avalonia.Controls.Primitives IsOpen = false; } - Closed?.Invoke(this, new PopupClosedEventArgs(closeEvent)); + Closed?.Invoke(this, EventArgs.Empty); } private void ListenForNonClientClick(RawInputEventArgs e) @@ -535,7 +555,7 @@ namespace Avalonia.Controls.Primitives if (IsLightDismissEnabled && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown) { - CloseCore(e); + CloseCore(); } } @@ -543,7 +563,28 @@ namespace Avalonia.Controls.Primitives { if (IsLightDismissEnabled && e.Source is IVisual v && !IsChildOrThis(v)) { - CloseCore(e); + CloseCore(); + + if (OverlayDismissEventPassThrough) + { + PassThroughEvent(e); + } + } + } + + private void PassThroughEvent(PointerPressedEventArgs e) + { + if (e.Source is LightDismissOverlayLayer layer && + layer.GetVisualRoot() is IInputElement root) + { + var p = e.GetCurrentPoint(root); + var hit = root.InputHitTest(p.Position, x => x != layer); + + if (hit != null) + { + hit.RaiseEvent(e); + e.Handled = true; + } } } diff --git a/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs b/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs deleted file mode 100644 index db554b3c82..0000000000 --- a/src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Avalonia.Interactivity; - -#nullable enable - -namespace Avalonia.Controls.Primitives -{ - /// - /// Holds data for the event. - /// - public class PopupClosedEventArgs : EventArgs - { - /// - /// Initializes a new instance of the class. - /// - /// - public PopupClosedEventArgs(EventArgs? closeEvent) - { - CloseEvent = closeEvent; - } - - /// - /// Gets the event that closed the popup, if any. - /// - /// - /// If is true, then this property will hold details of the - /// interaction that caused the popup to close if the close was caused by e.g. a pointer press - /// outside the popup. It can be used to mark the event as handled if the event should not - /// be propagated. - /// - public EventArgs? CloseEvent { get; } - } -} diff --git a/src/Avalonia.Input/InputExtensions.cs b/src/Avalonia.Input/InputExtensions.cs index 4babe711f2..cbe36583e6 100644 --- a/src/Avalonia.Input/InputExtensions.cs +++ b/src/Avalonia.Input/InputExtensions.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Input { /// @@ -22,7 +24,7 @@ namespace Avalonia.Input /// public static IEnumerable GetInputElementsAt(this IInputElement element, Point p) { - Contract.Requires(element != null); + element = element ?? throw new ArgumentNullException(nameof(element)); return element.GetVisualsAt(p, s_hitTestDelegate).Cast(); } @@ -33,13 +35,34 @@ namespace Avalonia.Input /// The element to test. /// The point on . /// The topmost at the specified position. - public static IInputElement InputHitTest(this IInputElement element, Point p) + public static IInputElement? InputHitTest(this IInputElement element, Point p) { - Contract.Requires(element != null); + element = element ?? throw new ArgumentNullException(nameof(element)); return element.GetVisualAt(p, s_hitTestDelegate) as IInputElement; } + /// + /// Returns the topmost active input element at a point on an . + /// + /// The element to test. + /// The point on . + /// + /// A filter predicate. If the predicate returns false then the visual and all its + /// children will be excluded from the results. + /// + /// The topmost at the specified position. + public static IInputElement? InputHitTest( + this IInputElement element, + Point p, + Func filter) + { + element = element ?? throw new ArgumentNullException(nameof(element)); + filter = filter ?? throw new ArgumentNullException(nameof(filter)); + + return element.GetVisualAt(p, x => s_hitTestDelegate(x) && filter(x)) as IInputElement; + } + private static bool IsHitTestVisible(IVisual visual) { var element = visual as IInputElement; diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 1d5b39cc14..422ade2f90 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -349,53 +349,42 @@ namespace Avalonia.Controls.UnitTests.Primitives } [Fact] - public void LightDismiss_Should_Not_Handle_Closing_Click() + public void OverlayDismissEventPassThrough_Should_Pass_Event_To_Window_Contents() { using (CreateServices()) { var window = PreparedWindow(); + var rendererMock = Mock.Get(window.Renderer); var target = new Popup() { PlacementTarget = window , IsLightDismissEnabled = true, + OverlayDismissEventPassThrough = true, }; - target.Open(); - - var e = CreatePointerPressedEventArgs(window); - window.RaiseEvent(e); + var raised = 0; + var border = new Border(); + window.Content = border; - Assert.False(e.Handled); - } - } + rendererMock.Setup(x => + x.HitTestFirst(new Point(10, 15), window, It.IsAny>())) + .Returns(border); - [Fact] - public void Should_Pass_Closing_Click_To_Closed_Event() - { - using (CreateServices()) - { - var window = PreparedWindow(); - var target = new Popup() + border.PointerPressed += (s, e) => { - PlacementTarget = window, - IsLightDismissEnabled = true, + Assert.Same(border, e.Source); + ++raised; }; target.Open(); + Assert.True(target.IsOpen); - var press = CreatePointerPressedEventArgs(window); - var raised = 0; - - target.Closed += (s, e) => - { - Assert.Same(press, e.CloseEvent); - ++raised; - }; - - var lightDismissLayer = window.FindDescendantOfType().LightDismissOverlayLayer; - lightDismissLayer.RaiseEvent(press); + var e = CreatePointerPressedEventArgs(window, new Point(10, 15)); + var overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(window); + overlay.RaiseEvent(e); Assert.Equal(1, raised); + Assert.False(target.IsOpen); } } @@ -411,14 +400,14 @@ namespace Avalonia.Controls.UnitTests.Primitives }))); } - private PointerPressedEventArgs CreatePointerPressedEventArgs(Window source) + private PointerPressedEventArgs CreatePointerPressedEventArgs(Window source, Point p) { var pointer = new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true); return new PointerPressedEventArgs( source, pointer, source, - default, + p, 0, new PointerPointProperties(RawInputModifiers.None, PointerUpdateKind.LeftButtonPressed), KeyModifiers.None); diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index 48a333dc54..9b77fbb009 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -3,6 +3,7 @@ using Avalonia.Controls.Primitives.PopupPositioning; using Avalonia.Input; using Moq; using Avalonia.Platform; +using Avalonia.Rendering; namespace Avalonia.UnitTests { @@ -39,6 +40,9 @@ namespace Avalonia.UnitTests return CreatePopupMock(windowImpl.Object).Object; }); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) + .Returns(Mock.Of()); + windowImpl.Setup(x => x.Dispose()).Callback(() => { windowImpl.Object.Closed?.Invoke(); From 714b0740db18387d055886360c37b9bf40fcf6dd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jul 2020 13:14:22 +0200 Subject: [PATCH 05/26] Make leak tests pass again. --- .../Primitives/PopupTests.cs | 13 ++++++++++--- tests/Avalonia.UnitTests/MockWindowingPlatform.cs | 3 --- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs index 422ade2f90..d9176ca55d 100644 --- a/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs @@ -14,6 +14,7 @@ using Avalonia.UnitTests; using Avalonia.VisualTree; using Xunit; using Avalonia.Input; +using Avalonia.Rendering; namespace Avalonia.Controls.UnitTests.Primitives { @@ -353,8 +354,14 @@ namespace Avalonia.Controls.UnitTests.Primitives { using (CreateServices()) { - var window = PreparedWindow(); - var rendererMock = Mock.Get(window.Renderer); + var renderer = new Mock(); + var platform = AvaloniaLocator.Current.GetService(); + var windowImpl = Mock.Get(platform.CreateWindow()); + windowImpl.Setup(x => x.CreateRenderer(It.IsAny())).Returns(renderer.Object); + + var window = new Window(windowImpl.Object); + window.ApplyTemplate(); + var target = new Popup() { PlacementTarget = window , @@ -366,7 +373,7 @@ namespace Avalonia.Controls.UnitTests.Primitives var border = new Border(); window.Content = border; - rendererMock.Setup(x => + renderer.Setup(x => x.HitTestFirst(new Point(10, 15), window, It.IsAny>())) .Returns(border); diff --git a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs index 9b77fbb009..72f8ab9fd0 100644 --- a/tests/Avalonia.UnitTests/MockWindowingPlatform.cs +++ b/tests/Avalonia.UnitTests/MockWindowingPlatform.cs @@ -40,9 +40,6 @@ namespace Avalonia.UnitTests return CreatePopupMock(windowImpl.Object).Object; }); - windowImpl.Setup(x => x.CreateRenderer(It.IsAny())) - .Returns(Mock.Of()); - windowImpl.Setup(x => x.Dispose()).Callback(() => { windowImpl.Object.Closed?.Invoke(); From dd563805cde3e864c08a89b0c04a8dc54cf53238 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jul 2020 13:24:41 +0200 Subject: [PATCH 06/26] Nullable enable menu-related classes. --- src/Avalonia.Controls/IMenuElement.cs | 4 +++- src/Avalonia.Controls/IMenuItem.cs | 6 ++++-- src/Avalonia.Controls/Menu.cs | 2 ++ src/Avalonia.Controls/MenuBase.cs | 8 ++++---- src/Avalonia.Controls/MenuItem.cs | 20 ++++++++++--------- .../Platform/DefaultMenuInteractionHandler.cs | 7 ++++--- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/Avalonia.Controls/IMenuElement.cs b/src/Avalonia.Controls/IMenuElement.cs index ee9d0fd6b6..426f265084 100644 --- a/src/Avalonia.Controls/IMenuElement.cs +++ b/src/Avalonia.Controls/IMenuElement.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; using Avalonia.Input; +#nullable enable + namespace Avalonia.Controls { /// @@ -11,7 +13,7 @@ namespace Avalonia.Controls /// /// Gets or sets the currently selected submenu item. /// - IMenuItem SelectedItem { get; set; } + IMenuItem? SelectedItem { get; set; } /// /// Gets the submenu items. diff --git a/src/Avalonia.Controls/IMenuItem.cs b/src/Avalonia.Controls/IMenuItem.cs index 132d565cb7..94d761f725 100644 --- a/src/Avalonia.Controls/IMenuItem.cs +++ b/src/Avalonia.Controls/IMenuItem.cs @@ -1,4 +1,6 @@ -namespace Avalonia.Controls +#nullable enable + +namespace Avalonia.Controls { /// /// Represents a . @@ -29,7 +31,7 @@ /// /// Gets the parent . /// - new IMenuElement Parent { get; } + new IMenuElement? Parent { get; } /// /// Raises a click event on the menu item. diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index 7205af0e75..3733137714 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -4,6 +4,8 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; +#nullable enable + namespace Avalonia.Controls { /// diff --git a/src/Avalonia.Controls/MenuBase.cs b/src/Avalonia.Controls/MenuBase.cs index 4554cb2bcf..0434928280 100644 --- a/src/Avalonia.Controls/MenuBase.cs +++ b/src/Avalonia.Controls/MenuBase.cs @@ -8,6 +8,8 @@ using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.LogicalTree; +#nullable enable + namespace Avalonia.Controls { /// @@ -51,9 +53,7 @@ namespace Avalonia.Controls /// The menu interaction handler. public MenuBase(IMenuInteractionHandler interactionHandler) { - Contract.Requires(interactionHandler != null); - - InteractionHandler = interactionHandler; + InteractionHandler = interactionHandler ?? throw new ArgumentNullException(nameof(interactionHandler)); } /// @@ -77,7 +77,7 @@ namespace Avalonia.Controls IMenuInteractionHandler IMenu.InteractionHandler => InteractionHandler; /// - IMenuItem IMenuElement.SelectedItem + IMenuItem? IMenuElement.SelectedItem { get { diff --git a/src/Avalonia.Controls/MenuItem.cs b/src/Avalonia.Controls/MenuItem.cs index 912abc6de3..aab099911b 100644 --- a/src/Avalonia.Controls/MenuItem.cs +++ b/src/Avalonia.Controls/MenuItem.cs @@ -12,6 +12,8 @@ using Avalonia.Interactivity; using Avalonia.LogicalTree; using Avalonia.VisualTree; +#nullable enable + namespace Avalonia.Controls { /// @@ -22,7 +24,7 @@ namespace Avalonia.Controls /// /// Defines the property. /// - public static readonly DirectProperty CommandProperty = + public static readonly DirectProperty CommandProperty = Button.CommandProperty.AddOwner( menuItem => menuItem.Command, (menuItem, command) => menuItem.Command = command, @@ -94,10 +96,10 @@ namespace Avalonia.Controls private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel()); - private ICommand _command; + private ICommand? _command; private bool _commandCanExecute = true; - private Popup _popup; - private IDisposable _gridHack; + private Popup? _popup; + private IDisposable? _gridHack; /// /// Initializes static members of the class. @@ -166,7 +168,7 @@ namespace Avalonia.Controls /// /// Gets or sets the command associated with the menu item. /// - public ICommand Command + public ICommand? Command { get { return _command; } set { SetAndRaise(CommandProperty, ref _command, value); } @@ -246,7 +248,7 @@ namespace Avalonia.Controls bool IMenuItem.IsPointerOverSubMenu => _popup?.IsPointerOverPopup ?? false; /// - IMenuElement IMenuItem.Parent => Parent as IMenuElement; + IMenuElement? IMenuItem.Parent => Parent as IMenuElement; protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute; @@ -254,7 +256,7 @@ namespace Avalonia.Controls bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap); /// - IMenuItem IMenuElement.SelectedItem + IMenuItem? IMenuElement.SelectedItem { get { @@ -551,7 +553,7 @@ namespace Avalonia.Controls /// The property change event. private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e) { - if ((bool)e.NewValue) + if ((bool)e.NewValue!) { Focus(); } @@ -563,7 +565,7 @@ namespace Avalonia.Controls /// The property change event. private void SubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e) { - var value = (bool)e.NewValue; + var value = (bool)e.NewValue!; if (value) { diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index 2a7ea12d79..be92b89b73 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -3,7 +3,6 @@ using Avalonia.Input; using Avalonia.Input.Raw; using Avalonia.Interactivity; using Avalonia.LogicalTree; -using Avalonia.Platform; using Avalonia.Rendering; using Avalonia.Threading; using Avalonia.VisualTree; @@ -235,7 +234,9 @@ namespace Avalonia.Controls.Platform // If the the parent is an IMenu which successfully moved its selection, // and the current menu is open then close the current menu and open the // new menu. - if (item.IsSubMenuOpen && item.Parent is IMenu) + if (item.IsSubMenuOpen && + item.Parent is IMenu && + item.Parent.SelectedItem is object) { item.Close(); Open(item.Parent.SelectedItem, true); @@ -385,7 +386,7 @@ namespace Avalonia.Controls.Platform { if (e.Source == Menu) { - Menu.MoveSelection(NavigationDirection.First, true); + Menu?.MoveSelection(NavigationDirection.First, true); } } From 226c57b7d792b9ab1de96afd1ebec71d4a9a086f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jul 2020 13:25:30 +0200 Subject: [PATCH 07/26] Ensure that Open is called on main menu. --- .../Platform/DefaultMenuInteractionHandler.cs | 5 +++++ .../DefaultMenuInteractionHandlerTests.cs | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs index be92b89b73..6d6398bcda 100644 --- a/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs +++ b/src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs @@ -364,6 +364,11 @@ namespace Avalonia.Controls.Platform } else { + if (item.IsTopLevel && item.Parent is IMainMenu mainMenu) + { + mainMenu.Open(); + } + Open(item, false); } diff --git a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs index 97b6bf9d24..64f35049ce 100644 --- a/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs @@ -124,6 +124,24 @@ namespace Avalonia.Controls.UnitTests.Platform Assert.True(e.Handled); } + [Fact] + public void Click_On_TopLevel_Calls_MainMenu_Open() + { + var target = new DefaultMenuInteractionHandler(false); + var menu = new Mock(); + menu.As(); + + var item = Mock.Of(x => + x.IsTopLevel == true && + x.HasSubMenu == true && + x.Parent == menu.Object); + + var e = CreatePressed(item); + + target.PointerPressed(item, e); + menu.Verify(x => x.Open()); + } + [Fact] public void Click_On_Open_TopLevel_Menu_Closes_Menu() { From f0eafa70d0d7e8b066cf01cccfe5c6c85774ec20 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Fri, 10 Jul 2020 20:27:42 +0200 Subject: [PATCH 08/26] Added LightDismissOverlayLayer.InputPassThroughElement. Which implements something similar to UWP's `FlyoutBase.OverlayInputPassThroughElement`. With this feature, `Menu`/`MenuItem` can use `IsLightDismissEnabled="True"` fixing #3965 for top-level menus. --- src/Avalonia.Controls/Menu.cs | 17 ++++++++++++++ .../Primitives/LightDismissOverlayLayer.cs | 22 ++++++++++++++++++- src/Avalonia.Themes.Default/MenuItem.xaml | 2 ++ src/Avalonia.Themes.Fluent/MenuItem.xaml | 5 +++-- .../Rendering/ICustomSimpleHitTest.cs | 11 ++++++++++ .../Rendering/SceneGraph/Scene.cs | 6 +++++ 6 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Menu.cs b/src/Avalonia.Controls/Menu.cs index 3733137714..f74daa791d 100644 --- a/src/Avalonia.Controls/Menu.cs +++ b/src/Avalonia.Controls/Menu.cs @@ -1,4 +1,5 @@ using Avalonia.Controls.Platform; +using Avalonia.Controls.Primitives; using Avalonia.Controls.Templates; using Avalonia.Input; using Avalonia.Interactivity; @@ -16,6 +17,8 @@ namespace Avalonia.Controls private static readonly ITemplate DefaultPanel = new FuncTemplate(() => new StackPanel { Orientation = Orientation.Horizontal }); + private LightDismissOverlayLayer? _overlay; + /// /// Initializes a new instance of the class. /// @@ -45,6 +48,13 @@ namespace Avalonia.Controls return; } + if (_overlay?.InputPassThroughElement == this) + { + _overlay.InputPassThroughElement = null; + } + + _overlay = null; + foreach (var i in ((IMenu)this).SubItems) { i.Close(); @@ -68,6 +78,13 @@ namespace Avalonia.Controls return; } + _overlay = LightDismissOverlayLayer.GetLightDismissOverlayLayer(this); + + if (_overlay is object) + { + _overlay.InputPassThroughElement = this; + } + IsOpen = true; RaiseEvent(new RoutedEventArgs diff --git a/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs index ede9a9b635..752eedb68a 100644 --- a/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs +++ b/src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs @@ -1,6 +1,8 @@ using System; using System.Linq; using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Rendering; using Avalonia.Styling; using Avalonia.VisualTree; @@ -11,8 +13,10 @@ namespace Avalonia.Controls.Primitives /// /// A layer that is used to dismiss a when the user clicks outside. /// - public class LightDismissOverlayLayer : Border + public class LightDismissOverlayLayer : Border, ICustomHitTest { + public IInputElement? InputPassThroughElement { get; set; } + /// /// Returns the light dismiss overlay for a specified visual. /// @@ -37,5 +41,21 @@ namespace Avalonia.Controls.Primitives return manager?.LightDismissOverlayLayer; } + + public bool HitTest(Point point) + { + if (InputPassThroughElement is object) + { + var p = point.Transform(this.TransformToVisual(VisualRoot)!.Value); + var hit = VisualRoot.GetVisualAt(p, x => x != this); + + if (hit is object) + { + return !InputPassThroughElement.IsVisualAncestorOf(hit); + } + } + + return true; + } } } diff --git a/src/Avalonia.Themes.Default/MenuItem.xaml b/src/Avalonia.Themes.Default/MenuItem.xaml index 0eb87166b2..b1f536a704 100644 --- a/src/Avalonia.Themes.Default/MenuItem.xaml +++ b/src/Avalonia.Themes.Default/MenuItem.xaml @@ -59,6 +59,7 @@ Grid.Column="4"/> + IsLightDismissEnabled="True" + IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}"> + /// Allows customization of hit-testing for all renderers. + /// + /// + /// Note that this interface can only used to make a portion of a control non-hittable, it + /// cannot expand the hittable area of a control. + /// + public interface ICustomHitTest : ICustomSimpleHitTest + { + } + public static class CustomSimpleHitTestExtensions { public static bool HitTestCustom(this IVisual visual, Point point) diff --git a/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs b/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs index 0f6001516d..4f5c97cdff 100644 --- a/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs +++ b/src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs @@ -304,6 +304,12 @@ namespace Avalonia.Rendering.SceneGraph clipped = !node.GeometryClip.FillContains(controlPoint.Value); } + if (!clipped && node.Visual is ICustomHitTest custom) + { + var controlPoint = _sceneRoot.Visual.TranslatePoint(_point, node.Visual); + clipped = !custom.HitTest(controlPoint.Value); + } + return !clipped; } From b43c8f4ceffeb1a86bcc6ecb13250715f6ff4f62 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 15 Jul 2020 17:59:30 +0200 Subject: [PATCH 09/26] Added `Popup.OverlayInputPassThroughElement`. --- src/Avalonia.Controls/Primitives/Popup.cs | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Controls/Primitives/Popup.cs b/src/Avalonia.Controls/Primitives/Popup.cs index c97d90baed..71efd322ec 100644 --- a/src/Avalonia.Controls/Primitives/Popup.cs +++ b/src/Avalonia.Controls/Primitives/Popup.cs @@ -87,6 +87,12 @@ namespace Avalonia.Controls.Primitives public static readonly StyledProperty OverlayDismissEventPassThroughProperty = AvaloniaProperty.Register(nameof(OverlayDismissEventPassThrough)); + public static readonly DirectProperty OverlayInputPassThroughElementProperty = + AvaloniaProperty.RegisterDirect( + nameof(OverlayInputPassThroughElement), + o => o.OverlayInputPassThroughElement, + (o, v) => o.OverlayInputPassThroughElement = v); + /// /// Defines the property. /// @@ -125,6 +131,7 @@ namespace Avalonia.Controls.Primitives private bool _isOpen; private bool _ignoreIsOpenChanged; private PopupOpenState? _openState; + private IInputElement _overlayInputPassThroughElement; /// /// Initializes static members of the class. @@ -286,6 +293,16 @@ namespace Avalonia.Controls.Primitives set => SetValue(OverlayDismissEventPassThroughProperty, value); } + /// + /// Gets or sets an element that should receive pointer input events even when underneath + /// the popup's overlay. + /// + public IInputElement OverlayInputPassThroughElement + { + get => _overlayInputPassThroughElement; + set => SetAndRaise(OverlayInputPassThroughElementProperty, ref _overlayInputPassThroughElement, value); + } + /// /// Gets or sets the Horizontal offset of the popup in relation to the . /// @@ -430,7 +447,14 @@ namespace Avalonia.Controls.Primitives if (dismissLayer != null) { dismissLayer.IsVisible = true; - DeferCleanup(Disposable.Create(() => dismissLayer.IsVisible = false)); + dismissLayer.InputPassThroughElement = _overlayInputPassThroughElement; + + DeferCleanup(Disposable.Create(() => + { + dismissLayer.IsVisible = false; + dismissLayer.InputPassThroughElement = null; + })); + DeferCleanup(SubscribeToEventHandler>( dismissLayer, PointerPressedDismissOverlay, @@ -694,7 +718,7 @@ namespace Avalonia.Controls.Primitives private void WindowLostFocus() { - if(IsLightDismissEnabled) + if (IsLightDismissEnabled) Close(); } From c37144b59633f8f52f917a77f5bceb7c0e678647 Mon Sep 17 00:00:00 2001 From: Andrey Kunchev Date: Thu, 16 Jul 2020 13:03:26 +0300 Subject: [PATCH 10/26] add failing unit tests for #4306 --- .../ListBoxTests_Multiple.cs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs diff --git a/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs new file mode 100644 index 0000000000..7c7cdd08db --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs @@ -0,0 +1,126 @@ +using System.Linq; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Styling; +using Avalonia.UnitTests; +using Avalonia.VisualTree; +using Xunit; + +namespace Avalonia.Controls.UnitTests +{ + public class ListBoxTests_Multiple + { + [Fact] + public void Focusing_Item_With_Shift_And_Arrow_Key_Should_Add_To_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItem = "Foo"; + + target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Shift + }); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void Focusing_Item_With_Ctrl_And_Arrow_Key_Should_Add_To_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItem = "Foo"; + + target.Presenter.Panel.Children[1].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Control + }); + + Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems); + } + + [Fact] + public void Focusing_Selected_Item_With_Ctrl_And_Arrow_Key_Should_Remove_From_Selection() + { + var target = new ListBox + { + Template = new FuncControlTemplate(CreateListBoxTemplate), + Items = new[] { "Foo", "Bar", "Baz " }, + SelectionMode = SelectionMode.Multiple + }; + + ApplyTemplate(target); + + target.SelectedItems.Add("Foo"); + target.SelectedItems.Add("Bar"); + + target.Presenter.Panel.Children[0].RaiseEvent(new GotFocusEventArgs + { + RoutedEvent = InputElement.GotFocusEvent, + NavigationMethod = NavigationMethod.Directional, + KeyModifiers = KeyModifiers.Control + }); + + Assert.Equal(new[] { "Bar" }, target.SelectedItems); + } + + private Control CreateListBoxTemplate(ITemplatedControl parent, INameScope scope) + { + return new ScrollViewer + { + Template = new FuncControlTemplate(CreateScrollViewerTemplate), + Content = new ItemsPresenter + { + Name = "PART_ItemsPresenter", + [~ItemsPresenter.ItemsProperty] = parent.GetObservable(ItemsControl.ItemsProperty).ToBinding(), + }.RegisterInNameScope(scope) + }; + } + + private Control CreateScrollViewerTemplate(ITemplatedControl parent, INameScope scope) + { + return new ScrollContentPresenter + { + Name = "PART_ContentPresenter", + [~ContentPresenter.ContentProperty] = + parent.GetObservable(ContentControl.ContentProperty).ToBinding(), + }.RegisterInNameScope(scope); + } + + private void ApplyTemplate(ListBox target) + { + // Apply the template to the ListBox itself. + target.ApplyTemplate(); + + // Then to its inner ScrollViewer. + var scrollViewer = (ScrollViewer)target.GetVisualChildren().Single(); + scrollViewer.ApplyTemplate(); + + // Then make the ScrollViewer create its child. + ((ContentPresenter)scrollViewer.Presenter).UpdateChild(); + + // Now the ItemsPresenter should be reigstered, so apply its template. + target.Presenter.ApplyTemplate(); + } + } +} From c08d88afcff791837d28b0a1dd73105da0d1cc6e Mon Sep 17 00:00:00 2001 From: Andrey Kunchev Date: Thu, 16 Jul 2020 12:47:26 +0300 Subject: [PATCH 11/26] pass key modifiers to focus --- src/Avalonia.Controls/ItemsControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/ItemsControl.cs b/src/Avalonia.Controls/ItemsControl.cs index 6e0ad66699..1aa7945901 100644 --- a/src/Avalonia.Controls/ItemsControl.cs +++ b/src/Avalonia.Controls/ItemsControl.cs @@ -295,7 +295,7 @@ namespace Avalonia.Controls if (next != null) { - focus.Focus(next, NavigationMethod.Directional); + focus.Focus(next, NavigationMethod.Directional, e.KeyModifiers); e.Handled = true; } From e5c373acd06489b0e715282158488712259723eb Mon Sep 17 00:00:00 2001 From: Andrey Kunchev Date: Thu, 16 Jul 2020 12:48:12 +0300 Subject: [PATCH 12/26] pass ctrl key state to selection logic in listbox --- src/Avalonia.Controls/ListBox.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Avalonia.Controls/ListBox.cs b/src/Avalonia.Controls/ListBox.cs index 3c21cd2c38..a085bfb6bc 100644 --- a/src/Avalonia.Controls/ListBox.cs +++ b/src/Avalonia.Controls/ListBox.cs @@ -136,7 +136,8 @@ namespace Avalonia.Controls e.Handled = UpdateSelectionFromEventSource( e.Source, true, - (e.KeyModifiers & KeyModifiers.Shift) != 0); + (e.KeyModifiers & KeyModifiers.Shift) != 0, + (e.KeyModifiers & KeyModifiers.Control) != 0); } } From 177c6530931449ff500bb103c65d68c8af6d92b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20Onak?= Date: Fri, 31 Jul 2020 22:40:11 +0200 Subject: [PATCH 13/26] Revert GridSplitter size for default theme --- src/Avalonia.Themes.Default/GridSplitter.xaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Avalonia.Themes.Default/GridSplitter.xaml b/src/Avalonia.Themes.Default/GridSplitter.xaml index dc5cd002dc..6d9cb4f31f 100644 --- a/src/Avalonia.Themes.Default/GridSplitter.xaml +++ b/src/Avalonia.Themes.Default/GridSplitter.xaml @@ -2,8 +2,8 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 4e63d1d223..e5b654b490 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -55,4 +55,7 @@ + + + diff --git a/src/Avalonia.Themes.Default/SplitView.xaml b/src/Avalonia.Themes.Default/SplitView.xaml new file mode 100644 index 0000000000..4a9a7be164 --- /dev/null +++ b/src/Avalonia.Themes.Default/SplitView.xaml @@ -0,0 +1,219 @@ + + + + 320 + 48 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Default/TimePicker.xaml b/src/Avalonia.Themes.Default/TimePicker.xaml new file mode 100644 index 0000000000..6c4d0c97bd --- /dev/null +++ b/src/Avalonia.Themes.Default/TimePicker.xaml @@ -0,0 +1,283 @@ + + + + + 40 + 1 + 1 + 0,0,0,4 + 40 + 41 + 242 + 456 + 0,3,0,6 + 0,3,0,6 + + + + + + + + + + + + + + + + + + + + + + + + + + From 96d1d7358192511886a8e33ea54df17485ce7309 Mon Sep 17 00:00:00 2001 From: amwx Date: Tue, 4 Aug 2020 23:14:28 -0500 Subject: [PATCH 20/26] SplitView Default style --- src/Avalonia.Themes.Default/SplitView.xaml | 219 +++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/Avalonia.Themes.Default/SplitView.xaml diff --git a/src/Avalonia.Themes.Default/SplitView.xaml b/src/Avalonia.Themes.Default/SplitView.xaml new file mode 100644 index 0000000000..4a9a7be164 --- /dev/null +++ b/src/Avalonia.Themes.Default/SplitView.xaml @@ -0,0 +1,219 @@ + + + + 320 + 48 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 80863f44a9d38731f15c8bb4551d54c153c2b5ca Mon Sep 17 00:00:00 2001 From: amwx Date: Tue, 4 Aug 2020 23:15:01 -0500 Subject: [PATCH 21/26] Date/Time Picker default style --- src/Avalonia.Themes.Default/DatePicker.xaml | 334 ++++++++++++++++++++ src/Avalonia.Themes.Default/TimePicker.xaml | 283 +++++++++++++++++ 2 files changed, 617 insertions(+) create mode 100644 src/Avalonia.Themes.Default/DatePicker.xaml create mode 100644 src/Avalonia.Themes.Default/TimePicker.xaml diff --git a/src/Avalonia.Themes.Default/DatePicker.xaml b/src/Avalonia.Themes.Default/DatePicker.xaml new file mode 100644 index 0000000000..da878c88e2 --- /dev/null +++ b/src/Avalonia.Themes.Default/DatePicker.xaml @@ -0,0 +1,334 @@ + + + + + 0,0,0,4 + 40 + 40 + 41 + 296 + 456 + 0,3,0,6 + 9,3,0,6 + 0,3,0,6 + 9,3,0,6 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Avalonia.Themes.Default/TimePicker.xaml b/src/Avalonia.Themes.Default/TimePicker.xaml new file mode 100644 index 0000000000..6c4d0c97bd --- /dev/null +++ b/src/Avalonia.Themes.Default/TimePicker.xaml @@ -0,0 +1,283 @@ + + + + + 40 + 1 + 1 + 0,0,0,4 + 40 + 41 + 242 + 456 + 0,3,0,6 + 0,3,0,6 + + + + + + + + + + + + + + + + + + + + + + + + + + From 7ef5078610930498485f7d578d816418ad838734 Mon Sep 17 00:00:00 2001 From: amwx Date: Tue, 4 Aug 2020 23:15:12 -0500 Subject: [PATCH 22/26] Default theme support --- src/Avalonia.Themes.Default/Accents/BaseDark.xaml | 3 +++ src/Avalonia.Themes.Default/Accents/BaseLight.xaml | 3 +++ src/Avalonia.Themes.Default/DefaultTheme.xaml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/src/Avalonia.Themes.Default/Accents/BaseDark.xaml b/src/Avalonia.Themes.Default/Accents/BaseDark.xaml index 4274f7ee4d..44dfb9ea48 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseDark.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseDark.xaml @@ -58,6 +58,9 @@ + + + 1,1,1,1 diff --git a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml index b1c0946d0f..9ed3207235 100644 --- a/src/Avalonia.Themes.Default/Accents/BaseLight.xaml +++ b/src/Avalonia.Themes.Default/Accents/BaseLight.xaml @@ -61,6 +61,9 @@ + + + 1 diff --git a/src/Avalonia.Themes.Default/DefaultTheme.xaml b/src/Avalonia.Themes.Default/DefaultTheme.xaml index 4e63d1d223..e5b654b490 100644 --- a/src/Avalonia.Themes.Default/DefaultTheme.xaml +++ b/src/Avalonia.Themes.Default/DefaultTheme.xaml @@ -55,4 +55,7 @@ + + + From 4f7a701ab79044ba29a23e65a48446271ff81dfc Mon Sep 17 00:00:00 2001 From: Benedikt Schroeder Date: Wed, 5 Aug 2020 06:35:37 +0200 Subject: [PATCH 23/26] Reuse the shaped text runs as much as possible --- .../Media/TextFormatting/TextFormatterImpl.cs | 79 +++++++++++++------ .../Media/TextFormatting/TextLayout.cs | 5 +- .../Media/TextFormatting/TextLineImpl.cs | 4 +- src/Skia/Avalonia.Skia/TextShaperImpl.cs | 10 +-- 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs index 9318fcc68e..b116249fd4 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextFormatterImpl.cs @@ -31,7 +31,8 @@ namespace Avalonia.Media.TextFormatting case TextWrapping.WrapWithOverflow: case TextWrapping.Wrap: { - textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties); + textLine = PerformTextWrapping(textRuns, textRange, paragraphWidth, paragraphProperties, + nextLineBreak); break; } default: @@ -118,7 +119,7 @@ namespace Avalonia.Media.TextFormatting /// The text run's. /// The length to split at. /// The split text runs. - internal static SplitTextRunsResult SplitTextRuns(IReadOnlyList textRuns, int length) + internal static SplitTextRunsResult SplitTextRuns(List textRuns, int length) { var currentLength = 0; @@ -134,13 +135,13 @@ namespace Avalonia.Media.TextFormatting var firstCount = currentRun.GlyphRun.Characters.Length >= 1 ? i + 1 : i; - var first = new ShapedTextCharacters[firstCount]; + var first = new List(firstCount); if (firstCount > 1) { for (var j = 0; j < i; j++) { - first[j] = textRuns[j]; + first.Add(textRuns[j]); } } @@ -148,7 +149,7 @@ namespace Avalonia.Media.TextFormatting if (currentLength + currentRun.GlyphRun.Characters.Length == length) { - var second = new ShapedTextCharacters[secondCount]; + var second = new List(secondCount); var offset = currentRun.GlyphRun.Characters.Length > 1 ? 1 : 0; @@ -156,11 +157,11 @@ namespace Avalonia.Media.TextFormatting { for (var j = 0; j < secondCount; j++) { - second[j] = textRuns[i + j + offset]; + second.Add(textRuns[i + j + offset]); } } - first[i] = currentRun; + first.Add(currentRun); return new SplitTextRunsResult(first, second); } @@ -168,22 +169,22 @@ namespace Avalonia.Media.TextFormatting { secondCount++; - var second = new ShapedTextCharacters[secondCount]; + var second = new List(secondCount); + + var split = currentRun.Split(length - currentLength); + + first.Add(split.First); + + second.Add(split.Second); if (secondCount > 0) { for (var j = 1; j < secondCount; j++) { - second[j] = textRuns[i + j]; + second.Add(textRuns[i + j]); } } - var split = currentRun.Split(length - currentLength); - - first[i] = split.First; - - second[0] = split.Second; - return new SplitTextRunsResult(first, second); } } @@ -201,7 +202,7 @@ namespace Avalonia.Media.TextFormatting /// /// The formatted text runs. /// - private static IReadOnlyList FetchTextRuns(ITextSource textSource, + private static List FetchTextRuns(ITextSource textSource, int firstTextSourceIndex, TextLineBreak previousLineBreak, out TextLineBreak nextLineBreak) { nextLineBreak = default; @@ -212,8 +213,10 @@ namespace Avalonia.Media.TextFormatting if (previousLineBreak != null) { - foreach (var shapedCharacters in previousLineBreak.RemainingCharacters) + for (var index = 0; index < previousLineBreak.RemainingCharacters.Count; index++) { + var shapedCharacters = previousLineBreak.RemainingCharacters[index]; + if (shapedCharacters == null) { continue; @@ -225,6 +228,14 @@ namespace Avalonia.Media.TextFormatting { var splitResult = SplitTextRuns(textRuns, currentLength + runLineBreak.PositionWrap); + if (++index < previousLineBreak.RemainingCharacters.Count) + { + for (; index < previousLineBreak.RemainingCharacters.Count; index++) + { + splitResult.Second.Add(previousLineBreak.RemainingCharacters[index]); + } + } + nextLineBreak = new TextLineBreak(splitResult.Second); return splitResult.First; @@ -323,9 +334,10 @@ namespace Avalonia.Media.TextFormatting /// The text range that is covered by the text runs. /// The paragraph width. /// The text paragraph properties. + /// The current line break if the line was explicitly broken. /// The wrapped text line. - private static TextLine PerformTextWrapping(IReadOnlyList textRuns, TextRange textRange, - double paragraphWidth, TextParagraphProperties paragraphProperties) + private static TextLine PerformTextWrapping(List textRuns, TextRange textRange, + double paragraphWidth, TextParagraphProperties paragraphProperties, TextLineBreak currentLineBreak) { var availableWidth = paragraphWidth; var currentWidth = 0.0; @@ -388,8 +400,22 @@ namespace Avalonia.Media.TextFormatting var textLineMetrics = TextLineMetrics.Create(splitResult.First, new TextRange(textRange.Start, currentLength), paragraphWidth, paragraphProperties); - var lineBreak = splitResult.Second != null && splitResult.Second.Count > 0 ? - new TextLineBreak(splitResult.Second) : + var remainingCharacters = splitResult.Second; + + if (currentLineBreak?.RemainingCharacters != null) + { + if (remainingCharacters != null) + { + remainingCharacters.AddRange(currentLineBreak.RemainingCharacters); + } + else + { + remainingCharacters = new List(currentLineBreak.RemainingCharacters); + } + } + + var lineBreak = remainingCharacters != null && remainingCharacters.Count > 0 ? + new TextLineBreak(remainingCharacters) : null; return new TextLineImpl(splitResult.First, textLineMetrics, lineBreak); @@ -403,7 +429,10 @@ namespace Avalonia.Media.TextFormatting } return new TextLineImpl(textRuns, - TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties)); + TextLineMetrics.Create(textRuns, textRange, paragraphWidth, paragraphProperties), + currentLineBreak?.RemainingCharacters != null ? + new TextLineBreak(currentLineBreak.RemainingCharacters) : + null); } /// @@ -434,7 +463,7 @@ namespace Avalonia.Media.TextFormatting internal readonly struct SplitTextRunsResult { - public SplitTextRunsResult(IReadOnlyList first, IReadOnlyList second) + public SplitTextRunsResult(List first, List second) { First = first; @@ -447,7 +476,7 @@ namespace Avalonia.Media.TextFormatting /// /// The first text runs. /// - public IReadOnlyList First { get; } + public List First { get; } /// /// Gets the second text runs. @@ -455,7 +484,7 @@ namespace Avalonia.Media.TextFormatting /// /// The second text runs. /// - public IReadOnlyList Second { get; } + public List Second { get; } } private struct TextRunEnumerator diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs index 14602a2560..fa7d6cb4bf 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLayout.cs @@ -183,7 +183,10 @@ namespace Avalonia.Media.TextFormatting var glyphRun = TextShaper.Current.ShapeText(new ReadOnlySlice(s_empty, startingIndex, 1), properties.Typeface, properties.FontRenderingEmSize, properties.CultureInfo); - var textRuns = new[] { new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) }; + var textRuns = new List + { + new ShapedTextCharacters(glyphRun, _paragraphProperties.DefaultTextRunProperties) + }; return new TextLineImpl(textRuns, TextLineMetrics.Create(textRuns, new TextRange(startingIndex, 1), MaxWidth, _paragraphProperties)); diff --git a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs index 435752160e..8b44e32c48 100644 --- a/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs +++ b/src/Avalonia.Visuals/Media/TextFormatting/TextLineImpl.cs @@ -6,9 +6,9 @@ namespace Avalonia.Media.TextFormatting { internal class TextLineImpl : TextLine { - private readonly IReadOnlyList _textRuns; + private readonly List _textRuns; - public TextLineImpl(IReadOnlyList textRuns, TextLineMetrics lineMetrics, + public TextLineImpl(List textRuns, TextLineMetrics lineMetrics, TextLineBreak lineBreak = null, bool hasCollapsed = false) { _textRuns = textRuns; diff --git a/src/Skia/Avalonia.Skia/TextShaperImpl.cs b/src/Skia/Avalonia.Skia/TextShaperImpl.cs index b0384a1fdf..61075171fe 100644 --- a/src/Skia/Avalonia.Skia/TextShaperImpl.cs +++ b/src/Skia/Avalonia.Skia/TextShaperImpl.cs @@ -123,10 +123,7 @@ namespace Avalonia.Skia return; } - if (offsetBuffer == null) - { - offsetBuffer = new Vector[glyphPositions.Length]; - } + offsetBuffer ??= new Vector[glyphPositions.Length]; var offsetX = position.XOffset * textScale; @@ -138,10 +135,7 @@ namespace Avalonia.Skia private static void SetAdvance(ReadOnlySpan glyphPositions, int index, double textScale, ref double[] advanceBuffer) { - if (advanceBuffer == null) - { - advanceBuffer = new double[glyphPositions.Length]; - } + advanceBuffer ??= new double[glyphPositions.Length]; // Depends on direction of layout // advanceBuffer[index] = buffer.GlyphPositions[index].YAdvance * textScale; From a91d6322112c679e96dc8968280bf62832139b9e Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Aug 2020 16:33:09 +0200 Subject: [PATCH 24/26] Move ListBox page view model to its own file. Also remove `SelectedItem` and `SelectedItems` in favor of `SelectionModel`. --- samples/ControlCatalog/Pages/ListBoxPage.xaml | 3 +- .../ControlCatalog/Pages/ListBoxPage.xaml.cs | 68 +------------------ .../ViewModels/ListBoxPageViewModel.cs | 65 ++++++++++++++++++ 3 files changed, 68 insertions(+), 68 deletions(-) create mode 100644 samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs diff --git a/samples/ControlCatalog/Pages/ListBoxPage.xaml b/samples/ControlCatalog/Pages/ListBoxPage.xaml index f4d81418ac..edf3d41bf5 100644 --- a/samples/ControlCatalog/Pages/ListBoxPage.xaml +++ b/samples/ControlCatalog/Pages/ListBoxPage.xaml @@ -11,8 +11,7 @@ Spacing="16"> (Enumerable.Range(1, 10000).Select(i => GenerateItem())); - SelectedItems = new ObservableCollection(); - - AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); - - RemoveItemCommand = ReactiveCommand.Create(() => - { - while (SelectedItems.Count > 0) - { - Items.Remove(SelectedItems[0]); - } - }); - - SelectRandomItemCommand = ReactiveCommand.Create(() => - { - var random = new Random(); - - SelectedItem = Items[random.Next(Items.Count - 1)]; - }); - } - - public ObservableCollection Items { get; } - - private string _selectedItem; - - public string SelectedItem - { - get { return _selectedItem; } - set { this.RaiseAndSetIfChanged(ref _selectedItem, value); } - } - - - public ObservableCollection SelectedItems { get; } - - public ReactiveCommand AddItemCommand { get; } - - public ReactiveCommand RemoveItemCommand { get; } - - public ReactiveCommand SelectRandomItemCommand { get; } - - public SelectionMode SelectionMode - { - get => _selectionMode; - set - { - SelectedItems.Clear(); - this.RaiseAndSetIfChanged(ref _selectionMode, value); - } - } - - private string GenerateItem() => $"Item {_counter++.ToString()}"; - } } } diff --git a/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs new file mode 100644 index 0000000000..6bdb5c0103 --- /dev/null +++ b/samples/ControlCatalog/ViewModels/ListBoxPageViewModel.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reactive; +using Avalonia.Controls; +using ReactiveUI; + +namespace ControlCatalog.ViewModels +{ + public class ListBoxPageViewModel : ReactiveObject + { + private int _counter; + private SelectionMode _selectionMode; + + public ListBoxPageViewModel() + { + Items = new ObservableCollection(Enumerable.Range(1, 10000).Select(i => GenerateItem())); + Selection = new SelectionModel(); + Selection.Select(1); + + AddItemCommand = ReactiveCommand.Create(() => Items.Add(GenerateItem())); + + RemoveItemCommand = ReactiveCommand.Create(() => + { + while (Selection.SelectedItems.Count > 0) + { + Items.Remove((string)Selection.SelectedItems.First()); + } + }); + + SelectRandomItemCommand = ReactiveCommand.Create(() => + { + var random = new Random(); + + using (Selection.Update()) + { + Selection.ClearSelection(); + Selection.Select(random.Next(Items.Count - 1)); + } + }); + } + + public ObservableCollection Items { get; } + + public SelectionModel Selection { get; } + + public ReactiveCommand AddItemCommand { get; } + + public ReactiveCommand RemoveItemCommand { get; } + + public ReactiveCommand SelectRandomItemCommand { get; } + + public SelectionMode SelectionMode + { + get => _selectionMode; + set + { + Selection.ClearSelection(); + this.RaiseAndSetIfChanged(ref _selectionMode, value); + } + } + + private string GenerateItem() => $"Item {_counter++.ToString()}"; + } +} From d2af884aedf68f4534e2afa20e4c282cc4e7eba2 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Aug 2020 17:13:02 +0200 Subject: [PATCH 25/26] Added a failing test for #4272. --- .../Utils/SelectedItemsSyncTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs index 3ab5950974..4aa7e24aa7 100644 --- a/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs +++ b/tests/Avalonia.Controls.UnitTests/Utils/SelectedItemsSyncTests.cs @@ -208,6 +208,20 @@ namespace Avalonia.Controls.UnitTests.Utils target.SetItems(new[] { "foo", "bar", "baz" })); } + [Fact] + public void Selected_Items_Can_Be_Set_Before_SelectionModel_Source() + { + var model = new SelectionModel(); + var target = new SelectedItemsSync(model); + var items = new AvaloniaList { "foo", "bar", "baz" }; + var selectedItems = new AvaloniaList { "bar" }; + + target.SetItems(selectedItems); + model.Source = items; + + Assert.Equal(new IndexPath(1), model.SelectedIndex); + } + private static SelectedItemsSync CreateTarget( IEnumerable items = null) { From 0f9ac73b4fc43dde5384e7eb4825a87e0266446f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 5 Aug 2020 17:13:18 +0200 Subject: [PATCH 26/26] Handle uninitialized Source in SelectedItemsSync. If the `SelectionModel` passed to `SelectedItemsSync` has not yet had a `Source` assigned, set a flag to write the selected items to the `SelectionModel` when `Source` is set. Fixes #4272 --- .../Utils/SelectedItemsSync.cs | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs index c127771990..91cef9fe64 100644 --- a/src/Avalonia.Controls/Utils/SelectedItemsSync.cs +++ b/src/Avalonia.Controls/Utils/SelectedItemsSync.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Specialized; +using System.ComponentModel; using System.Linq; using Avalonia.Collections; @@ -16,6 +17,7 @@ namespace Avalonia.Controls.Utils private IList? _items; private bool _updatingItems; private bool _updatingModel; + private bool _initializeOnSourceAssignment; public SelectedItemsSync(ISelectionModel model) { @@ -63,10 +65,18 @@ namespace Avalonia.Controls.Utils _updatingModel = true; _items = items; - using (Model.Update()) + if (Model.Source is object) { - Model.ClearSelection(); - Add(items); + using (Model.Update()) + { + Model.ClearSelection(); + Add(items); + } + } + else if (!_initializeOnSourceAssignment) + { + Model.PropertyChanged += SelectionModelPropertyChanged; + _initializeOnSourceAssignment = true; } if (_items is INotifyCollectionChanged incc2) @@ -86,9 +96,11 @@ namespace Avalonia.Controls.Utils if (_items != null) { + Model.PropertyChanged -= SelectionModelPropertyChanged; Model.SelectionChanged -= SelectionModelSelectionChanged; Model = model; Model.SelectionChanged += SelectionModelSelectionChanged; + _initializeOnSourceAssignment = false; try { @@ -175,6 +187,25 @@ namespace Avalonia.Controls.Utils } } + private void SelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (_initializeOnSourceAssignment && + _items != null && + e.PropertyName == nameof(SelectionModel.Source)) + { + try + { + _updatingModel = true; + Add(_items); + _initializeOnSourceAssignment = false; + } + finally + { + _updatingModel = false; + } + } + } + private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e) { if (_updatingModel)