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