]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Feature/make radial menu great again (#32653)
authorFildrance <fildrance@gmail.com>
Thu, 23 Jan 2025 12:16:58 +0000 (15:16 +0300)
committerGitHub <noreply@github.com>
Thu, 23 Jan 2025 12:16:58 +0000 (13:16 +0100)
* it works! kinda

* so it works now

* minor cleanup

* central button now is useful too

* more cleanup

* minor cleanup

* more cleanup

* refactor: migrated code from toolbox (as it was rejected as too specific)

* feat: moved border drawing for radial menu into RadialMenuTextureButton. Radial menu position setting into was moved to OverrideArrange to not being called on every frame

* refactor: major reworks!

* renamed DrawBagleSector to DrawAnnulusSector

* Remove strange indexing

* Regularize math

* refactor: re-orienting segment elements to be Y-mirrored

* refactor: extracted radial menu radius multiplier property, changed color pallet for radial menu button

* refactor: removed icon backgrounds on textures used in current radial menu buttons with sectors, RadialContainer Radius renamed and now actually changed control radius.

* refactor: in RadialMenuTextureButtonWithSector all sector colors are converted to and from sRGB in property getter-setters

* refactor: renamed srgb to include Srgb suffix so devs gonna see that its srgb clearly

* fix: enabled any functional keys pressed when pushing radial menu buttons

* fix: radial menu sector now scales with UIScale

* fix: accept only one event when clicking on radial menu ContextualButton

* fix: now radial menu buttons accepts only click/alt-click, now clicks outside menu closes menu always

---------

Co-authored-by: pa.pecherskij <pa.pecherskij@interfax.ru>
Co-authored-by: Eoin Mcloughlin <helloworld@eoinrul.es>
Content.Client/Chat/UI/EmotesMenu.xaml
Content.Client/Chat/UI/EmotesMenu.xaml.cs
Content.Client/Ghost/GhostRoleRadioMenu.xaml.cs
Content.Client/RCD/RCDMenu.xaml
Content.Client/RCD/RCDMenu.xaml.cs
Content.Client/Silicons/StationAi/StationAiMenu.xaml
Content.Client/Silicons/StationAi/StationAiMenu.xaml.cs
Content.Client/UserInterface/Controls/RadialContainer.cs
Content.Client/UserInterface/Controls/RadialMenu.cs

index cc4d5bb77e9f67f26419f626ce343c4a217a4f86..845b631617144c60a9b31de2198b24a18b803065 100644 (file)
@@ -1,4 +1,4 @@
-<ui:RadialMenu xmlns="https://spacestation14.io"
+<ui:RadialMenu xmlns="https://spacestation14.io"
                 xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
                 BackButtonStyleClass="RadialMenuBackButton"
                 CloseButtonStyleClass="RadialMenuCloseButton"
@@ -7,25 +7,25 @@
                 MinSize="450 450">
 
     <!-- Main -->
-    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" Radius="64" ReserveSpaceForHiddenChildren="False">
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'emote-menu-category-general'}" TargetLayer="General" Visible="False">
+    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" InitialRadius="100" ReserveSpaceForHiddenChildren="False">
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'emote-menu-category-general'}" TargetLayer="General" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Clothing/Head/Soft/mimesoft.rsi/icon.png"/>
-        </ui:RadialMenuTextureButton>
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'emote-menu-category-vocal'}" TargetLayer="Vocal" Visible="False">
+        </ui:RadialMenuTextureButtonWithSector>
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'emote-menu-category-vocal'}" TargetLayer="Vocal" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Emotes/vocal.png"/>
-        </ui:RadialMenuTextureButton>
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'emote-menu-category-hands'}" TargetLayer="Hands" Visible="False">
+        </ui:RadialMenuTextureButtonWithSector>
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'emote-menu-category-hands'}" TargetLayer="Hands" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Clothing/Hands/Gloves/latex.rsi/icon.png"/>
-        </ui:RadialMenuTextureButton>
+        </ui:RadialMenuTextureButtonWithSector>
     </ui:RadialContainer>
 
     <!-- General -->
