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