Browse Source

Merge branch 'master' into fixes/4403-setter-binding-unsetvalue

pull/4446/head
Jumar Macato 6 years ago
committed by GitHub
parent
commit
a8b4518737
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      src/Avalonia.Controls/ApiCompatBaseline.txt
  2. 7
      src/Avalonia.Controls/AutoCompleteBox.cs
  3. 7
      src/Avalonia.Controls/Calendar/CalendarDatePicker.cs
  4. 22
      src/Avalonia.Controls/ComboBox.cs
  5. 3
      src/Avalonia.Controls/ContextMenu.cs
  6. 4
      src/Avalonia.Controls/IMenuElement.cs
  7. 6
      src/Avalonia.Controls/IMenuItem.cs
  8. 2
      src/Avalonia.Controls/ItemsControl.cs
  9. 3
      src/Avalonia.Controls/ListBox.cs
  10. 5
      src/Avalonia.Controls/Menu.cs
  11. 8
      src/Avalonia.Controls/MenuBase.cs
  12. 64
      src/Avalonia.Controls/MenuItem.cs
  13. 12
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  14. 61
      src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs
  15. 143
      src/Avalonia.Controls/Primitives/Popup.cs
  16. 33
      src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs
  17. 26
      src/Avalonia.Controls/Primitives/VisualLayerManager.cs
  18. 3
      src/Avalonia.DesignerSupport/DesignWindowLoader.cs
  19. 29
      src/Avalonia.Input/InputExtensions.cs
  20. 2
      src/Avalonia.Themes.Default/AutoCompleteBox.xaml
  21. 2
      src/Avalonia.Themes.Default/ComboBox.xaml
  22. 4
      src/Avalonia.Themes.Default/GridSplitter.xaml
  23. 6
      src/Avalonia.Themes.Default/MenuItem.xaml
  24. 2
      src/Avalonia.Themes.Fluent/AutoCompleteBox.xaml
  25. 2
      src/Avalonia.Themes.Fluent/CalendarDatePicker.xaml
  26. 2
      src/Avalonia.Themes.Fluent/ComboBox.xaml
  27. 2
      src/Avalonia.Themes.Fluent/DatePicker.xaml
  28. 5
      src/Avalonia.Themes.Fluent/MenuItem.xaml
  29. 2
      src/Avalonia.Themes.Fluent/TimePicker.xaml
  30. 11
      src/Avalonia.Visuals/Rendering/ICustomSimpleHitTest.cs
  31. 6
      src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs
  32. 126
      tests/Avalonia.Controls.UnitTests/ListBoxTests_Multiple.cs
  33. 18
      tests/Avalonia.Controls.UnitTests/Platform/DefaultMenuInteractionHandlerTests.cs
  34. 59
      tests/Avalonia.Controls.UnitTests/Primitives/PopupTests.cs
  35. 1
      tests/Avalonia.UnitTests/MockWindowingPlatform.cs

7
src/Avalonia.Controls/ApiCompatBaseline.txt

@ -0,0 +1,7 @@
Compat issues with assembly Avalonia.Controls:
MembersMustExist : Member 'protected void Avalonia.Controls.ComboBox.PopupClosedOverride(Avalonia.Controls.Primitives.PopupClosedEventArgs)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public Avalonia.StyledProperty<System.Boolean> Avalonia.StyledProperty<System.Boolean> Avalonia.Controls.Primitives.Popup.StaysOpenProperty' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Controls.Primitives.Popup.add_Closed(System.EventHandler<Avalonia.Controls.Primitives.PopupClosedEventArgs>)' does not exist in the implementation but it does exist in the contract.
MembersMustExist : Member 'public void Avalonia.Controls.Primitives.Popup.remove_Closed(System.EventHandler<Avalonia.Controls.Primitives.PopupClosedEventArgs>)' does not exist in the implementation but it does exist in the contract.
TypesMustExist : Type 'Avalonia.Controls.Primitives.PopupClosedEventArgs' does not exist in the implementation but it does exist in the contract.
Total Issues: 5

7
src/Avalonia.Controls/AutoCompleteBox.cs

@ -1647,7 +1647,7 @@ namespace Avalonia.Controls
/// </summary>
/// <param name="sender">The source object.</param>
/// <param name="e">The event data.</param>
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)
{

7
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);
}