-    <ui:RadialContainer Name="General"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="General"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
     <!-- Vocal -->
-    <ui:RadialContainer Name="Vocal"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="Vocal"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
     <!-- Hands -->
-    <ui:RadialContainer Name="Hands"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="Hands"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
 </ui:RadialMenu>
index f3b7837f21a59cb58533579399f1b4c55d62dc9b..80daa405a68eede6515e5811076b7242c93ee16b 100644 (file)
@@ -50,7 +50,6 @@ public sealed partial class EmotesMenu : RadialMenu
 
             var button = new EmoteMenuButton
             {
-                StyleClasses = { "RadialMenuButton" },
                 SetSize = new Vector2(64f, 64f),
                 ToolTip = Loc.GetString(emote.Name),
                 ProtoId = emote.ID,
@@ -106,7 +105,7 @@ public sealed partial class EmotesMenu : RadialMenu
 }
 
 
-public sealed class EmoteMenuButton : RadialMenuTextureButton
+public sealed class EmoteMenuButton : RadialMenuTextureButtonWithSector
 {
     public ProtoId<EmotePrototype> ProtoId { get; set; }
 }
index 3897b1b949ca8e571aba583d7f80daec633c0483..1b65eac6ed952dfb13278fe2e4c51bc93d9cf241 100644 (file)
@@ -51,7 +51,6 @@ public sealed partial class GhostRoleRadioMenu : RadialMenu
 
             var button = new GhostRoleRadioMenuButton()
             {
-                StyleClasses = { "RadialMenuButton" },
                 SetSize = new Vector2(64, 64),
                 ToolTip = Loc.GetString(ghostRoleProto.Name),
                 ProtoId = ghostRoleProto.ID,
@@ -100,7 +99,7 @@ public sealed partial class GhostRoleRadioMenu : RadialMenu
     }
 }
 
-public sealed class GhostRoleRadioMenuButton : RadialMenuTextureButton
+public sealed class GhostRoleRadioMenuButton : RadialMenuTextureButtonWithSector
 {
     public ProtoId<GhostRolePrototype> ProtoId { get; set; }
 }
index b3d5367a5fdd9a5f2b991f76eabd9d95bc421fd3..d8ab0ac8f4c81fd4dc250345f19870ea7ea0ccb8 100644 (file)
     <!-- The radial menu will try to open so that its center is located where the player's cursor is currently -->
 
     <!-- Entry layer (shows main categories) -->
-    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" Radius="64" ReserveSpaceForHiddenChildren="False">
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'rcd-component-walls-and-flooring'}" TargetLayer="WallsAndFlooring" Visible="False">
+    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" InitialRadius="100" ReserveSpaceForHiddenChildren="False">
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-walls-and-flooring'}" TargetLayer="WallsAndFlooring" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/walls_and_flooring.png"/>
-        </ui:RadialMenuTextureButton>
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'rcd-component-windows-and-grilles'}" TargetLayer="WindowsAndGrilles" Visible="False">
+        </ui:RadialMenuTextureButtonWithSector>
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-windows-and-grilles'}" TargetLayer="WindowsAndGrilles" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/windows_and_grilles.png"/>
-        </ui:RadialMenuTextureButton>
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'rcd-component-airlocks'}" TargetLayer="Airlocks" Visible="False">
+        </ui:RadialMenuTextureButtonWithSector>
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-airlocks'}" TargetLayer="Airlocks" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/airlocks.png"/>
-        </ui:RadialMenuTextureButton>
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'rcd-component-electrical'}" TargetLayer="Electrical" Visible="False">
+        </ui:RadialMenuTextureButtonWithSector>
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-electrical'}" TargetLayer="Electrical" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/multicoil.png"/>
-        </ui:RadialMenuTextureButton>
-        <ui:RadialMenuTextureButton StyleClasses="RadialMenuButton" SetSize="64 64" ToolTip="{Loc 'rcd-component-lighting'}" TargetLayer="Lighting" Visible="False">
+        </ui:RadialMenuTextureButtonWithSector>
+        <ui:RadialMenuTextureButtonWithSector SetSize="64 64" ToolTip="{Loc 'rcd-component-lighting'}" TargetLayer="Lighting" Visible="False">
             <TextureRect VerticalAlignment="Center" HorizontalAlignment="Center" TextureScale="2 2" TexturePath="/Textures/Interface/Radial/RCD/lighting.png"/>
