Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
bevyengine
GitHub Repository: bevyengine/bevy
Path: blob/main/crates/bevy_ui_widgets/src/radio.rs
6849 views
1
use accesskit::Role;
2
use bevy_a11y::AccessibilityNode;
3
use bevy_app::{App, Plugin};
4
use bevy_ecs::{
5
component::Component,
6
hierarchy::{ChildOf, Children},
7
observer::On,
8
query::{Has, With},
9
reflect::ReflectComponent,
10
system::{Commands, In, Query},
11
};
12
use bevy_input::keyboard::{KeyCode, KeyboardInput};
13
use bevy_input::ButtonState;
14
use bevy_input_focus::FocusedInput;
15
use bevy_picking::events::{Click, Pointer};
16
use bevy_reflect::Reflect;
17
use bevy_ui::{Checkable, Checked, InteractionDisabled};
18
19
use crate::{Activate, Callback, Notify};
20
21
/// Headless widget implementation for a "radio button group". This component is used to group
22
/// multiple [`RadioButton`] components together, allowing them to behave as a single unit. It
23
/// implements the tab navigation logic and keyboard shortcuts for radio buttons.
24
///
25
/// The [`RadioGroup`] component does not have any state itself, and makes no assumptions about
26
/// what, if any, value is associated with each radio button, or what Rust type that value might be.
27
/// Instead, the output of the group is the entity id of the selected button. The app can then
28
/// derive the selected value from this using app-specific means, such as accessing a component on
29
/// the individual buttons.
30
///
31
/// The [`RadioGroup`] doesn't actually set the [`Checked`] states directly, that is presumed to
32
/// happen by the app or via some external data-binding scheme. Typically, each button would be
33
/// associated with a particular constant value, and would be checked whenever that value is equal
34
/// to the group's value. This also means that as long as each button's associated value is unique
35
/// within the group, it should never be the case that more than one button is selected at a time.
36
#[derive(Component, Debug)]
37
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)))]
38
pub struct RadioGroup {
39
/// Callback which is called when the selected radio button changes.
40
pub on_change: Callback<In<Activate>>,
41
}
42
43
/// Headless widget implementation for radio buttons. These should be enclosed within a
44
/// [`RadioGroup`] widget, which is responsible for the mutual exclusion logic.
45
///
46
/// According to the WAI-ARIA best practices document, radio buttons should not be focusable,
47
/// but rather the enclosing group should be focusable.
48
/// See <https://www.w3.org/WAI/ARIA/apg/patterns/radio>/
49
#[derive(Component, Debug)]
50
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioButton)), Checkable)]
51
#[derive(Reflect)]
52
#[reflect(Component)]
53
pub struct RadioButton;
54
55
fn radio_group_on_key_input(
56
mut ev: On<FocusedInput<KeyboardInput>>,
57
q_group: Query<&RadioGroup>,
58
q_radio: Query<(Has<Checked>, Has<InteractionDisabled>), With<RadioButton>>,
59
q_children: Query<&Children>,
60
mut commands: Commands,
61
) {
62
if let Ok(RadioGroup { on_change }) = q_group.get(ev.focused_entity) {
63
let event = &ev.event().input;
64
if event.state == ButtonState::Pressed
65
&& !event.repeat
66
&& matches!(
67
event.key_code,
68
KeyCode::ArrowUp
69
| KeyCode::ArrowDown
70
| KeyCode::ArrowLeft
71
| KeyCode::ArrowRight
72
| KeyCode::Home
73
| KeyCode::End
74
)
75
{
76
let key_code = event.key_code;
77
ev.propagate(false);
78
79
// Find all radio descendants that are not disabled
80
let radio_buttons = q_children
81
.iter_descendants(ev.focused_entity)
82
.filter_map(|child_id| match q_radio.get(child_id) {
83
Ok((checked, false)) => Some((child_id, checked)),
84
Ok((_, true)) | Err(_) => None,
85
})
86
.collect::<Vec<_>>();
87
if radio_buttons.is_empty() {
88
return; // No enabled radio buttons in the group
89
}
90
let current_index = radio_buttons
91
.iter()
92
.position(|(_, checked)| *checked)
93
.unwrap_or(usize::MAX); // Default to invalid index if none are checked
94
95
let next_index = match key_code {
96
KeyCode::ArrowUp | KeyCode::ArrowLeft => {
97
// Navigate to the previous radio button in the group
98
if current_index == 0 || current_index >= radio_buttons.len() {
99
// If we're at the first one, wrap around to the last
100
radio_buttons.len() - 1
101
} else {
102
// Move to the previous one
103
current_index - 1
104
}
105
}
106
KeyCode::ArrowDown | KeyCode::ArrowRight => {
107
// Navigate to the next radio button in the group
108
if current_index >= radio_buttons.len() - 1 {
109
// If we're at the last one, wrap around to the first
110
0
111
} else {
112
// Move to the next one
113
current_index + 1
114
}
115
}
116
KeyCode::Home => {
117
// Navigate to the first radio button in the group
118
0
119
}
120
KeyCode::End => {
121
// Navigate to the last radio button in the group
122
radio_buttons.len() - 1
123
}
124
_ => {
125
return;
126
}
127
};
128
129
if current_index == next_index {
130
// If the next index is the same as the current, do nothing
131
return;
132
}
133
134
let (next_id, _) = radio_buttons[next_index];
135
136
// Trigger the on_change event for the newly checked radio button
137
commands.notify_with(on_change, Activate(next_id));
138
}
139
}
140
}
141
142
fn radio_group_on_button_click(
143
mut ev: On<Pointer<Click>>,
144
q_group: Query<&RadioGroup>,
145
q_radio: Query<(Has<Checked>, Has<InteractionDisabled>), With<RadioButton>>,
146
q_parents: Query<&ChildOf>,
147
q_children: Query<&Children>,
148
mut commands: Commands,
149
) {
150
if let Ok(RadioGroup { on_change }) = q_group.get(ev.entity) {
151
// Starting with the original target, search upward for a radio button.
152
let radio_id = if q_radio.contains(ev.original_event_target()) {
153
ev.original_event_target()
154
} else {
155
// Search ancestors for the first radio button
156
let mut found_radio = None;
157
for ancestor in q_parents.iter_ancestors(ev.original_event_target()) {
158
if q_group.contains(ancestor) {
159
// We reached a radio group before finding a radio button, bail out
160
return;
161
}
162
if q_radio.contains(ancestor) {
163
found_radio = Some(ancestor);
164
break;
165
}
166
}
167
168
match found_radio {
169
Some(radio) => radio,
170
None => return, // No radio button found in the ancestor chain
171
}
172
};
173
174
// Radio button is disabled.
175
if q_radio.get(radio_id).unwrap().1 {
176
return;
177
}
178
179
// Gather all the enabled radio group descendants for exclusion.
180
let radio_buttons = q_children
181
.iter_descendants(ev.entity)
182
.filter_map(|child_id| match q_radio.get(child_id) {
183
Ok((checked, false)) => Some((child_id, checked)),
184
Ok((_, true)) | Err(_) => None,
185
})
186
.collect::<Vec<_>>();
187
188
if radio_buttons.is_empty() {
189
return; // No enabled radio buttons in the group
190
}
191
192
// Pick out the radio button that is currently checked.
193
ev.propagate(false);
194
let current_radio = radio_buttons
195
.iter()
196
.find(|(_, checked)| *checked)
197
.map(|(id, _)| *id);
198
199
if current_radio == Some(radio_id) {
200
// If they clicked the currently checked radio button, do nothing
201
return;
202
}
203
204
// Trigger the on_change event for the newly checked radio button
205
commands.notify_with(on_change, Activate(radio_id));
206
}
207
}
208
209
/// Plugin that adds the observers for the [`RadioGroup`] widget.
210
pub struct RadioGroupPlugin;
211
212
impl Plugin for RadioGroupPlugin {
213
fn build(&self, app: &mut App) {
214
app.add_observer(radio_group_on_key_input)
215
.add_observer(radio_group_on_button_click);
216
}
217
}
218
219