22
src/Avalonia.Controls/ComboBox.cs

@ -290,24 +290,6 @@ namespace Avalonia.Controls
_popup = e.NameScope.Get<Popup>("PART_Popup");
_popup.Opened += PopupOpened;
_popup.Closed += PopupClosed;
}
/// <summary>
/// Called when the ComboBox popup is closed, with the <see cref="PopupClosedEventArgs"/>
/// that caused the popup to close.
/// </summary>
/// <param name="e">The event args.</param>
/// <remarks>
/// This method can be overridden to control whether the event that caused the popup to close
/// is swallowed or passed through.
/// </remarks>
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();

3
src/Avalonia.Controls/ContextMenu.cs

@ -265,7 +265,8 @@ namespace Avalonia.Controls
PlacementMode = PlacementMode,
PlacementRect = PlacementRect,
PlacementTarget = PlacementTarget ?? control,
StaysOpen = false
IsLightDismissEnabled = true,
OverlayDismissEventPassThrough = true,
};
_popup.Opened += PopupOpened;

4
src/Avalonia.Controls/IMenuElement.cs

@ -1,6 +1,8 @@
using System.Collections.Generic;
using Avalonia.Input;
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
@ -11,7 +13,7 @@ namespace Avalonia.Controls
/// <summary>
/// Gets or sets the currently selected submenu item.
/// </summary>
IMenuItem SelectedItem { get; set; }
IMenuItem? SelectedItem { get; set; }
/// <summary>
/// Gets the submenu items.

6
src/Avalonia.Controls/IMenuItem.cs

@ -1,4 +1,6 @@
namespace Avalonia.Controls
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
/// Represents a <see cref="MenuItem"/>.
@ -29,7 +31,7 @@
/// <summary>
/// Gets the parent <see cref="IMenuElement"/>.
/// </summary>
new IMenuElement Parent { get; }
new IMenuElement? Parent { get; }
/// <summary>
/// Raises a click event on the menu item.

2
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;
}

3
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);
}
}

5
src/Avalonia.Controls/Menu.cs

@ -1,9 +1,12 @@
using Avalonia.Controls.Platform;
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
@ -14,6 +17,8 @@ namespace Avalonia.Controls
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel { Orientation = Orientation.Horizontal });
private LightDismissOverlayLayer? _overlay;
/// <summary>
/// Initializes a new instance of the <see cref="Menu"/> class.
/// </summary>

8
src/Avalonia.Controls/MenuBase.cs