-        </ui:RadialMenuTextureButton>
+        </ui:RadialMenuTextureButtonWithSector>
     </ui:RadialContainer>
 
     <!-- Walls and flooring -->
-    <ui:RadialContainer Name="WallsAndFlooring"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="WallsAndFlooring"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
     <!-- Windows and grilles -->
-    <ui:RadialContainer Name="WindowsAndGrilles"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="WindowsAndGrilles"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
     <!-- Airlocks -->
-    <ui:RadialContainer Name="Airlocks"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="Airlocks"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
     <!-- Computer and machine frames -->
-    <ui:RadialContainer Name="Electrical"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="Electrical"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
        
     <!-- Lighting -->
-    <ui:RadialContainer Name="Lighting"  VerticalExpand="True" HorizontalExpand="True" Radius="64"/>
+    <ui:RadialContainer Name="Lighting"  VerticalExpand="True" HorizontalExpand="True" InitialRadius="100"/>
 
 </ui:RadialMenu>
index f0d27d6b1fb853e626a9843712dd677263a594be..7ea9894e41e2bce911b0774ce22e9cffc6fad9f1 100644 (file)
@@ -74,7 +74,6 @@ public sealed partial class RCDMenu : RadialMenu
 
             var button = new RCDMenuButton()
             {
-                StyleClasses = { "RadialMenuButton" },
                 SetSize = new Vector2(64f, 64f),
                 ToolTip = tooltip,
                 ProtoId = protoId,
@@ -99,9 +98,7 @@ public sealed partial class RCDMenu : RadialMenu
             // is visible in the main radial container (as these all start with Visible = false)
             foreach (var child in main.Children)
             {
-                var castChild = child as RadialMenuTextureButton;
-
-                if (castChild is not RadialMenuTextureButton)
+                if (child is not RadialMenuTextureButton castChild)
                     continue;
 
                 if (castChild.TargetLayer == proto.Category)
@@ -169,12 +166,7 @@ public sealed partial class RCDMenu : RadialMenu
     }
 }
 
-public sealed class RCDMenuButton : RadialMenuTextureButton
+public sealed class RCDMenuButton : RadialMenuTextureButtonWithSector
 {
     public ProtoId<RCDPrototype> ProtoId { get; set; }
-
-    public RCDMenuButton()
-    {
-
-    }
 }
index d56fc832898490aba3dcaf1f3ddb4f265de36148..cfa0b93234e7c8390ead4a8b3faac97a0366199d 100644 (file)
@@ -1,4 +1,4 @@
-<ui:RadialMenu xmlns="https://spacestation14.io"
+<ui:RadialMenu xmlns="https://spacestation14.io"
                 xmlns:ui="clr-namespace:Content.Client.UserInterface.Controls"
                 BackButtonStyleClass="RadialMenuBackButton"
                 CloseButtonStyleClass="RadialMenuCloseButton"
@@ -7,7 +7,7 @@
                 MinSize="450 450">
 
     <!-- Main -->
-    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" Radius="64" ReserveSpaceForHiddenChildren="False">
+    <ui:RadialContainer Name="Main" VerticalExpand="True" HorizontalExpand="True" InitialRadius="100" ReserveSpaceForHiddenChildren="False">
     </ui:RadialContainer>
 
 </ui:RadialMenu>
index b152f5ead8b1777548ac740633666ae469a4e517..a536d911f3c41569ccb2564c249940d740b316ef 100644 (file)
@@ -54,7 +54,6 @@ public sealed partial class StationAiMenu : RadialMenu
             // TODO: This radial boilerplate is quite annoying
             var button = new StationAiMenuButton(action.Event)
             {
-                StyleClasses = { "RadialMenuButton" },
                 SetSize = new Vector2(64f, 64f),
                 ToolTip = action.Tooltip != null ? Loc.GetString(action.Tooltip) : null,
             };
