From b980d4ac22a80ca02ec4b6c7dff796a2f65f9593 Mon Sep 17 00:00:00 2001 From: Talin Date: Mon, 30 Jun 2025 23:59:14 -0700 Subject: [PATCH] Feathers checkbox (#19900) Adds checkbox and radio buttons to feathers. Showcase: feathers-checkbox-radio --- crates/bevy_core_widgets/src/core_radio.rs | 5 + crates/bevy_feathers/Cargo.toml | 2 + crates/bevy_feathers/src/constants.rs | 8 +- crates/bevy_feathers/src/controls/checkbox.rs | 304 ++++++++++++++++++ crates/bevy_feathers/src/controls/mod.rs | 6 +- crates/bevy_feathers/src/controls/radio.rs | 268 +++++++++++++++ crates/bevy_feathers/src/dark_theme.rs | 45 +++ crates/bevy_feathers/src/lib.rs | 1 + crates/bevy_feathers/src/theme.rs | 17 + crates/bevy_feathers/src/tokens.rs | 25 ++ examples/ui/feathers.rs | 62 +++- release-content/release-notes/feathers.md | 2 +- 12 files changed, 739 insertions(+), 6 deletions(-) create mode 100644 crates/bevy_feathers/src/controls/checkbox.rs create mode 100644 crates/bevy_feathers/src/controls/radio.rs diff --git a/crates/bevy_core_widgets/src/core_radio.rs b/crates/bevy_core_widgets/src/core_radio.rs index 6e6fd82d0c..a6c99a0d04 100644 --- a/crates/bevy_core_widgets/src/core_radio.rs +++ b/crates/bevy_core_widgets/src/core_radio.rs @@ -170,6 +170,11 @@ fn radio_group_on_button_click( } }; + // Radio button is disabled. + if q_radio.get(radio_id).unwrap().1 { + return; + } + // Gather all the enabled radio group descendants for exclusion. let radio_buttons = q_children .iter_descendants(ev.target()) diff --git a/crates/bevy_feathers/Cargo.toml b/crates/bevy_feathers/Cargo.toml index 8348906047..3e1aded969 100644 --- a/crates/bevy_feathers/Cargo.toml +++ b/crates/bevy_feathers/Cargo.toml @@ -17,8 +17,10 @@ bevy_core_widgets = { path = "../bevy_core_widgets", version = "0.17.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.17.0-dev" } bevy_input_focus = { path = "../bevy_input_focus", version = "0.17.0-dev" } bevy_log = { path = "../bevy_log", version = "0.17.0-dev" } +bevy_math = { path = "../bevy_math", version = "0.17.0-dev" } bevy_picking = { path = "../bevy_picking", version = "0.17.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.17.0-dev" } +bevy_render = { path = "../bevy_render", version = "0.17.0-dev" } bevy_text = { path = "../bevy_text", version = "0.17.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.17.0-dev", features = [ "bevy_ui_picking_backend", diff --git a/crates/bevy_feathers/src/constants.rs b/crates/bevy_feathers/src/constants.rs index 7ba451b65e..651bb60b29 100644 --- a/crates/bevy_feathers/src/constants.rs +++ b/crates/bevy_feathers/src/constants.rs @@ -19,5 +19,11 @@ pub mod size { use bevy_ui::Val; /// Common row size for buttons, sliders, spinners, etc. - pub const ROW_HEIGHT: Val = Val::Px(22.0); + pub const ROW_HEIGHT: Val = Val::Px(24.0); + + /// Width and height of a checkbox + pub const CHECKBOX_SIZE: Val = Val::Px(18.0); + + /// Width and height of a radio button + pub const RADIO_SIZE: Val = Val::Px(18.0); } diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs new file mode 100644 index 0000000000..6e4235961a --- /dev/null +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -0,0 +1,304 @@ +use bevy_app::{Plugin, PreUpdate}; +use bevy_core_widgets::{Callback, CoreCheckbox}; +use bevy_ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::{ChildOf, Children}, + lifecycle::RemovedComponents, + query::{Added, Changed, Has, Or, With}, + schedule::IntoScheduleConfigs, + spawn::{Spawn, SpawnRelated, SpawnableList}, + system::{Commands, In, Query}, +}; +use bevy_input_focus::tab_navigation::TabIndex; +use bevy_math::Rot2; +use bevy_picking::{hover::Hovered, PickingSystems}; +use bevy_render::view::Visibility; +use bevy_ui::{ + AlignItems, BorderRadius, Checked, Display, FlexDirection, InteractionDisabled, JustifyContent, + Node, PositionType, UiRect, UiTransform, Val, +}; +use bevy_winit::cursor::CursorIcon; + +use crate::{ + constants::{fonts, size}, + font_styles::InheritableFont, + handle_or_path::HandleOrPath, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + tokens, +}; + +/// Parameters for the checkbox template, passed to [`checkbox`] function. +#[derive(Default)] +pub struct CheckboxProps { + /// Change handler + pub on_change: Callback>, +} + +/// Marker for the checkbox outline +#[derive(Component, Default, Clone)] +struct CheckboxOutline; + +/// Marker for the checkbox check mark +#[derive(Component, Default, Clone)] +struct CheckboxMark; + +/// Template function to spawn a checkbox. +/// +/// # Arguments +/// * `props` - construction properties for the checkbox. +/// * `overrides` - a bundle of components that are merged in with the normal checkbox components. +/// * `label` - the label of the checkbox. +pub fn checkbox + Send + Sync + 'static, B: Bundle>( + props: CheckboxProps, + overrides: B, + label: C, +) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::Start, + align_items: AlignItems::Center, + column_gap: Val::Px(4.0), + ..Default::default() + }, + CoreCheckbox { + on_change: props.on_change, + }, + Hovered::default(), + CursorIcon::System(bevy_window::SystemCursorIcon::Pointer), + TabIndex(0), + ThemeFontColor(tokens::CHECKBOX_TEXT), + InheritableFont { + font: HandleOrPath::Path(fonts::REGULAR.to_owned()), + font_size: 14.0, + }, + overrides, + Children::spawn(( + Spawn(( + Node { + width: size::CHECKBOX_SIZE, + height: size::CHECKBOX_SIZE, + border: UiRect::all(Val::Px(2.0)), + ..Default::default() + }, + CheckboxOutline, + BorderRadius::all(Val::Px(4.0)), + ThemeBackgroundColor(tokens::CHECKBOX_BG), + ThemeBorderColor(tokens::CHECKBOX_BORDER), + children![( + // Cheesy checkmark: rotated node with L-shaped border. + Node { + position_type: PositionType::Absolute, + left: Val::Px(4.0), + top: Val::Px(0.0), + width: Val::Px(6.), + height: Val::Px(11.), + border: UiRect { + bottom: Val::Px(2.0), + right: Val::Px(2.0), + ..Default::default() + }, + ..Default::default() + }, + UiTransform::from_rotation(Rot2::FRAC_PI_4), + CheckboxMark, + ThemeBorderColor(tokens::CHECKBOX_MARK), + )], + )), + label, + )), + ) +} + +fn update_checkbox_styles( + q_checkboxes: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeFontColor, + ), + ( + With, + Or<(Changed, Added, Added)>, + ), + >, + q_children: Query<&Children>, + mut q_outline: Query<(&ThemeBackgroundColor, &ThemeBorderColor), With>, + mut q_mark: Query<&ThemeBorderColor, With>, + mut commands: Commands, +) { + for (checkbox_ent, disabled, checked, hovered, font_color) in q_checkboxes.iter() { + let Some(outline_ent) = q_children + .iter_descendants(checkbox_ent) + .find(|en| q_outline.contains(*en)) + else { + continue; + }; + let Some(mark_ent) = q_children + .iter_descendants(checkbox_ent) + .find(|en| q_mark.contains(*en)) + else { + continue; + }; + let (outline_bg, outline_border) = q_outline.get_mut(outline_ent).unwrap(); + let mark_color = q_mark.get_mut(mark_ent).unwrap(); + set_checkbox_colors( + checkbox_ent, + outline_ent, + mark_ent, + disabled, + checked, + hovered.0, + outline_bg, + outline_border, + mark_color, + font_color, + &mut commands, + ); + } +} + +fn update_checkbox_styles_remove( + q_checkboxes: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeFontColor, + ), + With, + >, + q_children: Query<&Children>, + mut q_outline: Query<(&ThemeBackgroundColor, &ThemeBorderColor), With>, + mut q_mark: Query<&ThemeBorderColor, 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((checkbox_ent, disabled, checked, hovered, font_color)) = + q_checkboxes.get(ent) + { + let Some(outline_ent) = q_children + .iter_descendants(checkbox_ent) + .find(|en| q_outline.contains(*en)) + else { + return; + }; + let Some(mark_ent) = q_children + .iter_descendants(checkbox_ent) + .find(|en| q_mark.contains(*en)) + else { + return; + }; + let (outline_bg, outline_border) = q_outline.get_mut(outline_ent).unwrap(); + let mark_color = q_mark.get_mut(mark_ent).unwrap(); + set_checkbox_colors( + checkbox_ent, + outline_ent, + mark_ent, + disabled, + checked, + hovered.0, + outline_bg, + outline_border, + mark_color, + font_color, + &mut commands, + ); + } + }); +} + +fn set_checkbox_colors( + checkbox_ent: Entity, + outline_ent: Entity, + mark_ent: Entity, + disabled: bool, + checked: bool, + hovered: bool, + outline_bg: &ThemeBackgroundColor, + outline_border: &ThemeBorderColor, + mark_color: &ThemeBorderColor, + font_color: &ThemeFontColor, + commands: &mut Commands, +) { + let outline_border_token = match (disabled, hovered) { + (true, _) => tokens::CHECKBOX_BORDER_DISABLED, + (false, true) => tokens::CHECKBOX_BORDER_HOVER, + _ => tokens::CHECKBOX_BORDER, + }; + + let outline_bg_token = match (disabled, checked) { + (true, true) => tokens::CHECKBOX_BG_CHECKED_DISABLED, + (true, false) => tokens::CHECKBOX_BG_DISABLED, + (false, true) => tokens::CHECKBOX_BG_CHECKED, + (false, false) => tokens::CHECKBOX_BG, + }; + + let mark_token = match disabled { + true => tokens::CHECKBOX_MARK_DISABLED, + false => tokens::CHECKBOX_MARK, + }; + + let font_color_token = match disabled { + true => tokens::CHECKBOX_TEXT_DISABLED, + false => tokens::CHECKBOX_TEXT, + }; + + // Change outline background + if outline_bg.0 != outline_bg_token { + commands + .entity(outline_ent) + .insert(ThemeBackgroundColor(outline_bg_token)); + } + + // Change outline border + if outline_border.0 != outline_border_token { + commands + .entity(outline_ent) + .insert(ThemeBorderColor(outline_border_token)); + } + + // Change mark color + if mark_color.0 != mark_token { + commands + .entity(mark_ent) + .insert(ThemeBorderColor(mark_token)); + } + + // Change mark visibility + commands.entity(mark_ent).insert(match checked { + true => Visibility::Visible, + false => Visibility::Hidden, + }); + + // Change font color + if font_color.0 != font_color_token { + commands + .entity(checkbox_ent) + .insert(ThemeFontColor(font_color_token)); + } +} + +/// Plugin which registers the systems for updating the checkbox styles. +pub struct CheckboxPlugin; + +impl Plugin for CheckboxPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + (update_checkbox_styles, update_checkbox_styles_remove).in_set(PickingSystems::Last), + ); + } +} diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 293dcdf93d..92c5a76907 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -2,9 +2,13 @@ use bevy_app::Plugin; mod button; +mod checkbox; +mod radio; mod slider; pub use button::{button, ButtonPlugin, ButtonProps, ButtonVariant}; +pub use checkbox::{checkbox, CheckboxPlugin, CheckboxProps}; +pub use radio::{radio, RadioPlugin}; pub use slider::{slider, SliderPlugin, SliderProps}; /// Plugin which registers all `bevy_feathers` controls. @@ -12,6 +16,6 @@ pub struct ControlsPlugin; impl Plugin for ControlsPlugin { fn build(&self, app: &mut bevy_app::App) { - app.add_plugins((ButtonPlugin, SliderPlugin)); + app.add_plugins((ButtonPlugin, CheckboxPlugin, RadioPlugin, SliderPlugin)); } } diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs new file mode 100644 index 0000000000..a08ffcfa8d --- /dev/null +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -0,0 +1,268 @@ +use bevy_app::{Plugin, PreUpdate}; +use bevy_core_widgets::CoreRadio; +use bevy_ecs::{ + bundle::Bundle, + children, + component::Component, + entity::Entity, + hierarchy::{ChildOf, Children}, + lifecycle::RemovedComponents, + query::{Added, Changed, Has, Or, With}, + schedule::IntoScheduleConfigs, + spawn::{Spawn, SpawnRelated, SpawnableList}, + system::{Commands, Query}, +}; +use bevy_input_focus::tab_navigation::TabIndex; +use bevy_picking::{hover::Hovered, PickingSystems}; +use bevy_render::view::Visibility; +use bevy_ui::{ + AlignItems, BorderRadius, Checked, Display, FlexDirection, InteractionDisabled, JustifyContent, + Node, UiRect, Val, +}; +use bevy_winit::cursor::CursorIcon; + +use crate::{ + constants::{fonts, size}, + font_styles::InheritableFont, + handle_or_path::HandleOrPath, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + tokens, +}; + +/// Marker for the radio outline +#[derive(Component, Default, Clone)] +struct RadioOutline; + +/// Marker for the radio check mark +#[derive(Component, Default, Clone)] +struct RadioMark; + +/// Template function to spawn a radio. +/// +/// # Arguments +/// * `props` - construction properties for the radio. +/// * `overrides` - a bundle of components that are merged in with the normal radio components. +/// * `label` - the label of the radio. +pub fn radio + Send + Sync + 'static, B: Bundle>( + overrides: B, + label: C, +) -> impl Bundle { + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::Start, + align_items: AlignItems::Center, + column_gap: Val::Px(4.0), + ..Default::default() + }, + CoreRadio, + Hovered::default(), + CursorIcon::System(bevy_window::SystemCursorIcon::Pointer), + TabIndex(0), + ThemeFontColor(tokens::RADIO_TEXT), + InheritableFont { + font: HandleOrPath::Path(fonts::REGULAR.to_owned()), + font_size: 14.0, + }, + overrides, + Children::spawn(( + Spawn(( + Node { + display: Display::Flex, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + width: size::RADIO_SIZE, + height: size::RADIO_SIZE, + border: UiRect::all(Val::Px(2.0)), + ..Default::default() + }, + RadioOutline, + BorderRadius::MAX, + ThemeBorderColor(tokens::RADIO_BORDER), + children![( + // Cheesy checkmark: rotated node with L-shaped border. + Node { + width: Val::Px(8.), + height: Val::Px(8.), + ..Default::default() + }, + BorderRadius::MAX, + RadioMark, + ThemeBackgroundColor(tokens::RADIO_MARK), + )], + )), + label, + )), + ) +} + +fn update_radio_styles( + q_radioes: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeFontColor, + ), + ( + With, + Or<(Changed, Added, Added)>, + ), + >, + q_children: Query<&Children>, + mut q_outline: Query<&ThemeBorderColor, With>, + mut q_mark: Query<&ThemeBackgroundColor, With>, + mut commands: Commands, +) { + for (radio_ent, disabled, checked, hovered, font_color) in q_radioes.iter() { + let Some(outline_ent) = q_children + .iter_descendants(radio_ent) + .find(|en| q_outline.contains(*en)) + else { + continue; + }; + let Some(mark_ent) = q_children + .iter_descendants(radio_ent) + .find(|en| q_mark.contains(*en)) + else { + continue; + }; + let outline_border = q_outline.get_mut(outline_ent).unwrap(); + let mark_color = q_mark.get_mut(mark_ent).unwrap(); + set_radio_colors( + radio_ent, + outline_ent, + mark_ent, + disabled, + checked, + hovered.0, + outline_border, + mark_color, + font_color, + &mut commands, + ); + } +} + +fn update_radio_styles_remove( + q_radioes: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeFontColor, + ), + With, + >, + q_children: Query<&Children>, + mut q_outline: Query<&ThemeBorderColor, With>, + mut q_mark: Query<&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((radio_ent, disabled, checked, hovered, font_color)) = q_radioes.get(ent) { + let Some(outline_ent) = q_children + .iter_descendants(radio_ent) + .find(|en| q_outline.contains(*en)) + else { + return; + }; + let Some(mark_ent) = q_children + .iter_descendants(radio_ent) + .find(|en| q_mark.contains(*en)) + else { + return; + }; + let outline_border = q_outline.get_mut(outline_ent).unwrap(); + let mark_color = q_mark.get_mut(mark_ent).unwrap(); + set_radio_colors( + radio_ent, + outline_ent, + mark_ent, + disabled, + checked, + hovered.0, + outline_border, + mark_color, + font_color, + &mut commands, + ); + } + }); +} + +fn set_radio_colors( + radio_ent: Entity, + outline_ent: Entity, + mark_ent: Entity, + disabled: bool, + checked: bool, + hovered: bool, + outline_border: &ThemeBorderColor, + mark_color: &ThemeBackgroundColor, + font_color: &ThemeFontColor, + commands: &mut Commands, +) { + let outline_border_token = match (disabled, hovered) { + (true, _) => tokens::RADIO_BORDER_DISABLED, + (false, true) => tokens::RADIO_BORDER_HOVER, + _ => tokens::RADIO_BORDER, + }; + + let mark_token = match disabled { + true => tokens::RADIO_MARK_DISABLED, + false => tokens::RADIO_MARK, + }; + + let font_color_token = match disabled { + true => tokens::RADIO_TEXT_DISABLED, + false => tokens::RADIO_TEXT, + }; + + // Change outline border + if outline_border.0 != outline_border_token { + commands + .entity(outline_ent) + .insert(ThemeBorderColor(outline_border_token)); + } + + // Change mark color + if mark_color.0 != mark_token { + commands + .entity(mark_ent) + .insert(ThemeBorderColor(mark_token)); + } + + // Change mark visibility + commands.entity(mark_ent).insert(match checked { + true => Visibility::Visible, + false => Visibility::Hidden, + }); + + // Change font color + if font_color.0 != font_color_token { + commands + .entity(radio_ent) + .insert(ThemeFontColor(font_color_token)); + } +} + +/// Plugin which registers the systems for updating the radio styles. +pub struct RadioPlugin; + +impl Plugin for RadioPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + (update_radio_styles, update_radio_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 6ac80e3e47..add354abd7 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -48,6 +48,51 @@ pub fn create_dark_theme() -> ThemeProps { tokens::SLIDER_TEXT_DISABLED.into(), palette::WHITE.with_alpha(0.5), ), + (tokens::CHECKBOX_BG.into(), palette::GRAY_3), + (tokens::CHECKBOX_BG_CHECKED.into(), palette::ACCENT), + ( + tokens::CHECKBOX_BG_DISABLED.into(), + palette::GRAY_1.with_alpha(0.5), + ), + ( + tokens::CHECKBOX_BG_CHECKED_DISABLED.into(), + palette::GRAY_3.with_alpha(0.5), + ), + (tokens::CHECKBOX_BORDER.into(), palette::GRAY_3), + ( + tokens::CHECKBOX_BORDER_HOVER.into(), + palette::GRAY_3.lighter(0.1), + ), + ( + tokens::CHECKBOX_BORDER_DISABLED.into(), + palette::GRAY_3.with_alpha(0.5), + ), + (tokens::CHECKBOX_MARK.into(), palette::WHITE), + (tokens::CHECKBOX_MARK_DISABLED.into(), palette::LIGHT_GRAY_2), + (tokens::CHECKBOX_TEXT.into(), palette::LIGHT_GRAY_1), + ( + tokens::CHECKBOX_TEXT_DISABLED.into(), + palette::LIGHT_GRAY_1.with_alpha(0.5), + ), + (tokens::RADIO_BORDER.into(), palette::GRAY_3), + ( + tokens::RADIO_BORDER_HOVER.into(), + palette::GRAY_3.lighter(0.1), + ), + ( + tokens::RADIO_BORDER_DISABLED.into(), + palette::GRAY_3.with_alpha(0.5), + ), + (tokens::RADIO_MARK.into(), palette::ACCENT), + ( + tokens::RADIO_MARK_DISABLED.into(), + palette::ACCENT.with_alpha(0.5), + ), + (tokens::RADIO_TEXT.into(), palette::LIGHT_GRAY_1), + ( + tokens::RADIO_TEXT_DISABLED.into(), + palette::LIGHT_GRAY_1.with_alpha(0.5), + ), ]), } } diff --git a/crates/bevy_feathers/src/lib.rs b/crates/bevy_feathers/src/lib.rs index de396462e3..ab02304a85 100644 --- a/crates/bevy_feathers/src/lib.rs +++ b/crates/bevy_feathers/src/lib.rs @@ -67,6 +67,7 @@ impl Plugin for FeathersPlugin { app.add_systems(PostUpdate, theme::update_theme) .add_observer(theme::on_changed_background) + .add_observer(theme::on_changed_border) .add_observer(theme::on_changed_font_color) .add_observer(font_styles::on_changed_font); } diff --git a/crates/bevy_feathers/src/theme.rs b/crates/bevy_feathers/src/theme.rs index fdca99fcbe..9969b54846 100644 --- a/crates/bevy_feathers/src/theme.rs +++ b/crates/bevy_feathers/src/theme.rs @@ -73,6 +73,7 @@ pub struct ThemedText; pub(crate) fn update_theme( mut q_background: Query<(&mut BackgroundColor, &ThemeBackgroundColor)>, + mut q_border: Query<(&mut BorderColor, &ThemeBorderColor)>, theme: Res, ) { if theme.is_changed() { @@ -80,6 +81,11 @@ pub(crate) fn update_theme( for (mut bg, theme_bg) in q_background.iter_mut() { bg.0 = theme.color(theme_bg.0); } + + // Update all border colors + for (mut border, theme_border) in q_border.iter_mut() { + border.set_all(theme.color(theme_border.0)); + } } } @@ -97,6 +103,17 @@ pub(crate) fn on_changed_background( } } +pub(crate) fn on_changed_border( + ev: On, + mut q_border: Query<(&mut BorderColor, &ThemeBorderColor), Changed>, + theme: Res, +) { + // Update background colors where the design token has changed. + if let Ok((mut border, theme_border)) = q_border.get_mut(ev.target()) { + border.set_all(theme.color(theme_border.0)); + } +} + /// An observer which looks for changes to the [`ThemeFontColor`] component on an entity, and /// propagates downward the text color to all participating text entities. pub(crate) fn on_changed_font_color( diff --git a/crates/bevy_feathers/src/tokens.rs b/crates/bevy_feathers/src/tokens.rs index d254d1a09b..d85cf02996 100644 --- a/crates/bevy_feathers/src/tokens.rs +++ b/crates/bevy_feathers/src/tokens.rs @@ -60,6 +60,14 @@ pub const SLIDER_TEXT_DISABLED: &str = "feathers.slider.text.disabled"; // Checkbox +/// Checkbox background around the checkmark +pub const CHECKBOX_BG: &str = "feathers.checkbox.bg"; +/// Checkbox border around the checkmark (disabled) +pub const CHECKBOX_BG_DISABLED: &str = "feathers.checkbox.bg.disabled"; +/// Checkbox background around the checkmark +pub const CHECKBOX_BG_CHECKED: &str = "feathers.checkbox.bg.checked"; +/// Checkbox border around the checkmark (disabled) +pub const CHECKBOX_BG_CHECKED_DISABLED: &str = "feathers.checkbox.bg.checked.disabled"; /// Checkbox border around the checkmark pub const CHECKBOX_BORDER: &str = "feathers.checkbox.border"; /// Checkbox border around the checkmark (hovered) @@ -74,3 +82,20 @@ pub const CHECKBOX_MARK_DISABLED: &str = "feathers.checkbox.mark.disabled"; pub const CHECKBOX_TEXT: &str = "feathers.checkbox.text"; /// Checkbox label text (disabled) pub const CHECKBOX_TEXT_DISABLED: &str = "feathers.checkbox.text.disabled"; + +// Radio button + +/// Radio border around the checkmark +pub const RADIO_BORDER: &str = "feathers.radio.border"; +/// Radio border around the checkmark (hovered) +pub const RADIO_BORDER_HOVER: &str = "feathers.radio.border.hover"; +/// Radio border around the checkmark (disabled) +pub const RADIO_BORDER_DISABLED: &str = "feathers.radio.border.disabled"; +/// Radio check mark +pub const RADIO_MARK: &str = "feathers.radio.mark"; +/// Radio check mark (disabled) +pub const RADIO_MARK_DISABLED: &str = "feathers.radio.mark.disabled"; +/// Radio label text +pub const RADIO_TEXT: &str = "feathers.radio.text"; +/// Radio label text (disabled) +pub const RADIO_TEXT_DISABLED: &str = "feathers.radio.text.disabled"; diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index c5954f45c5..45f959e1c4 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -1,9 +1,11 @@ //! This example shows off the various Bevy Feathers widgets. use bevy::{ - core_widgets::{Callback, CoreWidgetsPlugin, SliderStep}, + core_widgets::{Callback, CoreRadio, CoreRadioGroup, CoreWidgetsPlugin, SliderStep}, feathers::{ - controls::{button, slider, ButtonProps, ButtonVariant, SliderProps}, + controls::{ + button, checkbox, radio, slider, ButtonProps, ButtonVariant, CheckboxProps, SliderProps, + }, dark_theme::create_dark_theme, rounded_corners::RoundedCorners, theme::{ThemeBackgroundColor, ThemedText, UiTheme}, @@ -14,7 +16,7 @@ use bevy::{ InputDispatchPlugin, }, prelude::*, - ui::InteractionDisabled, + ui::{Checked, InteractionDisabled}, winit::WinitSettings, }; @@ -42,6 +44,19 @@ fn setup(mut commands: Commands) { } fn demo_root(commands: &mut Commands) -> impl Bundle { + // Update radio button states based on notification from radio group. + let radio_exclusion = commands.register_system( + |ent: In, q_radio: Query>, mut commands: Commands| { + for radio in q_radio.iter() { + if radio == *ent { + commands.entity(radio).insert(Checked); + } else { + commands.entity(radio).remove::(); + } + } + }, + ); + ( Node { width: Val::Percent(100.0), @@ -166,6 +181,47 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { (), Spawn((Text::new("Button"), ThemedText)) ), + checkbox( + CheckboxProps { + on_change: Callback::Ignore, + }, + Checked, + Spawn((Text::new("Checkbox"), ThemedText)) + ), + checkbox( + CheckboxProps { + on_change: Callback::Ignore, + }, + InteractionDisabled, + Spawn((Text::new("Disabled"), ThemedText)) + ), + checkbox( + CheckboxProps { + on_change: Callback::Ignore, + }, + (InteractionDisabled, Checked), + Spawn((Text::new("Disabled+Checked"), ThemedText)) + ), + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.0), + ..default() + }, + CoreRadioGroup { + on_change: Callback::System(radio_exclusion), + }, + children![ + radio(Checked, Spawn((Text::new("One"), ThemedText))), + radio((), Spawn((Text::new("Two"), ThemedText))), + radio((), Spawn((Text::new("Three"), ThemedText))), + radio( + InteractionDisabled, + Spawn((Text::new("Disabled"), ThemedText)) + ), + ] + ), slider( SliderProps { max: 100.0, diff --git a/release-content/release-notes/feathers.md b/release-content/release-notes/feathers.md index 199a406db6..754c57ebc2 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] +pull_requests: [19730, 19900] --- To make it easier for Bevy engine developers and third-party tool creators to make comfortable, visually cohesive tooling,