diff --git a/crates/bevy_feathers/Cargo.toml b/crates/bevy_feathers/Cargo.toml index 3e1aded969..07d883704a 100644 --- a/crates/bevy_feathers/Cargo.toml +++ b/crates/bevy_feathers/Cargo.toml @@ -10,6 +10,7 @@ keywords = ["bevy"] [dependencies] # bevy +bevy_a11y = { path = "../bevy_a11y", version = "0.17.0-dev" } bevy_app = { path = "../bevy_app", version = "0.17.0-dev" } bevy_asset = { path = "../bevy_asset", version = "0.17.0-dev" } bevy_color = { path = "../bevy_color", version = "0.17.0-dev" } diff --git a/crates/bevy_feathers/src/constants.rs b/crates/bevy_feathers/src/constants.rs index 651bb60b29..359e5a4935 100644 --- a/crates/bevy_feathers/src/constants.rs +++ b/crates/bevy_feathers/src/constants.rs @@ -26,4 +26,10 @@ pub mod size { /// Width and height of a radio button pub const RADIO_SIZE: Val = Val::Px(18.0); + + /// Width of a toggle switch + pub const TOGGLE_WIDTH: Val = Val::Px(32.0); + + /// Height of a toggle switch + pub const TOGGLE_HEIGHT: Val = Val::Px(18.0); } diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index 6e4235961a..f81e357c21 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -37,6 +37,10 @@ pub struct CheckboxProps { pub on_change: Callback>, } +/// Marker for the checkbox frame (contains both checkbox and label) +#[derive(Component, Default, Clone)] +struct CheckboxFrame; + /// Marker for the checkbox outline #[derive(Component, Default, Clone)] struct CheckboxOutline; @@ -68,6 +72,7 @@ pub fn checkbox + Send + Sync + 'static, B: Bundle>( CoreCheckbox { on_change: props.on_change, }, + CheckboxFrame, Hovered::default(), CursorIcon::System(bevy_window::SystemCursorIcon::Pointer), TabIndex(0), @@ -124,7 +129,7 @@ fn update_checkbox_styles( &ThemeFontColor, ), ( - With, + With, Or<(Changed, Added, Added)>, ), >, @@ -173,7 +178,7 @@ fn update_checkbox_styles_remove( &Hovered, &ThemeFontColor, ), - With, + With, >, q_children: Query<&Children>, mut q_outline: Query<(&ThemeBackgroundColor, &ThemeBorderColor), With>, diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 92c5a76907..ecad39707b 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -5,17 +5,25 @@ mod button; mod checkbox; mod radio; mod slider; +mod toggle_switch; pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant}; pub use checkbox::{checkbox, CheckboxPlugin, CheckboxProps}; pub use radio::{radio, RadioPlugin}; pub use slider::{slider, SliderPlugin, SliderProps}; +pub use toggle_switch::{toggle_switch, ToggleSwitchPlugin, ToggleSwitchProps}; /// Plugin which registers all `bevy_feathers` controls. pub struct ControlsPlugin; impl Plugin for ControlsPlugin { fn build(&self, app: &mut bevy_app::App) { - app.add_plugins((ButtonPlugin, CheckboxPlugin, RadioPlugin, SliderPlugin)); + app.add_plugins(( + ButtonPlugin, + CheckboxPlugin, + RadioPlugin, + SliderPlugin, + ToggleSwitchPlugin, + )); } } diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs new file mode 100644 index 0000000000..bc473d8d81 --- /dev/null +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -0,0 +1,249 @@ +use accesskit::Role; +use bevy_a11y::AccessibilityNode; +use bevy_app::{Plugin, PreUpdate}; +use bevy_core_widgets::{Callback, CoreCheckbox}; +use bevy_ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::Children, + lifecycle::RemovedComponents, + query::{Added, Changed, Has, Or, With}, + schedule::IntoScheduleConfigs, + spawn::SpawnRelated, + system::{Commands, In, Query}, + world::Mut, +}; +use bevy_input_focus::tab_navigation::TabIndex; +use bevy_picking::{hover::Hovered, PickingSystems}; +use bevy_ui::{BorderRadius, Checked, InteractionDisabled, Node, PositionType, UiRect, Val}; +use bevy_winit::cursor::CursorIcon; + +use crate::{ + constants::size, + theme::{ThemeBackgroundColor, ThemeBorderColor}, + tokens, +}; + +/// Parameters for the toggle switch template, passed to [`toggle_switch`] function. +#[derive(Default)] +pub struct ToggleSwitchProps { + /// Change handler + pub on_change: Callback>, +} + +/// Marker for the toggle switch outline +#[derive(Component, Default, Clone)] +struct ToggleSwitchOutline; + +/// Marker for the toggle switch slide +#[derive(Component, Default, Clone)] +struct ToggleSwitchSlide; + +/// Template function to spawn a toggle switch. +/// +/// # Arguments +/// * `props` - construction properties for the toggle switch. +/// * `overrides` - a bundle of components that are merged in with the normal toggle switch components. +pub fn toggle_switch(props: ToggleSwitchProps, overrides: B) -> impl Bundle { + ( + Node { + width: size::TOGGLE_WIDTH, + height: size::TOGGLE_HEIGHT, + border: UiRect::all(Val::Px(2.0)), + ..Default::default() + }, + CoreCheckbox { + on_change: props.on_change, + }, + ToggleSwitchOutline, + BorderRadius::all(Val::Px(5.0)), + ThemeBackgroundColor(tokens::SWITCH_BG), + ThemeBorderColor(tokens::SWITCH_BORDER), + AccessibilityNode(accesskit::Node::new(Role::Switch)), + Hovered::default(), + CursorIcon::System(bevy_window::SystemCursorIcon::Pointer), + TabIndex(0), + overrides, + children![( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Px(0.), + bottom: Val::Px(0.), + width: Val::Percent(50.), + ..Default::default() + }, + BorderRadius::all(Val::Px(3.0)), + ToggleSwitchSlide, + ThemeBackgroundColor(tokens::SWITCH_SLIDE), + )], + ) +} + +fn update_switch_styles( + q_switches: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeBackgroundColor, + &ThemeBorderColor, + ), + ( + With, + Or<(Changed, Added, Added)>, + ), + >, + q_children: Query<&Children>, + mut q_slide: Query<(&mut Node, &ThemeBackgroundColor), With>, + mut commands: Commands, +) { + for (switch_ent, disabled, checked, hovered, outline_bg, outline_border) in q_switches.iter() { + let Some(slide_ent) = q_children + .iter_descendants(switch_ent) + .find(|en| q_slide.contains(*en)) + else { + continue; + }; + // Safety: since we just checked the query, should always work. + let (ref mut slide_style, slide_color) = q_slide.get_mut(slide_ent).unwrap(); + set_switch_colors( + switch_ent, + slide_ent, + disabled, + checked, + hovered.0, + outline_bg, + outline_border, + slide_style, + slide_color, + &mut commands, + ); + } +} + +fn update_switch_styles_remove( + q_switches: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeBackgroundColor, + &ThemeBorderColor, + ), + With, + >, + q_children: Query<&Children>, + mut q_slide: Query<(&mut Node, &ThemeBackgroundColor), With>, + mut removed_disabled: RemovedComponents, + mut removed_checked: RemovedComponents, + mut commands: Commands, +) { + removed_disabled + .read() + .chain(removed_checked.read()) + .for_each(|ent| { + if let Ok((switch_ent, disabled, checked, hovered, outline_bg, outline_border)) = + q_switches.get(ent) + { + let Some(slide_ent) = q_children + .iter_descendants(switch_ent) + .find(|en| q_slide.contains(*en)) + else { + return; + }; + // Safety: since we just checked the query, should always work. + let (ref mut slide_style, slide_color) = q_slide.get_mut(slide_ent).unwrap(); + set_switch_colors( + switch_ent, + slide_ent, + disabled, + checked, + hovered.0, + outline_bg, + outline_border, + slide_style, + slide_color, + &mut commands, + ); + } + }); +} + +fn set_switch_colors( + switch_ent: Entity, + slide_ent: Entity, + disabled: bool, + checked: bool, + hovered: bool, + outline_bg: &ThemeBackgroundColor, + outline_border: &ThemeBorderColor, + slide_style: &mut Mut, + slide_color: &ThemeBackgroundColor, + commands: &mut Commands, +) { + let outline_border_token = match (disabled, hovered) { + (true, _) => tokens::SWITCH_BORDER_DISABLED, + (false, true) => tokens::SWITCH_BORDER_HOVER, + _ => tokens::SWITCH_BORDER, + }; + + let outline_bg_token = match (disabled, checked) { + (true, true) => tokens::SWITCH_BG_CHECKED_DISABLED, + (true, false) => tokens::SWITCH_BG_DISABLED, + (false, true) => tokens::SWITCH_BG_CHECKED, + (false, false) => tokens::SWITCH_BG, + }; + + let slide_token = match disabled { + true => tokens::SWITCH_SLIDE_DISABLED, + false => tokens::SWITCH_SLIDE, + }; + + let slide_pos = match checked { + true => Val::Percent(50.), + false => Val::Percent(0.), + }; + + // Change outline background + if outline_bg.0 != outline_bg_token { + commands + .entity(switch_ent) + .insert(ThemeBackgroundColor(outline_bg_token)); + } + + // Change outline border + if outline_border.0 != outline_border_token { + commands + .entity(switch_ent) + .insert(ThemeBorderColor(outline_border_token)); + } + + // Change slide color + if slide_color.0 != slide_token { + commands + .entity(slide_ent) + .insert(ThemeBackgroundColor(slide_token)); + } + + // Change slide position + if slide_pos != slide_style.left { + slide_style.left = slide_pos; + } +} + +/// Plugin which registers the systems for updating the toggle switch styles. +pub struct ToggleSwitchPlugin; + +impl Plugin for ToggleSwitchPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + (update_switch_styles, update_switch_styles_remove).in_set(PickingSystems::Last), + ); + } +} diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index add354abd7..c3ff4e4204 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -10,6 +10,7 @@ pub fn create_dark_theme() -> ThemeProps { ThemeProps { color: HashMap::from([ (tokens::WINDOW_BG.into(), palette::GRAY_0), + // Button (tokens::BUTTON_BG.into(), palette::GRAY_3), ( tokens::BUTTON_BG_HOVER.into(), @@ -40,6 +41,7 @@ pub fn create_dark_theme() -> ThemeProps { tokens::BUTTON_PRIMARY_TEXT_DISABLED.into(), palette::WHITE.with_alpha(0.5), ), + // Slider (tokens::SLIDER_BG.into(), palette::GRAY_1), (tokens::SLIDER_BAR.into(), palette::ACCENT), (tokens::SLIDER_BAR_DISABLED.into(), palette::GRAY_2), @@ -48,6 +50,7 @@ pub fn create_dark_theme() -> ThemeProps { tokens::SLIDER_TEXT_DISABLED.into(), palette::WHITE.with_alpha(0.5), ), + // Checkbox (tokens::CHECKBOX_BG.into(), palette::GRAY_3), (tokens::CHECKBOX_BG_CHECKED.into(), palette::ACCENT), ( @@ -74,6 +77,7 @@ pub fn create_dark_theme() -> ThemeProps { tokens::CHECKBOX_TEXT_DISABLED.into(), palette::LIGHT_GRAY_1.with_alpha(0.5), ), + // Radio (tokens::RADIO_BORDER.into(), palette::GRAY_3), ( tokens::RADIO_BORDER_HOVER.into(), @@ -93,6 +97,31 @@ pub fn create_dark_theme() -> ThemeProps { tokens::RADIO_TEXT_DISABLED.into(), palette::LIGHT_GRAY_1.with_alpha(0.5), ), + // Toggle Switch + (tokens::SWITCH_BG.into(), palette::GRAY_3), + (tokens::SWITCH_BG_CHECKED.into(), palette::ACCENT), + ( + tokens::SWITCH_BG_DISABLED.into(), + palette::GRAY_1.with_alpha(0.5), + ), + ( + tokens::SWITCH_BG_CHECKED_DISABLED.into(), + palette::GRAY_3.with_alpha(0.5), + ), + (tokens::SWITCH_BORDER.into(), palette::GRAY_3), + ( + tokens::SWITCH_BORDER_HOVER.into(), + palette::GRAY_3.lighter(0.1), + ), + ( + tokens::SWITCH_BORDER_DISABLED.into(), + palette::GRAY_3.with_alpha(0.5), + ), + (tokens::SWITCH_SLIDE.into(), palette::LIGHT_GRAY_2), + ( + tokens::SWITCH_SLIDE_DISABLED.into(), + palette::LIGHT_GRAY_2.with_alpha(0.3), + ), ]), } } diff --git a/crates/bevy_feathers/src/tokens.rs b/crates/bevy_feathers/src/tokens.rs index d85cf02996..453dc94c5e 100644 --- a/crates/bevy_feathers/src/tokens.rs +++ b/crates/bevy_feathers/src/tokens.rs @@ -99,3 +99,24 @@ pub const RADIO_MARK_DISABLED: &str = "feathers.radio.mark.disabled"; pub const RADIO_TEXT: &str = "feathers.radio.text"; /// Radio label text (disabled) pub const RADIO_TEXT_DISABLED: &str = "feathers.radio.text.disabled"; + +// Toggle Switch + +/// Switch background around the checkmark +pub const SWITCH_BG: &str = "feathers.switch.bg"; +/// Switch border around the checkmark (disabled) +pub const SWITCH_BG_DISABLED: &str = "feathers.switch.bg.disabled"; +/// Switch background around the checkmark +pub const SWITCH_BG_CHECKED: &str = "feathers.switch.bg.checked"; +/// Switch border around the checkmark (disabled) +pub const SWITCH_BG_CHECKED_DISABLED: &str = "feathers.switch.bg.checked.disabled"; +/// Switch border around the checkmark +pub const SWITCH_BORDER: &str = "feathers.switch.border"; +/// Switch border around the checkmark (hovered) +pub const SWITCH_BORDER_HOVER: &str = "feathers.switch.border.hover"; +/// Switch border around the checkmark (disabled) +pub const SWITCH_BORDER_DISABLED: &str = "feathers.switch.border.disabled"; +/// Switch slide +pub const SWITCH_SLIDE: &str = "feathers.switch.slide"; +/// Switch slide (disabled) +pub const SWITCH_SLIDE_DISABLED: &str = "feathers.switch.slide.disabled"; diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index 45f959e1c4..ae6ec31f4c 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -4,7 +4,8 @@ use bevy::{ core_widgets::{Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugin, SliderStep}, feathers::{ controls::{ - button, checkbox, radio, slider, ButtonProps, ButtonVariant, CheckboxProps, SliderProps, + button, checkbox, radio, slider, toggle_switch, ButtonProps, ButtonVariant, + CheckboxProps, SliderProps, ToggleSwitchProps, }, dark_theme::create_dark_theme, rounded_corners::RoundedCorners, @@ -222,6 +223,36 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { ), ] ), + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + justify_content: JustifyContent::Start, + column_gap: Val::Px(8.0), + ..default() + }, + children![ + toggle_switch( + ToggleSwitchProps { + on_change: Callback::Ignore, + }, + (), + ), + toggle_switch( + ToggleSwitchProps { + on_change: Callback::Ignore, + }, + InteractionDisabled, + ), + toggle_switch( + ToggleSwitchProps { + on_change: Callback::Ignore, + }, + (InteractionDisabled, Checked), + ), + ] + ), slider( SliderProps { max: 100.0, diff --git a/release-content/release-notes/feathers.md b/release-content/release-notes/feathers.md index 754c57ebc2..16d0cd5b70 100644 --- a/release-content/release-notes/feathers.md +++ b/release-content/release-notes/feathers.md @@ -1,7 +1,7 @@ --- title: Bevy Feathers authors: ["@viridia", "@Atlas16A"] -pull_requests: [19730, 19900] +pull_requests: [19730, 19900, 19928] --- To make it easier for Bevy engine developers and third-party tool creators to make comfortable, visually cohesive tooling,