@@ -121,7 +120,7 @@ public sealed partial class StationAiMenu : RadialMenu
     }
 }
 
-public sealed class StationAiMenuButton(BaseStationAiAction action) : RadialMenuTextureButton
+public sealed class StationAiMenuButton(BaseStationAiAction action) : RadialMenuTextureButtonWithSector
 {
     public BaseStationAiAction Action = action;
 }
index be9b8817a065140263f891a944aa47ae9ebe9099..72555aab5f3083014fe18c82b441153d04276a02 100644 (file)
@@ -1,4 +1,3 @@
-using Robust.Client.Graphics;
 using Robust.Client.UserInterface.Controls;
 using System.Linq;
 using System.Numerics;
@@ -8,6 +7,11 @@ namespace Content.Client.UserInterface.Controls;
 [Virtual]
 public class RadialContainer : LayoutContainer
 {
+    /// <summary>
+    /// Increment of radius per child element to be rendered.
+    /// </summary>
+    private const float RadiusIncrement = 5f;
+
     /// <summary>
     /// Specifies the anglular range, in radians, in which child elements will be placed.
     /// The first value denotes the angle at which the first element is to be placed, and
@@ -49,10 +53,30 @@ public class RadialContainer : LayoutContainer
     public RAlignment RadialAlignment { get; set; } = RAlignment.Clockwise;
 
     /// <summary>
-    /// Determines how far from the radial container's center that its child elements will be placed
+    /// Radial menu radius determines how far from the radial container's center its child elements will be placed.
+    /// To correctly display dynamic amount of elements control actually resizes depending on amount of child buttons,
+    /// but uses this property as base value for final radius calculation.
     /// </summary>
     [ViewVariables(VVAccess.ReadWrite)]
-    public float Radius { get; set; } = 100f;
+    public float InitialRadius { get; set; } = 100f;
+
+    /// <summary>
+    /// Radial menu radius determines how far from the radial container's center its child elements will be placed.
+    /// This is dynamically calculated (based on child button count) radius, result of <see cref="InitialRadius"/> and
+    /// <see cref="RadiusIncrement"/> multiplied by currently visible child button count.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadOnly)]
+    public float CalculatedRadius { get; private set; }
+
+    /// <summary>
+    /// Determines radial menu button sectors inner radius, is a multiplier of <see cref="InitialRadius"/>.
+    /// </summary>
+    public float InnerRadiusMultiplier { get; set; } = 0.5f;
+
+    /// <summary>
+    /// Determines radial menu button sectors outer radius, is a multiplier of <see cref="InitialRadius"/>.
+    /// </summary>
+    public float OuterRadiusMultiplier { get; set; } = 1.5f;
 
     /// <summary>
     /// Sets whether the container should reserve a space on the layout for child which are not currently visible
@@ -67,37 +91,74 @@ public class RadialContainer : LayoutContainer
     {
 
     }
-       
-    protected override void Draw(DrawingHandleScreen handle)
+
+    /// <inheritdoc />
+    protected override Vector2 ArrangeOverride(Vector2 finalSize)
     {
-               
-        const float baseRadius = 100f;
-        const float radiusIncrement = 5f;
-               
-        var children = ReserveSpaceForHiddenChildren ? Children : Children.Where(x => x.Visible);
+        var children = ReserveSpaceForHiddenChildren
+            ? Children
+            : Children.Where(x => x.Visible);
+
         var childCount = children.Count();
-               
-               // Add padding from the center at higher child counts so they don't overlap.
-               Radius = baseRadius + (childCount * radiusIncrement);
+
+        // Add padding from the center at higher child counts so they don't overlap.
+        CalculatedRadius = InitialRadius + (childCount * RadiusIncrement);
+
+        var isAntiClockwise = RadialAlignment == RAlignment.AntiClockwise;
 
         // Determine the size of the arc, accounting for clockwise and anti-clockwise arrangements
         var arc = AngularRange.Y - AngularRange.X;
-        arc = (arc < 0) ? MathF.Tau + arc : arc;
-        arc = (RadialAlignment == RAlignment.AntiClockwise) ? MathF.Tau - arc : arc;
+        arc = arc < 0
+            ? MathF.Tau + arc
+            : arc;
+        arc = isAntiClockwise
+            ? MathF.Tau - arc
+            : arc;
 
         // Account for both circular arrangements and arc-based arrangements
-        var childMod = MathHelper.CloseTo(arc, MathF.Tau, 0.01f) ? 0 : 1;
+        var childMod = MathHelper.CloseTo(arc, MathF.Tau, 0.01f)
+            ? 0
+            : 1;
 
         // Determine the separation between child elements
         var sepAngle = arc / (childCount - childMod);
-        sepAngle *= (RadialAlignment == RAlignment.AntiClockwise) ? -1f : 1f;
+        sepAngle *= isAntiClockwise
+            ? -1f
+            : 1f;
+
+        var controlCenter = finalSize * 0.5f;
 
         // Adjust the positions of all the child elements
-        foreach (var (i, child) in children.Select((x, i) => (i, x)))
+        var query = children.Select((x, index) => (index, x));
+        foreach (var (childIndex, child) in query)
         {
-            var position = new Vector2(Radius * MathF.Sin(AngularRange.X + sepAngle * i) + Width / 2f - child.Width / 2f, -Radius * MathF.Cos(AngularRange.X + sepAngle * i) + Height / 2f - child.Height / 2f);
+            const float angleOffset = MathF.PI * 0.5f;
+
+            var targetAngleOfChild = AngularRange.X + sepAngle * (childIndex + 0.5f) + angleOffset;
+
+            // flooring values for snapping float values to physical grid -
+            // it prevents gaps and overlapping between different button segments
+            var position = new Vector2(
+                    MathF.Floor(CalculatedRadius * MathF.Cos(targetAngleOfChild)),
+                    MathF.Floor(-CalculatedRadius * MathF.Sin(targetAngleOfChild))
+                ) + controlCenter - child.DesiredSize * 0.5f + Position;
+
             SetPosition(child, position);
+
+            // radial menu buttons with sector need to also know in which sector and around which point
+            // they should be rendered, how much space sector should should take etc.
+            if (child is IRadialMenuItemWithSector tb)
+            {
+                tb.AngleSectorFrom = sepAngle * childIndex;
+                tb.AngleSectorTo = sepAngle * (childIndex + 1);
+                tb.AngleOffset = angleOffset;
+                tb.InnerRadius = CalculatedRadius * InnerRadiusMultiplier;
+                tb.OuterRadius = CalculatedRadius * OuterRadiusMultiplier;
+                tb.ParentCenter = controlCenter;
+            }
         }
+
+        return base.ArrangeOverride(finalSize);
     }
 
     /// <summary>
@@ -109,4 +170,5 @@ public class RadialContainer : LayoutContainer
         Clockwise,
         AntiClockwise,
     }
