Radial menus are in good shape

This commit is contained in:
2026-05-18 16:12:26 -04:00
parent 0b23c82e73
commit 1c2be1b4d8
7 changed files with 183 additions and 175 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
Content/Widgets/teardrop.uasset LFS Normal file

Binary file not shown.

View File

@@ -1,6 +1,8 @@
#include "RadialMenu.h"
#include "Rendering/DrawElements.h"
#include "Engine/Texture2D.h"
#include "Fonts/FontMeasure.h"
#include "Framework/Application/SlateApplication.h"
#include "Styling/SlateBrush.h"
#include "Widgets/SLeafWidget.h"
@@ -8,17 +10,30 @@
TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Config)
{
TArray<FRadialMenuItem> Items;
if (Config.NumItems < 0) return Items;
const int32 NumItems = Config.MenuItems.Num();
if (NumItems <= 0) return Items;
int32 NumRight = (Config.NumItems / 2);
int32 NumLeft = Config.NumItems - NumRight;
Items.SetNum(Config.NumItems);
int32 NumRight = (NumItems / 2);
int32 NumLeft = NumItems - NumRight;
Items.SetNum(NumItems);
// Measure each non-empty label first; the spoke layout's item height
// is derived from the tallest label.
const TSharedRef<FSlateFontMeasure> FontMeasure = FSlateApplication::Get().GetRenderer()->GetFontMeasureService();
double MaxTextHeight = 5.0;
for (int32 I = 0; I < NumItems; I++)
{
if (Config.MenuItems[I].IsEmpty()) continue;
Items[I].TextSize = FontMeasure->Measure(Config.MenuItems[I], Config.Font);
MaxTextHeight = FMath::Max(MaxTextHeight, Items[I].TextSize.Y);
}
const float ItemHeight = static_cast<float>(MaxTextHeight * 1.2);
View LeftItems(Items.GetData(), NumLeft);
View RightItems(Items.GetData() + NumLeft, NumRight);
CalculateSpokes(LeftItems, Config.NumItems, Config.ItemHeight, Config.InnerRadius, Config.MinSpoke);
CalculateSpokes(RightItems, Config.NumItems, Config.ItemHeight, Config.InnerRadius, Config.MinSpoke);
CalculateSpokes(LeftItems, NumItems, ItemHeight, Config.InnerRadius, Config.MinSpoke);
CalculateSpokes(RightItems, NumItems, ItemHeight, Config.InnerRadius, Config.MinSpoke);
double LeftWidth = WidestSpoke(LeftItems);
double RightWidth = WidestSpoke(RightItems);
double HalfWidth = FMath::Max(LeftWidth, RightWidth) + Config.Spread;
@@ -29,6 +44,29 @@ TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Conf
return Items;
}
TArray<FVector2D> FRadialMenuItem::CalculateDirections(int32 NumItems)
{
TArray<FVector2D> Result;
if (NumItems <= 0) return Result;
const int32 NumRight = (NumItems / 2);
const int32 NumLeft = NumItems - NumRight;
Result.SetNum(NumItems);
// Left side: SpokeVector with X flipped (matches FlipHorizontal in Calculate).
for (int32 I = 0; I < NumLeft; I++)
{
FVector2D V = SpokeVector(I, NumLeft, NumItems);
V.X = -V.X;
Result[I] = V;
}
for (int32 I = 0; I < NumRight; I++)
{
Result[NumLeft + I] = SpokeVector(I, NumRight, NumItems);
}
return Result;
}
FVector2D FRadialMenuItem::SpokeVector(int32 I, int32 NSide, int32 NTotal)
{
double SpokeAngle = 1.0 / NTotal;
@@ -130,6 +168,13 @@ public:
Invalidate(EInvalidateWidgetReason::Layout);
}
void SetPointer(FVector2D NewPointer)
{
if (PointerVector == NewPointer) return;
PointerVector = NewPointer;
Invalidate(EInvalidateWidgetReason::Paint);
}
protected:
virtual FVector2D ComputeDesiredSize(float) const override
{
@@ -144,6 +189,14 @@ protected:
Max.X = FMath::Max(Max.X, P.X);
Max.Y = FMath::Max(Max.Y, P.Y);
}
if (Item.TextSize.X <= 0) continue;
const double Gap = Config.Spread * 0.5;
const double TextLeft = Item.RightSide ? (Item.Point3.X + Gap) : (Item.Point3.X - Gap - Item.TextSize.X);
const double TextRight = Item.RightSide ? (Item.Point3.X + Gap + Item.TextSize.X) : (Item.Point3.X - Gap);
Min.X = FMath::Min(Min.X, TextLeft);
Max.X = FMath::Max(Max.X, TextRight);
Min.Y = FMath::Min(Min.Y, Item.Point3.Y - Item.TextSize.Y * 0.5);
Max.Y = FMath::Max(Max.Y, Item.Point3.Y + Item.TextSize.Y * 0.5);
}
// Symmetric around the origin so the menu draws centered.
double HalfW = FMath::Max(FMath::Abs(Min.X), FMath::Abs(Max.X));
@@ -162,7 +215,7 @@ protected:
{
const FRadialMenuItem& Item = Items[I];
const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.LineColor;
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
const float Thickness = bSelected ? Config.SelectedThickness : Config.LineThickness;
TArray<FVector2D> Points;
Points.Add(Center + Item.Point1);
@@ -171,168 +224,140 @@ protected:
FSlateDrawElement::MakeLines(OutDrawElements, LayerId, PaintGeom, Points, ESlateDrawEffect::None, Color, true, Thickness);
}
if (Config.DotRadius <= 0.0f) return LayerId;
if (Config.DotTexture == nullptr) return LayerId;
FSlateBrush DotBrush;
DotBrush.SetResourceObject(Config.DotTexture);
const FVector2D DotSize(Config.DotRadius * 2.0f);
// Draw item labels, vertically centered on Point3. Right-side items
// are left-aligned to Point3; left-side items are right-aligned.
for (int32 I = 0; I < Items.Num(); I++)
{
const FRadialMenuItem& Item = Items[I];
if (Item.TextSize.X <= 0) continue;
const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.LineColor;
for (const FVector2D& Point : { Item.Point1, Item.Point3 })
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
const double Gap = Config.Spread * 0.5;
FVector2D TextPos;
TextPos.X = Item.RightSide ? (Item.Point3.X + Gap) : (Item.Point3.X - Gap - Item.TextSize.X);
TextPos.Y = Item.Point3.Y - Item.TextSize.Y * 0.5;
FSlateDrawElement::MakeText(
OutDrawElements,
LayerId + 1,
AllottedGeometry.ToPaintGeometry(Item.TextSize, FSlateLayoutTransform(Center + TextPos)),
Config.MenuItems[I],
Config.Font,
ESlateDrawEffect::None,
Color);
}
if (Config.DotRadius > 0.0f && Config.DotTexture != nullptr)
{
FSlateBrush DotBrush;
DotBrush.SetResourceObject(Config.DotTexture);
const FVector2D DotSize(Config.DotRadius * 2.0f);
for (int32 I = 0; I < Items.Num(); I++)
{
const FVector2D LocalPoint = Center + Point;
FSlateDrawElement::MakeBox(
OutDrawElements,
LayerId,
AllottedGeometry.ToPaintGeometry(DotSize, FSlateLayoutTransform(LocalPoint - FVector2D(Config.DotRadius))),
&DotBrush,
ESlateDrawEffect::None,
Color);
const FRadialMenuItem& Item = Items[I];
const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
for (const FVector2D& Point : { Item.Point1, Item.Point3 })
{
const FVector2D LocalPoint = Center + Point;
FSlateDrawElement::MakeBox(
OutDrawElements,
LayerId + 1,
AllottedGeometry.ToPaintGeometry(DotSize, FSlateLayoutTransform(LocalPoint - FVector2D(Config.DotRadius))),
&DotBrush,
ESlateDrawEffect::None,
Color);
}
}
}
return LayerId;
// Teardrop: visualizes PointerVector inside InnerRadius. The texture's
// tip naturally points up (0,-1); rotate it to align with PointerVector.
// When PointerVector is (0,0), the teardrop stays upright at the center.
if (Config.TeardropRadius > 0.0f && Config.Teardrop != nullptr)
{
FSlateBrush TeardropBrush;
TeardropBrush.SetResourceObject(Config.Teardrop);
const FVector2D TeardropSize(Config.TeardropRadius * 2.0f);
const FVector2D TeardropCenter = Center + PointerVector * Config.InnerRadius;
const float Angle = PointerVector.IsZero()
? 0.0f
: static_cast<float>(FMath::Atan2(PointerVector.X, -PointerVector.Y));
FSlateDrawElement::MakeRotatedBox(
OutDrawElements,
LayerId + 1,
AllottedGeometry.ToPaintGeometry(TeardropSize, FSlateLayoutTransform(TeardropCenter - FVector2D(Config.TeardropRadius))),
&TeardropBrush,
ESlateDrawEffect::None,
Angle);
}
return LayerId + 1;
}
private:
FRadialMenuConfig Config;
TArray<FRadialMenuItem> Items;
FVector2D PointerVector = {0,0};
};
void URadialMenuWidget::SetNumItems(int32 NewNumItems)
void URadialMenuWidget::SetMenuItems(const TArray<FString>& NewMenuItems)
{
if (Config.NumItems == NewNumItems) return;
if (Config.MenuItems == NewMenuItems) return;
Config.NumItems = NewNumItems;
Config.MenuItems = NewMenuItems;
Config.SelectedItem = -1;
PointerVector = FVector2D();
SynchronizeProperties();
}
void URadialMenuWidget::SetItemHeight(float NewItemHeight)
FString URadialMenuWidget::GetSelectedMenuItem() const
{
if (Config.ItemHeight == NewItemHeight) return;
Config.ItemHeight = NewItemHeight;
SynchronizeProperties();
if (!Config.MenuItems.IsValidIndex(Config.SelectedItem)) return FString();
return Config.MenuItems[Config.SelectedItem];
}
void URadialMenuWidget::SetInnerRadius(float NewInnerRadius)
void URadialMenuWidget::SetSelectedItem(int32 NewSelectedItem)
{
if (Config.InnerRadius == NewInnerRadius) return;
Config.InnerRadius = NewInnerRadius;
SynchronizeProperties();
}
void URadialMenuWidget::SetMinSpoke(float NewMinSpoke)
{
if (Config.MinSpoke == NewMinSpoke) return;
Config.MinSpoke = NewMinSpoke;
SynchronizeProperties();
}
void URadialMenuWidget::SetSpread(float NewSpread)
{
if (Config.Spread == NewSpread) return;
Config.Spread = NewSpread;
SynchronizeProperties();
}
void URadialMenuWidget::SetLineThickness(float NewLineThickness)
{
if (Config.LineThickness == NewLineThickness) return;
Config.LineThickness = NewLineThickness;
SynchronizeProperties();
}
void URadialMenuWidget::SetLineColor(FLinearColor NewLineColor)
{
if (Config.LineColor == NewLineColor) return;
Config.LineColor = NewLineColor;
SynchronizeProperties();
}
void URadialMenuWidget::SetDotTexture(UTexture2D* NewDotTexture)
{
if (Config.DotTexture == NewDotTexture) return;
Config.DotTexture = NewDotTexture;
SynchronizeProperties();
}
void URadialMenuWidget::SetDotRadius(float NewDotRadius)
{
if (Config.DotRadius == NewDotRadius) return;
Config.DotRadius = NewDotRadius;
SynchronizeProperties();
}
bool URadialMenuWidget::SetSelectedItem(int32 NewSelectedItem)
{
if (Config.SelectedItem == NewSelectedItem) return false;
if (Config.SelectedItem == NewSelectedItem) return;
Config.SelectedItem = NewSelectedItem;
SynchronizeProperties();
return true;
}
void URadialMenuWidget::SetSelectedColor(FLinearColor NewSelectedColor)
{
if (Config.SelectedColor == NewSelectedColor) return;
Config.SelectedColor = NewSelectedColor;
SynchronizeProperties();
}
void URadialMenuWidget::SetSelectedThickness(float NewSelectedThickness)
{
if (Config.SelectedThickness == NewSelectedThickness) return;
Config.SelectedThickness = NewSelectedThickness;
SynchronizeProperties();
}
void URadialMenuWidget::SynchronizeProperties()
{
Super::SynchronizeProperties();
Items = FRadialMenuItem::Calculate(Config);
if (Items.Num() == 0)
if (Directions.Num() != Config.MenuItems.Num())
{
PointerVector = FVector2D(0,0);
Directions = FRadialMenuItem::CalculateDirections(Config.MenuItems.Num());
}
if (MySlateWidget.IsValid())
{
MySlateWidget->SetConfig(Config);
MySlateWidget->SetPointer(PointerVector);
}
}
bool URadialMenuWidget::AddPointer(FVector2D Direction, float Scale)
void URadialMenuWidget::AddPointer(FVector2D Direction, float Scale)
{
if (!IsVisible())
if ((!IsVisible()) || (Directions.Num() == 0))
{
return false;
}
if (Items.Num() == 0)
{
return false;
PointerVector = FVector2D();
SetSelectedItem(-1);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
return;
}
PointerVector += (Direction * Scale);
if (PointerVector.Length() < 0.5)
{
return SetSelectedItem(-1);
SetSelectedItem(-1);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
return;
}
if (PointerVector.Length() > 1.0)
@@ -342,24 +367,19 @@ bool URadialMenuWidget::AddPointer(FVector2D Direction, float Scale)
int32 BestIndex = -1;
double BestDot = -2.0;
for (int32 I = 0; I < Items.Num(); I++)
for (int32 I = 0; I < Directions.Num(); I++)
{
FVector2D SpokeDir = Items[I].Point1;
FVector2D SpokeDir = Directions[I];
if (!SpokeDir.Normalize()) continue;
double Dot = FVector2D::DotProduct(SpokeDir, PointerVector);
if (Dot <= BestDot) continue;
BestDot = Dot;
BestIndex = I;
}
return SetSelectedItem(BestIndex);
SetSelectedItem(BestIndex);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
}
FLinearColor URadialMenuWidget::GetItemColor(int N) const
{
return (N == Config.SelectedItem) ? Config.SelectedColor : Config.LineColor;
}
TSharedRef<SWidget> URadialMenuWidget::RebuildWidget()
{
MySlateWidget = SNew(SRadialMenu, Config);

View File

@@ -7,6 +7,7 @@
#include "CoreMinimal.h"
#include "Components/Widget.h"
#include "Fonts/SlateFontInfo.h"
#include "RadialMenu.generated.h"
class SRadialMenu;
@@ -19,10 +20,10 @@ struct FRadialMenuConfig
GENERATED_BODY()
UPROPERTY(EditAnywhere, Category="RadialMenu")
int32 NumItems = 8;
TArray<FString> MenuItems;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float ItemHeight = 20.0f;
FSlateFontInfo Font;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float InnerRadius = 20.0f;
@@ -37,7 +38,7 @@ struct FRadialMenuConfig
float LineThickness = 4.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
FLinearColor LineColor = FLinearColor::White;
FLinearColor ItemColor = FLinearColor::White;
UPROPERTY(EditAnywhere, Category="RadialMenu")
TObjectPtr<UTexture2D> DotTexture;
@@ -45,6 +46,12 @@ struct FRadialMenuConfig
UPROPERTY(EditAnywhere, Category="RadialMenu")
float DotRadius = 3.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
TObjectPtr<UTexture2D> Teardrop;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float TeardropRadius = 0.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
int32 SelectedItem = -1;
@@ -73,8 +80,16 @@ struct FRadialMenuItem
UPROPERTY(BlueprintReadOnly)
bool RightSide = false;
UPROPERTY(BlueprintReadOnly)
FVector2D TextSize = {0,0};
static TArray<FRadialMenuItem> Calculate(const FRadialMenuConfig &Config);
// Like Calculate, but only produces the unit-vector direction of
// each spoke. Cheaper to evaluate when only the spoke directions
// are needed (e.g. for hit-testing a pointer against the wheel).
static TArray<FVector2D> CalculateDirections(int32 NumItems);
private:
using View = TArrayView<FRadialMenuItem>;
@@ -112,59 +127,29 @@ class URadialMenuWidget : public UWidget
public:
UFUNCTION(BlueprintCallable)
void SetNumItems(int32 NewNumItems);
void SetMenuItems(const TArray<FString>& NewMenuItems);
UFUNCTION(BlueprintCallable)
void SetItemHeight(float NewItemHeight);
void ClearMenuItems() { SetMenuItems({}); }
UFUNCTION(BlueprintCallable)
void SetInnerRadius(float NewInnerRadius);
UFUNCTION(BlueprintCallable)
void SetMinSpoke(float NewMinSpoke);
UFUNCTION(BlueprintCallable)
void SetSpread(float NewSpread);
UFUNCTION(BlueprintCallable)
void SetLineThickness(float NewLineThickness);
UFUNCTION(BlueprintCallable)
void SetLineColor(FLinearColor NewLineColor);
UFUNCTION(BlueprintCallable)
void SetDotTexture(UTexture2D* NewDotTexture);
UFUNCTION(BlueprintCallable)
void SetDotRadius(float NewDotRadius);
UFUNCTION(BlueprintCallable)
bool SetSelectedItem(int32 NewSelectedItem);
UFUNCTION(BlueprintCallable)
void SetSelectedColor(FLinearColor NewSelectedColor);
UFUNCTION(BlueprintCallable)
void SetSelectedThickness(float NewSelectedThickness);
UFUNCTION(BlueprintCallable)
const TArray<FRadialMenuItem> &GetItems() const { return Items; }
void SetSelectedItem(int32 NewSelectedItem);
// Returns true if the selected item changed.
UFUNCTION(BlueprintCallable)
bool AddPointer(FVector2D Direction, float Scale);
void AddPointer(FVector2D Direction, float Scale);
UFUNCTION(BlueprintCallable)
FVector2D GetPointer() const { return PointerVector; }
UFUNCTION(BlueprintCallable)
FLinearColor GetItemColor(int N) const;
FString GetSelectedMenuItem() const;
protected:
UPROPERTY(EditAnywhere)
FRadialMenuConfig Config;
TArray<FRadialMenuItem> Items;
TArray<FVector2D> Directions;
FVector2D PointerVector = {0,0};