1
0

ContextMenuUIController.cs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. using System.Numerics;
  2. using System.Threading;
  3. using Content.Client.CombatMode;
  4. using Content.Client.Gameplay;
  5. using Content.Client.Mapping;
  6. using Robust.Client.UserInterface;
  7. using Robust.Client.UserInterface.Controllers;
  8. using Timer = Robust.Shared.Timing.Timer;
  9. namespace Content.Client.ContextMenu.UI
  10. {
  11. /// <summary>
  12. /// This class handles all the logic associated with showing a context menu, as well as all the state for the
  13. /// entire context menu stack, including verb and entity menus. It does not currently support multiple
  14. /// open context menus.
  15. /// </summary>
  16. /// <remarks>
  17. /// This largely involves setting up timers to open and close sub-menus when hovering over other menu elements.
  18. /// </remarks>
  19. public sealed class ContextMenuUIController : UIController, IOnStateEntered<GameplayState>, IOnStateExited<GameplayState>, IOnSystemChanged<CombatModeSystem>, IOnStateEntered<MappingState>, IOnStateExited<MappingState>
  20. {
  21. public static readonly TimeSpan HoverDelay = TimeSpan.FromSeconds(0.2);
  22. /// <summary>
  23. /// Root menu of the entire context menu.
  24. /// </summary>
  25. public ContextMenuPopup RootMenu = default!;
  26. public Stack<ContextMenuPopup> Menus { get; } = new();
  27. /// <summary>
  28. /// Used to cancel the timer that opens menus.
  29. /// </summary>
  30. public CancellationTokenSource? CancelOpen;
  31. /// <summary>
  32. /// Used to cancel the timer that closes menus.
  33. /// </summary>
  34. public CancellationTokenSource? CancelClose;
  35. public Action? OnContextClosed;
  36. public Action<ContextMenuElement>? OnContextMouseEntered;
  37. public Action<ContextMenuElement>? OnContextMouseExited;
  38. public Action<ContextMenuElement>? OnSubMenuOpened;
  39. public Action<ContextMenuElement, GUIBoundKeyEventArgs>? OnContextKeyEvent;
  40. private bool _setup;
  41. public void OnStateEntered(GameplayState state)
  42. {
  43. Setup();
  44. }
  45. public void OnStateExited(GameplayState state)
  46. {
  47. Shutdown();
  48. }
  49. public void OnStateEntered(MappingState state)
  50. {
  51. Setup();
  52. }
  53. public void OnStateExited(MappingState state)
  54. {
  55. Shutdown();
  56. }
  57. public void Setup()
  58. {
  59. if (_setup)
  60. return;
  61. _setup = true;
  62. RootMenu = new(this, null);
  63. RootMenu.OnPopupHide += Close;
  64. Menus.Push(RootMenu);
  65. }
  66. public void Shutdown()
  67. {
  68. if (!_setup)
  69. return;
  70. _setup = false;
  71. Close();
  72. RootMenu.OnPopupHide -= Close;
  73. RootMenu.Dispose();
  74. RootMenu = default!;
  75. }
  76. /// <summary>
  77. /// Close and clear the root menu. This will also dispose any sub-menus.
  78. /// </summary>
  79. public void Close()
  80. {
  81. RootMenu.MenuBody.DisposeAllChildren();
  82. CancelOpen?.Cancel();
  83. CancelClose?.Cancel();
  84. OnContextClosed?.Invoke();
  85. RootMenu.Close();
  86. }
  87. /// <summary>
  88. /// Starts closing menus until the top-most menu is the given one.
  89. /// </summary>
  90. /// <remarks>
  91. /// Note that this does not actually check if the given menu IS a sub menu of this presenter. In that case
  92. /// this will close all menus.
  93. /// </remarks>
  94. public void CloseSubMenus(ContextMenuPopup? menu)
  95. {
  96. if (menu == null || !menu.Visible)
  97. return;
  98. while (Menus.TryPeek(out var subMenu) && subMenu != menu)
  99. {
  100. Menus.Pop().Close();
  101. }
  102. // ensure no accidental double-closing happens.
  103. CancelClose?.Cancel();
  104. CancelClose = null;
  105. }
  106. /// <summary>
  107. /// Start a timer to open this element's sub-menu.
  108. /// </summary>
  109. private void OnMouseEntered(ContextMenuElement element)
  110. {
  111. if (!Menus.TryPeek(out var topMenu))
  112. {
  113. Logger.Error("Context Menu: Mouse entered menu without any open menus?");
  114. return;
  115. }
  116. if (element.ParentMenu == topMenu || element.SubMenu == topMenu)
  117. CancelClose?.Cancel();
  118. if (element.SubMenu == topMenu)
  119. return;
  120. // open the sub-menu after a short delay.
  121. CancelOpen?.Cancel();
  122. CancelOpen = new();
  123. Timer.Spawn(HoverDelay, () => OpenSubMenu(element), CancelOpen.Token);
  124. }
  125. /// <summary>
  126. /// Start a timer to close this element's sub-menu.
  127. /// </summary>
  128. /// <remarks>
  129. /// Note that this timer will be aborted when entering the actual sub-menu itself.
  130. /// </remarks>
  131. private void OnMouseExited(ContextMenuElement element)
  132. {
  133. CancelOpen?.Cancel();
  134. if (element.SubMenu == null)
  135. return;
  136. CancelClose?.Cancel();
  137. CancelClose = new();
  138. Timer.Spawn(HoverDelay, () => CloseSubMenus(element.ParentMenu), CancelClose.Token);
  139. OnContextMouseExited?.Invoke(element);
  140. }
  141. private void OnKeyBindDown(ContextMenuElement element, GUIBoundKeyEventArgs args)
  142. {
  143. OnContextKeyEvent?.Invoke(element, args);
  144. }
  145. /// <summary>
  146. /// Opens a new sub menu, and close the old one.
  147. /// </summary>
  148. /// <remarks>
  149. /// If the given element has no sub-menu, just close the current one.
  150. /// </remarks>
  151. public void OpenSubMenu(ContextMenuElement element)
  152. {
  153. if (!Menus.TryPeek(out var topMenu))
  154. {
  155. Logger.Error("Context Menu: Attempting to open sub menu without any open menus?");
  156. return;
  157. }
  158. // If This is already the top most menu, do nothing.
  159. if (element.SubMenu == topMenu)
  160. return;
  161. // Was the parent menu closed or disposed before an open timer completed?
  162. if (element.Disposed || element.ParentMenu == null || !element.ParentMenu.Visible)
  163. return;
  164. // Close any currently open sub-menus up to this element's parent menu.
  165. CloseSubMenus(element.ParentMenu);
  166. // cancel any queued openings to prevent weird double-open scenarios.
  167. CancelOpen?.Cancel();
  168. CancelOpen = null;
  169. if (element.SubMenu == null)
  170. return;
  171. // open pop-up adjacent to the parent element. We want the sub-menu elements to align with this element
  172. // which depends on the panel container style margins.
  173. var altPos = element.GlobalPosition;
  174. var pos = altPos + new Vector2(element.Width + 2 * ContextMenuElement.ElementMargin, -2 * ContextMenuElement.ElementMargin);
  175. element.SubMenu.Open(UIBox2.FromDimensions(pos, new Vector2(1, 1)), altPos);
  176. // draw on top of other menus
  177. element.SubMenu.SetPositionLast();
  178. Menus.Push(element.SubMenu);
  179. OnSubMenuOpened?.Invoke(element);
  180. }
  181. /// <summary>
  182. /// Add an element to a menu and subscribe to GUI events.
  183. /// </summary>
  184. public void AddElement(ContextMenuPopup menu, ContextMenuElement element)
  185. {
  186. element.OnMouseEntered += _ => OnMouseEntered(element);
  187. element.OnMouseExited += _ => OnMouseExited(element);
  188. element.OnKeyBindDown += args => OnKeyBindDown(element, args);
  189. element.ParentMenu = menu;
  190. menu.MenuBody.AddChild(element);
  191. menu.InvalidateMeasure();
  192. }
  193. /// <summary>
  194. /// Removes event subscriptions when an element is removed from a menu,
  195. /// </summary>
  196. public void OnRemoveElement(ContextMenuPopup menu, Control control)
  197. {
  198. if (control is not ContextMenuElement element)
  199. return;
  200. element.OnMouseEntered -= _ => OnMouseEntered(element);
  201. element.OnMouseExited -= _ => OnMouseExited(element);
  202. element.OnKeyBindDown -= args => OnKeyBindDown(element, args);
  203. menu.InvalidateMeasure();
  204. }
  205. private void OnCombatModeUpdated(bool inCombatMode)
  206. {
  207. if (inCombatMode)
  208. Close();
  209. }
  210. public void OnSystemLoaded(CombatModeSystem system)
  211. {
  212. system.LocalPlayerCombatModeUpdated += OnCombatModeUpdated;
  213. }
  214. public void OnSystemUnloaded(CombatModeSystem system)
  215. {
  216. system.LocalPlayerCombatModeUpdated -= OnCombatModeUpdated;
  217. }
  218. }
  219. }