@ -8,6 +8,8 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.LogicalTree;
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
@ -51,9 +53,7 @@ namespace Avalonia.Controls
/// <param name="interactionHandler">The menu interaction handler.</param>
public MenuBase(IMenuInteractionHandler interactionHandler)
{
Contract.Requires<ArgumentNullException>(interactionHandler != null);
InteractionHandler = interactionHandler;
InteractionHandler = interactionHandler ?? throw new ArgumentNullException(nameof(interactionHandler));
}
/// <summary>
@ -77,7 +77,7 @@ namespace Avalonia.Controls
IMenuInteractionHandler IMenu.InteractionHandler => InteractionHandler;
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
IMenuItem? IMenuElement.SelectedItem
{
get
{

64
src/Avalonia.Controls/MenuItem.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Linq;
using System.Windows.Input;
using Avalonia.Controls.Generators;
using Avalonia.Controls.Mixins;
@ -12,6 +13,8 @@ using Avalonia.Interactivity;
using Avalonia.LogicalTree;
using Avalonia.VisualTree;
#nullable enable
namespace Avalonia.Controls
{
/// <summary>
@ -22,7 +25,7 @@ namespace Avalonia.Controls
/// <summary>
/// Defines the <see cref="Command"/> property.
/// </summary>
public static readonly DirectProperty<MenuItem, ICommand> CommandProperty =
public static readonly DirectProperty<MenuItem, ICommand?> CommandProperty =
Button.CommandProperty.AddOwner<MenuItem>(
menuItem => menuItem.Command,
(menuItem, command) => menuItem.Command = command,
@ -94,7 +97,7 @@ namespace Avalonia.Controls
private static readonly ITemplate<IPanel> DefaultPanel =
new FuncTemplate<IPanel>(() => new StackPanel());
private ICommand _command;
private ICommand? _command;
private bool _commandCanExecute = true;
private Popup _popup;
@ -118,6 +121,32 @@ namespace Avalonia.Controls
public MenuItem()
{
// HACK: This nasty but it's all WPF's fault. Grid uses an inherited attached
// property to store SharedSizeGroup state, except property inheritance is done
// down the logical tree. In this case, the control which is setting
// Grid.IsSharedSizeScope="True" is not in the logical tree. Instead of fixing
// the way Grid stores shared size state, the developers of WPF just created a
// binding of the internal state of the visual parent to the menu item. We don't
// have much choice but to do the same for now unless we want to refactor Grid,
// which I honestly am not brave enough to do right now. Here's the same hack in
// the WPF codebase:
//
// https://github.com/dotnet/wpf/blob/89537909bdf36bc918e88b37751add46a8980bb0/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/MenuItem.cs#L2126-L2141
//
// In addition to the hack from WPF, we also make sure to return null when we have
// no parent. If we don't do this, inheritance falls back to the logical tree,
// causing the shared size scope in the parent MenuItem to be used, breaking
// menu layout.
var parentSharedSizeScope = this.GetObservable(VisualParentProperty)
.SelectMany(x =>
{
var parent = x as Control;
return parent?.GetObservable(DefinitionBase.PrivateSharedSizeScopeProperty) ??
Observable.Return<DefinitionBase.SharedSizeScope>(null);
});
this.Bind(DefinitionBase.PrivateSharedSizeScopeProperty, parentSharedSizeScope);
}
/// <summary>
@ -165,7 +194,7 @@ namespace Avalonia.Controls
/// <summary>
/// Gets or sets the command associated with the menu item.
/// </summary>
public ICommand Command
public ICommand? Command
{
get { return _command; }
set { SetAndRaise(CommandProperty, ref _command, value); }
@ -245,7 +274,7 @@ namespace Avalonia.Controls
bool IMenuItem.IsPointerOverSubMenu => _popup?.IsPointerOverPopup ?? false;
/// <inheritdoc/>
IMenuElement IMenuItem.Parent => Parent as IMenuElement;
IMenuElement? IMenuItem.Parent => Parent as IMenuElement;
protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute;
@ -253,7 +282,7 @@ namespace Avalonia.Controls
bool IMenuElement.MoveSelection(NavigationDirection direction, bool wrap) => MoveSelection(direction, wrap);
/// <inheritdoc/>
IMenuItem IMenuElement.SelectedItem
IMenuItem? IMenuElement.SelectedItem
{
get
{
@ -324,27 +353,6 @@ namespace Avalonia.Controls
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (this.GetVisualParent() is IControl parent)
{
// HACK: This nasty but it's all WPF's fault. Grid uses an inherited attached
// property to store SharedSizeGroup state, except property inheritance is done
// down the logical tree. In this case, the control which is setting
// Grid.IsSharedSizeScope="True" is not in the logical tree. Instead of fixing
// the way Grid stores shared size state, the developers of WPF just created a
// binding of the internal state of the visual parent to the menu item. We don't
// have much choice but to do the same for now unless we want to refactor Grid,
// which I honestly am not brave enough to do right now. Here's the same hack in
// the WPF codebase:
//
// https://github.com/dotnet/wpf/blob/89537909bdf36bc918e88b37751add46a8980bb0/src/Microsoft.DotNet.Wpf/src/PresentationFramework/System/Windows/Controls/MenuItem.cs#L2126-L2141
SetValue(DefinitionBase.PrivateSharedSizeScopeProperty, parent.GetValue(DefinitionBase.PrivateSharedSizeScopeProperty));
}
}
/// <summary>
/// Called when the <see cref="MenuItem"/> is clicked.
/// </summary>
@ -545,7 +553,7 @@ namespace Avalonia.Controls
/// <param name="e">The property change event.</param>
private void IsSelectedChanged(AvaloniaPropertyChangedEventArgs e)
{
if ((bool)e.NewValue)
if ((bool)e.NewValue!)
{
Focus();
}
@ -557,7 +565,7 @@ namespace Avalonia.Controls
/// <param name="e">The property change event.</param>
private void SubMenuOpenChanged(AvaloniaPropertyChangedEventArgs e)
{
var value = (bool)e.NewValue;
var value = (bool)e.NewValue!;
if (value)
{

12
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);
@ -363,6 +364,11 @@ namespace Avalonia.Controls.Platform
}
else
{
if (item.IsTopLevel && item.Parent is IMainMenu mainMenu)
{
mainMenu.Open();
}
Open(item, false);
}
@ -385,7 +391,7 @@ namespace Avalonia.Controls.Platform
{
if (e.Source == Menu)
{
Menu.MoveSelection(NavigationDirection.First, true);
Menu?.MoveSelection(NavigationDirection.First, true);
}
}

61
src/Avalonia.Controls/Primitives/LightDismissOverlayLayer.cs

@ -0,0 +1,61 @@
using System;
using System.Linq;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Rendering;
using Avalonia.Styling;
using Avalonia.VisualTree;
#nullable enable
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// A layer that is used to dismiss a <see cref="Popup"/> when the user clicks outside.
/// </summary>
public class LightDismissOverlayLayer : Border, ICustomHitTest
{
public IInputElement? InputPassThroughElement { get; set; }
/// <summary>
/// Returns the light dismiss overlay for a specified visual.
/// </summary>
/// <param name="visual">The visual.</param>
/// <returns>The light dismiss overlay, or null if none found.</returns>
public static LightDismissOverlayLayer? GetLightDismissOverlayLayer(IVisual visual)
{
visual = visual ?? throw new ArgumentNullException(nameof(visual));
VisualLayerManager? manager;
if (visual is TopLevel topLevel)
{
manager = topLevel.GetTemplateChildren()
.OfType<VisualLayerManager>()
.FirstOrDefault();
}
else
{
manager = visual.FindAncestorOfType<VisualLayerManager>();
}
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;
}
}
}

143
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,12 +84,27 @@ namespace Avalonia.Controls.Primitives
AvaloniaProperty.Register<Popup, bool>(nameof(ObeyScreenEdges), true);
#pragma warning restore 618
public static readonly StyledProperty<bool> OverlayDismissEventPassThroughProperty =
AvaloniaProperty.Register<Popup, bool>(nameof(OverlayDismissEventPassThrough));
public static readonly DirectProperty<Popup, IInputElement> OverlayInputPassThroughElementProperty =
AvaloniaProperty.RegisterDirect<Popup, IInputElement>(
nameof(OverlayInputPassThroughElement),
o => o.OverlayInputPassThroughElement,
(o, v) => o.OverlayInputPassThroughElement = v);
/// <summary>
/// Defines the <see cref="HorizontalOffset"/> property.
/// </summary>
public static readonly StyledProperty<double> HorizontalOffsetProperty =
AvaloniaProperty.Register<Popup, double>(nameof(HorizontalOffset));
/// <summary>
/// Defines the <see cref="IsLightDismissEnabled"/> property.
/// </summary>
public static readonly StyledProperty<bool> IsLightDismissEnabledProperty =
AvaloniaProperty.Register<Popup, bool>(nameof(IsLightDismissEnabled));
/// <summary>
/// Defines the <see cref="VerticalOffset"/> property.
/// </summary>
@ -101,8 +114,13 @@ namespace Avalonia.Controls.Primitives
/// <summary>
/// Defines the <see cref="StaysOpen"/> property.
/// </summary>
public static readonly StyledProperty<bool> StaysOpenProperty =
AvaloniaProperty.Register<Popup, bool>(nameof(StaysOpen), true);
[Obsolete("Use IsLightDismissEnabledProperty")]
public static readonly DirectProperty<Popup, bool> StaysOpenProperty =
AvaloniaProperty.RegisterDirect<Popup, bool>(
nameof(StaysOpen),
o => o.StaysOpen,
(o, v) => o.StaysOpen = v,
true);
/// <summary>
/// Defines the <see cref="Topmost"/> property.
@ -113,6 +131,7 @@ namespace Avalonia.Controls.Primitives
private bool _isOpen;
private bool _ignoreIsOpenChanged;
private PopupOpenState? _openState;
private IInputElement _overlayInputPassThroughElement;
/// <summary>
/// Initializes static members of the <see cref="Popup"/> class.
@ -127,7 +146,7 @@ namespace Avalonia.Controls.Primitives
/// <summary>
/// Raised when the popup closes.
/// </summary>
public event EventHandler<PopupClosedEventArgs>? Closed;
public event EventHandler<EventArgs>? Closed;
/// <summary>
/// Raised when the popup opens.
@ -165,6 +184,18 @@ namespace Avalonia.Controls.Primitives
set;
}
/// <summary>
/// Gets or sets a value that determines how the <see cref="Popup"/> can be dismissed.
/// </summary>
/// <remarks>
/// Light dismiss is when the user taps on any area other than the popup.
/// </remarks>
public bool IsLightDismissEnabled
{
get => GetValue(IsLightDismissEnabledProperty);
set => SetValue(IsLightDismissEnabledProperty, value);
}
/// <summary>
/// Gets or sets a value indicating whether the popup is currently open.
/// </summary>
@ -246,6 +277,32 @@ namespace Avalonia.Controls.Primitives
set => SetValue(ObeyScreenEdgesProperty, value);
}
/// <summary>
/// Gets or sets a value indicating whether the event that closes the popup is passed
/// through to the parent window.
/// </summary>
/// <remarks>
/// When <see cref="IsLightDismissEnabled"/> is set to true, clicks outside the the popup
/// cause the popup to close. When <see cref="OverlayDismissEventPassThrough"/> 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.
/// </remarks>
public bool OverlayDismissEventPassThrough
{
get => GetValue(OverlayDismissEventPassThroughProperty);
set => SetValue(OverlayDismissEventPassThroughProperty, value);
}
/// <summary>
/// Gets or sets an element that should receive pointer input events even when underneath
/// the popup's overlay.
/// </summary>
public IInputElement OverlayInputPassThroughElement
{
get => _overlayInputPassThroughElement;
set => SetAndRaise(OverlayInputPassThroughElementProperty, ref _overlayInputPassThroughElement, value);
}
/// <summary>
/// Gets or sets the Horizontal offset of the popup in relation to the <see cref="PlacementTarget"/>.
/// </summary>
@ -268,10 +325,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.
/// </summary>
[Obsolete("Use IsLightDismissEnabled")]
public bool StaysOpen
{
get { return GetValue(StaysOpenProperty); }
set { SetValue(StaysOpenProperty, value); }
get => !IsLightDismissEnabled;
set => IsLightDismissEnabled = !value;
}
/// <summary>
@ -363,14 +421,12 @@ namespace Avalonia.Controls.Primitives
if (parentPopupRoot?.Parent is Popup popup)
{
DeferCleanup(SubscribeToEventHandler<Popup, EventHandler<PopupClosedEventArgs>>(popup, ParentClosed,
DeferCleanup(SubscribeToEventHandler<Popup, EventHandler<EventArgs>>(popup, ParentClosed,
(x, handler) => x.Closed += handler,
(x, handler) => x.Closed -= handler));
}
}
DeferCleanup(topLevel.AddDisposableHandler(PointerPressedEvent, PointerPressedOutside, RoutingStrategies.Tunnel));
DeferCleanup(InputManager.Instance?.Process.Subscribe(ListenForNonClientClick));
var cleanupPopup = Disposable.Create((popupHost, handlerCleanup), state =>
@ -384,6 +440,29 @@ namespace Avalonia.Controls.Primitives
state.popupHost.Dispose();
});
if (IsLightDismissEnabled)
{
var dismissLayer = LightDismissOverlayLayer.GetLightDismissOverlayLayer(placementTarget);
if (dismissLayer != null)
{
dismissLayer.IsVisible = true;
dismissLayer.InputPassThroughElement = _overlayInputPassThroughElement;
DeferCleanup(Disposable.Create(() =>
{
dismissLayer.IsVisible = false;
dismissLayer.InputPassThroughElement = null;
}));
DeferCleanup(SubscribeToEventHandler<LightDismissOverlayLayer, EventHandler<PointerPressedEventArgs>>(
dismissLayer,
PointerPressedDismissOverlay,
(x, handler) => x.PointerPressed += handler,
(x, handler) => x.PointerPressed -= handler));
}
}
_openState = new PopupOpenState(topLevel, popupHost, cleanupPopup);
WindowManagerAddShadowHintChanged(popupHost, WindowManagerAddShadowHint);
@ -401,7 +480,7 @@ namespace Avalonia.Controls.Primitives
/// <summary>
/// Closes the popup.
/// </summary>
public void Close() => CloseCore(null);
public void Close() => CloseCore();
/// <summary>
/// Measures the control.
@ -471,7 +550,7 @@ namespace Avalonia.Controls.Primitives
}
}
private void CloseCore(EventArgs? closeEvent)
private void CloseCore()
{
if (_openState is null)
{
@ -491,24 +570,46 @@ namespace Avalonia.Controls.Primitives
IsOpen = false;
}
Closed?.Invoke(this, new PopupClosedEventArgs(closeEvent));
Closed?.Invoke(this, EventArgs.Empty);
}
private void ListenForNonClientClick(RawInputEventArgs e)
{
var mouse = e as RawPointerEventArgs;
if (!StaysOpen && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown)
if (IsLightDismissEnabled && mouse?.Type == RawPointerEventType.NonClientLeftButtonDown)
{
CloseCore(e);
CloseCore();
}
}
private void PointerPressedOutside(object sender, PointerPressedEventArgs e)
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);
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)
{
e.Pointer.Capture(hit);
hit.RaiseEvent(e);
e.Handled = true;
}
}
}
@ -602,7 +703,7 @@ namespace Avalonia.Controls.Primitives
private void WindowDeactivated(object sender, EventArgs e)
{
if (!StaysOpen)
if (IsLightDismissEnabled)
{
Close();
}
@ -610,7 +711,7 @@ namespace Avalonia.Controls.Primitives
private void ParentClosed(object sender, EventArgs e)
{
if (!StaysOpen)
if (IsLightDismissEnabled)
{
Close();
}
@ -618,7 +719,7 @@ namespace Avalonia.Controls.Primitives
private void WindowLostFocus()
{
if(!StaysOpen)
if (IsLightDismissEnabled)
Close();
}

33
src/Avalonia.Controls/Primitives/PopupClosedEventArgs.cs

@ -1,33 +0,0 @@
using System;
using Avalonia.Interactivity;
#nullable enable
namespace Avalonia.Controls.Primitives
{
/// <summary>
/// Holds data for the <see cref="Popup.Closed"/> event.
/// </summary>
public class PopupClosedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="PopupClosedEventArgs"/> class.
/// </summary>
/// <param name="closeEvent"></param>
public PopupClosedEventArgs(EventArgs? closeEvent)
{
CloseEvent = closeEvent;
}
/// <summary>
/// Gets the event that closed the popup, if any.
/// </summary>
/// <remarks>
/// If <see cref="Popup.StaysOpen"/> is false, 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.
/// </remarks>
public EventArgs? CloseEvent { get; }
}
}

26
src/Avalonia.Controls/Primitives/VisualLayerManager.cs

@ -1,6 +1,6 @@
using System.Collections.Generic;
using Avalonia.LogicalTree;
using Avalonia.Metadata;
using Avalonia.Media;
namespace Avalonia.Controls.Primitives
{
@ -8,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<Control> _layers = new List<Control>();
@ -62,6 +63,27 @@ namespace Avalonia.Controls.Primitives
}
}
public LightDismissOverlayLayer LightDismissOverlayLayer
{
get
{
if (IsPopup)
return null;
var rv = FindLayer<LightDismissOverlayLayer>();
if (rv == null)
{
rv = new LightDismissOverlayLayer
{
Background = Brushes.Transparent,
IsVisible = false
};
AddLayer(rv, LightDismissOverlayZIndex);
}
return rv;
}
}
T FindLayer<T>() where T : class
{
foreach (var layer in _layers)

3
src/Avalonia.DesignerSupport/DesignWindowLoader.cs

@ -69,6 +69,8 @@ namespace Avalonia.DesignerSupport
window = new Window() {Content = (Control)control};
}
Design.ApplyDesignModeProperties(window, control);
if (!window.IsSet(Window.SizeToContentProperty))
{
if (double.IsNaN(window.Width))
@ -83,7 +85,6 @@ namespace Avalonia.DesignerSupport
}
}
window.Show();
Design.ApplyDesignModeProperties(window, control);
return window;
}
}

