#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::Calculate(const FRadialMenuConfig &Config) { TArray 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 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(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 FRadialMenuItem::CalculateDirections(int32 NumItems) { TArray 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 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(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 Items; FVector2D PointerVector = {0,0}; }; void URadialMenuWidget::SetMenuItems(const TArray& 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) { SetPointer(PointerVector + (Direction * Scale), 1.0); } void URadialMenuWidget::SetPointer(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.75) { 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 URadialMenuWidget::RebuildWidget() { MySlateWidget = SNew(SRadialMenu, Config); return MySlateWidget.ToSharedRef(); } void URadialMenuWidget::ReleaseSlateResources(bool bReleaseChildren) { Super::ReleaseSlateResources(bReleaseChildren); MySlateWidget.Reset(); }