RadioControl / RadioGroup

July 2, 2026 · View on GitHub

Single-select radio button control, coordinated through an explicit group object that enforces the single-selection invariant.

Overview

RadioControl<T> renders a (●) / (○) indicator next to a text label and selects itself through a shared RadioGroup<T> when activated. The group is the single source of truth: it owns the selected value, enforces the single-selection rule, and fires SelectionChanged exactly once per change. Each radio's Checked property is computed — it reflects EqualityComparer<T>.Default.Equals(Group.SelectedValue, Value) and carries no stored state, making desync impossible by design.

Radios are independent Tab stops that you lay out wherever your window design requires — inside a stack of controls in a ScrollablePanelControl, spread across multiple columns of a GridControl, or mixed with other control types. The group coordinates them all regardless of layout nesting, because membership is explicit (each radio holds a reference to the group) rather than discovered by scanning siblings.

The label wraps to the available width by default (Wrap = true), with continuation lines hanging-indented to align under the label text rather than under the marker. Set Wrap = false for single-line clipped behavior.

See also: CheckboxControl

Quick Start

// Simple string group — label doubles as value
var group = Controls.RadioGroup<string>()
    .OnSelectionChanged(value => windowSystem.NotificationStateService
        .ShowNotification("Theme", value ?? "none", NotificationSeverity.Info))
    .Build();

panel.AddControl(Controls.Radio(group, "Light").Build());
panel.AddControl(Controls.Radio(group, "Dark").Selected().Build());
panel.AddControl(Controls.Radio(group, "System").Build());

RadioGroup API

RadioGroup<T> is a non-visual coordination object — it has no layout or paint. Create one via the builder or the constructor:

var group = Controls.RadioGroup<MyEnum>()
    .Required()
    .WithSelectedValue(MyEnum.DefaultOption)
    .OnSelectionChanged(v => Apply(v))
    .Build();

RadioGroupBuilder<T> Methods

MethodDescription
.AllowDeselect(bool allow = true)Clicking the already-selected radio clears the selection. No-op when Required is also set (Required wins). Default: false.
.Required(bool required = true)Once a selection exists, deselect and Clear() become no-ops. Does not force an initial selection — the group starts empty. Default: false.
.WithSelectedValue(T value)Pre-selects a value when the group is built.
.OnSelectionChanged(Action<T?> handler)Subscribes to SelectionChanged at build time. The argument is the new value, or null/default when the selection is cleared.
.Build()Returns the configured RadioGroup<T>.

RadioGroup<T> Properties

PropertyTypeDescription
SelectedValueT?The currently selected value. Setting it updates the selection (honoring Required), repaints the affected members, and fires SelectionChanged once. Default: default(T) (no selection).
HasSelectionbooltrue when a value is selected.
AllowDeselectboolSee builder method above.
RequiredboolSee builder method above.
SelectedRadioRadioControl<T>?The member whose Value equals the current selection, or null.
MembersIReadOnlyList<RadioControl<T>>Radios registered to this group, in registration order.

RadioGroup<T> Methods

MethodDescription
Clear()Clears the selection to none. No-op when Required is true and a selection exists. Fires SelectionChanged once if the state changed.

RadioGroup<T> Events

EventArgumentsDescription
SelectionChangedEventHandler<T?>Fires once per selection change; argument is the new value or default when cleared.
SelectionChangedAsyncAsyncEventHandler<T?>Async counterpart of SelectionChanged.

Selection State Rules

SituationResult
Click/activate an unselected radioSelects it; fires SelectionChanged once.
Click/activate the already-selected radio, Required = false, AllowDeselect = false (default)No-op (classic radio behavior).
Click/activate the already-selected radio, Required = false, AllowDeselect = trueClears to none; fires SelectionChanged once.
Click/activate the already-selected radio, Required = trueNo-op; Required wins over AllowDeselect.
Clear(), Required = falseClears to none; fires SelectionChanged if changed.
Clear(), Required = true, HasSelection = trueNo-op.
New group (any Required setting)Starts with no selection. Required does not force an initial selection.