+
 }
index 5f56ad7f866d3d8cfaf7bfe5e65f393d51e75dc7..1b7f07aa2cc75cc7415d127fd27a27377beebf47 100644 (file)
@@ -3,6 +3,9 @@ using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.CustomControls;
 using System.Linq;
 using System.Numerics;
+using Content.Shared.Input;
+using Robust.Client.Graphics;
+using Robust.Shared.Input;
 
 namespace Content.Client.UserInterface.Controls;
 
@@ -12,11 +15,16 @@ public class RadialMenu : BaseWindow
     /// <summary>
     /// Contextual button used to traverse through previous layers of the radial menu
     /// </summary>
-    public TextureButton? ContextualButton { get; set; }
+    public RadialMenuContextualCentralTextureButton ContextualButton { get; }
+
+    /// <summary>
+    /// Button that represents outer area of menu (closes menu on outside clicks).
+    /// </summary>
+    public RadialMenuOuterAreaButton MenuOuterAreaButton { get; }
 
     /// <summary>
     /// 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
-    /// </summary>  
+    /// </summary>
     public string? BackButtonStyleClass
     {
         get
@@ -52,7 +60,7 @@ public class RadialMenu : BaseWindow
         }
     }
 
-    private List<Control> _path = new();
+    private readonly List<Control> _path = new();
     private string? _backButtonStyleClass;
     private string? _closeButtonStyleClass;
 
