394 lines
14 KiB
C++
394 lines
14 KiB
C++
#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"
|
|
|
|
|
|
TArray<FRadialMenuItem> FRadialMenuItem::Calculate(const FRadialMenuConfig &Config)
|
|
{
|
|
TArray<FRadialMenuItem> Items;
|
|
const int32 NumItems = Config.MenuItems.Num();
|
|
if (NumItems <= 0) return Items;
|
|
|
|
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, 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;
|
|
CalculateSpread(LeftItems, HalfWidth - LeftWidth);
|
|
CalculateSpread(RightItems, HalfWidth - RightWidth);
|
|
|
|
FlipHorizontal(LeftItems);
|
|
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;
|
|
double OffsetAngle = 0.5 * (0.5 - ((NSide - 1) * SpokeAngle));
|
|
double Revolutions = (I * SpokeAngle) + OffsetAngle;
|
|
double Radians = (Revolutions * 2.0 * UE_PI);
|
|
return FVector2D(FMath::Sin(Radians), -FMath::Cos(Radians));
|
|
}
|
|
|
|
void FRadialMenuItem::FlipHorizontal(View V)
|
|
{
|
|
for (FRadialMenuItem& Item : V)
|
|
{
|
|
Item.Point1.X = -Item.Point1.X;
|
|
Item.Point2.X = -Item.Point2.X;
|
|
Item.Point3.X = -Item.Point3.X;
|
|
Item.RightSide = !Item.RightSide;
|
|
}
|
|
}
|
|
|
|
double FRadialMenuItem::WidestSpoke(View V)
|
|
{
|
|
double Result = 0.0;
|
|
for (const FRadialMenuItem &Item : V)
|
|
{
|
|
Result = FMath::Max(Item.Point2.X, Result);
|
|
}
|
|
return Result;
|
|
}
|
|
|
|
void FRadialMenuItem::CalculateSpread(View V, double Offset)
|
|
{
|
|
for (FRadialMenuItem &Item : V)
|
|
{
|
|
Item.Point3 = Item.Point2 + FVector2D(Offset, 0.0);
|
|
}
|
|
}
|
|
|
|
void FRadialMenuItem::CalculateSpokes(View V, int32 TotalItems, float ItemHeight, float InnerRadius, float MinSpoke)
|
|
{
|
|
if (V.Num() == 0) return;
|
|
|
|
// RightSide is always initialized to true, it may get
|
|
// reversed by FlipHorizontal.
|
|
for (int32 I = 0; I < V.Num(); I++)
|
|
{
|
|
V[I].RightSide = true;
|
|
V[I].Point1 = SpokeVector(I, V.Num(), TotalItems) * InnerRadius;
|
|
}
|
|
|
|
// Calculate point2 for all spokes.
|
|
double NextLineMin = ItemHeight * 0.5;
|
|
int32 Mid = (V.Num() / 2);
|
|
if (V.Num() & 1)
|
|
{
|
|
V[Mid].Point2 = FVector2D(InnerRadius + MinSpoke, 0.0);
|
|
NextLineMin = ItemHeight;
|
|
Mid += 1;
|
|
}
|
|
for (int32 I = Mid; I < V.Num(); I++)
|
|
{
|
|
FVector2D UnitVec = SpokeVector(I, V.Num(), TotalItems);
|
|
double Y = (UnitVec.Y * (InnerRadius + MinSpoke));
|
|
if (Y < NextLineMin) Y = NextLineMin;
|
|
NextLineMin = Y + ItemHeight;
|
|
FVector2D Point2 = UnitVec * (Y / UnitVec.Y);
|
|
V[I].Point2 = Point2;
|
|
V[V.Num() - 1 - I].Point2 = Point2 * FVector2D(1.0,-1.0);
|
|
}
|
|
|
|
// The middle spoke is calculated using a different formula,
|
|
// which may result in a short spoke. If so, fix it to make
|
|
// it at least as long as the adjacent spoke.
|
|
if ((V.Num() & 1) && (V.Num() >= 3))
|
|
{
|
|
Mid = V.Num() / 2;
|
|
if (V[Mid].Point2.X < V[Mid + 1].Point2.X)
|
|
V[Mid].Point2.X = V[Mid + 1].Point2.X;
|
|
}
|
|
}
|
|
|
|
|
|
// Slate widget that paints the radial menu's polylines.
|
|
class SRadialMenu : public SLeafWidget
|
|
{
|
|
public:
|
|
SLATE_BEGIN_ARGS(SRadialMenu) {}
|
|
SLATE_END_ARGS()
|
|
|
|
void Construct(const FArguments& InArgs, const FRadialMenuConfig& InConfig)
|
|
{
|
|
Config = InConfig;
|
|
}
|
|
|
|
void SetConfig(const FRadialMenuConfig& NewConfig)
|
|
{
|
|
Config = NewConfig;
|
|
Items = FRadialMenuItem::Calculate(Config);
|
|
Invalidate(EInvalidateWidgetReason::Layout);
|
|
}
|
|
|
|
void SetPointer(FVector2D NewPointer)
|
|
{
|
|
if (PointerVector == NewPointer) return;
|
|
PointerVector = NewPointer;
|
|
Invalidate(EInvalidateWidgetReason::Paint);
|
|
}
|
|
|
|
protected:
|
|
virtual FVector2D ComputeDesiredSize(float) const override
|
|
{
|
|
FVector2D Min(0.0, 0.0);
|
|
FVector2D Max(0.0, 0.0);
|
|
for (const FRadialMenuItem &Item : Items)
|
|
{
|
|
for (const FVector2D &P : { Item.Point1, Item.Point2, Item.Point3 })
|
|
{
|
|
Min.X = FMath::Min(Min.X, P.X);
|
|
Min.Y = FMath::Min(Min.Y, P.Y);
|
|
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));
|
|
double HalfH = FMath::Max(FMath::Abs(Min.Y), FMath::Abs(Max.Y));
|
|
return FVector2D(HalfW * 2.0, HalfH * 2.0);
|
|
}
|
|
|
|
virtual int32 OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry,
|
|
const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements,
|
|
int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const override
|
|
{
|
|
const FVector2D Center = AllottedGeometry.GetLocalSize() * 0.5;
|
|
const FPaintGeometry PaintGeom = AllottedGeometry.ToPaintGeometry();
|
|
|
|
for (int32 I = 0; I < Items.Num(); I++)
|
|
{
|
|
const FRadialMenuItem& Item = Items[I];
|
|
const bool bSelected = (I == Config.SelectedItem);
|
|
const FLinearColor& Color = bSelected ? Config.SelectedColor : Config.ItemColor;
|
|
const float Thickness = bSelected ? Config.SelectedThickness : Config.LineThickness;
|
|
TArray<FVector2D> Points;
|
|
Points.Add(Center + Item.Point1);
|
|
Points.Add(Center + Item.Point2);
|
|
Points.Add(Center + Item.Point3);
|
|
FSlateDrawElement::MakeLines(OutDrawElements, LayerId, PaintGeom, Points, ESlateDrawEffect::None, Color, true, Thickness);
|
|
}
|
|
|
|
// 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.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 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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::SetMenuItems(const TArray<FString>& NewMenuItems)
|
|
{
|
|
if (Config.MenuItems == NewMenuItems) return;
|
|
|
|
Config.MenuItems = NewMenuItems;
|
|
Config.SelectedItem = -1;
|
|
PointerVector = FVector2D();
|
|
SynchronizeProperties();
|
|
}
|
|
|
|
FString URadialMenuWidget::GetSelectedMenuItem() const
|
|
{
|
|
if (!Config.MenuItems.IsValidIndex(Config.SelectedItem)) return FString();
|
|
return Config.MenuItems[Config.SelectedItem];
|
|
}
|
|
|
|
void URadialMenuWidget::SetSelectedItem(int32 NewSelectedItem)
|
|
{
|
|
if (Config.SelectedItem == NewSelectedItem) return;
|
|
|
|
Config.SelectedItem = NewSelectedItem;
|
|
SynchronizeProperties();
|
|
}
|
|
|
|
void URadialMenuWidget::SynchronizeProperties()
|
|
{
|
|
Super::SynchronizeProperties();
|
|
|
|
if (Directions.Num() != Config.MenuItems.Num())
|
|
{
|
|
Directions = FRadialMenuItem::CalculateDirections(Config.MenuItems.Num());
|
|
}
|
|
if (MySlateWidget.IsValid())
|
|
{
|
|
MySlateWidget->SetConfig(Config);
|
|
MySlateWidget->SetPointer(PointerVector);
|
|
}
|
|
}
|
|
|
|
void URadialMenuWidget::AddPointer(FVector2D Direction, float Scale)
|
|
{
|
|
if ((!IsVisible()) || (Directions.Num() == 0))
|
|
{
|
|
PointerVector = FVector2D();
|
|
SetSelectedItem(-1);
|
|
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
|
|
return;
|
|
}
|
|
|
|
PointerVector += (Direction * Scale);
|
|
|
|
if (PointerVector.Length() < 0.5)
|
|
{
|
|
SetSelectedItem(-1);
|
|
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
|
|
return;
|
|
}
|
|
|
|
if (PointerVector.Length() > 1.0)
|
|
{
|
|
PointerVector.Normalize();
|
|
}
|
|
|
|
int32 BestIndex = -1;
|
|
double BestDot = -2.0;
|
|
for (int32 I = 0; I < Directions.Num(); I++)
|
|
{
|
|
FVector2D SpokeDir = Directions[I];
|
|
if (!SpokeDir.Normalize()) continue;
|
|
double Dot = FVector2D::DotProduct(SpokeDir, PointerVector);
|
|
if (Dot <= BestDot) continue;
|
|
BestDot = Dot;
|
|
BestIndex = I;
|
|
}
|
|
SetSelectedItem(BestIndex);
|
|
if (MySlateWidget.IsValid()) MySlateWidget->SetPointer(PointerVector);
|
|
}
|
|
|
|
TSharedRef<SWidget> URadialMenuWidget::RebuildWidget()
|
|
{
|
|
MySlateWidget = SNew(SRadialMenu, Config);
|
|
return MySlateWidget.ToSharedRef();
|
|
}
|
|
|
|
void URadialMenuWidget::ReleaseSlateResources(bool bReleaseChildren)
|
|
{
|
|
Super::ReleaseSlateResources(bReleaseChildren);
|
|
MySlateWidget.Reset();
|
|
}
|