//! This experimental example illustrates how to create widgets using the `bevy_ui_widgets` widget set. //! //! These widgets have no inherent styling, so this example also shows how to implement custom styles. //! //! The patterns shown here are likely to change substantially as the `bevy_ui_widgets` crate //! matures, so please exercise caution if you are using this as a reference for your own code, //! and note that there are still "user experience" issues with this API. use bevy::{ color::palettes::basic::*, input_focus::{ tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, FocusCause, InputFocus, }, picking::hover::Hovered, prelude::*, ui::{Checked, InteractionDisabled, Pressed}, ui_widgets::{ checkbox_self_update, observe, popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, Activate, Button, Checkbox, MenuAction, MenuButton, MenuEvent, MenuItem, MenuPopup, RadioButton, RadioGroup, Slider, SliderDragState, SliderRange, SliderThumb, SliderValue, TrackClick, ValueChange, }, }; fn main() { App::new() .add_plugins((DefaultPlugins, TabNavigationPlugin)) .insert_resource(DemoWidgetStates { slider_value: 50.0, slider_click: TrackClick::Snap, }) .add_systems(Startup, setup) .add_systems( Update, ( update_widget_values, update_button_style, update_button_style2, update_slider_style.after(update_widget_values), update_slider_style2.after(update_widget_values), update_checkbox_or_radio_style.after(update_widget_values), update_checkbox_or_radio_style2.after(update_widget_values), update_menu_item_style, update_menu_item_style2, toggle_disabled, ), ) .run(); } const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15); const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25); const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35); const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05); const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35); const ELEMENT_OUTLINE: Color = Color::srgb(0.45, 0.45, 0.45); const ELEMENT_FILL: Color = Color::srgb(0.35, 0.75, 0.35); const ELEMENT_FILL_DISABLED: Color = Color::srgb(0.5019608, 0.5019608, 0.5019608); /// Marker which identifies buttons with a particular style, in this case the "Demo style". #[derive(Component)] struct DemoButton; /// Marker which identifies sliders with a particular style. #[derive(Component, Default)] struct DemoSlider; /// Marker which identifies the slider's thumb element. #[derive(Component, Default)] struct DemoSliderThumb; /// Marker which identifies checkboxes with a particular style. #[derive(Component, Default)] struct DemoCheckbox; /// Marker which identifies a styled radio button. We'll use this to change the track click /// behavior. #[derive(Component, Default)] struct DemoRadio(TrackClick); /// Menu anchor marker #[derive(Component)] struct DemoMenuAnchor; /// Menu button styling marker #[derive(Component)] struct DemoMenuButton; /// Menu item styling marker #[derive(Component)] struct DemoMenuItem; /// A struct to hold the state of various widgets shown in the demo. /// /// While it is possible to use the widget's own state components as the source of truth, /// in many cases widgets will be used to display dynamic data coming from deeper within the app, /// using some kind of data-binding. This example shows how to maintain an external source of /// truth for widget states. #[derive(Resource)] struct DemoWidgetStates { slider_value: f32, slider_click: TrackClick, } /// Update the widget states based on the changing resource. fn update_widget_values( res: Res, mut sliders: Query<(Entity, &mut Slider), With>, radios: Query<(Entity, &DemoRadio, Has)>, mut commands: Commands, ) { if res.is_changed() { for (slider_ent, mut slider) in sliders.iter_mut() { commands .entity(slider_ent) .insert(SliderValue(res.slider_value)); slider.track_click = res.slider_click; } for (radio_id, radio_value, checked) in radios.iter() { let will_be_checked = radio_value.0 == res.slider_click; if will_be_checked != checked { if will_be_checked { commands.entity(radio_id).insert(Checked); } else { commands.entity(radio_id).remove::(); } } } } } fn setup(mut commands: Commands, assets: Res) { // ui camera commands.spawn(Camera2d); commands.spawn(demo_root(&assets)); } fn demo_root(asset_server: &AssetServer) -> impl Bundle { ( Node { width: percent(100), height: percent(100), align_items: AlignItems::Center, justify_content: JustifyContent::Center, display: Display::Flex, flex_direction: FlexDirection::Column, row_gap: px(10), ..default() }, TabGroup::default(), children![ ( button(asset_server), observe(|_activate: On| { info!("Button clicked!"); }), ), ( slider(0.0, 100.0, 50.0), observe( |value_change: On>, mut widget_states: ResMut| { widget_states.slider_value = value_change.value; }, ) ), ( checkbox(asset_server, "Checkbox"), observe(checkbox_self_update) ), ( radio_group(asset_server), observe( |value_change: On>, mut widget_states: ResMut, q_radios: Query<&DemoRadio>| { if let Ok(radio) = q_radios.get(value_change.value) { widget_states.slider_click = radio.0; } }, ) ), menu_button(asset_server), Text::new("Press 'D' to toggle widget disabled states"), ], ) } fn button(asset_server: &AssetServer) -> impl Bundle { ( Node { width: px(150), height: px(65), border: UiRect::all(px(5)), border_radius: BorderRadius::MAX, justify_content: JustifyContent::Center, align_items: AlignItems::Center, ..default() }, DemoButton, Button, Hovered::default(), TabIndex(0), BorderColor::all(Color::BLACK), BackgroundColor(NORMAL_BUTTON), children![( Text::new("Button"), TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), font_size: FontSize::Px(33.0), ..default() }, TextColor(Color::srgb(0.9, 0.9, 0.9)), TextShadow::default(), )], ) } fn menu_button(asset_server: &AssetServer) -> impl Bundle { ( Node { ..default() }, DemoMenuAnchor, observe(on_menu_event), children![( Node { width: px(200), height: px(65), border: UiRect::all(px(5)), box_sizing: BoxSizing::BorderBox, justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, padding: UiRect::axes(px(16), px(0)), border_radius: BorderRadius::all(px(5)), ..default() }, DemoMenuButton, Button, MenuButton, Hovered::default(), TabIndex(0), BorderColor::all(Color::BLACK), BackgroundColor(NORMAL_BUTTON), children![ ( Text::new("Menu"), TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), font_size: FontSize::Px(33.0), ..default() }, TextColor(Color::srgb(0.9, 0.9, 0.9)), TextShadow::default(), ), ( Node { width: px(12), height: px(12), ..default() }, BackgroundColor(GRAY.into()), ) ], )], ) } fn update_button_style( mut buttons: Query< ( Has, &Hovered, Has, &mut BackgroundColor, &mut BorderColor, &Children, ), ( Or<( Changed, Changed, Added, )>, With, ), >, mut text_query: Query<&mut Text>, ) { for (pressed, hovered, disabled, mut color, mut border_color, children) in &mut buttons { let mut text = text_query.get_mut(children[0]).unwrap(); set_button_style( disabled, hovered.get(), pressed, &mut color, &mut border_color, &mut text, ); } } /// Supplementary system to detect removed marker components fn update_button_style2( mut buttons: Query< ( Has, &Hovered, Has, &mut BackgroundColor, &mut BorderColor, &Children, ), With, >, mut removed_depressed: RemovedComponents, mut removed_disabled: RemovedComponents, mut text_query: Query<&mut Text>, ) { removed_depressed .read() .chain(removed_disabled.read()) .for_each(|entity| { if let Ok((pressed, hovered, disabled, mut color, mut border_color, children)) = buttons.get_mut(entity) { let mut text = text_query.get_mut(children[0]).unwrap(); set_button_style( disabled, hovered.get(), pressed, &mut color, &mut border_color, &mut text, ); } }); } fn set_button_style( disabled: bool, hovered: bool, pressed: bool, color: &mut BackgroundColor, border_color: &mut BorderColor, text: &mut Text, ) { match (disabled, hovered, pressed) { // Disabled button (true, _, _) => { **text = "Disabled".to_string(); *color = NORMAL_BUTTON.into(); border_color.set_all(GRAY); } // Pressed and hovered button (false, true, true) => { **text = "Press".to_string(); *color = PRESSED_BUTTON.into(); border_color.set_all(RED); } // Hovered, unpressed button (false, true, false) => { **text = "Hover".to_string(); *color = HOVERED_BUTTON.into(); border_color.set_all(WHITE); } // Unhovered button (either pressed or not). (false, false, _) => { **text = "Button".to_string(); *color = NORMAL_BUTTON.into(); border_color.set_all(BLACK); } } } /// Create a demo slider fn slider(min: f32, max: f32, value: f32) -> impl Bundle { ( Node { display: Display::Flex, flex_direction: FlexDirection::Column, justify_content: JustifyContent::Center, align_items: AlignItems::Stretch, justify_items: JustifyItems::Center, column_gap: px(4), height: px(12), width: percent(30), ..default() }, Name::new("Slider"), Hovered::default(), DemoSlider, Slider { track_click: TrackClick::Snap, ..Default::default() }, SliderValue(value), SliderRange::new(min, max), TabIndex(0), Children::spawn(( // Slider background rail Spawn(( Node { height: px(6), border_radius: BorderRadius::all(px(3)), ..default() }, BackgroundColor(SLIDER_TRACK), // Border color for the slider )), // Invisible track to allow absolute placement of thumb entity. This is narrower than // the actual slider, which allows us to position the thumb entity using simple // percentages, without having to measure the actual width of the slider thumb. Spawn(( Node { display: Display::Flex, position_type: PositionType::Absolute, left: px(0), // Track is short by 12px to accommodate the thumb. right: px(12), top: px(0), bottom: px(0), ..default() }, children![( // Thumb DemoSliderThumb, SliderThumb, Node { display: Display::Flex, width: px(12), height: px(12), position_type: PositionType::Absolute, left: percent(0), // This will be updated by the slider's value border_radius: BorderRadius::MAX, ..default() }, BackgroundColor(SLIDER_THUMB), )], )), )), ) } /// Update the visuals of the slider based on the slider state. fn update_slider_style( sliders: Query< ( Entity, &SliderValue, &SliderRange, &Hovered, &SliderDragState, Has, ), ( Or<( Changed, Changed, Changed, Changed, Added, )>, With, ), >, children: Query<&Children>, mut thumbs: Query<(&mut Node, &mut BackgroundColor, Has), Without>, ) { for (slider_ent, value, range, hovered, drag_state, disabled) in sliders.iter() { for child in children.iter_descendants(slider_ent) { if let Ok((mut thumb_node, mut thumb_bg, is_thumb)) = thumbs.get_mut(child) && is_thumb { thumb_node.left = percent(range.thumb_position(value.0) * 100.0); thumb_bg.0 = thumb_color(disabled, hovered.0 | drag_state.dragging); } } } } fn update_slider_style2( sliders: Query< (Entity, &Hovered, &SliderDragState, Has), With, >, children: Query<&Children>, mut thumbs: Query<(&mut BackgroundColor, Has), Without>, mut removed_disabled: RemovedComponents, ) { removed_disabled.read().for_each(|entity| { if let Ok((slider_ent, hovered, drag_state, disabled)) = sliders.get(entity) { for child in children.iter_descendants(slider_ent) { if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child) && is_thumb { thumb_bg.0 = thumb_color(disabled, hovered.0 | drag_state.dragging); } } } }); } fn thumb_color(disabled: bool, hovered: bool) -> Color { match (disabled, hovered) { (true, _) => ELEMENT_FILL_DISABLED, (false, true) => SLIDER_THUMB.lighter(0.3), _ => SLIDER_THUMB, } } /// Create a demo checkbox fn checkbox(asset_server: &AssetServer, caption: &str) -> impl Bundle { ( Node { display: Display::Flex, flex_direction: FlexDirection::Row, justify_content: JustifyContent::FlexStart, align_items: AlignItems::Center, align_content: AlignContent::Center, column_gap: px(4), ..default() }, Name::new("Checkbox"), Hovered::default(), DemoCheckbox, Checkbox, TabIndex(0), Children::spawn(( Spawn(( // Checkbox outer Node { display: Display::Flex, width: px(16), height: px(16), border: UiRect::all(px(2)), border_radius: BorderRadius::all(px(3)), ..default() }, BorderColor::all(ELEMENT_OUTLINE), // Border color for the checkbox children![ // Checkbox inner ( Node { display: Display::Flex, width: px(8), height: px(8), position_type: PositionType::Absolute, left: px(2), top: px(2), ..default() }, BackgroundColor(ELEMENT_FILL), ), ], )), Spawn(( Text::new(caption), TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), font_size: FontSize::Px(20.0), ..default() }, )), )), ) } // Update the element's styles. fn update_checkbox_or_radio_style( mut q_checkbox: Query< (Has, &Hovered, Has, &Children), ( Or<(With, With)>, Or<( Added, Changed, Added, Added, )>, ), >, mut q_border_color: Query< (&mut BorderColor, &mut Children), (Without, Without), >, mut q_bg_color: Query<&mut BackgroundColor, (Without, Without)>, ) { for (checked, Hovered(is_hovering), is_disabled, children) in q_checkbox.iter_mut() { let Some(border_id) = children.first() else { continue; }; let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) else { continue; }; let Some(mark_id) = border_children.first() else { warn!("Checkbox does not have a mark entity."); continue; }; let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else { warn!("Checkbox mark entity lacking a background color."); continue; }; set_checkbox_or_radio_style( is_disabled, *is_hovering, checked, &mut border_color, &mut mark_bg, ); } } fn update_checkbox_or_radio_style2( mut q_checkbox: Query< (Has, &Hovered, Has, &Children), Or<(With, With)>, >, mut q_border_color: Query< (&mut BorderColor, &mut Children), (Without, Without), >, mut q_bg_color: Query< &mut BackgroundColor, (Without, Without, Without), >, mut removed_checked: RemovedComponents, mut removed_disabled: RemovedComponents, ) { removed_checked .read() .chain(removed_disabled.read()) .for_each(|entity| { if let Ok((checked, Hovered(is_hovering), is_disabled, children)) = q_checkbox.get_mut(entity) { let Some(border_id) = children.first() else { return; }; let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) else { return; }; let Some(mark_id) = border_children.first() else { warn!("Checkbox does not have a mark entity."); return; }; let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else { warn!("Checkbox mark entity lacking a background color."); return; }; set_checkbox_or_radio_style( is_disabled, *is_hovering, checked, &mut border_color, &mut mark_bg, ); } }); } fn set_checkbox_or_radio_style( disabled: bool, hovering: bool, checked: bool, border_color: &mut BorderColor, mark_bg: &mut BackgroundColor, ) { let color: Color = if disabled { // If the element is disabled, use a lighter color ELEMENT_OUTLINE.with_alpha(0.2) } else if hovering { // If hovering, use a lighter color ELEMENT_OUTLINE.lighter(0.2) } else { // Default color for the element ELEMENT_OUTLINE }; // Update the background color of the element border_color.set_all(color); let mark_color: Color = match (disabled, checked) { (true, true) => ELEMENT_FILL_DISABLED, (false, true) => ELEMENT_FILL, (_, false) => Srgba::NONE.into(), }; if mark_bg.0 != mark_color { // Update the color of the element mark_bg.0 = mark_color; } } /// Create a demo radio group fn radio_group(asset_server: &AssetServer) -> impl Bundle { ( Node { display: Display::Flex, flex_direction: FlexDirection::Column, align_items: AlignItems::Start, column_gap: px(4), ..default() }, Name::new("RadioGroup"), RadioGroup, TabIndex::default(), children![ (radio(asset_server, TrackClick::Drag, "Slider Drag"),), (radio(asset_server, TrackClick::Step, "Slider Step"),), (radio(asset_server, TrackClick::Snap, "Slider Snap"),) ], ) } /// Create a demo radio button fn radio(asset_server: &AssetServer, value: TrackClick, caption: &str) -> impl Bundle { ( Node { display: Display::Flex, flex_direction: FlexDirection::Row, justify_content: JustifyContent::FlexStart, align_items: AlignItems::Center, align_content: AlignContent::Center, column_gap: px(4), ..default() }, Name::new("RadioButton"), Hovered::default(), DemoRadio(value), RadioButton, Children::spawn(( Spawn(( // Radio outer Node { display: Display::Flex, width: px(16), height: px(16), border: UiRect::all(px(2)), border_radius: BorderRadius::MAX, ..default() }, BorderColor::all(ELEMENT_OUTLINE), // Border color for the radio button children![ // Radio inner ( Node { display: Display::Flex, width: px(8), height: px(8), position_type: PositionType::Absolute, left: px(2), top: px(2), border_radius: BorderRadius::MAX, ..default() }, BackgroundColor(ELEMENT_FILL), ), ], )), Spawn(( Text::new(caption), TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), font_size: FontSize::Px(20.0), ..default() }, )), )), ) } fn on_menu_event( menu_event: On, q_anchor: Single<(Entity, &Children), With>, q_popup: Query>, assets: Res, mut focus: ResMut, mut commands: Commands, ) { let (anchor, children) = q_anchor.into_inner(); let popup = children.iter().find_map(|c| q_popup.get(c).ok()); info!("Menu action: {:?}", menu_event.action); match menu_event.action { MenuAction::Open(_) => { if popup.is_none() { spawn_menu(anchor, assets, commands); } } MenuAction::Toggle => match popup { Some(popup) => commands.entity(popup).despawn(), None => spawn_menu(anchor, assets, commands), }, MenuAction::CloseAll => { if let Some(popup) = popup { commands.entity(popup).despawn(); } } MenuAction::FocusRoot => { focus.set(anchor, FocusCause::Navigated); } } } fn spawn_menu(anchor: Entity, assets: Res, mut commands: Commands) { let menu = commands .spawn(( Node { display: Display::Flex, flex_direction: FlexDirection::Column, min_height: px(10.), min_width: percent(100), border: UiRect::all(px(1)), position_type: PositionType::Absolute, ..default() }, MenuPopup::default(), BorderColor::all(GREEN), BackgroundColor(GRAY.into()), BoxShadow::new( Srgba::BLACK.with_alpha(0.9).into(), px(0), px(0), px(1), px(4), ), GlobalZIndex(100), Popover { positions: vec![ PopoverPlacement { side: PopoverSide::Bottom, align: PopoverAlign::Start, gap: 2.0, }, PopoverPlacement { side: PopoverSide::Top, align: PopoverAlign::Start, gap: 2.0, }, ], window_margin: 10.0, }, OverrideClip, children![ menu_item(&assets), menu_item(&assets), menu_item(&assets), menu_item(&assets) ], )) .id(); commands.entity(anchor).add_child(menu); } fn menu_item(asset_server: &AssetServer) -> impl Bundle { ( Node { padding: UiRect::axes(px(8), px(2)), justify_content: JustifyContent::Center, align_items: AlignItems::Start, ..default() }, DemoMenuItem, MenuItem, Hovered::default(), TabIndex(0), BackgroundColor(NORMAL_BUTTON), children![( Text::new("Menu Item"), TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf").into(), font_size: FontSize::Px(33.0), ..default() }, TextColor(Color::srgb(0.9, 0.9, 0.9)), TextShadow::default(), )], ) } fn update_menu_item_style( mut buttons: Query< ( Has, &Hovered, Has, &mut BackgroundColor, ), ( Or<( Changed, Changed, Added, )>, With, ), >, ) { for (pressed, hovered, disabled, mut color) in &mut buttons { set_menu_item_style(disabled, hovered.get(), pressed, &mut color); } } /// Supplementary system to detect removed marker components fn update_menu_item_style2( mut buttons: Query< ( Has, &Hovered, Has, &mut BackgroundColor, ), With, >, mut removed_depressed: RemovedComponents, mut removed_disabled: RemovedComponents, ) { removed_depressed .read() .chain(removed_disabled.read()) .for_each(|entity| { if let Ok((pressed, hovered, disabled, mut color)) = buttons.get_mut(entity) { set_menu_item_style(disabled, hovered.get(), pressed, &mut color); } }); } fn set_menu_item_style(disabled: bool, hovered: bool, pressed: bool, color: &mut BackgroundColor) { match (disabled, hovered, pressed) { // Pressed and hovered menu item (false, true, true) => { *color = PRESSED_BUTTON.into(); } // Hovered, unpressed menu item (false, true, false) => { *color = HOVERED_BUTTON.into(); } // Unhovered menu item (either pressed or not). _ => { *color = NORMAL_BUTTON.into(); } } } fn toggle_disabled( input: Res>, mut interaction_query: Query< (Entity, Has), Or<( With