Concept: Radial Selection & Slice State

The model behind UCrimsonRadialWheel: how a direction becomes a selection, how a slice's look is decided, and why the wheel is a composed widget rather than a base class.

Selection is by direction, not by hover

The cursor position is converted to a direction from the wheel center, and the slice whose angular sector contains that direction wins. The gamepad stick feeds the exact same function. Every widget the wheel spawns is hit-test invisible; the wheel does the math.

  • One code path serves mouse and stick, so they cannot drift apart.
  • Distance does not matter - a slice is selectable from anywhere along its angle past the dead zone.
  • Sector art can overlap freely, because nothing relies on widget geometry for hit testing.

Layout convention: slice 0 sits at 12 o'clock and slices proceed clockwise. GetSliceDirection returns the unit vector in UMG space (+X right, +Y down) so you can place your own decoration on the same circle.

One input source per update
Whichever device moved last owns the selection. Feeding both every frame breaks it in both directions: a resting cursor over a slice re-selects it forever and fights the stick, and a resting stick reads as (0,0) and would wipe a mouse selection. A centered stick is fed through deliberately once engaged - that zero is the cancel gesture on a gamepad.

State is data, the material has no logic

The wheel resolves a FCrimsonRadialSliceStateStyle per slice and pushes colors and scalars into a material instance. The material contains no notion of "focused" or "disabled" - it renders what it is handed, which is why a new state costs no material work.

StateWhenHow it reads
NormalRestingMuted crimson rim on a dark plum ground.
FocusedPointed atLifts off the hub, brightens, picks up a rim bloom.
DisabledbEnabled is falseDesaturated and hatched.
DestructiveFocused and bDestructiveHotter, stronger bloom, and a pulse.
Why disabled uses two signals
Desaturation alone reads as "dim", not as "blocked". The hatch is what makes it unambiguous. The same reasoning drives the pulse on Destructive: once the focus color is the brand crimson, "it is red" stops distinguishing anything, so motion carries the difference for a player who cannot separate the two reds.

Cooldown and hold progress are overlays, orthogonal to state - a slice can be disabled and cooling down, and both draw.

The wheel is composed, not inherited

UCrimsonRadialWheel is a UCommonUserWidget that hosts do not subclass - they embed it as a BindWidget. Its two shipped hosts could not share an ancestor: UCrimsonContextMenuWidget_Radial has to stay under UCrimsonContextMenuWidget_Modal for click-outside dismissal and viewport clamping, while UCrimsonRadialMenuWidget is a plain activatable screen. Composition lets both use the identical wheel.

Geometry adapts to slice count

Slice content is placed at the sector's area centroid, not at a fixed fraction between the radii:

text
(2/3) * (R^3 - r^3)/(R^2 - r^2) * sin(h)/h h = half-angle in radians

The sin(h)/h term pulls the centroid inward in proportion to how wide the sector is. A fixed fraction that looks centered on an eight-slice wheel sits far too close to the rim on a three-slice one - about 32 px out at a 620 px wheel. ContentRadiusScale multiplies this value, so 1.0 is centered at any slice count.

Widgets are pooled - state must be per-open

CommonUI's activatable container recycles widget instances rather than destroying them. A second open hands back the same object with all of its state intact.

Two rules that follow
Anything that must be true per-open belongs in NativeOnActivated, never behind a "first time" check such as Options.Num() == 0.

Anything cleaned up on close must undo its side effects, not merely forget them - dropping a tracking array without removing the widgets it tracked strands them on a canvas that outlives the widget, and the next rebuild draws a second set on top.

This is why the menu pushes its slices on every activation and the wheel restarts its own update timer defensively.

See also

  • How-To: Build a Radial Menu - the practical recipe.
  • Concept: Input Routing & Activatable Widgets - how activation and input configs interact.
  • API Reference -> Radial menus - the full surface.