@@ -60,8 +68,8 @@ public class RadialMenu : BaseWindow
     /// A free floating menu which enables the quick display of one or more radial containers
     /// </summary>
     /// <remarks>
-    /// 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  
+    /// 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
@@ -78,23 +86,56 @@ public class RadialMenu : BaseWindow
         }
 
         // Auto generate a contextual button for moving back through visited layers
-        ContextualButton = new TextureButton()
+        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);
+        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;
+        }
+    }
+
+    /// <inheritdoc />
+    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);
+        var children = Children.Where(x => x != ContextualButton && x != MenuOuterAreaButton);
 
         if (!children.Any())
             return null;
@@ -116,7 +157,7 @@ public class RadialMenu : BaseWindow
 
         foreach (var child in Children)
         {
-            if (child == ContextualButton)
+            if (child == ContextualButton || child == MenuOuterAreaButton)
                 continue;
 
             // Hide layers which are not of interest
@@ -129,6 +170,7 @@ public class RadialMenu : BaseWindow
             else
             {
                 child.Visible = true;
+                SetupContextualButtonData(child);
                 result = true;
             }
         }
@@ -158,7 +200,7 @@ public class RadialMenu : BaseWindow
         // Hide all children except the contextual button
         foreach (var child in Children)
         {
-            if (child != ContextualButton)
+            if (child != ContextualButton && child != MenuOuterAreaButton)
                 child.Visible = false;
         }
 
@@ -172,25 +214,104 @@ public class RadialMenu : BaseWindow
     }
 }
 
+/// <summary>
+/// Base class for radial menu buttons. Excludes all actions except clicks and alt-clicks
+/// from interactions.
+/// </summary>
+[Virtual]
+public class RadialMenuTextureButtonBase : TextureButton
+{
+    /// <inheritdoc />
+    protected RadialMenuTextureButtonBase()
+    {
+        EnableAllKeybinds = true;
+    }
+
+    /// <inheritdoc />
+    protected override void KeyBindUp(GUIBoundKeyEventArgs args)
+    {
+        if (args.Function == EngineKeyFunctions.UIClick
+            || args.Function == ContentKeyFunctions.AltActivateItemInWorld)
+            base.KeyBindUp(args);
+    }
+}
+
+/// <summary>
+/// Special button for closing radial menu or going back between radial menu levels.
+/// Is looking like just <see cref="TextureButton "/> 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.
+/// </summary>
+public sealed class RadialMenuContextualCentralTextureButton : RadialMenuTextureButtonBase
+{
+    public float InnerRadius { get; set; }
+
+    public Vector2? ParentCenter { get; set; }
+
+    /// <inheritdoc />
+    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;
+    }
+}
+
+/// <summary>
+/// Menu button for outer area of radial menu (covers everything 'outside').
+/// </summary>
+public sealed class RadialMenuOuterAreaButton : RadialMenuTextureButtonBase
+{
+    public float OuterRadius { get; set; }
+
+    public Vector2? ParentCenter { get; set; }
+
+    /// <inheritdoc />
+    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 RadialMenuButton : Button
+public class RadialMenuTextureButton : RadialMenuTextureButtonBase
 {
     /// <summary>
-    /// Upon clicking this button the radial menu will transition to the named layer
+    /// Upon clicking this button the radial menu will be moved to the named layer
     /// </summary>
-    public string? TargetLayer { get; set; }
+    public string TargetLayer { get; set; } = string.Empty;
 
     /// <summary>
-    /// A simple button that can move the user to a different layer within a radial menu
+    /// A simple texture button that can move the user to a different layer within a radial menu
     /// </summary>
-    public RadialMenuButton()
+    public RadialMenuTextureButton()
     {
+        EnableAllKeybinds = true;
         OnButtonUp += OnClicked;
     }
 
     private void OnClicked(ButtonEventArgs args)
     {
-        if (TargetLayer == null || TargetLayer == string.Empty)
+        if (TargetLayer == string.Empty)
             return;
 
         var parent = FindParentMultiLayerContainer(this);
@@ -205,51 +326,329 @@ public class RadialMenuButton : Button
     {
         foreach (var ancestor in control.GetSelfAndLogicalAncestors())
         {
-            if (ancestor is RadialMenu)
-                return ancestor as RadialMenu;
+            if (ancestor is RadialMenu menu)
+                return menu;
         }
 
         return null;
     }
 }
 
+public interface IRadialMenuItemWithSector
+{
+    /// <summary>
+    /// Angle in radian where button sector should start.
+    /// </summary>
+    public float AngleSectorFrom { set; }
+
+    /// <summary>
+    /// Angle in radian where button sector should end.
+    /// </summary>
+    public float AngleSectorTo { set; }
+
+    /// <summary>
+    /// Outer radius for drawing segment and pointer detection.
+    /// </summary>
+    public float OuterRadius { set; }
+
+    /// <summary>
+    /// Outer radius for drawing segment and pointer detection.
+    /// </summary>
+    public float InnerRadius { set; }
+
+    /// <summary>
+    /// Offset in radian by which menu button should be rotated.
+    /// </summary>
+    public float AngleOffset { set; }
+
+    /// <summary>
+    /// Coordinates of center in parent component - button container.
+    /// </summary>
+    public Vector2 ParentCenter { set; }
+}
+
 [Virtual]
-public class RadialMenuTextureButton : TextureButton
+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));
+
     /// <summary>
