Skip to main content

standard_widgets_observers/
standard_widgets_observers.rs

1//! This experimental example illustrates how to create widgets using the `bevy_ui_widgets` widget set.
2//!
3//! The patterns shown here are likely to change substantially as the `bevy_ui_widgets` crate
4//! matures, so please exercise caution if you are using this as a reference for your own code,
5//! and note that there are still "user experience" issues with this API.
6
7use bevy::{
8    color::palettes::basic::*,
9    input_focus::tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
10    picking::hover::Hovered,
11    prelude::*,
12    reflect::Is,
13    ui::{Checked, InteractionDisabled, Pressed},
14    ui_widgets::{
15        checkbox_self_update, observe, Activate, Button, Checkbox, Slider, SliderRange,
16        SliderThumb, SliderValue, ValueChange,
17    },
18};
19
20fn main() {
21    App::new()
22        .add_plugins((DefaultPlugins, TabNavigationPlugin))
23        .insert_resource(DemoWidgetStates { slider_value: 50.0 })
24        .add_systems(Startup, setup)
25        .add_observer(button_on_interaction::<Add, Pressed>)
26        .add_observer(button_on_interaction::<Remove, Pressed>)
27        .add_observer(button_on_interaction::<Add, InteractionDisabled>)
28        .add_observer(button_on_interaction::<Remove, InteractionDisabled>)
29        .add_observer(button_on_interaction::<Insert, Hovered>)
30        .add_observer(slider_on_interaction::<Add, InteractionDisabled>)
31        .add_observer(slider_on_interaction::<Remove, InteractionDisabled>)
32        .add_observer(slider_on_interaction::<Insert, Hovered>)
33        .add_observer(slider_on_change_value::<SliderValue>)
34        .add_observer(slider_on_change_value::<SliderRange>)
35        .add_observer(checkbox_on_interaction::<Add, InteractionDisabled>)
36        .add_observer(checkbox_on_interaction::<Remove, InteractionDisabled>)
37        .add_observer(checkbox_on_interaction::<Insert, Hovered>)
38        .add_observer(checkbox_on_interaction::<Add, Checked>)
39        .add_observer(checkbox_on_interaction::<Remove, Checked>)
40        .add_systems(Update, (update_widget_values, toggle_disabled))
41        .run();
42}
43
44const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
45const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
46const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
47const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05);
48const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35);
49const CHECKBOX_OUTLINE: Color = Color::srgb(0.45, 0.45, 0.45);
50const CHECKBOX_CHECK: Color = Color::srgb(0.35, 0.75, 0.35);
51
52/// Marker which identifies buttons with a particular style, in this case the "Demo style".
53#[derive(Component)]
54struct DemoButton;
55
56/// Marker which identifies sliders with a particular style.
57#[derive(Component, Default)]
58struct DemoSlider;
59
60/// Marker which identifies the slider's thumb element.
61#[derive(Component, Default)]
62struct DemoSliderThumb;
63
64/// Marker which identifies checkboxes with a particular style.
65#[derive(Component, Default)]
66struct DemoCheckbox;
67
68/// A struct to hold the state of various widgets shown in the demo.
69///
70/// While it is possible to use the widget's own state components as the source of truth,
71/// in many cases widgets will be used to display dynamic data coming from deeper within the app,
72/// using some kind of data-binding. This example shows how to maintain an external source of
73/// truth for widget states.
74#[derive(Resource)]
75struct DemoWidgetStates {
76    slider_value: f32,
77}
78
79fn setup(mut commands: Commands, assets: Res<AssetServer>) {
80    // ui camera
81    commands.spawn(Camera2d);
82    commands.spawn(demo_root(&assets));
83}
84
85fn demo_root(asset_server: &AssetServer) -> impl Bundle {
86    (
87        Node {
88            width: percent(100),
89            height: percent(100),
90            align_items: AlignItems::Center,
91            justify_content: JustifyContent::Center,
92            display: Display::Flex,
93            flex_direction: FlexDirection::Column,
94            row_gap: px(10),
95            ..default()
96        },
97        TabGroup::default(),
98        children![
99            (
100                button(asset_server),
101                observe(|_activate: On<Activate>| {
102                    info!("Button clicked!");
103                }),
104            ),
105            (
106                slider(0.0, 100.0, 50.0),
107                observe(
108                    |value_change: On<ValueChange<f32>>,
109                     mut widget_states: ResMut<DemoWidgetStates>| {
110                        widget_states.slider_value = value_change.value;
111                    },
112                )
113            ),
114            (
115                checkbox(asset_server, "Checkbox"),
116                observe(checkbox_self_update),
117            ),
118            Text::new("Press 'D' to toggle widget disabled states"),
119        ],
120    )
121}
122
123fn button(asset_server: &AssetServer) -> impl Bundle {
124    (
125        Node {
126            width: px(150),
127            height: px(65),
128            border: UiRect::all(px(5)),
129            justify_content: JustifyContent::Center,
130            align_items: AlignItems::Center,
131            border_radius: BorderRadius::MAX,
132            ..default()
133        },
134        DemoButton,
135        Button,
136        Hovered::default(),
137        TabIndex(0),
138        BorderColor::all(Color::BLACK),
139        BackgroundColor(NORMAL_BUTTON),
140        children![(
141            Text::new("Button"),
142            TextFont {
143                font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
144                font_size: FontSize::Px(33.0),
145                ..default()
146            },
147            TextColor(Color::srgb(0.9, 0.9, 0.9)),
148            TextShadow::default(),
149        )],
150    )
151}
152
153fn button_on_interaction<E: EntityEvent, C: Component>(
154    event: On<E, C>,
155    mut buttons: Query<
156        (
157            &Hovered,
158            Has<InteractionDisabled>,
159            Has<Pressed>,
160            &mut BackgroundColor,
161            &mut BorderColor,
162            &Children,
163        ),
164        With<DemoButton>,
165    >,
166    mut text_query: Query<&mut Text>,
167) {
168    if let Ok((hovered, disabled, pressed, mut color, mut border_color, children)) =
169        buttons.get_mut(event.event_target())
170    {
171        if children.is_empty() {
172            return;
173        }
174        let Ok(mut text) = text_query.get_mut(children[0]) else {
175            return;
176        };
177        let hovered = hovered.get();
178        // These "removal event checks" exist because the `Remove` event is triggered _before_ the component is actually
179        // removed, meaning it still shows up in the query. We're investigating the best way to improve this scenario.
180        let pressed = pressed && !(E::is::<Remove>() && C::is::<Pressed>());
181        let disabled = disabled && !(E::is::<Remove>() && C::is::<InteractionDisabled>());
182        match (disabled, hovered, pressed) {
183            // Disabled button
184            (true, _, _) => {
185                **text = "Disabled".to_string();
186                *color = NORMAL_BUTTON.into();
187                border_color.set_all(GRAY);
188            }
189
190            // Pressed and hovered button
191            (false, true, true) => {
192                **text = "Press".to_string();
193                *color = PRESSED_BUTTON.into();
194                border_color.set_all(RED);
195            }
196
197            // Hovered, unpressed button
198            (false, true, false) => {
199                **text = "Hover".to_string();
200                *color = HOVERED_BUTTON.into();
201                border_color.set_all(WHITE);
202            }
203
204            // Unhovered button (either pressed or not).
205            (false, false, _) => {
206                **text = "Button".to_string();
207                *color = NORMAL_BUTTON.into();
208                border_color.set_all(BLACK);
209            }
210        }
211    }
212}
213
214/// Create a demo slider
215fn slider(min: f32, max: f32, value: f32) -> impl Bundle {
216    (
217        Node {
218            display: Display::Flex,
219            flex_direction: FlexDirection::Column,
220            justify_content: JustifyContent::Center,
221            align_items: AlignItems::Stretch,
222            justify_items: JustifyItems::Center,
223            column_gap: px(4),
224            height: px(12),
225            width: percent(30),
226            ..default()
227        },
228        Name::new("Slider"),
229        Hovered::default(),
230        DemoSlider,
231        Slider::default(),
232        SliderValue(value),
233        SliderRange::new(min, max),
234        TabIndex(0),
235        Children::spawn((
236            // Slider background rail
237            Spawn((
238                Node {
239                    height: px(6),
240                    border_radius: BorderRadius::all(px(3)),
241                    ..default()
242                },
243                BackgroundColor(SLIDER_TRACK), // Border color for the checkbox
244            )),
245            // Invisible track to allow absolute placement of thumb entity. This is narrower than
246            // the actual slider, which allows us to position the thumb entity using simple
247            // percentages, without having to measure the actual width of the slider thumb.
248            Spawn((
249                Node {
250                    display: Display::Flex,
251                    position_type: PositionType::Absolute,
252                    left: px(0),
253                    // Track is short by 12px to accommodate the thumb.
254                    right: px(12),
255                    top: px(0),
256                    bottom: px(0),
257                    ..default()
258                },
259                children![(
260                    // Thumb
261                    DemoSliderThumb,
262                    SliderThumb,
263                    Node {
264                        display: Display::Flex,
265                        width: px(12),
266                        height: px(12),
267                        position_type: PositionType::Absolute,
268                        left: percent(0), // This will be updated by the slider's value
269                        border_radius: BorderRadius::MAX,
270                        ..default()
271                    },
272                    BackgroundColor(SLIDER_THUMB),
273                )],
274            )),
275        )),
276    )
277}
278
279fn slider_on_interaction<E: EntityEvent, C: Component>(
280    event: On<E, C>,
281    sliders: Query<(Entity, &Hovered, Has<InteractionDisabled>), With<DemoSlider>>,
282    children: Query<&Children>,
283    mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
284) {
285    if let Ok((slider_ent, hovered, disabled)) = sliders.get(event.event_target()) {
286        // These "removal event checks" exist because the `Remove` event is triggered _before_ the component is actually
287        // removed, meaning it still shows up in the query. We're investigating the best way to improve this scenario.
288        let disabled = disabled && !(E::is::<Remove>() && C::is::<InteractionDisabled>());
289        for child in children.iter_descendants(slider_ent) {
290            if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child)
291                && is_thumb
292            {
293                thumb_bg.0 = thumb_color(disabled, hovered.0);
294            }
295        }
296    }
297}
298
299fn slider_on_change_value<C: Component>(
300    insert: On<Insert, C>,
301    sliders: Query<(Entity, &SliderValue, &SliderRange), With<DemoSlider>>,
302    children: Query<&Children>,
303    mut thumbs: Query<(&mut Node, Has<DemoSliderThumb>), Without<DemoSlider>>,
304) {
305    if let Ok((slider_ent, value, range)) = sliders.get(insert.entity) {
306        for child in children.iter_descendants(slider_ent) {
307            if let Ok((mut thumb_node, is_thumb)) = thumbs.get_mut(child)
308                && is_thumb
309            {
310                thumb_node.left = percent(range.thumb_position(value.0) * 100.0);
311            }
312        }
313    }
314}
315
316fn thumb_color(disabled: bool, hovered: bool) -> Color {
317    match (disabled, hovered) {
318        (true, _) => GRAY.into(),
319
320        (false, true) => SLIDER_THUMB.lighter(0.3),
321
322        _ => SLIDER_THUMB,
323    }
324}
325
326/// Create a demo checkbox
327fn checkbox(asset_server: &AssetServer, caption: &str) -> impl Bundle {
328    (
329        Node {
330            display: Display::Flex,
331            flex_direction: FlexDirection::Row,
332            justify_content: JustifyContent::FlexStart,
333            align_items: AlignItems::Center,
334            align_content: AlignContent::Center,
335            column_gap: px(4),
336            ..default()
337        },
338        Name::new("Checkbox"),
339        Hovered::default(),
340        DemoCheckbox,
341        Checkbox,
342        TabIndex(0),
343        Children::spawn((
344            Spawn((
345                // Checkbox outer
346                Node {
347                    display: Display::Flex,
348                    width: px(16),
349                    height: px(16),
350                    border: UiRect::all(px(2)),
351                    border_radius: BorderRadius::all(px(3)),
352                    ..default()
353                },
354                BorderColor::all(CHECKBOX_OUTLINE), // Border color for the checkbox
355                children![
356                    // Checkbox inner
357                    (
358                        Node {
359                            display: Display::Flex,
360                            width: px(8),
361                            height: px(8),
362                            position_type: PositionType::Absolute,
363                            left: px(2),
364                            top: px(2),
365                            ..default()
366                        },
367                        BackgroundColor(Srgba::NONE.into()),
368                    ),
369                ],
370            )),
371            Spawn((
372                Text::new(caption),
373                TextFont {
374                    font: asset_server.load("fonts/FiraSans-Bold.ttf").into(),
375                    font_size: FontSize::Px(20.0),
376                    ..default()
377                },
378            )),
379        )),
380    )
381}
382
383fn checkbox_on_interaction<E: EntityEvent, C: Component>(
384    event: On<E, C>,
385    checkboxes: Query<
386        (&Hovered, Has<InteractionDisabled>, Has<Checked>, &Children),
387        With<DemoCheckbox>,
388    >,
389    mut borders: Query<(&mut BorderColor, &mut Children), Without<DemoCheckbox>>,
390    mut marks: Query<&mut BackgroundColor, (Without<DemoCheckbox>, Without<Children>)>,
391) {
392    if let Ok((hovered, disabled, checked, children)) = checkboxes.get(event.event_target()) {
393        let hovered = hovered.get();
394        // These "removal event checks" exist because the `Remove` event is triggered _before_ the component is actually
395        // removed, meaning it still shows up in the query. We're investigating the best way to improve this scenario.
396        let checked = checked && !(E::is::<Remove>() && C::is::<Checked>());
397        let disabled = disabled && !(E::is::<Remove>() && C::is::<InteractionDisabled>());
398
399        let Some(border_id) = children.first() else {
400            return;
401        };
402
403        let Ok((mut border_color, border_children)) = borders.get_mut(*border_id) else {
404            return;
405        };
406
407        let Some(mark_id) = border_children.first() else {
408            warn!("Checkbox does not have a mark entity.");
409            return;
410        };
411
412        let Ok(mut mark_bg) = marks.get_mut(*mark_id) else {
413            warn!("Checkbox mark entity lacking a background color.");
414            return;
415        };
416
417        let color: Color = if disabled {
418            // If the checkbox is disabled, use a lighter color
419            CHECKBOX_OUTLINE.with_alpha(0.2)
420        } else if hovered {
421            // If hovering, use a lighter color
422            CHECKBOX_OUTLINE.lighter(0.2)
423        } else {
424            // Default color for the checkbox
425            CHECKBOX_OUTLINE
426        };
427
428        // Update the background color of the check mark
429        border_color.set_all(color);
430
431        let mark_color: Color = match (disabled, checked) {
432            (true, true) => CHECKBOX_CHECK.with_alpha(0.5),
433            (false, true) => CHECKBOX_CHECK,
434            (_, false) => Srgba::NONE.into(),
435        };
436
437        if mark_bg.0 != mark_color {
438            // Update the color of the check mark
439            mark_bg.0 = mark_color;
440        }
441    }
442}
443
444/// Update the widget states based on the changing resource.
445fn update_widget_values(
446    res: Res<DemoWidgetStates>,
447    mut sliders: Query<Entity, With<DemoSlider>>,
448    mut commands: Commands,
449) {
450    if res.is_changed() {
451        for slider_ent in sliders.iter_mut() {
452            commands
453                .entity(slider_ent)
454                .insert(SliderValue(res.slider_value));
455        }
456    }
457}
458
459fn toggle_disabled(
460    input: Res<ButtonInput<KeyCode>>,
461    mut interaction_query: Query<
462        (Entity, Has<InteractionDisabled>),
463        Or<(With<Button>, With<Slider>, With<Checkbox>)>,
464    >,
465    mut commands: Commands,
466) {
467    if input.just_pressed(KeyCode::KeyD) {
468        for (entity, disabled) in &mut interaction_query {
469            if disabled {
470                info!("Widget enabled");
471                commands.entity(entity).remove::<InteractionDisabled>();
472            } else {
473                info!("Widget disabled");
474                commands.entity(entity).insert(InteractionDisabled);
475            }
476        }
477    }
478}