29
src/Avalonia.Input/InputExtensions.cs

@ -3,6 +3,8 @@ using System.Collections.Generic;
using System.Linq;
using Avalonia.VisualTree;
#nullable enable
namespace Avalonia.Input
{
/// <summary>
@ -22,7 +24,7 @@ namespace Avalonia.Input
/// </returns>
public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Point p)
{
Contract.Requires<ArgumentNullException>(element != null);
element = element ?? throw new ArgumentNullException(nameof(element));
return element.GetVisualsAt(p, s_hitTestDelegate).Cast<IInputElement>();
}
@ -33,13 +35,34 @@ namespace Avalonia.Input
/// <param name="element">The element to test.</param>
/// <param name="p">The point on <paramref name="element"/>.</param>
/// <returns>The topmost <see cref="IInputElement"/> at the specified position.</returns>
public static IInputElement InputHitTest(this IInputElement element, Point p)
public static IInputElement? InputHitTest(this IInputElement element, Point p)
{
Contract.Requires<ArgumentNullException>(element != null);
element = element ?? throw new ArgumentNullException(nameof(element));
return element.GetVisualAt(p, s_hitTestDelegate) as IInputElement;
}
/// <summary>
/// Returns the topmost active input element at a point on an <see cref="IInputElement"/>.
/// </summary>
/// <param name="element">The element to test.</param>
/// <param name="p">The point on <paramref name="element"/>.</param>
/// <param name="filter">
/// A filter predicate. If the predicate returns false then the visual and all its
/// children will be excluded from the results.
/// </param>
/// <returns>The topmost <see cref="IInputElement"/> at the specified position.</returns>
public static IInputElement? InputHitTest(
this IInputElement element,
Point p,
Func<IVisual, bool> 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;

2
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">
<Border BorderBrush="{DynamicResource ThemeBorderMidBrush}"
BorderThickness="1">
<ListBox Name="PART_SelectingItemsControl"

2
src/Avalonia.Themes.Default/ComboBox.xaml

@ -66,7 +66,7 @@
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
PlacementTarget="{TemplateBinding}"
StaysOpen="False">
IsLightDismissEnabled="True">
<Border BorderBrush="{DynamicResource ThemeBorderMidBrush}"
BorderThickness="1">
<ScrollViewer>

4
src/Avalonia.Themes.Default/GridSplitter.xaml

@ -2,8 +2,8 @@
<Style Selector="GridSplitter">
<Setter Property="Focusable" Value="True" />
<Setter Property="MinWidth" Value="6" />
<Setter Property="MinHeight" Value="6" />
<Setter Property="MinWidth" Value="1" />
<Setter Property="MinHeight" Value="1" />
<Setter Property="Background" Value="{DynamicResource ThemeControlMidBrush}" />
<Setter Property="PreviewContent">
<Template>

6
src/Avalonia.Themes.Default/MenuItem.xaml

@ -59,7 +59,7 @@
Grid.Column="4"/>
<Popup Name="PART_Popup"
PlacementMode="Right"
StaysOpen="True"
IsLightDismissEnabled="True"
IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource ThemeBorderMidBrush}"
@ -108,8 +108,8 @@
</ContentPresenter.DataTemplates>
</ContentPresenter>
<Popup Name="PART_Popup"
IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}"
StaysOpen="True">
IsLightDismissEnabled="True"
IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}">
<Border Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource ThemeBorderMidBrush}"
BorderThickness="{TemplateBinding BorderThickness}">

