Skip to content

Commit 6af4a03

Browse files
Fix toggle behavior and add styling to Feathers Radio Buttons (#23869)
# Objective Continuation of #23820 and #23830 This fixes the clicking behavior to toggle on mouse-release, or on mouse-press with the `ActivateOnPress` component. More styling tokens are also added for transition states. --- ## Showcase ### Before <img width="91" height="91" alt="Screenshot 2026-04-17 at 11 32 39 PM" src="https://github.com/user-attachments/assets/b1b8832d-7aaa-45ee-a641-4c100d44a232" /> ### After <img width="91" height="91" alt="Screenshot 2026-04-17 at 11 13 33 PM" src="https://github.com/user-attachments/assets/d3cf5bf6-1dfe-4d88-8dd1-fc719449e7b5" /> (Not pictured: new intermediate styling for mouse press) --------- Co-authored-by: François Mockers <francois.mockers@vleue.com>
1 parent 334c242 commit 6af4a03

6 files changed

Lines changed: 198 additions & 33 deletions

File tree

crates/bevy_feathers/src/controls/radio.rs

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ use bevy_scene::prelude::*;
2020
use bevy_text::FontWeight;
2121
use bevy_ui::{
2222
AlignItems, BorderRadius, Checked, Display, FlexDirection, InteractionDisabled, JustifyContent,
23-
Node, UiRect, Val,
23+
Node, Pressed, UiRect, Val,
2424
};
25-
use bevy_ui_widgets::RadioButton;
25+
use bevy_ui_widgets::{ActivateOnPress, RadioButton};
2626

2727
use crate::{
2828
constants::{fonts, size},
@@ -184,20 +184,29 @@ fn update_radio_styles(
184184
Entity,
185185
Has<InteractionDisabled>,
186186
Has<Checked>,
187+
Has<Pressed>,
188+
Has<ActivateOnPress>,
187189
&Hovered,
188190
&ThemeFontColor,
189191
),
190192
(
191193
With<RadioButton>,
192-
Or<(Changed<Hovered>, Added<Checked>, Added<InteractionDisabled>)>,
194+
Or<(
195+
Changed<Hovered>,
196+
Added<Checked>,
197+
Added<Pressed>,
198+
Added<InteractionDisabled>,
199+
)>,
193200
),
194201
>,
195202
q_children: Query<&Children>,
196203
mut q_outline: Query<&ThemeBorderColor, With<RadioOutline>>,
197204
mut q_mark: Query<&ThemeBackgroundColor, With<RadioMark>>,
198205
mut commands: Commands,
199206
) {
200-
for (radio_ent, disabled, checked, hovered, font_color) in q_radioes.iter() {
207+
for (radio_ent, disabled, checked, pressed, activate_on_press, hovered, font_color) in
208+
q_radioes.iter()
209+
{
201210
let Some(outline_ent) = q_children
202211
.iter_descendants(radio_ent)
203212
.find(|en| q_outline.contains(*en))
@@ -218,7 +227,9 @@ fn update_radio_styles(
218227
mark_ent,
219228
disabled,
220229
checked,
230+
pressed,
221231
hovered.0,
232+
activate_on_press,
222233
outline_border,
223234
mark_color,
224235
font_color,
@@ -233,6 +244,8 @@ fn update_radio_styles_remove(
233244
Entity,
234245
Has<InteractionDisabled>,
235246
Has<Checked>,
247+
Has<Pressed>,
248+
Has<ActivateOnPress>,
236249
&Hovered,
237250
&ThemeFontColor,
238251
),
@@ -243,13 +256,26 @@ fn update_radio_styles_remove(
243256
mut q_mark: Query<&ThemeBackgroundColor, With<RadioMark>>,
244257
mut removed_disabled: RemovedComponents<InteractionDisabled>,
245258
mut removed_checked: RemovedComponents<Checked>,
259+
mut remove_pressed: RemovedComponents<Pressed>,
260+
mut remove_activate_on_press: RemovedComponents<ActivateOnPress>,
246261
mut commands: Commands,
247262
) {
248263
removed_disabled
249264
.read()
250265
.chain(removed_checked.read())
266+
.chain(remove_pressed.read())
267+
.chain(remove_activate_on_press.read())
251268
.for_each(|ent| {
252-
if let Ok((radio_ent, disabled, checked, hovered, font_color)) = q_radioes.get(ent) {
269+
if let Ok((
270+
radio_ent,
271+
disabled,
272+
checked,
273+
pressed,
274+
activate_on_press,
275+
hovered,
276+
font_color,
277+
)) = q_radioes.get(ent)
278+
{
253279
let Some(outline_ent) = q_children
254280
.iter_descendants(radio_ent)
255281
.find(|en| q_outline.contains(*en))
@@ -270,7 +296,9 @@ fn update_radio_styles_remove(
270296
mark_ent,
271297
disabled,
272298
checked,
299+
pressed,
273300
hovered.0,
301+
activate_on_press,
274302
outline_border,
275303
mark_color,
276304
font_color,
@@ -286,21 +314,44 @@ fn set_radio_styles(
286314
mark_ent: Entity,
287315
disabled: bool,
288316
checked: bool,
317+
pressed: bool,
289318
hovered: bool,
319+
activate_on_press: bool,
290320
outline_border: &ThemeBorderColor,
291321
mark_color: &ThemeBackgroundColor,
292322
font_color: &ThemeFontColor,
293323
commands: &mut Commands,
294324
) {
295-
let outline_border_token = match (disabled, hovered) {
296-
(true, _) => tokens::RADIO_BORDER_DISABLED,
297-
(false, true) => tokens::RADIO_BORDER_HOVER,
298-
_ => tokens::RADIO_BORDER,
325+
let outline_border_token = if checked {
326+
if disabled {
327+
tokens::RADIO_BORDER_CHECKED_DISABLED
328+
} else if pressed && !activate_on_press {
329+
tokens::RADIO_BORDER_CHECKED_PRESSED
330+
} else if hovered {
331+
tokens::RADIO_BORDER_CHECKED_HOVER
332+
} else {
333+
tokens::RADIO_BORDER_CHECKED
334+
}
335+
} else {
336+
if disabled {
337+
tokens::RADIO_BORDER_DISABLED
338+
} else if pressed && !activate_on_press {
339+
tokens::RADIO_BORDER_PRESSED
340+
} else if hovered {
341+
tokens::RADIO_BORDER_HOVER
342+
} else {
343+
tokens::RADIO_BORDER
344+
}
299345
};
300346

301-
let mark_token = match disabled {
302-
true => tokens::RADIO_MARK_DISABLED,
303-
false => tokens::RADIO_MARK,
347+
let mark_token = if disabled {
348+
tokens::RADIO_MARK_DISABLED
349+
} else if pressed && !activate_on_press {
350+
tokens::RADIO_MARK_PRESSED
351+
} else if hovered {
352+
tokens::RADIO_MARK_HOVER
353+
} else {
354+
tokens::RADIO_MARK
304355
};
305356

306357
let font_color_token = match disabled {
@@ -324,7 +375,7 @@ fn set_radio_styles(
324375
if mark_color.0 != mark_token {
325376
commands
326377
.entity(mark_ent)
327-
.insert(ThemeBorderColor(mark_token));
378+
.insert(ThemeBackgroundColor(mark_token));
328379
}
329380

330381
// Change mark visibility

crates/bevy_feathers/src/dark_theme.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,13 +105,29 @@ pub fn create_dark_theme() -> ThemeProps {
105105
),
106106
// Radio
107107
(tokens::RADIO_BORDER, palette::GRAY_3),
108-
(tokens::RADIO_BORDER_HOVER, palette::GRAY_3.lighter(0.1)),
108+
(tokens::RADIO_BORDER_HOVER, palette::GRAY_3.lighter(0.05)),
109+
(tokens::RADIO_BORDER_PRESSED, palette::GRAY_3.lighter(0.1)),
109110
(
110111
tokens::RADIO_BORDER_DISABLED,
111112
palette::GRAY_3.with_alpha(0.5),
112113
),
114+
(tokens::RADIO_BORDER_CHECKED, palette::ACCENT),
115+
(
116+
tokens::RADIO_BORDER_CHECKED_HOVER,
117+
palette::ACCENT.lighter(0.05),
118+
),
119+
(
120+
tokens::RADIO_BORDER_CHECKED_PRESSED,
121+
palette::ACCENT.lighter(0.1),
122+
),
123+
(
124+
tokens::RADIO_BORDER_CHECKED_DISABLED,
125+
palette::GRAY_3.with_alpha(0.5),
126+
),
113127
(tokens::RADIO_MARK, palette::ACCENT),
114-
(tokens::RADIO_MARK_DISABLED, palette::ACCENT.with_alpha(0.5)),
128+
(tokens::RADIO_MARK_HOVER, palette::ACCENT.lighter(0.05)),
129+
(tokens::RADIO_MARK_PRESSED, palette::ACCENT.lighter(0.1)),
130+
(tokens::RADIO_MARK_DISABLED, palette::GRAY_3.with_alpha(0.5)),
115131
(tokens::RADIO_TEXT, palette::LIGHT_GRAY_1),
116132
(
117133
tokens::RADIO_TEXT_DISABLED,

crates/bevy_feathers/src/tokens.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,34 @@ pub const CHECKBOX_TEXT_DISABLED: ThemeToken =
147147

148148
// Radio button
149149

150-
/// Radio border around the checkmark
150+
/// Border around the radio button
151151
pub const RADIO_BORDER: ThemeToken = ThemeToken::new_static("feathers.radio.border");
152-
/// Radio border around the checkmark (hovered)
152+
/// Border around the radio button (hovered)
153153
pub const RADIO_BORDER_HOVER: ThemeToken = ThemeToken::new_static("feathers.radio.border.hover");
154-
/// Radio border around the checkmark (disabled)
154+
/// Border around the radio button (pressed)
155+
pub const RADIO_BORDER_PRESSED: ThemeToken =
156+
ThemeToken::new_static("feathers.radio.border.pressed");
157+
/// Border around the radio button (disabled)
155158
pub const RADIO_BORDER_DISABLED: ThemeToken =
156159
ThemeToken::new_static("feathers.radio.border.disabled");
160+
/// Border around the radio button (checked)
161+
pub const RADIO_BORDER_CHECKED: ThemeToken =
162+
ThemeToken::new_static("feathers.radio.border.checked");
163+
/// Border around the radio button (checked+hovered)
164+
pub const RADIO_BORDER_CHECKED_HOVER: ThemeToken =
165+
ThemeToken::new_static("feathers.radio.border.checked.hover");
166+
/// Border around the radio button (checked+pressed)
167+
pub const RADIO_BORDER_CHECKED_PRESSED: ThemeToken =
168+
ThemeToken::new_static("feathers.radio.border.checked.pressed");
169+
/// Border around the radio button (checked+disabled)
170+
pub const RADIO_BORDER_CHECKED_DISABLED: ThemeToken =
171+
ThemeToken::new_static("feathers.radio.border.checked.disabled");
157172
/// Radio check mark
158173
pub const RADIO_MARK: ThemeToken = ThemeToken::new_static("feathers.radio.mark");
174+
/// Radio check mark (hovered)
175+
pub const RADIO_MARK_HOVER: ThemeToken = ThemeToken::new_static("feathers.radio.mark.hover");
176+
/// Radio check mark (pressed)
177+
pub const RADIO_MARK_PRESSED: ThemeToken = ThemeToken::new_static("feathers.radio.mark.pressed");
159178
/// Radio check mark (disabled)
160179
pub const RADIO_MARK_DISABLED: ThemeToken = ThemeToken::new_static("feathers.radio.mark.disabled");
161180
/// Radio label text

crates/bevy_ui_widgets/src/checkbox.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ use bevy_input_focus::{FocusedInput, InputFocus, InputFocusVisible};
1515
use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release};
1616
use bevy_ui::{Checkable, Checked, InteractionDisabled, Pressed};
1717

18-
use crate::ActivateOnPress;
19-
use crate::ValueChange;
18+
use crate::{ActivateOnPress, ValueChange};
2019
use bevy_ecs::entity::Entity;
2120

2221
/// Headless widget implementation for checkboxes. The [`Checked`] component represents the current

crates/bevy_ui_widgets/src/radio.rs

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@ use bevy_ecs::{
66
entity::Entity,
77
hierarchy::{ChildOf, Children},
88
observer::On,
9-
query::{Has, With},
9+
query::{Has, With, Without},
1010
reflect::ReflectComponent,
1111
system::{Commands, Query},
1212
};
1313
use bevy_input::keyboard::{KeyCode, KeyboardInput};
1414
use bevy_input::ButtonState;
1515
use bevy_input_focus::FocusedInput;
16-
use bevy_picking::events::{Click, Pointer};
16+
use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release};
1717
use bevy_reflect::Reflect;
18-
use bevy_ui::{Checkable, Checked, InteractionDisabled};
18+
use bevy_ui::{Checkable, Checked, InteractionDisabled, Pressed};
1919

20-
use crate::ValueChange;
20+
use crate::{ActivateOnPress, ValueChange};
2121

2222
/// Headless widget implementation for a "radio button group". This component is used to group
2323
/// multiple [`RadioButton`] components together, allowing them to behave as a single unit. It
@@ -188,32 +188,108 @@ fn radio_button_on_key_input(
188188
}
189189

190190
fn radio_button_on_click(
191-
mut ev: On<Pointer<Click>>,
191+
mut click: On<Pointer<Click>>,
192192
q_group: Query<(), With<RadioGroup>>,
193-
q_radio: Query<(Has<InteractionDisabled>, Has<Checked>), With<RadioButton>>,
193+
q_radio: Query<
194+
(Has<InteractionDisabled>, Has<Checked>),
195+
(With<RadioButton>, Without<ActivateOnPress>),
196+
>,
194197
q_parents: Query<&ChildOf>,
195198
mut commands: Commands,
196199
) {
197-
let Ok((disabled, checked)) = q_radio.get(ev.entity) else {
200+
let Ok((disabled, checked)) = q_radio.get(click.entity) else {
198201
// Not a radio button
199202
return;
200203
};
201204

202-
ev.propagate(false);
205+
click.propagate(false);
203206

204207
// Radio button is disabled or already checked
205208
if disabled || checked {
206209
return;
207210
}
208211

209212
trigger_radio_button_and_radio_group_value_change(
210-
ev.entity,
213+
click.entity,
211214
&q_group,
212215
&q_parents,
213216
&mut commands,
214217
);
215218
}
216219

220+
fn radio_button_on_pointer_down(
221+
mut press: On<Pointer<Press>>,
222+
q_group: Query<(), With<RadioGroup>>,
223+
mut q_radio: Query<
224+
(
225+
Entity,
226+
Has<InteractionDisabled>,
227+
Has<Checked>,
228+
Has<Pressed>,
229+
Has<ActivateOnPress>,
230+
),
231+
With<RadioButton>,
232+
>,
233+
q_parents: Query<&ChildOf>,
234+
mut commands: Commands,
235+
) {
236+
if let Ok((radio, disabled, checked, pressed, activate_on_press)) =
237+
q_radio.get_mut(press.entity)
238+
{
239+
press.propagate(false);
240+
if !disabled && !pressed {
241+
commands.entity(radio).insert(Pressed);
242+
if activate_on_press && !checked {
243+
trigger_radio_button_and_radio_group_value_change(
244+
press.entity,
245+
&q_group,
246+
&q_parents,
247+
&mut commands,
248+
);
249+
}
250+
}
251+
}
252+
}
253+
254+
fn radio_button_on_pointer_up(
255+
mut release: On<Pointer<Release>>,
256+
mut q_radio: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<RadioButton>>,
257+
mut commands: Commands,
258+
) {
259+
if let Ok((radio, disabled, pressed)) = q_radio.get_mut(release.entity) {
260+
release.propagate(false);
261+
if !disabled && pressed {
262+
commands.entity(radio).remove::<Pressed>();
263+
}
264+
}
265+
}
266+
267+
fn radio_button_on_pointer_drag_end(
268+
mut drag_end: On<Pointer<DragEnd>>,
269+
mut q_radio: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<RadioButton>>,
270+
mut commands: Commands,
271+
) {
272+
if let Ok((radio, disabled, pressed)) = q_radio.get_mut(drag_end.entity) {
273+
drag_end.propagate(false);
274+
if !disabled && pressed {
275+
commands.entity(radio).remove::<Pressed>();
276+
}
277+
}
278+
}
279+
280+
fn checkbox_on_pointer_cancel(
281+
mut cancel: On<Pointer<Cancel>>,
282+
mut q_radio: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<RadioButton>>,
283+
mut commands: Commands,
284+
) {
285+
if let Ok((radio, disabled, pressed)) = q_radio.get_mut(cancel.entity) {
286+
cancel.propagate(false);
287+
if !disabled && pressed {
288+
commands.entity(radio).remove::<Pressed>();
289+
}
290+
}
291+
}
292+
217293
fn trigger_radio_button_and_radio_group_value_change(
218294
radio_button: Entity,
219295
q_group: &Query<(), With<RadioGroup>>,
@@ -247,6 +323,10 @@ impl Plugin for RadioGroupPlugin {
247323
fn build(&self, app: &mut App) {
248324
app.add_observer(radio_group_on_key_input)
249325
.add_observer(radio_button_on_click)
250-
.add_observer(radio_button_on_key_input);
326+
.add_observer(radio_button_on_key_input)
327+
.add_observer(radio_button_on_pointer_down)
328+
.add_observer(radio_button_on_pointer_up)
329+
.add_observer(radio_button_on_pointer_drag_end)
330+
.add_observer(checkbox_on_pointer_cancel);
251331
}
252332
}

0 commit comments

Comments
 (0)