using System.Linq; using System.Numerics; using Content.Shared.Input; using Robust.Client.Graphics; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Input; namespace Content.Client.UserInterface.Controls; [Virtual] public class RadialMenu : BaseWindow { /// /// Contextual button used to traverse through previous layers of the radial menu /// public RadialMenuContextualCentralTextureButton ContextualButton { get; } /// /// Button that represents outer area of menu (closes menu on outside clicks). /// public RadialMenuOuterAreaButton MenuOuterAreaButton { get; } /// /// 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 /// public string? BackButtonStyleClass { get { return _backButtonStyleClass; } set { _backButtonStyleClass = value; if (_path.Count > 0 && ContextualButton != null && _backButtonStyleClass != null) ContextualButton.SetOnlyStyleClass(_backButtonStyleClass); } } /// /// Set a style class to be applied to the contextual button when it will close the radial menu /// public string? CloseButtonStyleClass { get { return _closeButtonStyleClass; } set { _closeButtonStyleClass = value; if (_path.Count == 0 && ContextualButton != null && _closeButtonStyleClass != null) ContextualButton.SetOnlyStyleClass(_closeButtonStyleClass); } } private readonly List _path = new(); private string? _backButtonStyleClass; private string? _closeButtonStyleClass; /// /// A free floating menu which enables the quick display of one or more radial containers /// /// /// Only one radial container is visible at a time (each container forming a separate 'layer' within /// the menu), along with a contextual button at the menu center, which will either return the user /// to the previous layer or close the menu if there are no previous layers left to traverse. /// To create a functional radial menu, simply parent one or more named radial containers to it, /// and populate the radial containers with RadialMenuButtons. Setting the TargetLayer field of these /// buttons to the name of a radial conatiner will display the container in question to the user /// whenever it is clicked in additon to any other actions assigned to the button /// public RadialMenu() { // Hide all starting children (if any) except the first (this is the active layer) if (ChildCount > 1) { for (int i = 1; i < ChildCount; i++) GetChild(i).Visible = false; } // Auto generate a contextual button for moving back through visited layers ContextualButton = new RadialMenuContextualCentralTextureButton { HorizontalAlignment = HAlignment.Center, VerticalAlignment = VAlignment.Center, SetSize = new Vector2(64f, 64f), }; MenuOuterAreaButton = new RadialMenuOuterAreaButton(); ContextualButton.OnButtonUp += _ => ReturnToPreviousLayer(); MenuOuterAreaButton.OnButtonUp += _ => Close(); AddChild(ContextualButton); AddChild(MenuOuterAreaButton); // Hide any further add children, unless its promoted to the active layer OnChildAdded += child => { child.Visible = GetCurrentActiveLayer() == child; SetupContextualButtonData(child); }; } private void SetupContextualButtonData(Control child) { if (child is RadialContainer { Visible: true } container) { var parentCenter = MinSize * 0.5f; ContextualButton.ParentCenter = parentCenter; MenuOuterAreaButton.ParentCenter = parentCenter; ContextualButton.InnerRadius = container.CalculatedRadius * container.InnerRadiusMultiplier; MenuOuterAreaButton.OuterRadius = container.CalculatedRadius * container.OuterRadiusMultiplier; } } /// protected override Vector2 ArrangeOverride(Vector2 finalSize) { var result = base.ArrangeOverride(finalSize); var currentLayer = GetCurrentActiveLayer(); if (currentLayer != null) { SetupContextualButtonData(currentLayer); } return result; } private Control? GetCurrentActiveLayer() { var children = Children.Where(x => x != ContextualButton && x != MenuOuterAreaButton); if (!children.Any()) return null; return children.First(x => x.Visible); } public bool TryToMoveToNewLayer(Control newLayer) { var currentLayer = GetCurrentActiveLayer(); if (currentLayer == null) return false; var result = false; foreach (var child in Children) { if (child == ContextualButton || child == MenuOuterAreaButton) continue; // Hide layers which are not of interest if (result == true || child != newLayer) { child.Visible = false; } // Show the layer of interest else { child.Visible = true; SetupContextualButtonData(child); result = true; } } // Update the traversal path if (result) _path.Add(currentLayer); // Set the style class of the button if (_path.Count > 0 && ContextualButton != null && BackButtonStyleClass != null) ContextualButton.SetOnlyStyleClass(BackButtonStyleClass); return result; } public bool TryToMoveToNewLayer(string targetLayerControlName) { foreach (var child in Children) { if (child.Name == targetLayerControlName && child is RadialContainer) { return TryToMoveToNewLayer(child); } } return false; } public void ReturnToPreviousLayer() { // Close the menu if the traversal path is empty if (_path.Count == 0) { Close(); return; } var lastChild = _path[^1]; // Hide all children except the contextual button foreach (var child in Children) { if (child != ContextualButton && child != MenuOuterAreaButton) child.Visible = false; } // Make the last visited layer visible, update the path list lastChild.Visible = true; _path.RemoveAt(_path.Count - 1); // Set the style class of the button if (_path.Count == 0 && ContextualButton != null && CloseButtonStyleClass != null) ContextualButton.SetOnlyStyleClass(CloseButtonStyleClass); } } /// /// Base class for radial menu buttons. Excludes all actions except clicks and alt-clicks /// from interactions. /// [Virtual] public class RadialMenuTextureButtonBase : TextureButton { /// protected RadialMenuTextureButtonBase() { EnableAllKeybinds = true; } /// protected override void KeyBindUp(GUIBoundKeyEventArgs args) { if (args.Function == EngineKeyFunctions.UIClick || args.Function == ContentKeyFunctions.AltActivateItemInWorld) base.KeyBindUp(args); } } /// /// Special button for closing radial menu or going back between radial menu levels. /// Is looking like just but considers whole space around /// itself (til radial menu buttons) as itself in case of clicking. But this 'effect' /// works only if control have parent, and ActiveContainer property is set. /// Also considers all space outside of radial menu buttons as itself for clicking. /// public sealed class RadialMenuContextualCentralTextureButton : RadialMenuTextureButtonBase { public float InnerRadius { get; set; } public Vector2? ParentCenter { get; set; } /// protected override bool HasPoint(Vector2 point) { if (ParentCenter == null) { return base.HasPoint(point); } var distSquared = (point + Position - ParentCenter.Value).LengthSquared(); var innerRadiusSquared = InnerRadius * InnerRadius; // comparing to squared values is faster then making sqrt return distSquared < innerRadiusSquared; } } /// /// Menu button for outer area of radial menu (covers everything 'outside'). /// public sealed class RadialMenuOuterAreaButton : RadialMenuTextureButtonBase { public float OuterRadius { get; set; } public Vector2? ParentCenter { get; set; } /// protected override bool HasPoint(Vector2 point) { if (ParentCenter == null) { return base.HasPoint(point); } var distSquared = (point + Position - ParentCenter.Value).LengthSquared(); var outerRadiusSquared = OuterRadius * OuterRadius; // comparing to squared values is faster, then making sqrt return distSquared > outerRadiusSquared; } } [Virtual] public class RadialMenuTextureButton : RadialMenuTextureButtonBase { /// /// Upon clicking this button the radial menu will be moved to the layer of this control. /// public Control? TargetLayer { get; set; } /// /// Other way to set navigation to other container, as , /// but using property of target . /// public string? TargetLayerControlName { get; set; } /// /// A simple texture button that can move the user to a different layer within a radial menu /// public RadialMenuTextureButton() { EnableAllKeybinds = true; OnButtonUp += OnClicked; } private void OnClicked(ButtonEventArgs args) { if (TargetLayer == null && TargetLayerControlName == null) return; var parent = FindParentMultiLayerContainer(this); if (parent == null) return; if (TargetLayer != null) { parent.TryToMoveToNewLayer(TargetLayer); } else { parent.TryToMoveToNewLayer(TargetLayerControlName!); } } private RadialMenu? FindParentMultiLayerContainer(Control control) { foreach (var ancestor in control.GetSelfAndLogicalAncestors()) { if (ancestor is RadialMenu menu) return menu; } return null; } } public interface IRadialMenuItemWithSector { /// /// Angle in radian where button sector should start. /// public float AngleSectorFrom { set; } /// /// Angle in radian where button sector should end. /// public float AngleSectorTo { set; } /// /// Outer radius for drawing segment and pointer detection. /// public float OuterRadius { set; } /// /// Outer radius for drawing segment and pointer detection. /// public float InnerRadius { set; } /// /// Offset in radian by which menu button should be rotated. /// public float AngleOffset { set; } /// /// Coordinates of center in parent component - button container. /// public Vector2 ParentCenter { set; } } [Virtual] public class RadialMenuTextureButtonWithSector : RadialMenuTextureButton, IRadialMenuItemWithSector { private Vector2[]? _sectorPointsForDrawing; private float _angleSectorFrom; private float _angleSectorTo; private float _outerRadius; private float _innerRadius; private float _angleOffset; private bool _isWholeCircle; private Vector2? _parentCenter; private Color _backgroundColorSrgb = Color.ToSrgb(new Color(70, 73, 102, 128)); private Color _hoverBackgroundColorSrgb = Color.ToSrgb(new Color(87, 91, 127, 128)); private Color _borderColorSrgb = Color.ToSrgb(new Color(173, 216, 230, 70)); private Color _hoverBorderColorSrgb = Color.ToSrgb(new Color(87, 91, 127, 128)); /// /// Marker, that controls if border of segment should be rendered. Is false by default. /// /// /// By default color of border is same as color of background. Use /// and to change it. /// public bool DrawBorder { get; set; } = false; /// /// Marker, that control should render background of all sector. Is true by default. /// public bool DrawBackground { get; set; } = true; /// /// Color of background in non-hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally. /// public Color BackgroundColor { get => Color.FromSrgb(_backgroundColorSrgb); set => _backgroundColorSrgb = Color.ToSrgb(value); } /// /// Color of background in hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally. /// public Color HoverBackgroundColor { get => Color.FromSrgb(_hoverBackgroundColorSrgb); set => _hoverBackgroundColorSrgb = Color.ToSrgb(value); } /// /// Color of button border. Accepts RGB color, works with sRGB for DrawPrimitive internally. /// public Color BorderColor { get => Color.FromSrgb(_borderColorSrgb); set => _borderColorSrgb = Color.ToSrgb(value); } /// /// Color of button border when button is hovered. Accepts RGB color, works with sRGB for DrawPrimitive internally. /// public Color HoverBorderColor { get => Color.FromSrgb(_hoverBorderColorSrgb); set => _hoverBorderColorSrgb = Color.ToSrgb(value); } /// /// Color of separator lines. /// Separator lines are used to visually separate sector of radial menu items. /// public Color SeparatorColor { get; set; } = new Color(128, 128, 128, 128); /// float IRadialMenuItemWithSector.AngleSectorFrom { set { _angleSectorFrom = value; _isWholeCircle = IsWholeCircle(value, _angleSectorTo); } } /// float IRadialMenuItemWithSector.AngleSectorTo { set { _angleSectorTo = value; _isWholeCircle = IsWholeCircle(_angleSectorFrom, value); } } /// float IRadialMenuItemWithSector.OuterRadius { set => _outerRadius = value; } /// float IRadialMenuItemWithSector.InnerRadius { set => _innerRadius = value; } /// public float AngleOffset { set => _angleOffset = value; } /// Vector2 IRadialMenuItemWithSector.ParentCenter { set => _parentCenter = value; } /// /// A simple texture button that can move the user to a different layer within a radial menu /// public RadialMenuTextureButtonWithSector() { } /// protected override void Draw(DrawingHandleScreen handle) { base.Draw(handle); if (_parentCenter == null) { return; } // draw sector where space that button occupies actually is var containerCenter = (_parentCenter.Value - Position) * UIScale; var angleFrom = _angleSectorFrom + _angleOffset; var angleTo = _angleSectorTo + _angleOffset; if (DrawBackground) { var segmentColor = DrawMode == DrawModeEnum.Hover ? _hoverBackgroundColorSrgb : _backgroundColorSrgb; DrawAnnulusSector(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, segmentColor); } if (DrawBorder) { var borderColor = DrawMode == DrawModeEnum.Hover ? _hoverBorderColorSrgb : _borderColorSrgb; DrawAnnulusSector(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, borderColor, false); } if (!_isWholeCircle && DrawBorder) { DrawSeparatorLines(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, SeparatorColor); } } /// protected override bool HasPoint(Vector2 point) { if (_parentCenter == null) { return base.HasPoint(point); } var outerRadiusSquared = _outerRadius * _outerRadius; var innerRadiusSquared = _innerRadius * _innerRadius; var distSquared = (point + Position - _parentCenter.Value).LengthSquared(); var isInRadius = distSquared < outerRadiusSquared && distSquared > innerRadiusSquared; if (!isInRadius) { return false; } // difference from the center of the parent to the `point` var pointFromParent = point + Position - _parentCenter.Value; // Flip Y to get from ui coordinates to natural coordinates var angle = MathF.Atan2(-pointFromParent.Y, pointFromParent.X) - _angleOffset; if (angle < 0) { // atan2 range is -pi->pi, while angle sectors are // 0->2pi, so remap the result into that range angle = MathF.PI * 2 + angle; } var isInAngle = angle >= _angleSectorFrom && angle < _angleSectorTo; return isInAngle; } /// /// Draw segment between two concentrated circles from and to certain angles. /// /// Drawing handle, to which rendering should be delegated. /// Point where circle center should be. /// Radius of internal circle. /// Radius of external circle. /// Angle in radian, from which sector should start. /// Angle in radian, from which sector should start. /// Color for drawing. /// Should figure be filled, or have only border. private void DrawAnnulusSector( DrawingHandleScreen drawingHandleScreen, Vector2 center, float radiusInner, float radiusOuter, float angleSectorFrom, float angleSectorTo, Color color, bool filled = true ) { const float minimalSegmentSize = MathF.Tau / 128f; var requestedSegmentSize = angleSectorTo - angleSectorFrom; var segmentCount = (int)(requestedSegmentSize / minimalSegmentSize) + 1; var anglePerSegment = requestedSegmentSize / (segmentCount - 1); var bufferSize = segmentCount * 2; if (_sectorPointsForDrawing == null || _sectorPointsForDrawing.Length != bufferSize) { _sectorPointsForDrawing ??= new Vector2[bufferSize]; } for (var i = 0; i < segmentCount; i++) { var angle = angleSectorFrom + anglePerSegment * i; // Flip Y to get from ui coordinates to natural coordinates var unitPos = new Vector2(MathF.Cos(angle), -MathF.Sin(angle)); var outerPoint = center + unitPos * radiusOuter; var innerPoint = center + unitPos * radiusInner; if (filled) { // to make filled sector we need to create strip from triangles _sectorPointsForDrawing[i * 2] = outerPoint; _sectorPointsForDrawing[i * 2 + 1] = innerPoint; } else { // to make border of sector we need points ordered as sequences on radius _sectorPointsForDrawing[i] = outerPoint; _sectorPointsForDrawing[bufferSize - 1 - i] = innerPoint; } } var type = filled ? DrawPrimitiveTopology.TriangleStrip : DrawPrimitiveTopology.LineStrip; drawingHandleScreen.DrawPrimitives(type, _sectorPointsForDrawing, color); } private static void DrawSeparatorLines( DrawingHandleScreen drawingHandleScreen, Vector2 center, float radiusInner, float radiusOuter, float angleSectorFrom, float angleSectorTo, Color color ) { var fromPoint = new Angle(-angleSectorFrom).RotateVec(Vector2.UnitX); drawingHandleScreen.DrawLine( center + fromPoint * radiusOuter, center + fromPoint * radiusInner, color ); var toPoint = new Angle(-angleSectorTo).RotateVec(Vector2.UnitX); drawingHandleScreen.DrawLine( center + toPoint * radiusOuter, center + toPoint * radiusInner, color ); } private static bool IsWholeCircle(float angleSectorFrom, float angleSectorTo) { return new Angle(angleSectorFrom).EqualsApprox(new Angle(angleSectorTo)); } }