-    /// Upon clicking this button the radial menu will be moved to the named layer
+    /// Marker, that control should render border of segment. Is false by default.
     /// </summary>
-    public string TargetLayer { get; set; } = string.Empty;
+    /// <remarks>
+    /// By default color of border is same as color of background. Use <see cref="BorderColor"/>
+    /// and <see cref="HoverBorderColor"/> to change it.
+    /// </remarks>
+    public bool DrawBorder { get; set; } = false;
+
+    /// <summary>
+    /// Marker, that control should render background of all sector. Is true by default.
+    /// </summary>
+    public bool DrawBackground { get; set; } = true;
+
+    /// <summary>
+    /// Marker, that control should render separator lines.
+    /// Separator lines are used to visually separate sector of radial menu items.
+    /// Is true by default
+    /// </summary>
+    public bool DrawSeparators { get; set; } = true;
+
+    /// <summary>
+    /// Color of background in non-hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally.
+    /// </summary>
+    public Color BackgroundColor
+    {
+        get => Color.FromSrgb(_backgroundColorSrgb);
+        set => _backgroundColorSrgb = Color.ToSrgb(value);
+    }
+
+    /// <summary>
+    /// Color of background in hovered state. Accepts RGB color, works with sRGB for DrawPrimitive internally.
+    /// </summary>
+    public Color HoverBackgroundColor
+    {
+        get => Color.FromSrgb(_hoverBackgroundColorSrgb);
+        set => _hoverBackgroundColorSrgb = Color.ToSrgb(value);
+    }
+
+    /// <summary>
+    /// Color of button border. Accepts RGB color, works with sRGB for DrawPrimitive internally.
+    /// </summary>
+    public Color BorderColor
+    {
+        get => Color.FromSrgb(_borderColorSrgb);
+        set => _borderColorSrgb = Color.ToSrgb(value);
+    }
+
+    /// <summary>
+    /// Color of button border when button is hovered. Accepts RGB color, works with sRGB for DrawPrimitive internally.
+    /// </summary>
+    public Color HoverBorderColor
+    {
+        get => Color.FromSrgb(_hoverBorderColorSrgb);
+        set => _hoverBorderColorSrgb = Color.ToSrgb(value);
+    }
+
+    /// <summary>
+    /// Color of separator lines.
+    /// Separator lines are used to visually separate sector of radial menu items.
+    /// </summary>
+    public Color SeparatorColor { get; set; } = new Color(128, 128, 128, 128);
+
+    /// <inheritdoc />
+    float IRadialMenuItemWithSector.AngleSectorFrom
+    {
+        set
+        {
+            _angleSectorFrom = value;
+            _isWholeCircle = IsWholeCircle(value, _angleSectorTo);
+        }
+    }
+
+    /// <inheritdoc />
+    float IRadialMenuItemWithSector.AngleSectorTo
+    {
+        set
+        {
+            _angleSectorTo = value;
+            _isWholeCircle = IsWholeCircle(_angleSectorFrom, value);
+        }
+    }
+
+    /// <inheritdoc />
+    float IRadialMenuItemWithSector.OuterRadius { set => _outerRadius = value; }
+
+    /// <inheritdoc />
+    float IRadialMenuItemWithSector.InnerRadius { set => _innerRadius = value; }
+
+    /// <inheritdoc />
+    public float AngleOffset { set => _angleOffset = value; }
+
+    /// <inheritdoc />
+    Vector2 IRadialMenuItemWithSector.ParentCenter { set => _parentCenter = value; }
 
     /// <summary>
     /// A simple texture button that can move the user to a different layer within a radial menu
     /// </summary>
