#if PLATFORM_WINDOWS || PLATFORM_SDL #define USE_IS_FOREGROUND #else #endif #if PLATFORM_SDL #define USE_SDL_WORKAROUNDS #endif // Copyright (c) Wojciech Figat. All rights reserved. using System.Collections.Generic; using FlaxEngine; using FlaxEngine.Assertions; using FlaxEngine.GUI; namespace FlaxEditor.GUI.ContextMenu { /// /// Context menu popup directions. /// [HideInEditor] public enum ContextMenuDirection { /// /// The right down. /// RightDown, /// /// The right up. /// RightUp, /// /// The left down. /// LeftDown, /// /// The left up. /// LeftUp, } /// /// Base class for all context menu controls. /// /// [HideInEditor] public class ContextMenuBase : ContainerControl { private ContextMenuDirection _direction; private ContextMenuBase _parentCM; private bool _isSubMenu; private ContextMenuBase _childCM; private Window _window; private Control _previouslyFocused; /// /// Gets a value indicating whether use automatic popup direction fix based on the screen dimensions. /// protected virtual bool UseAutomaticDirectionFix => true; /// /// Returns true if context menu is opened /// public bool IsOpened => Parent != null; /// /// Gets the popup direction. /// public ContextMenuDirection Direction => _direction; /// /// Gets a value indicating whether any child context menu has been opened. /// public bool HasChildCMOpened => _childCM != null; /// /// Gets the topmost context menu. /// public ContextMenuBase TopmostCM { get { var cm = this; while (cm._parentCM != null && cm._isSubMenu) { cm = cm._parentCM; } return cm; } } /// /// Gets a value indicating whether this context menu is a sub-menu. Sub menus are treated like child context menus of the other menu (eg. hierarchy). /// public bool IsSubMenu => _isSubMenu; /// /// External dialog popups opened within the context window (eg. color picker) that should preserve context menu visibility (prevent from closing context menu). /// public List ExternalPopups = new List(); /// /// Optional flag that can disable popup visibility based on window focus and use external control via Hide. /// public bool UseVisibilityControl = true; /// /// Optional flag that can disable popup input capturing. Useful for transparent or visual-only popups. /// public bool UseInput = true; /// /// Initializes a new instance of the class. /// public ContextMenuBase() : base(0, 0, 120, 32) { _direction = ContextMenuDirection.RightDown; Visible = false; _isSubMenu = true; } /// /// Shows the empty menu popup on a screen. /// /// The target control. /// The target control area to cover. /// Created popup. public static ContextMenuBase ShowEmptyMenu(Control control, Rectangle area) { // Calculate the control size in the window space to handle scaled controls var upperLeft = control.PointToWindow(area.UpperLeft); var bottomRight = control.PointToWindow(area.BottomRight); var size = bottomRight - upperLeft; var popup = new ContextMenuBase(); popup.Size = size; popup.Show(control, area.Location + new Float2(0, (size.Y - popup.Height) * 0.5f)); return popup; } /// /// Show context menu over given control. /// /// Parent control to attach to it. /// Popup menu origin location in parent control coordinates. /// The custom popup direction. Null to use automatic direction. public virtual void Show(Control parent, Float2 location, ContextMenuDirection? direction = null) { Assert.IsNotNull(parent); bool isAlreadyVisible = Visible && _window; if (!isAlreadyVisible) Hide(); // Peek parent control window var parentWin = parent.RootWindow; if (parentWin == null) { Hide(); return; } // Check if show menu inside the other menu - then link as a child to prevent closing the calling menu window on lost focus if (_parentCM == null && parentWin.ChildrenCount == 1 && parentWin.Children[0] is ContextMenuBase parentCM) { Hide(); parentCM.ShowChild(this, parentCM.PointFromScreen(parent.PointToScreen(location)), false); return; } // Unlock and perform controls update Location = Float2.Zero; UnlockChildrenRecursive(); PerformLayout(); // Calculate popup direction and initial location (fit on a single monitor) var dpiScale = parentWin.DpiScale; var dpiSize = Size * dpiScale; var locationWS = parent.PointToWindow(location); var locationSS = parentWin.PointToScreen(locationWS); var monitorBounds = Platform.GetMonitorBounds(locationSS); var rightBottomLocationSS = locationSS + dpiSize; bool isUp = false, isLeft = false; if (UseAutomaticDirectionFix && direction == null) { var parentMenu = parent as ContextMenu; if (monitorBounds.Bottom < rightBottomLocationSS.Y) { isUp = true; locationSS.Y -= dpiSize.Y; if (parentMenu != null && parentMenu._childCM != null) locationSS.Y += 30.0f * dpiScale; } if (parentMenu == null) { if (monitorBounds.Right < rightBottomLocationSS.X) { isLeft = true; locationSS.X -= dpiSize.X; } } else if (monitorBounds.Right < rightBottomLocationSS.X || _parentCM?.Direction == ContextMenuDirection.LeftDown || _parentCM?.Direction == ContextMenuDirection.LeftUp) { isLeft = true; if (IsSubMenu && _parentCM != null) locationSS.X -= _parentCM.Width + dpiSize.X; else locationSS.X -= dpiSize.X; } } else if (direction.HasValue) { switch (direction.Value) { case ContextMenuDirection.RightUp: isUp = true; break; case ContextMenuDirection.LeftDown: isLeft = true; break; case ContextMenuDirection.LeftUp: isLeft = true; isUp = true; break; } if (isLeft) locationSS.X -= dpiSize.X; if (isUp) locationSS.Y -= dpiSize.Y; } // Update direction flag if (isUp) _direction = isLeft ? ContextMenuDirection.LeftUp : ContextMenuDirection.RightUp; else _direction = isLeft ? ContextMenuDirection.LeftDown : ContextMenuDirection.RightDown; if (isAlreadyVisible) { _window.ClientBounds = new Rectangle(locationSS, dpiSize); } else { // Create window var desc = CreateWindowSettings.Default; desc.Position = locationSS; desc.StartPosition = WindowStartPosition.Manual; desc.Size = dpiSize; desc.Fullscreen = false; desc.HasBorder = false; desc.SupportsTransparency = false; desc.ShowInTaskbar = false; desc.ActivateWhenFirstShown = UseInput; desc.AllowInput = UseInput; desc.AllowMinimize = false; desc.AllowMaximize = false; desc.AllowDragAndDrop = false; desc.IsTopmost = true; desc.Type = WindowType.Popup; desc.Parent = parentWin.Window; desc.Title = "ContextMenu"; desc.HasSizingFrame = false; OnWindowCreating(ref desc); _window = Platform.CreateWindow(ref desc); if (UseVisibilityControl) { _window.GotFocus += OnWindowGotFocus; _window.LostFocus += OnWindowLostFocus; } #if USE_IS_FOREGROUND && USE_SDL_WORKAROUNDS // The focus between popup and parent windows doesn't change, force hide the popup when clicked on parent parentWin.Window.MouseDown += OnWindowMouseDown; _window.Closed += () => parentWin.Window.MouseDown -= OnWindowMouseDown; #endif // Attach to the window _parentCM = parent as ContextMenuBase; Parent = _window.GUI; // Show Visible = true; if (_window == null) return; _window.Show(); } PerformLayout(); if (UseVisibilityControl) { _previouslyFocused = parentWin.FocusedControl; Focus(); OnShow(); } } /// /// Hide popup menu and all child menus. /// public virtual void Hide() { if (!Visible) return; // Lock update IsLayoutLocked = true; // Close child HideChild(); // Unlink from window Parent = null; // Close window if (_window != null) { var win = _window; _window = null; win.Close(); } // Unlink from parent if (_parentCM != null) { _parentCM._childCM = null; _parentCM = null; } // Return focus if (_previouslyFocused != null) { _previouslyFocused.RootWindow?.Focus(); _previouslyFocused.Focus(); _previouslyFocused = null; } // Hide Visible = false; OnHide(); } /// /// Shows new child context menu. /// /// The child menu. /// The child menu initial location. /// True if context menu is a normal sub-menu, otherwise it is a custom menu popup linked as child. public void ShowChild(ContextMenuBase child, Float2 location, bool isSubMenu = true) { // Hide current child HideChild(); // Set child _childCM = child; _childCM._parentCM = this; _childCM._isSubMenu = isSubMenu; // Show child _childCM.Show(this, location); } /// /// Hides child popup menu if any opened. /// public void HideChild() { if (_childCM != null) { _childCM.Hide(); _childCM = null; } } /// /// Updates the size of the window to match context menu dimensions. /// protected void UpdateWindowSize() { if (_window != null) { _window.ClientSize = Size * _window.DpiScale; } } /// /// Called when context menu window setup is performed. Can be used to adjust the popup window options. /// /// The settings. protected virtual void OnWindowCreating(ref CreateWindowSettings settings) { } /// /// Called on context menu show. /// protected virtual void OnShow() { // Nothing to do } /// /// Called on context menu hide. /// protected virtual void OnHide() { // Nothing to do } #if USE_IS_FOREGROUND /// /// Returns true if context menu is in foreground (eg. context window or any child window has user focus or user opened additional popup within this context). /// protected virtual bool IsForeground { get { // Any external popup is focused foreach (var externalPopup in ExternalPopups) { if (externalPopup && externalPopup.IsForegroundWindow) return true; } // Any context menu window is focused var anyForeground = false; var c = this; while (!anyForeground && c != null) { if (c._window != null && c._window.IsForegroundWindow) anyForeground = true; c = c._childCM; } return anyForeground; } } #if USE_SDL_WORKAROUNDS private void OnWindowGotFocus() { } private void OnWindowMouseDown(ref Float2 mousePosition, MouseButton button, ref bool handled) { // The user clicked outside the popup window Hide(); } #else private void OnWindowGotFocus() { var child = _childCM; if (child != null && _window && _window.IsForegroundWindow) { // Hide child if user clicked over parent (do it next frame to process other events before - eg. child windows focus loss) FlaxEngine.Scripting.InvokeOnUpdate(() => { if (child == _childCM) HideChild(); }); } } #endif private void OnWindowLostFocus() { // Skip for parent menus (child should handle lost of focus) if (_childCM != null) return; // Check if user stopped using that popup menu if (_parentCM != null) { // Focus parent if user clicked over the parent popup var mouse = _parentCM.PointFromScreen(FlaxEngine.Input.MouseScreenPosition); if (_parentCM.ContainsPoint(ref mouse)) { _parentCM._window.Focus(); } } } #else private void OnWindowGotFocus() { } private void OnWindowLostFocus() { // Skip for parent menus (child should handle lost of focus) if (_childCM != null) return; if (_parentCM != null) { if (IsMouseOver) return; // Check if any external popup is focused foreach (var externalPopup in ExternalPopups) { if (externalPopup && externalPopup.IsFocused) return; } // Check if mouse is over any of the parents ContextMenuBase focusCM = null; var cm = _parentCM; while (cm != null) { if (cm.IsMouseOver) focusCM = cm; cm = cm._parentCM; } if (focusCM != null) { // Focus on the clicked parent and hide any open sub-menus focusCM.HideChild(); focusCM._window?.Focus(); } else { // User clicked outside the context menus, hide the whole context menu tree TopmostCM.Hide(); } } else if (!IsMouseOver) { Hide(); } } #endif /// public override bool IsMouseOver { get { bool result = false; for (int i = 0; i < _children.Count; i++) { var c = _children[i]; if (c.Visible && c.IsMouseOver) { result = true; break; } } return result; } } #if USE_IS_FOREGROUND /// public override void Update(float deltaTime) { base.Update(deltaTime); // Let root context menu to check if none of the popup windows if (_parentCM == null && UseVisibilityControl && !IsForeground) { #if USE_SDL_WORKAROUNDS if (!IsMouseOver) Hide(); #else Hide(); #endif } } #endif /// public override void Draw() { // Draw background var style = Style.Current; var bounds = new Rectangle(Float2.Zero, Size); Render2D.FillRectangle(bounds, style.Background); Render2D.DrawRectangle(bounds, Color.Lerp(style.BackgroundSelected, style.Background, 0.6f)); base.Draw(); } /// public override bool OnMouseDown(Float2 location, MouseButton button) { base.OnMouseDown(location, button); return true; } /// public override bool OnMouseUp(Float2 location, MouseButton button) { base.OnMouseUp(location, button); return true; } /// public override bool OnKeyDown(KeyboardKeys key) { if (base.OnKeyDown(key)) return true; switch (key) { case KeyboardKeys.Escape: Hide(); return true; } return false; } /// public override void OnDestroy() { // Ensure to be hidden Hide(); base.OnDestroy(); } } }