Skip to content
246 changes: 246 additions & 0 deletions crates/bevy_additional_core_widgets/src/core_select.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
use accesskit::Role;
use bevy::{
a11y::AccessibilityNode,
ecs::system::SystemId,
input::{ButtonState, keyboard::KeyboardInput},
input_focus::{FocusedInput, InputFocus, InputFocusVisible},
prelude::*,
};

use bevy_core_widgets::{ButtonClicked, InteractionDisabled, ValueChange};

use crate::{IsSelected, interaction_states::DropdownOpen};

#[derive(Component, Debug)]
#[require(AccessibilityNode(accesskit::Node::new(Role::ComboBox)), DropdownOpen)]
pub struct CoreSelectTrigger {
pub on_click: Option<SystemId<In<bool>>>,
}

pub fn select_on_pointer_click(
mut trigger: Trigger<Pointer<Click>>,
q_state: Query<(&CoreSelectTrigger, &DropdownOpen, Has<InteractionDisabled>)>,
mut focus: ResMut<InputFocus>,
mut focus_visible: ResMut<InputFocusVisible>,
mut commands: Commands,
) {
if let Ok((select_trigger, DropdownOpen(clicked), disabled)) = q_state.get(trigger.target()) {
if !disabled {
let select_id = trigger.target();
focus.0 = Some(select_id);
focus_visible.0 = false;
trigger.propagate(false);

let is_open = clicked;
let new_clicked = !is_open;

if let Some(on_click) = select_trigger.on_click {
commands.run_system_with(on_click, new_clicked);
} else {
commands.trigger_targets(ValueChange(DropdownOpen(new_clicked)), select_id);
}
}
}
}

#[derive(Component, Debug)]
#[require(AccessibilityNode(accesskit::Node::new(Role::ListBox)))]
pub struct CoreSelectContent {
pub on_change: Option<SystemId<In<Entity>>>,
}

fn select_content_on_button_click(
mut trigger: Trigger<ButtonClicked>,
q_group: Query<(&CoreSelectContent, &Children)>,
q_select_item: Query<(&IsSelected, &ChildOf, Has<InteractionDisabled>), With<CoreSelectItem>>,
mut focus: ResMut<InputFocus>,
mut focus_visible: ResMut<InputFocusVisible>,
mut commands: Commands,
) {
let select_id = trigger.target();

// Find the select item button that was clicked.
let Ok((_, child_of, disabled)) = q_select_item.get(select_id) else {
return;
};

if !disabled {
// If the select item button is not disabled, propagate the click event.
trigger.propagate(false);

// Find the parent CoreSelectContent of the clicked select item button.
let group_id = child_of.parent();
let Ok((CoreSelectContent { on_change }, group_children)) = q_group.get(group_id) else {
warn!("select item button clicked without a valid CoreSelectContent parent");
return;
};

// Set focus to group and hide focus ring
focus.0 = Some(group_id);
focus_visible.0 = false;

// Get all the select root children.
let select_children = group_children
.iter()
.filter_map(|child_id| match q_select_item.get(child_id) {
Ok((is_selected, _, false)) => Some((child_id, is_selected.0)),
Ok((_, _, true)) => None,
Err(_) => None,
})
.collect::<Vec<_>>();

if select_children.is_empty() {
return; // No enabled select item buttons in the group
}

trigger.propagate(false);
let current_select_item = select_children
.iter()
.find(|(_, is_selected)| *is_selected)
.map(|(id, _)| *id);

if current_select_item == Some(select_id) {
// If they clicked the currently selected item, do nothing
return;
}

// Trigger the on_change event for the newly selected item
if let Some(on_change) = on_change {
commands.run_system_with(*on_change, select_id);
} else {
commands.trigger_targets(ValueChange(select_id), group_id);
}
}
}