⚠️ Choosing the type parameter T — equality matters

Selection is matched with EqualityComparer<T>.Default (an AOT-safe choice — no reflection). Every "is this radio selected?" check compares Group.SelectedValue to a radio's Value this way. That means how T implements equality directly determines whether selection works:

  • enum, primitive, string, record, or a struct → structural (value) equality by default. Everything "just works": group.SelectedValue = MyEnum.Large matches the radio whose Value is MyEnum.Large, even a freshly-read one.

  • A plain class that does not override Equals/GetHashCodereference equality. Two different instances that represent the same option are considered not equal. So this fails silently:

    // BAD: Option is a plain class with no Equals override
    var g = new RadioGroup<Option>();
    var r = new RadioControl<Option>(g, new Option("A"), "A");
    g.SelectedValue = new Option("A");   // a DIFFERENT instance
    // r.Checked == false  — no radio lights up, no exception, just "nothing selected"
    

    This is a common source of "my radio won't select" head-scratching, especially when the value comes from deserialization, a database row, or a .Select(...) projection that builds fresh instances.

Recommendation: prefer an enum, a record, or a value type for T. If you must use a reference type, either reuse the same instances you gave the radios (e.g. select via radio.Select() or group.SelectedValue = existingRadio.Value) or give the type real structural equality (a record, or override Equals/GetHashCode).

RadioControl API

Builder

// Typed value
var radio = Controls.Radio(group, MyEnum.Option, "Label text").Build();

// String group — label is the value
var radio = Controls.Radio(stringGroup, "Option label").Build();

Controls Factory Methods

MethodDescription
Controls.RadioGroup<T>()Returns a RadioGroupBuilder<T>.
Controls.Radio<T>(RadioGroup<T> group, T value, string label = "")Returns a RadioBuilder<T> for the given group, value, and optional label.
Controls.Radio(RadioGroup<string> group, string label)Shorthand for string groups where the label is also the value. Equivalent to Radio<string>(group, label, label).

RadioBuilder<T> Methods

Content

MethodDescription
.WithLabel(string label)Sets the label text.
.Selected()Sets this radio as the initially selected option (sets group.SelectedValue at build time). If two radios in the same group call .Selected(), the last built wins and a debug log entry is emitted.
.WithSelectedCharacter(string character)Overrides the selected indicator (default "●").
.WithUnselectedCharacter(string character)Overrides the unselected indicator (default "○").

Appearance

MethodDescription
.WithColorRole(ColorRole role, ThemeMode? mode = null)Sets the semantic color role.
.Outline(bool outline = true)Renders in outline style (role color on text, surface fill).

Layout

MethodDescription
.Wrap(bool wrap = true)Enables or disables label wrapping (default true).
.WithAlignment(HorizontalAlignment alignment)Horizontal alignment within the allocated space.
.WithVerticalAlignment(VerticalAlignment alignment)Vertical alignment within the allocated space.
.WithMargin(int left, int top, int right, int bottom)Per-side margin.
.WithMargin(int margin)Uniform margin.

Identity

MethodDescription
.WithName(string name)Name for FindControl<RadioControl<T>>(name) lookups.

Building

RadioControl<T> radio = builder.Build();

RadioControl<T> Properties

