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 "RadialMenu.h"
#include "Rendering/DrawElements.h" #include "Rendering/DrawElements.h"
#include "Engine/Texture2D.h" #include "Engine/Texture2D.h"
#include "Fonts/FontMeasure.h"
#include "Framework/Application/SlateApplication.h"
#include "Styling/SlateBrush.h" #include "Styling/SlateBrush.h"
#include "Widgets/SLeafWidget.h" #include "Widgets/SLeafWidget.h"
@@ -8,17 +10,30 @@
TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Config) TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Config)
{ {
TArray<FRadialMenuItem> Items; 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 NumRight = (NumItems / 2);
int32 NumLeft = Config.NumItems - NumRight; int32 NumLeft = NumItems - NumRight;
Items.SetNum(Config.NumItems); 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 LeftItems(Items.GetData(), NumLeft);
View RightItems(Items.GetData() + NumLeft, NumRight); View RightItems(Items.GetData() + NumLeft, NumRight);
CalculateSpokes(LeftItems, Config.NumItems, Config.ItemHeight, Config.InnerRadius, Config.MinSpoke); CalculateSpokes(LeftItems, NumItems, ItemHeight, Config.InnerRadius, Config.MinSpoke);
CalculateSpokes(RightItems, Config.NumItems, Config.ItemHeight, Config.InnerRadius, Config.MinSpoke); CalculateSpokes(RightItems, NumItems, ItemHeight, Config.InnerRadius, Config.MinSpoke);
double LeftWidth = WidestSpoke(LeftItems); double LeftWidth = WidestSpoke(LeftItems);
double RightWidth = WidestSpoke(RightItems); double RightWidth = WidestSpoke(RightItems);
double HalfWidth = FMath::Max(LeftWidth, RightWidth) + Config.Spread; double HalfWidth = FMath::Max(LeftWidth, RightWidth) + Config.Spread;
@@ -29,6 +44,29 @@ TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Conf
return Items; 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) FVector2D FRadialMenuItem::SpokeVector(int32 I, int32 NSide, int32 NTotal)
{ {
double SpokeAngle = 1.0 / NTotal; double SpokeAngle = 1.0 / NTotal;
@@ -130,6 +168,13 @@ public:
Invalidate(EInvalidateWidgetReason::Layout); Invalidate(EInvalidateWidgetReason::Layout);
} }
void SetPointer(FVector2D NewPointer)
{
if (PointerVector == NewPointer) return;
PointerVector = NewPointer;
Invalidate(EInvalidateWidgetReason::Paint);
}
protected: protected:
virtual FVector2D ComputeDesiredSize(float) const override virtual FVector2D ComputeDesiredSize(float) const override
{ {
@@ -144,6 +189,14 @@ protected:
Max.X = FMath::Max(Max.X, P.X); Max.X = FMath::Max(Max.X, P.X);
Max.Y = FMath::Max(Max.Y, P.Y); 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. // Symmetric around the origin so the menu draws centered.
double HalfW = FMath::Max(FMath::Abs(Min.X), FMath::Abs(Max.X)); double HalfW = FMath::Max(FMath::Abs(Min.X), FMath::Abs(Max.X));
@@ -162,7 +215,7 @@ protected:
{ {
const FRadialMenuItem& Item = Items[I]; const FRadialMenuItem& Item = Items[I];
const bool bSelected = (I == Config.SelectedItem); 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; const float Thickness = bSelected ? Config.SelectedThickness : Config.LineThickness;
TArray<FVector2D> Points; TArray<FVector2D> Points;
Points.Add(Center + Item.Point1); Points.Add(Center + Item.Point1);
@@ -171,9 +224,30 @@ protected:
FSlateDrawElement::MakeLines(OutDrawElements, LayerId, PaintGeom, Points, ESlateDrawEffect::None, Color, true, Thickness); FSlateDrawElement::MakeLines(OutDrawElements, LayerId, PaintGeom, Points, ESlateDrawEffect::None, Color, true, Thickness);
} }
if (Config.DotRadius <= 0.0f) return LayerId; // Draw item labels, vertically centered on Point3. Right-side items
if (Config.DotTexture == nullptr) return LayerId; // 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.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; FSlateBrush DotBrush;
DotBrush.SetResourceObject(Config.DotTexture); DotBrush.SetResourceObject(Config.DotTexture);
const FVector2D DotSize(Config.DotRadius * 2.0f); const FVector2D DotSize(Config.DotRadius * 2.0f);
@@ -181,158 +255,109 @@ protected:
{ {
const FRadialMenuItem& Item = Items[I]; const FRadialMenuItem& Item = Items[I];
const bool bSelected = (I == Config.SelectedItem); const bool bSelected = (I == Config.SelectedItem);
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.LineColor; const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
for (const FVector2D& Point : { Item.Point1, Item.Point3 }) for (const FVector2D& Point : { Item.Point1, Item.Point3 })
{ {
const FVector2D LocalPoint = Center + Point; const FVector2D LocalPoint = Center + Point;
FSlateDrawElement::MakeBox( FSlateDrawElement::MakeBox(
OutDrawElements, OutDrawElements,
LayerId, LayerId + 1,
AllottedGeometry.ToPaintGeometry(DotSize, FSlateLayoutTransform(LocalPoint - FVector2D(Config.DotRadius))), AllottedGeometry.ToPaintGeometry(DotSize, FSlateLayoutTransform(LocalPoint - FVector2D(Config.DotRadius))),
&DotBrush, &DotBrush,
ESlateDrawEffect::None, ESlateDrawEffect::None,
Color); 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: private:
FRadialMenuConfig Config; FRadialMenuConfig Config;
TArray<FRadialMenuItem> Items; 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(); SynchronizeProperties();
} }
void URadialMenuWidget::SetItemHeight(float NewItemHeight) FString URadialMenuWidget::GetSelectedMenuItem() const
{ {
if (Config.ItemHeight == NewItemHeight) return; if (!Config.MenuItems.IsValidIndex(Config.SelectedItem)) return FString();
return Config.MenuItems[Config.SelectedItem];
Config.ItemHeight = NewItemHeight;
SynchronizeProperties();
} }
void URadialMenuWidget::SetInnerRadius(float NewInnerRadius) void URadialMenuWidget::SetSelectedItem(int32 NewSelectedItem)
{ {
if (Config.InnerRadius == NewInnerRadius) return; if (Config.SelectedItem == NewSelectedItem) 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;
Config.SelectedItem = NewSelectedItem; Config.SelectedItem = NewSelectedItem;
SynchronizeProperties(); 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() void URadialMenuWidget::SynchronizeProperties()
{ {
Super::SynchronizeProperties(); Super::SynchronizeProperties();
Items = FRadialMenuItem::Calculate(Config); if (Directions.Num() != Config.MenuItems.Num())
if (Items.Num() == 0)
{ {
PointerVector = FVector2D(0,0); Directions = FRadialMenuItem::CalculateDirections(Config.MenuItems.Num());
} }
if (MySlateWidget.IsValid()) if (MySlateWidget.IsValid())
{ {
MySlateWidget->SetConfig(Config); 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; PointerVector = FVector2D();
} SetSelectedItem(-1);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
if (Items.Num() == 0) return;
{
return false;
} }
PointerVector += (Direction * Scale); PointerVector += (Direction * Scale);
if (PointerVector.Length() < 0.5) if (PointerVector.Length() < 0.5)
{ {
return SetSelectedItem(-1); SetSelectedItem(-1);
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
return;
} }
if (PointerVector.Length() > 1.0) if (PointerVector.Length() > 1.0)
@@ -342,24 +367,19 @@ bool URadialMenuWidget::AddPointer(FVector2D Direction, float Scale)
int32 BestIndex = -1; int32 BestIndex = -1;
double BestDot = -2.0; 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; if (!SpokeDir.Normalize()) continue;
double Dot = FVector2D::DotProduct(SpokeDir, PointerVector); double Dot = FVector2D::DotProduct(SpokeDir, PointerVector);
if (Dot <= BestDot) continue; if (Dot <= BestDot) continue;
BestDot = Dot; BestDot = Dot;
BestIndex = I; 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() TSharedRef<SWidget> URadialMenuWidget::RebuildWidget()
{ {
MySlateWidget = SNew(SRadialMenu, Config); MySlateWidget = SNew(SRadialMenu, Config);

View File

@@ -7,6 +7,7 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "Components/Widget.h" #include "Components/Widget.h"
#include "Fonts/SlateFontInfo.h"
#include "RadialMenu.generated.h" #include "RadialMenu.generated.h"
class SRadialMenu; class SRadialMenu;
@@ -19,10 +20,10 @@ struct FRadialMenuConfig
GENERATED_BODY() GENERATED_BODY()
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
int32 NumItems = 8; TArray<FString> MenuItems;
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
float ItemHeight = 20.0f; FSlateFontInfo Font;
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
float InnerRadius = 20.0f; float InnerRadius = 20.0f;
@@ -37,7 +38,7 @@ struct FRadialMenuConfig
float LineThickness = 4.0f; float LineThickness = 4.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
FLinearColor LineColor = FLinearColor::White; FLinearColor ItemColor = FLinearColor::White;
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
TObjectPtr<UTexture2D> DotTexture; TObjectPtr<UTexture2D> DotTexture;
@@ -45,6 +46,12 @@ struct FRadialMenuConfig
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
float DotRadius = 3.0f; float DotRadius = 3.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu")
TObjectPtr<UTexture2D> Teardrop;
UPROPERTY(EditAnywhere, Category="RadialMenu")
float TeardropRadius = 0.0f;
UPROPERTY(EditAnywhere, Category="RadialMenu") UPROPERTY(EditAnywhere, Category="RadialMenu")
int32 SelectedItem = -1; int32 SelectedItem = -1;
@@ -73,8 +80,16 @@ struct FRadialMenuItem
UPROPERTY(BlueprintReadOnly) UPROPERTY(BlueprintReadOnly)
bool RightSide = false; bool RightSide = false;
UPROPERTY(BlueprintReadOnly)
FVector2D TextSize = {0,0};
static TArray<FRadialMenuItem> Calculate(const FRadialMenuConfig &Config); 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: private:
using View = TArrayView<FRadialMenuItem>; using View = TArrayView<FRadialMenuItem>;
@@ -112,59 +127,29 @@ class URadialMenuWidget : public UWidget
public: public:
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
void SetNumItems(int32 NewNumItems); void SetMenuItems(const TArray<FString>& NewMenuItems);
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
void SetItemHeight(float NewItemHeight); void ClearMenuItems() { SetMenuItems({}); }
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
void SetInnerRadius(float NewInnerRadius); void SetSelectedItem(int32 NewSelectedItem);
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; }
// Returns true if the selected item changed. // Returns true if the selected item changed.
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
bool AddPointer(FVector2D Direction, float Scale); void AddPointer(FVector2D Direction, float Scale);
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
FVector2D GetPointer() const { return PointerVector; } FVector2D GetPointer() const { return PointerVector; }
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
FLinearColor GetItemColor(int N) const; FString GetSelectedMenuItem() const;
protected: protected:
UPROPERTY(EditAnywhere) UPROPERTY(EditAnywhere)
FRadialMenuConfig Config; FRadialMenuConfig Config;
TArray<FRadialMenuItem> Items; TArray<FVector2D> Directions;
FVector2D PointerVector = {0,0}; FVector2D PointerVector = {0,0};