2
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}">
<Border Name="PART_SuggestionsContainer"
Padding="{DynamicResource AutoCompleteListMargin}"

2
src/Avalonia.Themes.Fluent/CalendarDatePicker.xaml

@ -120,7 +120,7 @@
<Popup Name="PART_Popup"
PlacementTarget="{TemplateBinding}"
StaysOpen="False">
IsLightDismissEnabled="True">
<Calendar Name="PART_Calendar"
FirstDayOfWeek="{TemplateBinding FirstDayOfWeek}"
IsTodayHighlighted="{TemplateBinding IsTodayHighlighted}"/>

2
src/Avalonia.Themes.Fluent/ComboBox.xaml

@ -124,7 +124,7 @@
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
MaxHeight="{TemplateBinding MaxDropDownHeight}"
PlacementTarget="{TemplateBinding}"
StaysOpen="False">
IsLightDismissEnabled="True">
<Border x:Name="PopupBorder"
Background="{DynamicResource ComboBoxDropDownBackground}"
BorderBrush="{DynamicResource ComboBoxDropDownBorderBrush}"

2
src/Avalonia.Themes.Fluent/DatePicker.xaml

@ -190,7 +190,7 @@
</Button>
<Popup Name="Popup" WindowManagerAddShadowHint="False"
StaysOpen="False" PlacementTarget="{TemplateBinding}"
IsLightDismissEnabled="True" PlacementTarget="{TemplateBinding}"
PlacementMode="Bottom">
<DatePickerPresenter Name="PickerPresenter" />
</Popup>