fn select_content_on_key_input(
mut trigger: Trigger<FocusedInput<KeyboardInput>>,
q_group: Query<(&CoreSelectContent, &Children)>,
q_select_item: Query<(&IsSelected, &ChildOf, Has<InteractionDisabled>), With<CoreSelectItem>>,
mut commands: Commands,
) {
if let Ok((CoreSelectContent { on_change }, group_children)) = q_group.get(trigger.target()) {
let event = &trigger.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;
trigger.propagate(false);

let select_children = group_children
.iter()
.filter_map(|child_id| match q_select_item.get(child_id) {
Ok((is_selected, _, false)) => Some((child_id, is_selected.0)),
Ok((_, _, true)) => None,
Err(_) => None,
})
.collect::<Vec<_>>();

if select_children.is_empty() {
return; // No select items in the group
}
let current_index = select_children
.iter()
.position(|(_, is_selected)| *is_selected)
.unwrap_or(usize::MAX); // Default to invalid index if none are selected

let next_index = match key_code {
KeyCode::ArrowUp | KeyCode::ArrowLeft => {
// Navigate to the previous select item in the group
if current_index == 0 {
// If we're at the first one, wrap around to the last
select_children.len() - 1
} else {
// Move to the previous one
current_index - 1
}
}
KeyCode::ArrowDown | KeyCode::ArrowRight => {
// Navigate to the next select item in the group
if current_index >= select_children.len() - 1 {
// If we're at the last one, wrap around to the first
0
} else {
// Move to the next one
current_index + 1
}
}
KeyCode::Home => {
// Navigate to the first select item in the group
0
}
KeyCode::End => {
// Navigate to the last select item in the group
select_children.len() - 1
}
_ => {
return;
}
};

if current_index == next_index {
// If the next index is the same as the current, do nothing
return;
}

let (next_id, _) = select_children[next_index];

// Trigger the on_change event for the newly selected item
if let Some(on_change) = on_change {
commands.run_system_with(*on_change, next_id);
} else {
commands.trigger_targets(ValueChange(next_id), trigger.target());
}
}
}
}

#[derive(Component, Debug)]
#[require(
AccessibilityNode(accesskit::Node::new(Role::ListBoxOption)),
IsSelected
)]
pub struct CoreSelectItem;

fn select_item_on_pointer_click(
mut trigger: Trigger<Pointer<Click>>,
q_state: Query<(&IsSelected, Has<InteractionDisabled>), With<CoreSelectItem>>,
mut focus: ResMut<InputFocus>,
mut focus_visible: ResMut<InputFocusVisible>,
mut commands: Commands,
) {
if let Ok((is_selected, disabled)) = q_state.get(trigger.target()) {
if !disabled {
// If the select item button is not disabled, propagate the click event.
trigger.propagate(false);
let checkbox_id = trigger.target();
focus.0 = Some(checkbox_id);
focus_visible.0 = false;
trigger.propagate(false);
if is_selected.0 || disabled {
return;
}
commands.trigger_targets(ButtonClicked, trigger.target());
}
}
}

pub struct CoreSelectPlugin;

impl Plugin for CoreSelectPlugin {
fn build(&self, app: &mut App) {
app.add_observer(select_on_pointer_click)
.add_observer(select_content_on_button_click)
.add_observer(select_content_on_key_input)
.add_observer(select_item_on_pointer_click);
}
}
63 changes: 63 additions & 0 deletions crates/bevy_additional_core_widgets/src/interaction_states.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use bevy::{
a11y::AccessibilityNode,
ecs::{component::HookContext, world::DeferredWorld},
prelude::Component,
};

/// Component that indicates whether the select widget has a drodown.
#[derive(Component, Default, Debug)]
#[component(immutable, on_add = update_expanded_a11y, on_replace = update_expanded_a11y)]
pub struct DropdownOpen(pub bool);

pub fn update_expanded_a11y(mut world: DeferredWorld, context: HookContext) {
let mut entt = world.entity_mut(context.entity);
let is_open = entt.get::<DropdownOpen>().unwrap().0;

if let Some(mut accessibility_node) = entt.get_mut::<AccessibilityNode>() {
accessibility_node.set_expanded(is_open);
accessibility_node.set_has_popup(accesskit::HasPopup::Listbox); // Set to Listbox
} else {
eprintln!("Error in update_expanded_a11y()");
}
}