-    public RadialMenuTextureButton()
+    public RadialMenuTextureButtonWithSector()
     {
-        OnButtonUp += OnClicked;
     }
 
-    private void OnClicked(ButtonEventArgs args)
+    /// <inheritdoc />
+    protected override void Draw(DrawingHandleScreen handle)
     {
-        if (TargetLayer == string.Empty)
+        base.Draw(handle);
+
+        if (_parentCenter == null)
+        {
             return;
+        }
 
-        var parent = FindParentMultiLayerContainer(this);
+        // draw sector where space that button occupies actually is
+        var containerCenter = (_parentCenter.Value - Position) * UIScale;
 
-        if (parent == null)
-            return;
+        var angleFrom = _angleSectorFrom + _angleOffset;
+        var angleTo = _angleSectorTo + _angleOffset;
+        if (DrawBackground)
+        {
+            var segmentColor = DrawMode == DrawModeEnum.Hover
+                ? _hoverBackgroundColorSrgb
+                : _backgroundColorSrgb;
 
-        parent.TryToMoveToNewLayer(TargetLayer);
+            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 && DrawSeparators)
+        {
+            DrawSeparatorLines(handle, containerCenter, _innerRadius * UIScale, _outerRadius * UIScale, angleFrom, angleTo, SeparatorColor);
+        }
     }
 
-    private RadialMenu? FindParentMultiLayerContainer(Control control)
+    /// <inheritdoc />
+    protected override bool HasPoint(Vector2 point)
     {
-        foreach (var ancestor in control.GetSelfAndLogicalAncestors())
+        if (_parentCenter == null)
         {
-            if (ancestor is RadialMenu)
-                return ancestor as RadialMenu;
+            return base.HasPoint(point);
         }
 
-        return null;
+        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;
+    }
+
+    /// <summary>
+    /// Draw segment between two concentrated circles from and to certain angles.
+    /// </summary>
+    /// <param name="drawingHandleScreen">Drawing handle, to which rendering should be delegated.</param>
+    /// <param name="center">Point where circle center should be.</param>
+    /// <param name="radiusInner">Radius of internal circle.</param>
+    /// <param name="radiusOuter">Radius of external circle.</param>
+    /// <param name="angleSectorFrom">Angle in radian, from which sector should start.</param>
+    /// <param name="angleSectorTo">Angle in radian, from which sector should start.</param>
+    /// <param name="color">Color for drawing.</param>
+    /// <param name="filled">Should figure be filled, or have only border.</param>
+    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));
     }
 }