5
src/Avalonia.Themes.Fluent/MenuItem.xaml

@ -112,7 +112,7 @@
WindowManagerAddShadowHint="True"
PlacementMode="Right"
HorizontalOffset="{StaticResource MenuFlyoutSubItemPopupHorizontalOffset}"
StaysOpen="True"
IsLightDismissEnabled="True"
IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}">
<Border Background="{DynamicResource MenuFlyoutPresenterBackground}"
BorderBrush="{DynamicResource MenuFlyoutPresenterBorderBrush}"
@ -159,8 +159,9 @@
<Popup Name="PART_Popup"
WindowManagerAddShadowHint="False"
MinWidth="{Binding Bounds.Width, RelativeSource={RelativeSource TemplatedParent}}"
IsLightDismissEnabled="True"
IsOpen="{TemplateBinding IsSubMenuOpen, Mode=TwoWay}"
StaysOpen="True">
OverlayInputPassThroughElement="{Binding $parent[Menu]}">
<Border Background="{DynamicResource MenuFlyoutPresenterBackground}"
BorderBrush="{DynamicResource MenuFlyoutPresenterBorderBrush}"
BorderThickness="{DynamicResource MenuFlyoutPresenterBorderThemeThickness}"

2
src/Avalonia.Themes.Fluent/TimePicker.xaml