/// Component that indicates whether the item is selected
#[derive(Component, Default, Debug)]
#[component(on_add = update_is_selected_a11y, on_replace = update_is_selected_a11y)]
pub struct IsSelected(pub bool);

pub fn update_is_selected_a11y(mut world: DeferredWorld, context: HookContext) {
let mut entt = world.entity_mut(context.entity);
let is_selected = entt.get::<IsSelected>().unwrap().0;

if let Some(mut accessibility_node) = entt.get_mut::<AccessibilityNode>() {
accessibility_node.set_selected(is_selected);
} else {
eprintln!("Error in update_is_selected_a11y()");
}
}

/// Component that indicates the selected item

#[derive(Component, Debug)]
#[component(on_add = update_selected_item_a11y, on_replace = update_selected_item_a11y)]
pub struct SelectedItem {
pub label: String,
pub value: String,
}

pub fn update_selected_item_a11y(mut world: DeferredWorld, context: HookContext) {
let mut entt = world.entity_mut(context.entity);

let (label, value) = {
let selected_item = entt.get::<SelectedItem>().unwrap();
(selected_item.label.clone(), selected_item.value.clone())
};

if let Some(mut accessibility_node) = entt.get_mut::<AccessibilityNode>() {
accessibility_node.set_label(label);
accessibility_node.set_value(value);
} else {
eprintln!("Error in update_selected_item_a11y()");
}
}
7 changes: 5 additions & 2 deletions crates/bevy_additional_core_widgets/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
use bevy::app::{App, Plugin};
mod core_select;
mod core_switch;

mod interaction_states;
pub use core_select::{CoreSelectContent, CoreSelectItem, CoreSelectPlugin, CoreSelectTrigger};
pub use core_switch::{CoreSwitch, CoreSwitchPlugin};

pub use interaction_states::{DropdownOpen, IsSelected, SelectedItem};
pub struct AdditionalCoreWidgetsPlugin;

impl Plugin for AdditionalCoreWidgetsPlugin {
fn build(&self, app: &mut App) {
app.add_plugins((CoreSwitchPlugin,));
app.add_plugins((CoreSwitchPlugin, CoreSelectPlugin));
}
}

Expand Down
7 changes: 5 additions & 2 deletions crates/bevy_styled_widgets/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ use bevy_core_widgets::CoreWidgetsPlugin;

use ui::{
button::StyledButtonPlugin, checkbox::StyledCheckboxPlugin, input::StyledInputPlugin,
progress::StyledProgessPlugin, radio_group::StyledRadioGroupPlugin, slider::StyledSliderPlugin,
switch::StyledSwitchPlugin, text::StyledTextPlugin, toggle::StyledTogglePlugin,
progress::StyledProgessPlugin, radio_group::StyledRadioGroupPlugin,
select::StyledSelectTriggerPlugin, slider::StyledSliderPlugin, switch::StyledSwitchPlugin,
text::StyledTextPlugin, toggle::StyledTogglePlugin,
};

pub struct StyledWidgetsPlugin;
Expand All @@ -31,6 +32,7 @@ impl Plugin for StyledWidgetsPlugin {
StyledCheckboxPlugin,
StyledSliderPlugin,
StyledRadioGroupPlugin,
StyledSelectTriggerPlugin,
));
}
}
Expand All @@ -44,6 +46,7 @@ pub mod prelude {
pub use crate::ui::input::*;
pub use crate::ui::progress::*;
pub use crate::ui::radio_group::*;
pub use crate::ui::select::*;
pub use crate::ui::slider::*;
pub use crate::ui::switch::*;
pub use crate::ui::text::*;
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_styled_widgets/src/themes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub use manager::*;
pub mod checkbox;
pub mod progress;
pub mod radio;
pub mod select;
pub mod slider;
pub mod switch;
pub mod toggle;
Loading