From 886013278b92c7f6e130e5f895c81a7023737706 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 8 Jul 2025 16:21:49 -0700 Subject: [PATCH 1/2] Focus rings for feathers widgets and core widget examples. --- crates/bevy_feathers/src/controls/button.rs | 41 +++++++++++++--- crates/bevy_feathers/src/controls/checkbox.rs | 47 +++++++++++++++++-- crates/bevy_feathers/src/controls/radio.rs | 40 +++++++++++++--- crates/bevy_feathers/src/controls/slider.rs | 31 ++++++++++-- .../src/controls/toggle_switch.rs | 39 +++++++++++++-- crates/bevy_feathers/src/dark_theme.rs | 1 + examples/ui/core_widgets.rs | 25 +++++++++- examples/ui/core_widgets_observers.rs | 29 ++++++++++-- examples/ui/feathers.rs | 3 +- 9 files changed, 223 insertions(+), 33 deletions(-) diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index 5b6ad7117b..d4102a0e25 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -2,18 +2,21 @@ use bevy_app::{Plugin, PreUpdate}; use bevy_core_widgets::{Callback, CoreButton}; use bevy_ecs::{ bundle::Bundle, + change_detection::DetectChanges, component::Component, entity::Entity, hierarchy::{ChildOf, Children}, lifecycle::RemovedComponents, - query::{Added, Changed, Has, Or}, + query::{Added, Changed, Has, Or, With}, schedule::IntoScheduleConfigs, spawn::{SpawnRelated, SpawnableList}, - system::{Commands, Query}, + system::{Commands, Query, Res}, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::{tab_navigation::TabIndex, InputFocus}; use bevy_picking::{hover::Hovered, PickingSystems}; -use bevy_ui::{AlignItems, InteractionDisabled, JustifyContent, Node, Pressed, UiRect, Val}; +use bevy_ui::{ + AlignItems, InteractionDisabled, JustifyContent, Node, Outline, Pressed, UiRect, Val, +}; use bevy_winit::cursor::CursorIcon; use crate::{ @@ -21,7 +24,7 @@ use crate::{ font_styles::InheritableFont, handle_or_path::HandleOrPath, rounded_corners::RoundedCorners, - theme::{ThemeBackgroundColor, ThemeFontColor}, + theme::{ThemeBackgroundColor, ThemeFontColor, UiTheme}, tokens, }; @@ -195,6 +198,27 @@ fn set_button_colors( } } +fn update_button_focus( + mut commands: Commands, + focus: Res, + theme: Res, + query: Query>, +) { + if focus.is_changed() { + for button in query.iter() { + if focus.0 == Some(button) { + commands.entity(button).insert(Outline { + color: theme.color(tokens::FOCUS_RING), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(button).remove::(); + } + } + } +} + /// Plugin which registers the systems for updating the button styles. pub struct ButtonPlugin; @@ -202,7 +226,12 @@ impl Plugin for ButtonPlugin { fn build(&self, app: &mut bevy_app::App) { app.add_systems( PreUpdate, - (update_button_styles, update_button_styles_remove).in_set(PickingSystems::Last), + ( + update_button_styles, + update_button_styles_remove, + update_button_focus, + ) + .in_set(PickingSystems::Last), ); } } diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index f81e357c21..360db669d7 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -2,6 +2,7 @@ use bevy_app::{Plugin, PreUpdate}; use bevy_core_widgets::{Callback, CoreCheckbox}; use bevy_ecs::{ bundle::Bundle, + change_detection::DetectChanges, children, component::Component, entity::Entity, @@ -10,15 +11,15 @@ use bevy_ecs::{ query::{Added, Changed, Has, Or, With}, schedule::IntoScheduleConfigs, spawn::{Spawn, SpawnRelated, SpawnableList}, - system::{Commands, In, Query}, + system::{Commands, In, Query, Res}, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::{tab_navigation::TabIndex, InputFocus}; 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, + Node, Outline, PositionType, UiRect, UiTransform, Val, }; use bevy_winit::cursor::CursorIcon; @@ -26,7 +27,7 @@ use crate::{ constants::{fonts, size}, font_styles::InheritableFont, handle_or_path::HandleOrPath, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor, UiTheme}, tokens, }; @@ -296,6 +297,37 @@ fn set_checkbox_colors( } } +fn update_checkbox_focus( + mut commands: Commands, + focus: Res, + theme: Res, + q_checkbox: Query>, + q_children: Query<&Children>, + q_outline: Query<(), With>, +) { + if focus.is_changed() { + for checkbox_ent in q_checkbox.iter() { + // For a checkbox, we want to outline just the box, not the entire wiget with the label. + let Some(outline_ent) = q_children + .iter_descendants(checkbox_ent) + .find(|en| q_outline.contains(*en)) + else { + return; + }; + + if focus.0 == Some(checkbox_ent) { + commands.entity(outline_ent).insert(Outline { + color: theme.color(tokens::FOCUS_RING), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(outline_ent).remove::(); + } + } + } +} + /// Plugin which registers the systems for updating the checkbox styles. pub struct CheckboxPlugin; @@ -303,7 +335,12 @@ 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), + ( + update_checkbox_styles, + update_checkbox_styles_remove, + update_checkbox_focus, + ) + .in_set(PickingSystems::Last), ); } } diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs index a08ffcfa8d..f3b5916d8a 100644 --- a/crates/bevy_feathers/src/controls/radio.rs +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -1,7 +1,8 @@ use bevy_app::{Plugin, PreUpdate}; -use bevy_core_widgets::CoreRadio; +use bevy_core_widgets::{CoreRadio, CoreRadioGroup}; use bevy_ecs::{ bundle::Bundle, + change_detection::DetectChanges, children, component::Component, entity::Entity, @@ -10,14 +11,14 @@ use bevy_ecs::{ query::{Added, Changed, Has, Or, With}, schedule::IntoScheduleConfigs, spawn::{Spawn, SpawnRelated, SpawnableList}, - system::{Commands, Query}, + system::{Commands, Query, Res}, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::InputFocus; use bevy_picking::{hover::Hovered, PickingSystems}; use bevy_render::view::Visibility; use bevy_ui::{ AlignItems, BorderRadius, Checked, Display, FlexDirection, InteractionDisabled, JustifyContent, - Node, UiRect, Val, + Node, Outline, UiRect, Val, }; use bevy_winit::cursor::CursorIcon; @@ -25,7 +26,7 @@ use crate::{ constants::{fonts, size}, font_styles::InheritableFont, handle_or_path::HandleOrPath, - theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor, UiTheme}, tokens, }; @@ -59,7 +60,6 @@ pub fn radio + Send + Sync + 'static, B: Bundle>( CoreRadio, Hovered::default(), CursorIcon::System(bevy_window::SystemCursorIcon::Pointer), - TabIndex(0), ThemeFontColor(tokens::RADIO_TEXT), InheritableFont { font: HandleOrPath::Path(fonts::REGULAR.to_owned()), @@ -255,6 +255,27 @@ fn set_radio_colors( } } +fn update_radio_group_focus( + mut commands: Commands, + focus: Res, + theme: Res, + q_groups: Query>, +) { + if focus.is_changed() { + for group in q_groups.iter() { + if focus.0 == Some(group) { + commands.entity(group).insert(Outline { + color: theme.color(tokens::FOCUS_RING), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(group).remove::(); + } + } + } +} + /// Plugin which registers the systems for updating the radio styles. pub struct RadioPlugin; @@ -262,7 +283,12 @@ 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), + ( + update_radio_styles, + update_radio_styles_remove, + update_radio_group_focus, + ) + .in_set(PickingSystems::Last), ); } } diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index fa1978e06c..ad74ce08b1 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -5,6 +5,7 @@ use bevy_color::Color; use bevy_core_widgets::{Callback, CoreSlider, SliderRange, SliderValue, TrackClick}; use bevy_ecs::{ bundle::Bundle, + change_detection::DetectChanges, children, component::Component, entity::Entity, @@ -13,14 +14,14 @@ use bevy_ecs::{ query::{Added, Changed, Has, Or, Spawned, With}, schedule::IntoScheduleConfigs, spawn::SpawnRelated, - system::{In, Query, Res}, + system::{Commands, In, Query, Res}, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::{tab_navigation::TabIndex, InputFocus}; use bevy_picking::PickingSystems; use bevy_ui::{ widget::Text, AlignItems, BackgroundGradient, ColorStop, Display, FlexDirection, Gradient, - InteractionDisabled, InterpolationColorSpace, JustifyContent, LinearGradient, Node, UiRect, - Val, + InteractionDisabled, InterpolationColorSpace, JustifyContent, LinearGradient, Node, Outline, + UiRect, Val, }; use bevy_winit::cursor::CursorIcon; @@ -190,6 +191,27 @@ fn update_slider_pos( } } +fn update_slider_focus( + mut commands: Commands, + focus: Res, + theme: Res, + q_sliders: Query>, +) { + if focus.is_changed() { + for slider in q_sliders.iter() { + if focus.0 == Some(slider) { + commands.entity(slider).insert(Outline { + color: theme.color(tokens::FOCUS_RING), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(slider).remove::(); + } + } + } +} + /// Plugin which registers the systems for updating the slider styles. pub struct SliderPlugin; @@ -201,6 +223,7 @@ impl Plugin for SliderPlugin { update_slider_colors, update_slider_colors_remove, update_slider_pos, + update_slider_focus, ) .in_set(PickingSystems::Last), ); diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs index bc473d8d81..a43db52a1b 100644 --- a/crates/bevy_feathers/src/controls/toggle_switch.rs +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -4,6 +4,7 @@ use bevy_app::{Plugin, PreUpdate}; use bevy_core_widgets::{Callback, CoreCheckbox}; use bevy_ecs::{ bundle::Bundle, + change_detection::DetectChanges, children, component::Component, entity::Entity, @@ -12,17 +13,19 @@ use bevy_ecs::{ query::{Added, Changed, Has, Or, With}, schedule::IntoScheduleConfigs, spawn::SpawnRelated, - system::{Commands, In, Query}, + system::{Commands, In, Query, Res}, world::Mut, }; -use bevy_input_focus::tab_navigation::TabIndex; +use bevy_input_focus::{tab_navigation::TabIndex, InputFocus}; use bevy_picking::{hover::Hovered, PickingSystems}; -use bevy_ui::{BorderRadius, Checked, InteractionDisabled, Node, PositionType, UiRect, Val}; +use bevy_ui::{ + BorderRadius, Checked, InteractionDisabled, Node, Outline, PositionType, UiRect, Val, +}; use bevy_winit::cursor::CursorIcon; use crate::{ constants::size, - theme::{ThemeBackgroundColor, ThemeBorderColor}, + theme::{ThemeBackgroundColor, ThemeBorderColor, UiTheme}, tokens, }; @@ -236,6 +239,27 @@ fn set_switch_colors( } } +fn update_switch_focus( + mut commands: Commands, + focus: Res, + theme: Res, + q_switches: Query>, +) { + if focus.is_changed() { + for switch in q_switches.iter() { + if focus.0 == Some(switch) { + commands.entity(switch).insert(Outline { + color: theme.color(tokens::FOCUS_RING), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(switch).remove::(); + } + } + } +} + /// Plugin which registers the systems for updating the toggle switch styles. pub struct ToggleSwitchPlugin; @@ -243,7 +267,12 @@ 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), + ( + update_switch_styles, + update_switch_styles_remove, + update_switch_focus, + ) + .in_set(PickingSystems::Last), ); } } diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index c3ff4e4204..9854f127b7 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), + (tokens::FOCUS_RING.into(), palette::ACCENT.with_alpha(0.5)), // Button (tokens::BUTTON_BG.into(), palette::GRAY_3), ( diff --git a/examples/ui/core_widgets.rs b/examples/ui/core_widgets.rs index 86aaa820f8..23075248b8 100644 --- a/examples/ui/core_widgets.rs +++ b/examples/ui/core_widgets.rs @@ -1,7 +1,7 @@ //! This example illustrates how to create widgets using the `bevy_core_widgets` widget set. use bevy::{ - color::palettes::basic::*, + color::palettes::{self, basic::*}, core_widgets::{ Callback, CoreButton, CoreCheckbox, CoreRadio, CoreRadioGroup, CoreSlider, CoreSliderDragState, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue, @@ -9,7 +9,7 @@ use bevy::{ }, input_focus::{ tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, - InputDispatchPlugin, + InputDispatchPlugin, InputFocus, }, picking::hover::Hovered, prelude::*, @@ -43,6 +43,7 @@ fn main() { update_checkbox_or_radio_style.after(update_widget_values), update_checkbox_or_radio_style2.after(update_widget_values), toggle_disabled, + focus_system, ), ) .run(); @@ -739,6 +740,26 @@ fn radio(asset_server: &AssetServer, value: TrackClick, caption: &str) -> impl B ) } +fn focus_system( + mut commands: Commands, + focus: Res, + mut query: Query>, +) { + if focus.is_changed() { + for button in query.iter_mut() { + if focus.0 == Some(button) { + commands.entity(button).insert(Outline { + color: palettes::tailwind::BLUE_700.into(), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(button).remove::(); + } + } + } +} + fn toggle_disabled( input: Res>, mut interaction_query: Query< diff --git a/examples/ui/core_widgets_observers.rs b/examples/ui/core_widgets_observers.rs index 1ab4cda3b0..d352b80343 100644 --- a/examples/ui/core_widgets_observers.rs +++ b/examples/ui/core_widgets_observers.rs @@ -1,7 +1,7 @@ //! This example illustrates how to create widgets using the `bevy_core_widgets` widget set. use bevy::{ - color::palettes::basic::*, + color::palettes::{self, basic::*}, core_widgets::{ Callback, CoreButton, CoreCheckbox, CoreSlider, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue, @@ -9,7 +9,7 @@ use bevy::{ ecs::system::SystemId, input_focus::{ tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, - InputDispatchPlugin, + InputDispatchPlugin, InputFocus, }, picking::hover::Hovered, prelude::*, @@ -44,7 +44,10 @@ fn main() { .add_observer(checkbox_on_change_hover) .add_observer(checkbox_on_add_checked) .add_observer(checkbox_on_remove_checked) - .add_systems(Update, (update_widget_values, toggle_disabled)) + .add_systems( + Update, + (update_widget_values, toggle_disabled, focus_system), + ) .run(); } @@ -730,6 +733,26 @@ fn update_widget_values( } } +fn focus_system( + mut commands: Commands, + focus: Res, + mut query: Query>, +) { + if focus.is_changed() { + for button in query.iter_mut() { + if focus.0 == Some(button) { + commands.entity(button).insert(Outline { + color: palettes::tailwind::BLUE_700.into(), + width: Val::Px(2.0), + offset: Val::Px(2.0), + }); + } else { + commands.entity(button).remove::(); + } + } + } +} + fn toggle_disabled( input: Res>, mut interaction_query: Query< diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index ae6ec31f4c..61eb877bdb 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -13,7 +13,7 @@ use bevy::{ tokens, FeathersPlugin, }, input_focus::{ - tab_navigation::{TabGroup, TabNavigationPlugin}, + tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, InputDispatchPlugin, }, prelude::*, @@ -213,6 +213,7 @@ fn demo_root(commands: &mut Commands) -> impl Bundle { CoreRadioGroup { on_change: Callback::System(radio_exclusion), }, + TabIndex(0), children![ radio(Checked, Spawn((Text::new("One"), ThemedText))), radio((), Spawn((Text::new("Two"), ThemedText))), From 893ad207af4af024d82052a28df9cf7ed0ccaf33 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 8 Jul 2025 16:24:33 -0700 Subject: [PATCH 2/2] Update release note. --- release-content/release-notes/feathers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-content/release-notes/feathers.md b/release-content/release-notes/feathers.md index 16d0cd5b70..bee83e7e4a 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, 19928] +pull_requests: [19730, 19900, 19928, 20047] --- To make it easier for Bevy engine developers and third-party tool creators to make comfortable, visually cohesive tooling,