PropertyTypeDefaultDescription
GroupRadioGroup<T>(constructor)The coordinating group.
ValueT(constructor)The value this radio represents.
CheckedboolcomputedWhether this radio is the group's selected option. Computed from the group; never stored independently.
Labelstring""Text displayed next to the radio indicator.
SelectedCharacterstring"●"Indicator shown when selected. Empty or null falls back to "●".
UnselectedCharacterstring"○"Indicator shown when unselected. Empty or null falls back to "○".
WrapbooltrueWhether the label wraps to the available width with a hanging indent on continuation lines.
IsEnabledbooltrueWhether the control accepts input. A disabled radio that is currently selected still renders as selected (greyed, not cleared).
CheckmarkColorColortheme CheckboxCheckmarkColor / Color.Cyan1Color of the selected indicator glyph.
BackgroundColorColor?nullBackground color in the normal state. Inherits from container/theme when null.
ForegroundColorColortheme / Color.WhiteLabel color in the normal state.
FocusedBackgroundColorColor?nullBackground color when focused.
FocusedForegroundColorColortheme / Color.WhiteLabel color when focused.
DisabledBackgroundColorColor?nullBackground color when disabled.
DisabledForegroundColorColortheme / Color.DarkSlateGray1Label color when disabled.
HasFocusboolRead-only. Whether the control currently has keyboard focus.
CanReceiveFocusboolRead-only. true when IsEnabled.
WantsMouseEventsboolRead-only. true when IsEnabled.
CanFocusWithMouseboolRead-only. true when IsEnabled.

RadioControl<T> Methods

MethodDescription
Select()Activates this radio through the group, honoring the group's AllowDeselect/Required policy. Equivalent to a Space/Enter key press.

RadioControl<T> Events

EventArgumentsDescription
MouseClickEventHandler<MouseEventArgs>Fires when the radio is left-clicked.
MouseDoubleClickEventHandler<MouseEventArgs>Fires when the radio is double-clicked (does not select again).
MouseRightClickEventHandler<MouseEventArgs>Fires when the radio is right-clicked.
MouseEnterEventHandler<MouseEventArgs>Fires when the mouse enters the radio area.
MouseLeaveEventHandler<MouseEventArgs>Fires when the mouse leaves the radio area.
MouseMoveEventHandler<MouseEventArgs>Fires when the mouse moves over the radio.

Selection events are on RadioGroup<T>, not on individual radios.

Keyboard Support

KeyAction
SpaceSelect this radio through the group.
EnterSelect this radio through the group.
TabMove focus to the next control.
Shift+TabMove focus to the previous control.

Keys are only processed when the radio is enabled and focused.

Mouse Support

ActionResult
Left clickFocuses and selects the radio through the group.
Double clickFires MouseDoubleClick; does not repeat selection.
Right clickFires MouseRightClick.
Enter / LeaveFires MouseEnter / MouseLeave.

Clicks in the control's margin area are ignored.

Label Wrapping and Alignment

Wrap (default true)

When Wrap is true, the label wraps into the columns remaining after the indicator prefix ((●) ). Continuation lines are hanging-indented to align under the label text (not under the indicator), so long option descriptions read naturally:

(●) Enable the experimental low-latency
    renderer — this long label wraps
    across lines.

When Wrap is false, the label is clipped to a single line, matching CheckboxControl's behavior.

HorizontalAlignment

RadioControl<T> inherits HorizontalAlignment from BaseControl:

  • Left (default) — the indicator+label block is flush left.
  • Center — the block is centered within the allocated width (integer division, floored).
  • Right — the block is flush right.
  • Stretch — the block fills the full allocated width; the label wraps to the remaining width after the indicator prefix.

VerticalAlignment

VerticalAlignment positions the content block (all wrapped lines together) within a taller allocated row:

  • Top (default) — block starts at the top.
  • Center — block is vertically centered.
  • Bottom — block is flush with the bottom.
  • Fill — uses the natural line count from the top.

Examples

Simple String Group

var group = Controls.RadioGroup<string>()
    .OnSelectionChanged(value =>
        label.SetContent(new List<string> { $"Theme: {value ?? "none"}" }))
    .Build();

panel.AddControl(Controls.Radio(group, "Light").Build());
panel.AddControl(Controls.Radio(group, "Dark").Selected().Build());
panel.AddControl(Controls.Radio(group, "System").Build());

Typed Enum Group with Required

Once the user makes a selection, the group cannot return to "none":

private enum Size { Small, Medium, Large }

