using System;
using System.ComponentModel;
using System.Linq;
using Avalonia.Controls.Diagnostics;
using Avalonia.Controls.Primitives.PopupPositioning;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Logging;
using Avalonia.Reactive;
namespace Avalonia.Controls.Primitives
{
public abstract class PopupFlyoutBase : FlyoutBase, IPopupHostProvider
{
///
public static readonly StyledProperty PlacementProperty =
Popup.PlacementProperty.AddOwner();
///
public static readonly StyledProperty HorizontalOffsetProperty =
Popup.HorizontalOffsetProperty.AddOwner();
///
public static readonly StyledProperty VerticalOffsetProperty =
Popup.VerticalOffsetProperty.AddOwner();
///
public static readonly StyledProperty PlacementAnchorProperty =
Popup.PlacementAnchorProperty.AddOwner();
///
public static readonly StyledProperty PlacementGravityProperty =
Popup.PlacementGravityProperty.AddOwner();
///
public static readonly StyledProperty CustomPopupPlacementCallbackProperty =
Popup.CustomPopupPlacementCallbackProperty.AddOwner();
///
/// Defines the property
///
public static readonly StyledProperty ShowModeProperty =
AvaloniaProperty.Register(nameof(ShowMode));
///
/// Defines the property
///
public static readonly StyledProperty OverlayDismissEventPassThroughProperty =
Popup.OverlayDismissEventPassThroughProperty.AddOwner();
///
/// Defines the property
///
public static readonly StyledProperty OverlayInputPassThroughElementProperty =
Popup.OverlayInputPassThroughElementProperty.AddOwner();
///
/// Defines the property
///
public static readonly StyledProperty PlacementConstraintAdjustmentProperty =
Popup.PlacementConstraintAdjustmentProperty.AddOwner();
private readonly Lazy _popupLazy;
private Rect? _enlargedPopupRect;
private PixelRect? _enlargePopupRectScreenPixelRect;
private IDisposable? _transientDisposable;
private Action? _popupHostChangedHandler;
private bool _isOpen;
private bool _ignoreIsOpenChanged;
private Control? _lastPlacementTarget;
static PopupFlyoutBase()
{
IsOpenProperty.Changed.AddClassHandler(
(x, e) => x.IsOpenChanged((AvaloniaPropertyChangedEventArgs)e));
Control.ContextFlyoutProperty.Changed.Subscribe(OnContextFlyoutPropertyChanged);
}
public PopupFlyoutBase()
{
_popupLazy = new Lazy(() => CreatePopup());
}
public Popup Popup => _popupLazy.Value;
///
public PlacementMode Placement
{
get => GetValue(PlacementProperty);
set => SetValue(PlacementProperty, value);
}
///
public PopupGravity PlacementGravity
{
get => GetValue(PlacementGravityProperty);
set => SetValue(PlacementGravityProperty, value);
}
///
public PopupAnchor PlacementAnchor
{
get => GetValue(PlacementAnchorProperty);
set => SetValue(PlacementAnchorProperty, value);
}
///
public double HorizontalOffset
{
get => GetValue(HorizontalOffsetProperty);
set => SetValue(HorizontalOffsetProperty, value);
}
///
public double VerticalOffset
{
get => GetValue(VerticalOffsetProperty);
set => SetValue(VerticalOffsetProperty, value);
}
///
public CustomPopupPlacementCallback? CustomPopupPlacementCallback
{
get => GetValue(CustomPopupPlacementCallbackProperty);
set => SetValue(CustomPopupPlacementCallbackProperty, value);
}
///
/// Gets or sets the desired ShowMode
///
public FlyoutShowMode ShowMode
{
get => GetValue(ShowModeProperty);
set => SetValue(ShowModeProperty, value);
}
///
/// Gets or sets a value indicating whether the event that closes the flyout is passed
/// through to the parent window.
///
///
/// Clicks outside 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 an element that should receive pointer input events even when underneath
/// the flyout's overlay.
///
public IInputElement? OverlayInputPassThroughElement
{
get => GetValue(OverlayInputPassThroughElementProperty);
set => SetValue(OverlayInputPassThroughElementProperty, value);
}
///
public PopupPositionerConstraintAdjustment PlacementConstraintAdjustment
{
get => GetValue(PlacementConstraintAdjustmentProperty);
set => SetValue(PlacementConstraintAdjustmentProperty, value);
}
IPopupHost? IPopupHostProvider.PopupHost => Popup?.Host;
event Action? IPopupHostProvider.PopupHostChanged
{
add => _popupHostChangedHandler += value;
remove => _popupHostChangedHandler -= value;
}
public event EventHandler? Closing;
public event EventHandler? Opening;
///
/// Pre-registers a control as the default placement target for this flyout.
/// Used by owning controls (e.g. ) so that setting
/// to true works on first use.
///
internal void SetDefaultPlacementTarget(Control? target)
{
_lastPlacementTarget = target;
}
///
/// Shows the Flyout at the given Control
///
/// The control to show the Flyout at
public sealed override void ShowAt(Control placementTarget)
{
ShowAtCore(placementTarget);
}
///
/// Shows the Flyout for the given control at the current pointer location, as in a ContextFlyout
///
/// The target control
/// True to show at pointer
public void ShowAt(Control placementTarget, bool showAtPointer)
{
ShowAtCore(placementTarget, showAtPointer);
}
///
/// Hides the Flyout
///
public sealed override void Hide()
{
HideCore();
}
/// True, if action was handled
protected virtual bool HideCore(bool canCancel = true)
{
if (!_isOpen)
{
return false;
}
if (canCancel)
{
if (CancelClosing())
{
return false;
}
}
_isOpen = false;
using (BeginIgnoringIsOpen())
{
SetCurrentValue(IsOpenProperty, false);
}
Popup.IsOpen = false;
Popup.PlacementTarget = null;
Popup.SetPopupParent(null);
// Ensure this isn't active
_transientDisposable?.Dispose();
_transientDisposable = null;
_enlargedPopupRect = null;
_enlargePopupRectScreenPixelRect = null;
if (Target != null)
{
Target.DetachedFromVisualTree -= PlacementTarget_DetachedFromVisualTree;
Target.KeyUp -= OnPlacementTargetOrPopupKeyUp;
}
OnClosed();
Target = null;
return true;
}
/// True, if action was handled
protected virtual bool ShowAtCore(Control placementTarget, bool showAtPointer = false)
{
if (placementTarget == null)
{
throw new ArgumentNullException(nameof(placementTarget));
}
_lastPlacementTarget = placementTarget;
if (_isOpen)
{
if (placementTarget == Target)
{
return false;
}
else // Close before opening a new one
{
_ = HideCore(false);
}
}
Popup.PlacementTarget = Target = placementTarget;
Popup.SetPopupParent(placementTarget);
if (Popup.Child == null)
{
Popup.Child = CreatePresenter();
}
Popup.OverlayDismissEventPassThrough = OverlayDismissEventPassThrough;
Popup.OverlayInputPassThroughElement = OverlayInputPassThroughElement;
if (CancelOpening())
{
return false;
}
PositionPopup(showAtPointer);
_isOpen = true;
using (BeginIgnoringIsOpen())
{
SetCurrentValue(IsOpenProperty, true);
}
Popup.IsOpen = true;
OnOpened();
placementTarget.DetachedFromVisualTree += PlacementTarget_DetachedFromVisualTree;
placementTarget.KeyUp += OnPlacementTargetOrPopupKeyUp;
if (ShowMode == FlyoutShowMode.Standard)
{
// Try and focus content inside Flyout
if (Popup.Child.Focusable)
{
Popup.Child.Focus();
}
else
{
var nextFocus = KeyboardNavigationHandler.GetNext(Popup.Child, NavigationDirection.Next);
nextFocus?.Focus();
}
}
else if (ShowMode == FlyoutShowMode.TransientWithDismissOnPointerMoveAway)
{
_transientDisposable = InputManager.Instance?.Process.Subscribe(HandleTransientDismiss);
}
return true;
}
private void PlacementTarget_DetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_ = HideCore(false);
_lastPlacementTarget = null;
}
private void HandleTransientDismiss(RawInputEventArgs args)
{
if (args is RawPointerEventArgs pArgs && pArgs.Type == RawPointerEventType.Move)
{
// In ShowMode = TransientWithDismissOnPointerMoveAway, the Flyout is kept
// shown as long as the pointer is within a certain px distance from the
// flyout itself. I'm not sure what WinUI uses, but I'm defaulting to
// 100px, which seems about right
// enlargedPopupRect is the Flyout bounds enlarged 100px
// For windowed popups, enlargedPopupRect is in screen coordinates,
// for overlay popups, its in OverlayLayer coordinates
if (_enlargedPopupRect == null && _enlargePopupRectScreenPixelRect == null)
{
// Only do this once when the Flyout opens & cache the result
if (Popup?.Host is PopupRoot root)
{
// Get the popup root bounds and convert to screen coordinates
var tmp = root.Bounds.Inflate(100);
_enlargePopupRectScreenPixelRect = new PixelRect(root.PointToScreen(tmp.TopLeft), root.PointToScreen(tmp.BottomRight));
}
else if (Popup?.Host is OverlayPopupHost host)
{
// Overlay popups are in OverlayLayer coordinates, just use that
_enlargedPopupRect = host.Bounds.Inflate(100);
}
return;
}
if (Popup?.Host is PopupRoot && pArgs.Root.RootElement is { } eventRoot)
{
// As long as the pointer stays within the enlargedPopupRect
// the flyout stays open. If it leaves, close it
// Despite working in screen coordinates, leaving the TopLevel
// window will not close this (as pointer events stop), which
// does match UWP
var pt = eventRoot.PointToScreen(pArgs.Position);
if (!_enlargePopupRectScreenPixelRect?.Contains(pt) ?? false)
{
HideCore(false);
}
}
else if (Popup?.Host is OverlayPopupHost)
{
// Same as above here, but just different coordinate space
// so we don't need to translate
if (!_enlargedPopupRect?.Contains(pArgs.Position) ?? false)
{
HideCore(false);
}
}
}
}
protected virtual void OnOpening(CancelEventArgs args)
{
Opening?.Invoke(this, args);
}
protected virtual void OnClosing(CancelEventArgs args)
{
Closing?.Invoke(this, args);
}
///
/// Used to create the content the Flyout displays
///
///
protected abstract Control CreatePresenter();
private Popup CreatePopup()
{
var popup = new Popup
{
WindowManagerAddShadowHint = false,
IsLightDismissEnabled = true,
};
popup.Opened += OnPopupOpened;
popup.Closed += OnPopupClosed;
popup.Closing += OnPopupClosing;
popup.KeyUp += OnPlacementTargetOrPopupKeyUp;
return popup;
}
private void OnPopupOpened(object? sender, EventArgs e)
{
_isOpen = true;
using (BeginIgnoringIsOpen())
{
SetCurrentValue(IsOpenProperty, true);
}
_popupHostChangedHandler?.Invoke(Popup.Host);
}
private void OnPopupClosing(object? sender, CancelEventArgs e)
{
if (_isOpen)
{
e.Cancel = CancelClosing();
}
}
private void OnPopupClosed(object? sender, EventArgs e)
{
HideCore(false);
_popupHostChangedHandler?.Invoke(null);
}
// This method is handling both popup logical tree and target logical tree.
private void OnPlacementTargetOrPopupKeyUp(object? sender, KeyEventArgs e)
{
if (!e.Handled
&& _isOpen
&& Target?.ContextFlyout == this)
{
var keymap = Application.Current!.PlatformSettings?.HotkeyConfiguration;
if (keymap?.OpenContextMenu.Any(k => k.Matches(e)) == true)
{
e.Handled = HideCore();
}
}
}
private void IsOpenChanged(AvaloniaPropertyChangedEventArgs e)
{
if (_ignoreIsOpenChanged)
{
return;
}
if (e.NewValue.Value)
{
if (_lastPlacementTarget != null && ShowAtCore(_lastPlacementTarget))
{
return;
}
// No target, or opening was cancelled — revert so IsOpen stays honest
using (BeginIgnoringIsOpen())
{
SetCurrentValue(IsOpenProperty, false);
}
}
else
{
if (!HideCore())
{
// Closing was cancelled — revert so IsOpen stays honest
using (BeginIgnoringIsOpen())
{
SetCurrentValue(IsOpenProperty, true);
}
}
}
}
private IgnoreIsOpenScope BeginIgnoringIsOpen()
{
return new IgnoreIsOpenScope(this);
}
private readonly struct IgnoreIsOpenScope : IDisposable
{
private readonly PopupFlyoutBase _owner;
public IgnoreIsOpenScope(PopupFlyoutBase owner)
{
_owner = owner;
_owner._ignoreIsOpenChanged = true;
}
public void Dispose()
{
_owner._ignoreIsOpenChanged = false;
}
}
private void PositionPopup(bool showAtPointer)
{
Size sz;
// Popup.Child can't be null here, it was set in ShowAtCore.
if (Popup.Child!.DesiredSize == default)
{
// Popup may not have been shown yet. Measure content
sz = LayoutHelper.MeasureChild(Popup.Child, Size.Infinity, new Thickness());
}
else
{
sz = Popup.Child.DesiredSize;
}
Popup.VerticalOffset = VerticalOffset;
Popup.HorizontalOffset = HorizontalOffset;
Popup.PlacementAnchor = PlacementAnchor;
Popup.PlacementGravity = PlacementGravity;
Popup.CustomPopupPlacementCallback = CustomPopupPlacementCallback;
if (showAtPointer)
{
Popup.Placement = PlacementMode.Pointer;
}
else
{
Popup.Placement = Placement;
Popup.PlacementConstraintAdjustment = PlacementConstraintAdjustment;
}
}
private static void OnContextFlyoutPropertyChanged(AvaloniaPropertyChangedEventArgs args)
{
if (args.Sender is Control c)
{
if (args.OldValue is FlyoutBase)
{
c.ContextRequested -= OnControlContextRequested;
c.ContextCanceled -= OnControlContextCanceled;
}
if (args.NewValue is FlyoutBase)
{
c.ContextRequested += OnControlContextRequested;
c.ContextCanceled += OnControlContextCanceled;
}
}
}
private static void OnControlContextCanceled(object? sender, RoutedEventArgs e)
{
if (!e.Handled
&& sender is Control control
&& control.ContextFlyout is { } flyout
&& flyout.IsOpen)
{
flyout.Hide();
}
}
private static void OnControlContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (!e.Handled
&& sender is Control control
&& control.ContextFlyout is { } flyout)
{
if (control.ContextMenu != null)
{
Logger.TryGet(LogEventLevel.Verbose, "FlyoutBase")?.Log(control, "ContextMenu and ContextFlyout are both set, defaulting to ContextMenu");
return;
}
if (flyout is PopupFlyoutBase popupFlyout)
{
// We do not support absolute popup positioning yet, so we ignore "point" at this moment.
var triggeredByPointerInput = e.TryGetPosition(null, out _);
e.Handled = popupFlyout.ShowAtCore(control, triggeredByPointerInput);
}
else
{
flyout.ShowAt(control);
e.Handled = true;
}
}
}
private bool CancelClosing()
{
var eventArgs = new CancelEventArgs();
OnClosing(eventArgs);
return eventArgs.Cancel;
}
private bool CancelOpening()
{
var eventArgs = new CancelEventArgs();
OnOpening(eventArgs);
return eventArgs.Cancel;
}
internal static void SetPresenterClasses(Control? presenter, Classes classes)
{
if (presenter is null)
{
return;
}
//Remove any classes no longer in use, ignoring pseudo classes
for (int i = presenter.Classes.Count - 1; i >= 0; i--)
{
if (!classes.Contains(presenter.Classes[i]) &&
!presenter.Classes[i].Contains(':'))
{
presenter.Classes.RemoveAt(i);
}
}
//Add new classes
presenter.Classes.AddRange(classes);
}
}
}