using System.Threading; using Content.Client.Gameplay; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controllers; using Timer = Robust.Shared.Timing.Timer; namespace Content.Client.ContextMenu.UI { /// /// This class handles all the logic associated with showing a context menu, as well as all the state for the /// entire context menu stack, including verb and entity menus. It does not currently support multiple /// open context menus. /// /// /// This largely involves setting up timers to open and close sub-menus when hovering over other menu elements. /// public sealed class ContextMenuUIController : UIController, IOnStateEntered, IOnStateExited { public static readonly TimeSpan HoverDelay = TimeSpan.FromSeconds(0.2); /// /// Root menu of the entire context menu. /// public ContextMenuPopup RootMenu = default!; 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 Action? OnContextClosed; public Action? OnContextMouseEntered; public Action? OnContextMouseExited; public Action? OnSubMenuOpened; public Action? OnContextKeyEvent; public void OnStateEntered(GameplayState state) { RootMenu = new(this, null); RootMenu.OnPopupHide += Close; Menus.Push(RootMenu); } public void OnStateExited(GameplayState state) { Close(); RootMenu.OnPopupHide -= Close; RootMenu.Dispose(); } /// /// Close and clear the root menu. This will also dispose any sub-menus. /// public void Close() { RootMenu.MenuBody.DisposeAllChildren(); CancelOpen?.Cancel(); CancelClose?.Cancel(); OnContextClosed?.Invoke(); } /// /// 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. /// private 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); OnContextMouseEntered?.Invoke(element); } /// /// Start a timer to close this element's sub-menu. /// /// /// Note that this timer will be aborted when entering the actual sub-menu itself. /// private void OnMouseExited(ContextMenuElement element) { CancelOpen?.Cancel(); if (element.SubMenu == null) return; CancelClose?.Cancel(); CancelClose = new(); Timer.Spawn(HoverDelay, () => CloseSubMenus(element.ParentMenu), CancelClose.Token); OnContextMouseExited?.Invoke(element); } private void OnKeyBindDown(ContextMenuElement element, GUIBoundKeyEventArgs args) { OnContextKeyEvent?.Invoke(element, 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 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); OnSubMenuOpened?.Invoke(element); } /// /// 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(); } } }