using System; using System.Collections.Generic; using System.Threading; using Robust.Client.UserInterface; using Robust.Shared.Log; using Robust.Shared.Maths; using Timer = Robust.Shared.Timing.Timer; namespace Content.Client.ContextMenu.UI { /// /// This class handles all the logic associated with showing a context menu. /// /// /// This largely involves setting up timers to open and close sub-menus when hovering over other menu elements. /// [Virtual] public class ContextMenuPresenter : IDisposable { public static readonly TimeSpan HoverDelay = TimeSpan.FromSeconds(0.2); public ContextMenuPopup RootMenu; public Stack Menus { get; } = new(); /// /// Used to cancel the timer that opens menus. /// public CancellationTokenSource? CancelOpen; /// /// Used to cancel the timer that closes menus. /// public CancellationTokenSource? CancelClose; public ContextMenuPresenter() { RootMenu = new(this, null); RootMenu.OnPopupHide += RootMenu.MenuBody.DisposeAllChildren; Menus.Push(RootMenu); } /// /// Dispose of all UI elements. /// public virtual void Dispose() { RootMenu.OnPopupHide -= RootMenu.MenuBody.DisposeAllChildren; RootMenu.Dispose(); } /// /// Close and clear the root menu. This will also dispose any sub-menus. /// public virtual void Close() { RootMenu.Close(); CancelOpen?.Cancel(); CancelClose?.Cancel(); } /// /// Starts closing menus until the top-most menu is the given one. /// /// /// Note that this does not actually check if the given menu IS a sub menu of this presenter. In that case /// this will close all menus. /// public void CloseSubMenus(ContextMenuPopup? menu) { if (menu == null || !menu.Visible) return; while (Menus.TryPeek(out var subMenu) && subMenu != menu) { Menus.Pop().Close(); } // ensure no accidental double-closing happens. CancelClose?.Cancel(); CancelClose = null; } /// /// Start a timer to open this element's sub-menu. /// public virtual void OnMouseEntered(ContextMenuElement element) { if (!Menus.TryPeek(out var topMenu)) { Logger.Error("Context Menu: Mouse entered menu without any open menus?"); return; } if (element.ParentMenu == topMenu || element.SubMenu == topMenu) CancelClose?.Cancel(); if (element.SubMenu == topMenu) return; // open the sub-menu after a short delay. CancelOpen?.Cancel(); CancelOpen = new(); Timer.Spawn(HoverDelay, () => OpenSubMenu(element), CancelOpen.Token); } /// /// Start a timer to close this element's sub-menu. /// /// /// Note that this timer will be aborted when entering the actual sub-menu itself. /// public virtual void OnMouseExited(ContextMenuElement element) { CancelOpen?.Cancel(); if (element.SubMenu == null) return; CancelClose?.Cancel(); CancelClose = new(); Timer.Spawn(HoverDelay, () => CloseSubMenus(element.ParentMenu), CancelClose.Token); } public virtual void OnKeyBindDown(ContextMenuElement element, GUIBoundKeyEventArgs args) { } /// /// Opens a new sub menu, and close the old one. /// /// /// If the given element has no sub-menu, just close the current one. /// public virtual void OpenSubMenu(ContextMenuElement element) { if (!Menus.TryPeek(out var topMenu)) { Logger.Error("Context Menu: Attempting to open sub menu without any open menus?"); return; } // If This is already the top most menu, do nothing. if (element.SubMenu == topMenu) return; // Was the parent menu closed or disposed before an open timer completed? if (element.Disposed || element.ParentMenu == null || !element.ParentMenu.Visible) return; // Close any currently open sub-menus up to this element's parent menu. CloseSubMenus(element.ParentMenu); // cancel any queued openings to prevent weird double-open scenarios. CancelOpen?.Cancel(); CancelOpen = null; if (element.SubMenu == null) return; // open pop-up adjacent to the parent element. We want the sub-menu elements to align with this element // which depends on the panel container style margins. var altPos = element.GlobalPosition; var pos = altPos + (element.Width + 2*ContextMenuElement.ElementMargin, - 2*ContextMenuElement.ElementMargin); element.SubMenu.Open(UIBox2.FromDimensions(pos, (1, 1)), altPos); // draw on top of other menus element.SubMenu.SetPositionLast(); Menus.Push(element.SubMenu); } /// /// Add an element to a menu and subscribe to GUI events. /// public void AddElement(ContextMenuPopup menu, ContextMenuElement element) { element.OnMouseEntered += _ => OnMouseEntered(element); element.OnMouseExited += _ => OnMouseExited(element); element.OnKeyBindDown += args => OnKeyBindDown(element, args); element.ParentMenu = menu; menu.MenuBody.AddChild(element); menu.InvalidateMeasure(); } /// /// Removes event subscriptions when an element is removed from a menu, /// public void OnRemoveElement(ContextMenuPopup menu, Control control) { if (control is not ContextMenuElement element) return; element.OnMouseEntered -= _ => OnMouseEntered(element); element.OnMouseExited -= _ => OnMouseExited(element); element.OnKeyBindDown -= args => OnKeyBindDown(element, args); menu.InvalidateMeasure(); } } }