@ -123,7 +123,7 @@
</Button>
<Popup Name="Popup" WindowManagerAddShadowHint="False"
StaysOpen="False" PlacementTarget="{TemplateBinding}"
IsLightDismissEnabled="True" PlacementTarget="{TemplateBinding}"
PlacementMode="Bottom">
<TimePickerPresenter Name="PickerPresenter" />
</Popup>

11
src/Avalonia.Visuals/Rendering/ICustomSimpleHitTest.cs

@ -14,6 +14,17 @@ namespace Avalonia.Rendering
bool HitTest(Point point);
}
/// <summary>
/// Allows customization of hit-testing for all renderers.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface ICustomHitTest : ICustomSimpleHitTest
{
}
public static class CustomSimpleHitTestExtensions
{
public static bool HitTestCustom(this IVisual visual, Point point)

6
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;
}

126
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();
}
}
}

18
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<IMainMenu>();
menu.As<IMenuElement>();
var item = Mock.Of<IMenuItem>(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()
{

59
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
{
@ -349,52 +350,48 @@ namespace Avalonia.Controls.UnitTests.Primitives
}
[Fact]
public void StaysOpen_False_Should_Not_Handle_Closing_Click()
public void OverlayDismissEventPassThrough_Should_Pass_Event_To_Window_Contents()
{
using (CreateServices())
{
var window = PreparedWindow();
var renderer = new Mock<IRenderer>();
var platform = AvaloniaLocator.Current.GetService<IWindowingPlatform>();
var windowImpl = Mock.Get(platform.CreateWindow());
windowImpl.Setup(x => x.CreateRenderer(It.IsAny<IRenderRoot>())).Returns(renderer.Object);
var window = new Window(windowImpl.Object);
window.ApplyTemplate();
var target = new Popup()
{
PlacementTarget = window ,
StaysOpen = false,
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);
}
}
renderer.Setup(x =>
x.HitTestFirst(new Point(10, 15), window, It.IsAny<Func<IVisual, bool>>()))
.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,
StaysOpen = false,
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;
};
window.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);
}
}
@ -410,14 +407,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);

1
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
{

Loading…
Cancel
Save