var group = Controls.RadioGroup<Size>()
    .Required()
    .OnSelectionChanged(v =>
        readout.SetContent(new List<string> { $"Size: {v}" }))
    .Build();

panel.AddControl(Controls.Radio(group, Size.Small, "Small").Build());
panel.AddControl(Controls.Radio(group, Size.Medium, "Medium").Selected().Build());
panel.AddControl(Controls.Radio(group, Size.Large, "Large").Build());

AllowDeselect Group

Clicking the selected radio clears the group back to "none":

var group = Controls.RadioGroup<string>()
    .AllowDeselect()
    .Build();

panel.AddControl(Controls.Radio(group, "Card").Build());
panel.AddControl(Controls.Radio(group, "Cash").Build());

Long Labels with Wrapping

Wrapping is on by default; set a narrow margin so the label has room to wrap:

var group = Controls.RadioGroup<string>().Build();
panel.AddControl(
    Controls.Radio(group, "experimental",
        "Enable the experimental low-latency renderer — this long label wraps across lines with a hanging indent under the marker.")
        .Selected()
        .WithMargin(2, 0, 2, 0)
        .Build()
);

Disabled Radio (Disabled-but-Selected)

A disabled radio does not accept input but still renders its current selection state — it shows greyed-but-filled when it is the group's selected member:

var group = Controls.RadioGroup<string>().Build();
var selected = Controls.Radio(group, "Locked (selected)").Selected().Build();
selected.IsEnabled = false;   // greyed, still shows ●

var other = Controls.Radio(group, "Locked (unselected)").Build();
other.IsEnabled = false;

panel.AddControl(selected);
panel.AddControl(other);

Cross-Column Group (Grid Layout)

Because coordination goes through the group object (not through sibling scanning), radios from the same group can be placed in different grid columns or even different panels:

var groupA = Controls.RadioGroup<string>().Build();

// Group A spans two ScrollablePanel cells in different columns
var col0 = Controls.ScrollablePanel()
    .AddControl(Controls.Radio(groupA, "Alpha").Build())
    .AddControl(Controls.Radio(groupA, "Bravo").Build())
    .Build();

var col1 = Controls.ScrollablePanel()
    .AddControl(Controls.Radio(groupA, "Charlie").Build())
    .AddControl(Controls.Radio(groupA, "Delta").Build())
    .Build();

var grid = Controls.Grid()
    .Columns(GridLength.Star(1), GridLength.Star(1))
    .Rows(GridLength.Auto())
    .Place(col0, 0, 0)
    .Place(col1, 0, 1)
    .Build();

// Selecting "Delta" (col 1) automatically deselects "Alpha"/"Bravo" (col 0).

Best Practices

  1. One group object per mutually-exclusive set. Groups are explicit — if you want two independent sets of options, create two RadioGroup<T> instances.
  2. Use .Required() for mandatory choices. Once the user picks an option, Required prevents accidental deselection. It does not force a default — use .Selected() on a radio or .WithSelectedValue(...) on the group builder for that.
  3. React at the group level. Subscribe to RadioGroup<T>.SelectionChanged (or .OnSelectionChanged(...) on the builder) rather than attaching click handlers to individual radios. The group fires exactly once per change.
  4. Typed values over strings where possible. Using an enum or record T lets the compiler catch value typos and makes EqualityComparer<T>.Default meaningful without any extra work.
  5. Wrap long labels. Wrap defaults to true; keep it on for descriptive option text so it flows naturally. Use a right/left margin to control the available wrapping width.
  6. Mutate group and radio state on the UI thread. Wrap background-thread changes in windowSystem.EnqueueOnUIThread(...). Container?.Invalidate(Invalidation.Repaint) is the only thread-safe call from background threads.
  7. Don't store Checked yourself. Checked is computed from the group; reading group.SelectedValue or group.HasSelection is the canonical way to check the current selection, not polling individual radios.

See Also


Back to Controls | Back to Main Documentation