From 1c2be1b4d8115735833968b26eb2cc49af59b2c9 Mon Sep 17 00:00:00 2001 From: jyelon Date: Mon, 18 May 2026 16:12:26 -0400 Subject: [PATCH] Radial menus are in good shape --- Content/Widgets/WB_Hotkeys.uasset | 2 +- Content/Widgets/WB_Menu.uasset | 4 +- Content/Widgets/WB_Menu_Item.uasset | 3 - Content/Widgets/basic-border.uasset | 3 + Content/Widgets/teardrop.uasset | 3 + Source/Integration/RadialMenu.cpp | 276 +++++++++++++++------------- Source/Integration/RadialMenu.h | 67 +++---- 7 files changed, 183 insertions(+), 175 deletions(-) delete mode 100644 Content/Widgets/WB_Menu_Item.uasset create mode 100644 Content/Widgets/basic-border.uasset create mode 100644 Content/Widgets/teardrop.uasset diff --git a/Content/Widgets/WB_Hotkeys.uasset b/Content/Widgets/WB_Hotkeys.uasset index 96269f22..daf5bce3 100644 --- a/Content/Widgets/WB_Hotkeys.uasset +++ b/Content/Widgets/WB_Hotkeys.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6dedb64171d43ffe71a1935fb18fc44127a4404dd3f77bad897158f514d23ac2 +oid sha256:129db4b554d1effb902d7c3887d4be922847ca350258b39e7d3444aa47000bdc size 292218 diff --git a/Content/Widgets/WB_Menu.uasset b/Content/Widgets/WB_Menu.uasset index 1ef194c3..04311cbe 100644 --- a/Content/Widgets/WB_Menu.uasset +++ b/Content/Widgets/WB_Menu.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d69e87fe955c4d10512477e624e8c59e56142fbd205be7a21bbf27617088508b -size 280353 +oid sha256:afc9e0a879537cc635ad88865eba8fa6479a11464b0d4bc2d641527b5d297ebb +size 218369 diff --git a/Content/Widgets/WB_Menu_Item.uasset b/Content/Widgets/WB_Menu_Item.uasset deleted file mode 100644 index f81127cb..00000000 --- a/Content/Widgets/WB_Menu_Item.uasset +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:045443642d50d9e8fe191bf6464137c46d50b405d50daaa74f193cff57a031e2 -size 57301 diff --git a/Content/Widgets/basic-border.uasset b/Content/Widgets/basic-border.uasset new file mode 100644 index 00000000..a2466a88 --- /dev/null +++ b/Content/Widgets/basic-border.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d682b00d51c3e0f25d478d97aa018f320e82eaafee3ddd732f680b22b7a40d4 +size 12681 diff --git a/Content/Widgets/teardrop.uasset b/Content/Widgets/teardrop.uasset new file mode 100644 index 00000000..0926e67e --- /dev/null +++ b/Content/Widgets/teardrop.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce36f0f67caecdf59e3564218f691a3027899610c8e5533feb9d88a6fd9baffe +size 12068 diff --git a/Source/Integration/RadialMenu.cpp b/Source/Integration/RadialMenu.cpp index 93508629..afdc5864 100644 --- a/Source/Integration/RadialMenu.cpp +++ b/Source/Integration/RadialMenu.cpp @@ -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::Calculate(const FRadialMenuConfig &Config) { TArray 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 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, 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::Calculate(const FRadialMenuConfig &Conf 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; @@ -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 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(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::SetNumItems(int32 NewNumItems) +void URadialMenuWidget::SetMenuItems(const TArray& 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 URadialMenuWidget::RebuildWidget() { MySlateWidget = SNew(SRadialMenu, Config); diff --git a/Source/Integration/RadialMenu.h b/Source/Integration/RadialMenu.h index b93c1eff..2cc80456 100644 --- a/Source/Integration/RadialMenu.h +++ b/Source/Integration/RadialMenu.h @@ -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 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 DotTexture; @@ -45,6 +46,12 @@ struct FRadialMenuConfig UPROPERTY(EditAnywhere, Category="RadialMenu") float DotRadius = 3.0f; + UPROPERTY(EditAnywhere, Category="RadialMenu") + TObjectPtr 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 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 CalculateDirections(int32 NumItems); + private: using View = TArrayView; @@ -112,59 +127,29 @@ class URadialMenuWidget : public UWidget public: UFUNCTION(BlueprintCallable) - void SetNumItems(int32 NewNumItems); + void SetMenuItems(const TArray& 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 &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 Items; + TArray Directions; FVector2D PointerVector = {0,0};