use accesskit::Role;
use bevy_a11y::AccessibilityNode;
use bevy_app::{App, Plugin};
use bevy_ecs::{
component::Component,
entity::Entity,
hierarchy::{ChildOf, Children},
observer::On,
query::{Has, With, Without},
reflect::ReflectComponent,
system::{Commands, Query},
};
use bevy_input::keyboard::{KeyCode, KeyboardInput};
use bevy_input::ButtonState;
use bevy_input_focus::FocusedInput;
use bevy_picking::events::{
PointerCancel, PointerClick, PointerDragEnd, PointerPress, PointerRelease,
};
use bevy_reflect::Reflect;
use bevy_ui::{Checkable, Checked, InteractionDisabled, Pressed};
use crate::{ActivateOnPress, ValueChange};
#[derive(Component, Debug, Clone, Default)]
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)))]
#[derive(Reflect)]
#[reflect(Component)]
pub struct RadioGroup;
#[derive(Component, Debug, Clone, Default)]
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioButton)), Checkable)]
#[derive(Reflect)]
#[reflect(Component)]
pub struct RadioButton;
fn radio_group_on_key_input(
mut ev: On<FocusedInput<KeyboardInput>>,
q_group: Query<(), With<RadioGroup>>,
q_radio: Query<(Has<Checked>, Has<InteractionDisabled>), With<RadioButton>>,
q_children: Query<&Children>,
mut commands: Commands,
) {
if q_group.contains(ev.focused_entity) {
let event = &ev.event().input;
if event.state == ButtonState::Pressed
&& !event.repeat
&& matches!(
event.key_code,
KeyCode::ArrowUp
| KeyCode::ArrowDown
| KeyCode::ArrowLeft
| KeyCode::ArrowRight
| KeyCode::Home
| KeyCode::End
)
{
let key_code = event.key_code;
ev.propagate(false);
let radio_buttons = q_children
.iter_descendants(ev.focused_entity)
.filter_map(|child_id| match q_radio.get(child_id) {
Ok((checked, false)) => Some((child_id, checked)),
Ok((_, true)) | Err(_) => None,
})
.collect::<Vec<_>>();
if radio_buttons.is_empty() {
return; }
let current_index = radio_buttons
.iter()
.position(|(_, checked)| *checked)
.unwrap_or(usize::MAX);
let next_index = match key_code {
KeyCode::ArrowUp | KeyCode::ArrowLeft => {
if current_index == 0 || current_index >= radio_buttons.len() {
radio_buttons.len() - 1
} else {
current_index - 1
}
}
KeyCode::ArrowDown | KeyCode::ArrowRight => {
if current_index >= radio_buttons.len() - 1 {
0
} else {
current_index + 1
}
}
KeyCode::Home => {
0
}
KeyCode::End => {
radio_buttons.len() - 1
}
_ => {
return;
}
};
if current_index == next_index {
return;
}
let (next_id, _) = radio_buttons[next_index];
commands.trigger(ValueChange::<bool> {
source: next_id,
value: true,
is_final: true,
});
commands.trigger(ValueChange::<Entity> {
source: ev.focused_entity,
value: next_id,
is_final: true,
});
}
}
}
fn radio_button_on_key_input(
mut ev: On<FocusedInput<KeyboardInput>>,
q_radio_button: Query<(Has<InteractionDisabled>, Has<Checked>), With<RadioButton>>,
q_group: Query<(), With<RadioGroup>>,
q_parents: Query<&ChildOf>,
mut commands: Commands,
) {
let Ok((disabled, checked)) = q_radio_button.get(ev.focused_entity) else {
return;
};
let event = &ev.event().input;
if event.state == ButtonState::Pressed
&& !event.repeat
&& (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space)
{
ev.propagate(false);
if disabled || checked {
return;
}
trigger_radio_button_and_radio_group_value_change(
ev.focused_entity,
&q_group,
&q_parents,
&mut commands,
);
}
}
fn radio_button_on_click(
mut click: On<PointerClick>,
q_group: Query<(), With<RadioGroup>>,
q_radio: Query<
(Has<InteractionDisabled>, Has<Checked>),
(With<RadioButton>, Without<ActivateOnPress>),
>,
q_parents: Query<&ChildOf>,
mut commands: Commands,
) {
let Ok((disabled, checked)) = q_radio.get(click.entity) else {
return;
};
click.propagate(false);
if disabled || checked {
return;
}
trigger_radio_button_and_radio_group_value_change(
click.entity,
&q_group,
&q_parents,
&mut commands,
);
}
fn radio_button_on_pointer_down(
mut press: On<PointerPress>,
q_group: Query<(), With<RadioGroup>>,
mut q_radio: Query<
(
Entity,
Has<InteractionDisabled>,
Has<Checked>,
Has<Pressed>,
Has<ActivateOnPress>,
),
With<RadioButton>,
>,
q_parents: Query<&ChildOf>,
mut commands: Commands,
) {
if let Ok((radio, disabled, checked, pressed, activate_on_press)) =
q_radio.get_mut(press.entity)
{
press.propagate(false);
if !disabled && !pressed {
commands.entity(radio).insert(Pressed);
if activate_on_press && !checked {
trigger_radio_button_and_radio_group_value_change(
press.entity,
&q_group,
&q_parents,
&mut commands,
);
}
}
}
}
fn radio_button_on_pointer_up(
mut release: On<PointerRelease>,
mut q_radio: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<RadioButton>>,
mut commands: Commands,
) {
if let Ok((radio, disabled, pressed)) = q_radio.get_mut(release.entity) {
release.propagate(false);
if !disabled && pressed {
commands.entity(radio).remove::<Pressed>();
}
}
}
fn radio_button_on_pointer_drag_end(
mut drag_end: On<PointerDragEnd>,
mut q_radio: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<RadioButton>>,
mut commands: Commands,
) {
if let Ok((radio, disabled, pressed)) = q_radio.get_mut(drag_end.entity) {
drag_end.propagate(false);
if !disabled && pressed {
commands.entity(radio).remove::<Pressed>();
}
}
}
fn radio_button_on_pointer_cancel(
mut cancel: On<PointerCancel>,
mut q_radio: Query<(Entity, Has<InteractionDisabled>, Has<Pressed>), With<RadioButton>>,
mut commands: Commands,
) {
if let Ok((radio, disabled, pressed)) = q_radio.get_mut(cancel.entity) {
cancel.propagate(false);
if !disabled && pressed {
commands.entity(radio).remove::<Pressed>();
}
}
}
fn trigger_radio_button_and_radio_group_value_change(
radio_button: Entity,
q_group: &Query<(), With<RadioGroup>>,
q_parents: &Query<&ChildOf>,
commands: &mut Commands,
) {
commands.trigger(ValueChange::<bool> {
source: radio_button,
value: true,
is_final: true,
});
let radio_group = q_parents
.iter_ancestors(radio_button)
.find(|ancestor| q_group.contains(*ancestor));
if let Some(radio_group) = radio_group {
commands.trigger(ValueChange::<Entity> {
source: radio_group,
value: radio_button,
is_final: true,
});
}
}
pub struct RadioGroupPlugin;
impl Plugin for RadioGroupPlugin {
fn build(&self, app: &mut App) {
app.add_observer(radio_group_on_key_input)
.add_observer(radio_button_on_click)
.add_observer(radio_button_on_key_input)
.add_observer(radio_button_on_pointer_down)
.add_observer(radio_button_on_pointer_up)
.add_observer(radio_button_on_pointer_drag_end)
.add_observer(radio_button_on_pointer_cancel);
}
}
pub fn radio_self_update(
value_change: On<ValueChange<Entity>>,
q_radio_group: Query<&Children, With<RadioGroup>>,
q_radio: Query<Entity, With<RadioButton>>,
mut commands: Commands,
) {
let Ok(children) = q_radio_group.get(value_change.source) else {
return;
};
let mut iter = q_radio.iter_many(children).matched();
while let Some(radio) = iter.fetch_next() {
if radio == value_change.value {
commands.entity(radio).insert(Checked);
} else {
commands.entity(radio).remove::<Checked>();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy_app::App;
use bevy_input::InputPlugin;
use bevy_input_focus::{tab_navigation::TabNavigationPlugin, InputFocusPlugin};
use bevy_math::Vec2;
use bevy_picking::backend::HitData;
use bevy_picking::events::Pointer;
use bevy_picking::pointer::{Location, PointerButton, PointerId};
use bevy_window::{PrimaryWindow, Window, WindowRef};
fn radio_app() -> (App, Entity) {
let mut app = App::new();
app.add_plugins((
InputPlugin,
InputFocusPlugin,
TabNavigationPlugin,
RadioGroupPlugin,
));
app.add_observer(radio_self_update);
let window = app
.world_mut()
.spawn((Window::default(), PrimaryWindow))
.id();
app.update();
(app, window)
}
fn window_location(window: Entity) -> Location {
Location {
target: bevy_camera::NormalizedRenderTarget::Window(
WindowRef::Entity(window).normalize(Some(window)).unwrap(),
),
position: Vec2::ZERO,
}
}
fn click_entity(app: &mut App, target: Entity, window: Entity) {
app.world_mut().trigger(PointerClick {
entity: target,
pointer: Pointer::new(PointerId::Mouse, window_location(window)),
button: PointerButton::Primary,
hit: HitData::new(window, 0.0, None, None),
duration: core::time::Duration::from_millis(10),
count: 1,
});
app.update();
}
#[test]
fn click_moves_radio_selection_within_group() {
let (mut app, window) = radio_app();
let group = app.world_mut().spawn((RadioGroup, ChildOf(window))).id();
let option_a = app.world_mut().spawn((RadioButton, ChildOf(group))).id();
let option_b = app.world_mut().spawn((RadioButton, ChildOf(group))).id();
app.update();
assert!(!app.world().entity(option_a).contains::<Checked>());
assert!(!app.world().entity(option_b).contains::<Checked>());
click_entity(&mut app, option_a, window);
assert!(app.world().entity(option_a).contains::<Checked>());
assert!(!app.world().entity(option_b).contains::<Checked>());
click_entity(&mut app, option_b, window);
assert!(
!app.world().entity(option_a).contains::<Checked>(),
"selecting another option must uncheck the previous one"
);
assert!(app.world().entity(option_b).contains::<Checked>());
}
#[test]
fn clicking_checked_radio_keeps_it_selected() {
let (mut app, window) = radio_app();
let group = app.world_mut().spawn((RadioGroup, ChildOf(window))).id();
let option = app.world_mut().spawn((RadioButton, ChildOf(group))).id();
app.update();
click_entity(&mut app, option, window);
assert!(app.world().entity(option).contains::<Checked>());
click_entity(&mut app, option, window);
assert!(
app.world().entity(option).contains::<Checked>(),
"clicking an already-selected radio must not deselect it"
);
}
#[test]
fn disabled_radio_button_does_not_select() {
let (mut app, window) = radio_app();
let group = app.world_mut().spawn((RadioGroup, ChildOf(window))).id();
let option = app
.world_mut()
.spawn((RadioButton, InteractionDisabled, ChildOf(group)))
.id();
app.update();
click_entity(&mut app, option, window);
assert!(
!app.world().entity(option).contains::<Checked>(),
"a disabled radio option must not become selected"
);
}
}