RadialMenu.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. using Robust.Client.UserInterface;
  2. using Robust.Client.UserInterface.Controls;
  3. using Robust.Client.UserInterface.CustomControls;
  4. using System.Linq;
  5. using System.Numerics;
  6. using Content.Shared.Input;
  7. using Robust.Client.Graphics;
  8. using Robust.Shared.Input;
  9. namespace Content.Client.UserInterface.Controls;
  10. [Virtual]
  11. public class RadialMenu : BaseWindow
  12. {
  13. /// <summary>
  14. /// Contextual button used to traverse through previous layers of the radial menu
  15. /// </summary>
  16. public RadialMenuContextualCentralTextureButton ContextualButton { get; }
  17. /// <summary>
  18. /// Button that represents outer area of menu (closes menu on outside clicks).
  19. /// </summary>
  20. public RadialMenuOuterAreaButton MenuOuterAreaButton { get; }
  21. /// <summary>
  22. /// Set a style class to be applied to the contextual button when it is set to move the user back through previous layers of the radial menu
  23. /// </summary>
  24. public string? BackButtonStyleClass
  25. {
  26. get
  27. {
  28. return _backButtonStyleClass;
  29. }
  30. set
  31. {
  32. _backButtonStyleClass = value;
  33. if (_path.Count > 0 && ContextualButton != null && _backButtonStyleClass != null)
  34. ContextualButton.SetOnlyStyleClass(_backButtonStyleClass);
  35. }
  36. }
  37. /// <summary>
  38. /// Set a style class to be applied to the contextual button when it will close the radial menu
  39. /// </summary>
  40. public string? CloseButtonStyleClass
  41. {
  42. get
  43. {
  44. return _closeButtonStyleClass;
  45. }
  46. set
  47. {
  48. _closeButtonStyleClass = value;
  49. if (_path.Count == 0 && ContextualButton != null && _closeButtonStyleClass != null)
  50. ContextualButton.SetOnlyStyleClass(_closeButtonStyleClass);
  51. }
  52. }
  53. private readonly List<Control> _path = new();
  54. private string? _backButtonStyleClass;
  55. private string? _closeButtonStyleClass;
  56. /// <summary>
  57. /// A free floating menu which enables the quick display of one or more radial containers
  58. /// </summary>
  59. /// <remarks>
  60. /// Only one radial container is visible at a time (each container forming a separate 'layer' within
  61. /// the menu), along with a contextual button at the menu center, which will either return the user
  62. /// to the previous layer or close the menu if there are no previous layers left to traverse.
  63. /// To create a functional radial menu, simply parent one or more named radial containers to it,
  64. /// and populate the radial containers with RadialMenuButtons. Setting the TargetLayer field of these
  65. /// buttons to the name of a radial conatiner will display the container in question to the user
  66. /// whenever it is clicked in additon to any other actions assigned to the button
  67. /// </remarks>
  68. public RadialMenu()
  69. {
  70. // Hide all starting children (if any) except the first (this is the active layer)
  71. if (ChildCount > 1)
  72. {
  73. for (int i = 1; i < ChildCount; i++)
  74. GetChild(i).Visible = false;
  75. }
  76. // Auto generate a contextual button for moving back through visited layers
  77. ContextualButton = new RadialMenuContextualCentralTextureButton
  78. {
  79. HorizontalAlignment = HAlignment.Center,
  80. VerticalAlignment = VAlignment.Center,
  81. SetSize = new Vector2(64f, 64f),
  82. };
  83. MenuOuterAreaButton = new RadialMenuOuterAreaButton();
  84. ContextualButton.OnButtonUp += _ => ReturnToPreviousLayer();
  85. MenuOuterAreaButton.OnButtonUp += _ => Close();
  86. AddChild(ContextualButton);
  87. AddChild(MenuOuterAreaButton);
  88. // Hide any further add children, unless its promoted to the active layer
  89. OnChildAdded += child =>
  90. {
  91. child.Visible = GetCurrentActiveLayer() == child;
  92. SetupContextualButtonData(child);
  93. };
  94. }
  95. private void SetupContextualButtonData(Control child)
  96. {
  97. if (child is RadialContainer { Visible: true } container)
  98. {
  99. var parentCenter = MinSize * 0.5f;
  100. ContextualButton.ParentCenter = parentCenter;
  101. MenuOuterAreaButton.ParentCenter = parentCenter;
  102. ContextualButton.InnerRadius = container.CalculatedRadius * container.InnerRadiusMultiplier;
  103. MenuOuterAreaButton.OuterRadius = container.CalculatedRadius * container.OuterRadiusMultiplier;
  104. }
  105. }
  106. /// <inheritdoc />
  107. protected override Vector2 ArrangeOverride(Vector2 finalSize)
  108. {
  109. var result = base.ArrangeOverride(finalSize);
  110. var currentLayer = GetCurrentActiveLayer();
  111. if (currentLayer != null)
  112. {
  113. SetupContextualButtonData(currentLayer);
  114. }
  115. return result;
  116. }
  117. private Control? GetCurrentActiveLayer()
  118. {
  119. var children = Children.Where(x => x != ContextualButton && x != MenuOuterAreaButton);
  120. if (!children.Any())
  121. return null;
  122. return children.First(x => x.Visible);
  123. }
  124. public bool TryToMoveToNewLayer(string newLayer)
  125. {
  126. if (newLayer == string.Empty)
  127. return false;
  128. var currentLayer = GetCurrentActiveLayer();
  129. if (currentLayer == null)
  130. return false;
  131. var result = false;
  132. foreach (var child in Children)
  133. {
  134. if (child == ContextualButton || child == MenuOuterAreaButton)
  135. continue;
  136. // Hide layers which are not of interest
  137. if (result == true || child.Name != newLayer)
  138. {
  139. child.Visible = false;
  140. }
  141. // Show the layer of interest
  142. else
  143. {
  144. child.Visible = true;
  145. SetupContextualButtonData(child);
  146. result = true;
  147. }
  148. }
  149. // Update the traversal path
  150. if (result)
  151. _path.Add(currentLayer);
  152. // Set the style class of the button
  153. if (_path.Count > 0 && ContextualButton != null && BackButtonStyleClass != null)
  154. ContextualButton.SetOnlyStyleClass(BackButtonStyleClass);
  155. return result;
  156. }
  157. public void ReturnToPreviousLayer()
  158. {
  159. // Close the menu if the traversal path is empty
  160. if (_path.Count == 0)
  161. {
  162. Close();
  163. return;
  164. }
  165. var lastChild = _path[^1];
  166. // Hide all children except the contextual button
  167. foreach (var child in Children)
  168. {
  169. if (child != ContextualButton && child != MenuOuterAreaButton)
  170. child.Visible = false;
  171. }
  172. // Make the last visited layer visible, update the path list
  173. lastChild.Visible = true;
  174. _path.RemoveAt(_path.Count - 1);
  175. // Set the style class of the button
  176. if (_path.Count == 0 && ContextualButton != null && CloseButtonStyleClass != null)
  177. ContextualButton.SetOnlyStyleClass(CloseButtonStyleClass);
  178. }
  179. }
  180. /// <summary>
  181. /// Base class for radial menu buttons. Excludes all actions except clicks and alt-clicks
  182. /// from interactions.
  183. /// </summary>
  184. [Virtual]
  185. public class RadialMenuTextureButtonBase : TextureButton
  186. {
  187. /// <inheritdoc />
  188. protected RadialMenuTextureButtonBase()
  189. {
  190. EnableAllKeybinds = true;
  191. }
  192. /// <inheritdoc />
  193. protected override void KeyBindUp(GUIBoundKeyEventArgs args)
  194. {
  195. if (args.Function == EngineKeyFunctions.UIClick
  196. || args.Function == ContentKeyFunctions.AltActivateItemInWorld)
  197. base.KeyBindUp(args);
  198. }
  199. }
  200. /// <summary>
  201. /// Special button for closing radial menu or going back between radial menu levels.
  202. /// Is looking like just <see cref="TextureButton "/> but considers whole space around
  203. /// itself (til radial menu buttons) as itself in case of clicking. But this 'effect'
  204. /// works only if control have parent, and ActiveContainer property is set.
  205. /// Also considers all space outside of radial menu buttons as itself for clicking.
  206. /// </summary>
  207. public sealed class RadialMenuContextualCentralTextureButton : RadialMenuTextureButtonBase
  208. {
  209. public float InnerRadius { get; set; }
  210. public Vector2? ParentCenter { get; set; }
  211. /// <inheritdoc />
  212. protected override bool HasPoint(Vector2 point)
  213. {
  214. if (ParentCenter == null)
  215. {
  216. return base.HasPoint(point);
  217. }
  218. var distSquared = (point + Position - ParentCenter.Value).LengthSquared();
  219. var innerRadiusSquared = InnerRadius * InnerRadius;
  220. // comparing to squared values is faster then making sqrt
  221. return distSquared < innerRadiusSquared;
  222. }
  223. }
  224. /// <summary>
  225. /// Menu button for outer area of radial menu (covers everything 'outside').
  226. /// </summary>
  227. public sealed class RadialMenuOuterAreaButton : RadialMenuTextureButtonBase
  228. {
  229. public float OuterRadius { get; set; }
  230. public Vector2? ParentCenter { get; set; }
  231. /// <inheritdoc />
  232. protected override bool HasPoint(Vector2 point)
  233. {
  234. if (ParentCenter == null)
  235. {
  236. return base.HasPoint(point);
  237. }
  238. var distSquared = (point + Position - ParentCenter.Value).LengthSquared();
  239. var outerRadiusSquared = OuterRadius * OuterRadius;
  240. // comparing to squared values is faster, then making sqrt
  241. return distSquared > outerRadiusSquared;
  242. }
  243. }
  244. [Virtual]
  245. public class RadialMenuTextureButton : RadialMenuTextureButtonBase
  246. {
  247. /// <summary>
  248. /// Upon clicking this button the radial menu will be moved to the named layer
  249. /// </summary>
  250. public string TargetLayer { get; set; } = string.Empty;
  251. /// <summary>
  252. /// A simple texture button that can move the user to a different layer within a radial menu
  253. /// </summary>
  254. public RadialMenuTextureButton()
  255. {
  256. EnableAllKeybinds = true;
  257. OnButtonUp += OnClicked;
  258. }
  259. private void OnClicked(ButtonEventArgs args)
  260. {
  261. if (TargetLayer == string.Empty)
  262. return;
  263. var parent = FindParentMultiLayerContainer(this);
  264. if (parent == null)
  265. return;
  266. parent.TryToMoveToNewLayer(TargetLayer);
  267. }
  268. private RadialMenu? FindParentMultiLayerContainer(Control control)
  269. {
  270. foreach (var ancestor in control.GetSelfAndLogicalAncestors())
  271. {
  272. if (ancestor is RadialMenu menu)
  273. return menu;
  274. }
  275. return null;
  276. }
  277. }
  278. public interface IRadialMenuItemWithSector
  279. {
  280. /// <summary>
  281. /// Angle in radian where button sector should start.
  282. /// </summary>
  283. public float AngleSectorFrom { set; }
  284. /// <summary>
  285. /// Angle in radian where button sector should end.
  286. /// </summary>
  287. public float AngleSectorTo { set; }
  288. /// <summary>
  289. /// Outer radius for drawing segment and pointer detection.
  290. /// </summary>
  291. public float OuterRadius { set; }
  292. /// <summary>
  293. /// Outer radius for drawing segment and pointer detection.
  294. /// </summary>
  295. public float InnerRadius { set; }
  296. /// <summary>
  297. /// Offset in radian by which menu button should be rotated.
  298. /// </summary>
  299. public float AngleOffset { set; }
  300. /// <summary>
  301. /// Coordinates of center in parent component - button container.
  302. /// </summary>
  303. public Vector2 ParentCenter { set; }
  304. }
  305. [Virtual]
  306. public class RadialMenuTextureButtonWithSector : RadialMenuTextureButton, IRadialMenuItemWithSector
  307. {
  308. private Vector2[]? _sectorPointsForDrawing;
  309. private float _angleSectorFrom;
  310. private float _angleSectorTo;
  311. private float _outerRadius;
  312. private float _innerRadius;
  313. private float _angleOffset;
  314. private bool _isWholeCircle;
  315. private Vector2? _parentCenter;
  316. private Color _backgroundColorSrgb = Color.ToSrgb(new Color(70, 73, 102, 128));
  317. private Color _hoverBackgroundColorSrgb = Color.ToSrgb(new Color(87, 91, 127, 128));
  318. private Color _borderColorSrgb = Color.ToSrgb(new Color(173, 216, 230, 70));
  319. private Color _hoverBorderColorSrgb = Color.ToSrgb(new Color(87, 91, 127, 128));
  320. /// <summary>
  321. /// Marker, that control should render border of segment. Is false by default.
  322. /// </summary>
  323. /// <remarks>
  324. /// By default color of border is same as color of background. Use <see cref="BorderColor"/>
  325. /// and <see cref="HoverBorderColor"/> to change it.
  326. /// </remarks>
  327. public bool DrawBorder { get; set; } = false;
  328. /// <summary>
  329. /// Marker, that control should render background of all sector. Is true by default.
  330. /// </summary>
  331. public bool DrawBackground { get; set; } = true;
  332. /// <summary>
  333. /// Marker, that control should render separator lines.
  334. /// Separator lines are used to visually separate sector of radial menu items.
  335. /// Is true by default
  336. /// </summary>
  337. public bool DrawSeparators { get; set; } = true;
  338. /// <summary>
  339. /// Color of background in non-hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally.
  340. /// </summary>
  341. public Color BackgroundColor
  342. {
  343. get => Color.FromSrgb(_backgroundColorSrgb);
  344. set => _backgroundColorSrgb = Color.ToSrgb(value);
  345. }
  346. /// <summary>
  347. /// Color of background in hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally.
  348. /// </summary>
  349. public Color HoverBackgroundColor
  350. {
  351. get => Color.FromSrgb(_hoverBackgroundColorSrgb);
  352. set => _hoverBackgroundColorSrgb = Color.ToSrgb(value);
  353. }
  354. /// <summary>
  355. /// Color of button border. Accepts RGB color, works with sRGB for DrawPrimitive internally.
  356. /// </summary>
  357. public Color BorderColor
  358. {
  359. get => Color.FromSrgb(_borderColorSrgb);
  360. set => _borderColorSrgb = Color.ToSrgb(value);
  361. }
  362. /// <summary>
  363. /// Color of button border when button is hovered. Accepts RGB color, works with sRGB for DrawPrimitive internally.
  364. /// </summary>
  365. public Color HoverBorderColor
  366. {
  367. get => Color.FromSrgb(_hoverBorderColorSrgb);
  368. set => _hoverBorderColorSrgb = Color.ToSrgb(value);
  369. }
  370. /// <summary>
  371. /// Color of separator lines.
  372. /// Separator lines are used to visually separate sector of radial menu items.
  373. /// </summary>
  374. public Color SeparatorColor { get; set; } = new Color(128, 128, 128, 128);
  375. /// <inheritdoc />
  376. float IRadialMenuItemWithSector.AngleSectorFrom
  377. {
  378. set
  379. {
  380. _angleSectorFrom = value;
  381. _isWholeCircle = IsWholeCircle(value, _angleSectorTo);
  382. }
  383. }
  384. /// <inheritdoc />
  385. float IRadialMenuItemWithSector.AngleSectorTo
  386. {
  387. set
  388. {
  389. _angleSectorTo = value;
  390. _isWholeCircle = IsWholeCircle(_angleSectorFrom, value);
  391. }
  392. }
  393. /// <inheritdoc />
  394. float IRadialMenuItemWithSector.OuterRadius { set => _outerRadius = value; }
  395. /// <inheritdoc />
  396. float IRadialMenuItemWithSector.InnerRadius { set => _innerRadius = value; }
  397. /// <inheritdoc />
  398. public float AngleOffset { set => _angleOffset = value; }
  399. /// <inheritdoc />
  400. Vector2 IRadialMenuItemWithSector.ParentCenter { set => _parentCenter = value; }
  401. /// <summary>
  402. /// A simple texture button that can move the user to a different layer within a radial menu
  403. /// </summary>
  404. public RadialMenuTextureButtonWithSector()
  405. {
  406. }
  407. /// <inheritdoc />
  408. protected override void Draw(DrawingHandleScreen handle)
  409. {
  410. base.Draw(handle);
  411. if (_parentCenter == null)
  412. {
  413. return;
  414. }
  415. // draw sector where space that button occupies actually is
  416. var containerCenter = (_parentCenter.Value - Position) * UIScale;
  417. var angleFrom = _angleSectorFrom + _angleOffset;
  418. var angleTo = _angleSectorTo + _angleOffset;
  419. if (DrawBackground)
  420. {
  421. var segmentColor = DrawMode == DrawModeEnum.Hover
  422. ? _hoverBackgroundColorSrgb
  423. : _backgroundColorSrgb;
  424. DrawAnnulusSector(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, segmentColor);
  425. }
  426. if (DrawBorder)
  427. {
  428. var borderColor = DrawMode == DrawModeEnum.Hover
  429. ? _hoverBorderColorSrgb
  430. : _borderColorSrgb;
  431. DrawAnnulusSector(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, borderColor, false);
  432. }
  433. if (!_isWholeCircle && DrawSeparators)
  434. {
  435. DrawSeparatorLines(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, SeparatorColor);
  436. }
  437. }
  438. /// <inheritdoc />
  439. protected override bool HasPoint(Vector2 point)
  440. {
  441. if (_parentCenter == null)
  442. {
  443. return base.HasPoint(point);
  444. }
  445. var outerRadiusSquared = _outerRadius * _outerRadius;
  446. var innerRadiusSquared = _innerRadius * _innerRadius;
  447. var distSquared = (point + Position - _parentCenter.Value).LengthSquared();
  448. var isInRadius = distSquared < outerRadiusSquared && distSquared > innerRadiusSquared;
  449. if (!isInRadius)
  450. {
  451. return false;
  452. }
  453. // difference from the center of the parent to the `point`
  454. var pointFromParent = point + Position - _parentCenter.Value;
  455. // Flip Y to get from ui coordinates to natural coordinates
  456. var angle = MathF.Atan2(-pointFromParent.Y, pointFromParent.X) - _angleOffset;
  457. if (angle < 0)
  458. {
  459. // atan2 range is -pi->pi, while angle sectors are
  460. // 0->2pi, so remap the result into that range
  461. angle = MathF.PI * 2 + angle;
  462. }
  463. var isInAngle = angle >= _angleSectorFrom && angle < _angleSectorTo;
  464. return isInAngle;
  465. }
  466. /// <summary>
  467. /// Draw segment between two concentrated circles from and to certain angles.
  468. /// </summary>
  469. /// <param name="drawingHandleScreen">Drawing handle, to which rendering should be delegated.</param>
  470. /// <param name="center">Point where circle center should be.</param>
  471. /// <param name="radiusInner">Radius of internal circle.</param>
  472. /// <param name="radiusOuter">Radius of external circle.</param>
  473. /// <param name="angleSectorFrom">Angle in radian, from which sector should start.</param>
  474. /// <param name="angleSectorTo">Angle in radian, from which sector should start.</param>
  475. /// <param name="color">Color for drawing.</param>
  476. /// <param name="filled">Should figure be filled, or have only border.</param>
  477. private void DrawAnnulusSector(
  478. DrawingHandleScreen drawingHandleScreen,
  479. Vector2 center,
  480. float radiusInner,
  481. float radiusOuter,
  482. float angleSectorFrom,
  483. float angleSectorTo,
  484. Color color,
  485. bool filled = true
  486. )
  487. {
  488. const float minimalSegmentSize = MathF.Tau / 128f;
  489. var requestedSegmentSize = angleSectorTo - angleSectorFrom;
  490. var segmentCount = (int)(requestedSegmentSize / minimalSegmentSize) + 1;
  491. var anglePerSegment = requestedSegmentSize / (segmentCount - 1);
  492. var bufferSize = segmentCount * 2;
  493. if (_sectorPointsForDrawing == null || _sectorPointsForDrawing.Length != bufferSize)
  494. {
  495. _sectorPointsForDrawing ??= new Vector2[bufferSize];
  496. }
  497. for (var i = 0; i < segmentCount; i++)
  498. {
  499. var angle = angleSectorFrom + anglePerSegment * i;
  500. // Flip Y to get from ui coordinates to natural coordinates
  501. var unitPos = new Vector2(MathF.Cos(angle), -MathF.Sin(angle));
  502. var outerPoint = center + unitPos * radiusOuter;
  503. var innerPoint = center + unitPos * radiusInner;
  504. if (filled)
  505. {
  506. // to make filled sector we need to create strip from triangles
  507. _sectorPointsForDrawing[i * 2] = outerPoint;
  508. _sectorPointsForDrawing[i * 2 + 1] = innerPoint;
  509. }
  510. else
  511. {
  512. // to make border of sector we need points ordered as sequences on radius
  513. _sectorPointsForDrawing[i] = outerPoint;
  514. _sectorPointsForDrawing[bufferSize - 1 - i] = innerPoint;
  515. }
  516. }
  517. var type = filled
  518. ? DrawPrimitiveTopology.TriangleStrip
  519. : DrawPrimitiveTopology.LineStrip;
  520. drawingHandleScreen.DrawPrimitives(type, _sectorPointsForDrawing, color);
  521. }
  522. private static void DrawSeparatorLines(
  523. DrawingHandleScreen drawingHandleScreen,
  524. Vector2 center,
  525. float radiusInner,
  526. float radiusOuter,
  527. float angleSectorFrom,
  528. float angleSectorTo,
  529. Color color
  530. )
  531. {
  532. var fromPoint = new Angle(-angleSectorFrom).RotateVec(Vector2.UnitX);
  533. drawingHandleScreen.DrawLine(
  534. center + fromPoint * radiusOuter,
  535. center + fromPoint * radiusInner,
  536. color
  537. );
  538. var toPoint = new Angle(-angleSectorTo).RotateVec(Vector2.UnitX);
  539. drawingHandleScreen.DrawLine(
  540. center + toPoint * radiusOuter,
  541. center + toPoint * radiusInner,
  542. color
  543. );
  544. }
  545. private static bool IsWholeCircle(float angleSectorFrom, float angleSectorTo)
  546. {
  547. return new Angle(angleSectorFrom).EqualsApprox(new Angle(angleSectorTo));
  548. }
  549. }