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