standard_widgets/
standard_widgets.rs

1//! This experimental example illustrates how to create widgets using the `bevy_ui_widgets` widget set.
2//!
3//! These widgets have no inherent styling, so this example also shows how to implement custom styles.
4//!
5//! The patterns shown here are likely to change substantially as the `bevy_ui_widgets` crate
6//! matures, so please exercise caution if you are using this as a reference for your own code,
7//! and note that there are still "user experience" issues with this API.
8
9use bevy::{
10    color::palettes::basic::*,
11    input_focus::{
12        tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin},
13        InputDispatchPlugin,
14    },
15    picking::hover::Hovered,
16    prelude::*,
17    ui::{Checked, InteractionDisabled, Pressed},
18    ui_widgets::{
19        checkbox_self_update, observe, Activate, Button, Checkbox, CoreSliderDragState,
20        RadioButton, RadioGroup, Slider, SliderRange, SliderThumb, SliderValue, TrackClick,
21        UiWidgetsPlugins, ValueChange,
22    },
23};
24
25fn main() {
26    App::new()
27        .add_plugins((
28            DefaultPlugins,
29            UiWidgetsPlugins,
30            InputDispatchPlugin,
31            TabNavigationPlugin,
32        ))
33        .insert_resource(DemoWidgetStates {
34            slider_value: 50.0,
35            slider_click: TrackClick::Snap,
36        })
37        .add_systems(Startup, setup)
38        .add_systems(
39            Update,
40            (
41                update_widget_values,
42                update_button_style,
43                update_button_style2,
44                update_slider_style.after(update_widget_values),
45                update_slider_style2.after(update_widget_values),
46                update_checkbox_or_radio_style.after(update_widget_values),
47                update_checkbox_or_radio_style2.after(update_widget_values),
48                toggle_disabled,
49            ),
50        )
51        .run();
52}
53
54const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
55const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
56const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
57const SLIDER_TRACK: Color = Color::srgb(0.05, 0.05, 0.05);
58const SLIDER_THUMB: Color = Color::srgb(0.35, 0.75, 0.35);
59const ELEMENT_OUTLINE: Color = Color::srgb(0.45, 0.45, 0.45);
60const ELEMENT_FILL: Color = Color::srgb(0.35, 0.75, 0.35);
61const ELEMENT_FILL_DISABLED: Color = Color::srgb(0.5019608, 0.5019608, 0.5019608);
62
63/// Marker which identifies buttons with a particular style, in this case the "Demo style".
64#[derive(Component)]
65struct DemoButton;
66
67/// Marker which identifies sliders with a particular style.
68#[derive(Component, Default)]
69struct DemoSlider;
70
71/// Marker which identifies the slider's thumb element.
72#[derive(Component, Default)]
73struct DemoSliderThumb;
74
75/// Marker which identifies checkboxes with a particular style.
76#[derive(Component, Default)]
77struct DemoCheckbox;
78
79/// Marker which identifies a styled radio button. We'll use this to change the track click
80/// behavior.
81#[derive(Component, Default)]
82struct DemoRadio(TrackClick);
83
84/// A struct to hold the state of various widgets shown in the demo.
85///
86/// While it is possible to use the widget's own state components as the source of truth,
87/// in many cases widgets will be used to display dynamic data coming from deeper within the app,
88/// using some kind of data-binding. This example shows how to maintain an external source of
89/// truth for widget states.
90#[derive(Resource)]
91struct DemoWidgetStates {
92    slider_value: f32,
93    slider_click: TrackClick,
94}
95
96/// Update the widget states based on the changing resource.
97fn update_widget_values(
98    res: Res<DemoWidgetStates>,
99    mut sliders: Query<(Entity, &mut Slider), With<DemoSlider>>,
100    radios: Query<(Entity, &DemoRadio, Has<Checked>)>,
101    mut commands: Commands,
102) {
103    if res.is_changed() {
104        for (slider_ent, mut slider) in sliders.iter_mut() {
105            commands
106                .entity(slider_ent)
107                .insert(SliderValue(res.slider_value));
108            slider.track_click = res.slider_click;
109        }
110
111        for (radio_id, radio_value, checked) in radios.iter() {
112            let will_be_checked = radio_value.0 == res.slider_click;
113            if will_be_checked != checked {
114                if will_be_checked {
115                    commands.entity(radio_id).insert(Checked);
116                } else {
117                    commands.entity(radio_id).remove::<Checked>();
118                }
119            }
120        }
121    }
122}
123
124fn setup(mut commands: Commands, assets: Res<AssetServer>) {
125    // ui camera
126    commands.spawn(Camera2d);
127    commands.spawn(demo_root(&assets));
128}
129
130fn demo_root(asset_server: &AssetServer) -> impl Bundle {
131    (
132        Node {
133            width: percent(100),
134            height: percent(100),
135            align_items: AlignItems::Center,
136            justify_content: JustifyContent::Center,
137            display: Display::Flex,
138            flex_direction: FlexDirection::Column,
139            row_gap: px(10),
140            ..default()
141        },
142        TabGroup::default(),
143        children![
144            (
145                button(asset_server),
146                observe(|_activate: On<Activate>| {
147                    info!("Button clicked!");
148                }),
149            ),
150            (
151                slider(0.0, 100.0, 50.0),
152                observe(
153                    |value_change: On<ValueChange<f32>>,
154                     mut widget_states: ResMut<DemoWidgetStates>| {
155                        widget_states.slider_value = value_change.value;
156                    },
157                )
158            ),
159            (
160                checkbox(asset_server, "Checkbox"),
161                observe(checkbox_self_update)
162            ),
163            (
164                radio_group(asset_server),
165                observe(
166                    |value_change: On<ValueChange<Entity>>,
167                     mut widget_states: ResMut<DemoWidgetStates>,
168                     q_radios: Query<&DemoRadio>| {
169                        if let Ok(radio) = q_radios.get(value_change.value) {
170                            widget_states.slider_click = radio.0;
171                        }
172                    },
173                )
174            ),
175            Text::new("Press 'D' to toggle widget disabled states"),
176        ],
177    )
178}
179
180fn button(asset_server: &AssetServer) -> impl Bundle {
181    (
182        Node {
183            width: px(150),
184            height: px(65),
185            border: UiRect::all(px(5)),
186            justify_content: JustifyContent::Center,
187            align_items: AlignItems::Center,
188            ..default()
189        },
190        DemoButton,
191        Button,
192        Hovered::default(),
193        TabIndex(0),
194        BorderColor::all(Color::BLACK),
195        BorderRadius::MAX,
196        BackgroundColor(NORMAL_BUTTON),
197        children![(
198            Text::new("Button"),
199            TextFont {
200                font: asset_server.load("fonts/FiraSans-Bold.ttf"),
201                font_size: 33.0,
202                ..default()
203            },
204            TextColor(Color::srgb(0.9, 0.9, 0.9)),
205            TextShadow::default(),
206        )],
207    )
208}
209
210fn update_button_style(
211    mut buttons: Query<
212        (
213            Has<Pressed>,
214            &Hovered,
215            Has<InteractionDisabled>,
216            &mut BackgroundColor,
217            &mut BorderColor,
218            &Children,
219        ),
220        (
221            Or<(
222                Changed<Pressed>,
223                Changed<Hovered>,
224                Added<InteractionDisabled>,
225            )>,
226            With<DemoButton>,
227        ),
228    >,
229    mut text_query: Query<&mut Text>,
230) {
231    for (pressed, hovered, disabled, mut color, mut border_color, children) in &mut buttons {
232        let mut text = text_query.get_mut(children[0]).unwrap();
233        set_button_style(
234            disabled,
235            hovered.get(),
236            pressed,
237            &mut color,
238            &mut border_color,
239            &mut text,
240        );
241    }
242}
243
244/// Supplementary system to detect removed marker components
245fn update_button_style2(
246    mut buttons: Query<
247        (
248            Has<Pressed>,
249            &Hovered,
250            Has<InteractionDisabled>,
251            &mut BackgroundColor,
252            &mut BorderColor,
253            &Children,
254        ),
255        With<DemoButton>,
256    >,
257    mut removed_depressed: RemovedComponents<Pressed>,
258    mut removed_disabled: RemovedComponents<InteractionDisabled>,
259    mut text_query: Query<&mut Text>,
260) {
261    removed_depressed
262        .read()
263        .chain(removed_disabled.read())
264        .for_each(|entity| {
265            if let Ok((pressed, hovered, disabled, mut color, mut border_color, children)) =
266                buttons.get_mut(entity)
267            {
268                let mut text = text_query.get_mut(children[0]).unwrap();
269                set_button_style(
270                    disabled,
271                    hovered.get(),
272                    pressed,
273                    &mut color,
274                    &mut border_color,
275                    &mut text,
276                );
277            }
278        });
279}
280
281fn set_button_style(
282    disabled: bool,
283    hovered: bool,
284    pressed: bool,
285    color: &mut BackgroundColor,
286    border_color: &mut BorderColor,
287    text: &mut Text,
288) {
289    match (disabled, hovered, pressed) {
290        // Disabled button
291        (true, _, _) => {
292            **text = "Disabled".to_string();
293            *color = NORMAL_BUTTON.into();
294            border_color.set_all(GRAY);
295        }
296
297        // Pressed and hovered button
298        (false, true, true) => {
299            **text = "Press".to_string();
300            *color = PRESSED_BUTTON.into();
301            border_color.set_all(RED);
302        }
303
304        // Hovered, unpressed button
305        (false, true, false) => {
306            **text = "Hover".to_string();
307            *color = HOVERED_BUTTON.into();
308            border_color.set_all(WHITE);
309        }
310
311        // Unhovered button (either pressed or not).
312        (false, false, _) => {
313            **text = "Button".to_string();
314            *color = NORMAL_BUTTON.into();
315            border_color.set_all(BLACK);
316        }
317    }
318}
319
320/// Create a demo slider
321fn slider(min: f32, max: f32, value: f32) -> impl Bundle {
322    (
323        Node {
324            display: Display::Flex,
325            flex_direction: FlexDirection::Column,
326            justify_content: JustifyContent::Center,
327            align_items: AlignItems::Stretch,
328            justify_items: JustifyItems::Center,
329            column_gap: px(4),
330            height: px(12),
331            width: percent(30),
332            ..default()
333        },
334        Name::new("Slider"),
335        Hovered::default(),
336        DemoSlider,
337        Slider {
338            track_click: TrackClick::Snap,
339        },
340        SliderValue(value),
341        SliderRange::new(min, max),
342        TabIndex(0),
343        Children::spawn((
344            // Slider background rail
345            Spawn((
346                Node {
347                    height: px(6),
348                    ..default()
349                },
350                BackgroundColor(SLIDER_TRACK), // Border color for the slider
351                BorderRadius::all(px(3)),
352            )),
353            // Invisible track to allow absolute placement of thumb entity. This is narrower than
354            // the actual slider, which allows us to position the thumb entity using simple
355            // percentages, without having to measure the actual width of the slider thumb.
356            Spawn((
357                Node {
358                    display: Display::Flex,
359                    position_type: PositionType::Absolute,
360                    left: px(0),
361                    // Track is short by 12px to accommodate the thumb.
362                    right: px(12),
363                    top: px(0),
364                    bottom: px(0),
365                    ..default()
366                },
367                children![(
368                    // Thumb
369                    DemoSliderThumb,
370                    SliderThumb,
371                    Node {
372                        display: Display::Flex,
373                        width: px(12),
374                        height: px(12),
375                        position_type: PositionType::Absolute,
376                        left: percent(0), // This will be updated by the slider's value
377                        ..default()
378                    },
379                    BorderRadius::MAX,
380                    BackgroundColor(SLIDER_THUMB),
381                )],
382            )),
383        )),
384    )
385}
386
387/// Update the visuals of the slider based on the slider state.
388fn update_slider_style(
389    sliders: Query<
390        (
391            Entity,
392            &SliderValue,
393            &SliderRange,
394            &Hovered,
395            &CoreSliderDragState,
396            Has<InteractionDisabled>,
397        ),
398        (
399            Or<(
400                Changed<SliderValue>,
401                Changed<SliderRange>,
402                Changed<Hovered>,
403                Changed<CoreSliderDragState>,
404                Added<InteractionDisabled>,
405            )>,
406            With<DemoSlider>,
407        ),
408    >,
409    children: Query<&Children>,
410    mut thumbs: Query<(&mut Node, &mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
411) {
412    for (slider_ent, value, range, hovered, drag_state, disabled) in sliders.iter() {
413        for child in children.iter_descendants(slider_ent) {
414            if let Ok((mut thumb_node, mut thumb_bg, is_thumb)) = thumbs.get_mut(child)
415                && is_thumb
416            {
417                thumb_node.left = percent(range.thumb_position(value.0) * 100.0);
418                thumb_bg.0 = thumb_color(disabled, hovered.0 | drag_state.dragging);
419            }
420        }
421    }
422}
423
424fn update_slider_style2(
425    sliders: Query<
426        (
427            Entity,
428            &Hovered,
429            &CoreSliderDragState,
430            Has<InteractionDisabled>,
431        ),
432        With<DemoSlider>,
433    >,
434    children: Query<&Children>,
435    mut thumbs: Query<(&mut BackgroundColor, Has<DemoSliderThumb>), Without<DemoSlider>>,
436    mut removed_disabled: RemovedComponents<InteractionDisabled>,
437) {
438    removed_disabled.read().for_each(|entity| {
439        if let Ok((slider_ent, hovered, drag_state, disabled)) = sliders.get(entity) {
440            for child in children.iter_descendants(slider_ent) {
441                if let Ok((mut thumb_bg, is_thumb)) = thumbs.get_mut(child)
442                    && is_thumb
443                {
444                    thumb_bg.0 = thumb_color(disabled, hovered.0 | drag_state.dragging);
445                }
446            }
447        }
448    });
449}
450
451fn thumb_color(disabled: bool, hovered: bool) -> Color {
452    match (disabled, hovered) {
453        (true, _) => ELEMENT_FILL_DISABLED,
454
455        (false, true) => SLIDER_THUMB.lighter(0.3),
456
457        _ => SLIDER_THUMB,
458    }
459}
460
461/// Create a demo checkbox
462fn checkbox(asset_server: &AssetServer, caption: &str) -> impl Bundle {
463    (
464        Node {
465            display: Display::Flex,
466            flex_direction: FlexDirection::Row,
467            justify_content: JustifyContent::FlexStart,
468            align_items: AlignItems::Center,
469            align_content: AlignContent::Center,
470            column_gap: px(4),
471            ..default()
472        },
473        Name::new("Checkbox"),
474        Hovered::default(),
475        DemoCheckbox,
476        Checkbox,
477        TabIndex(0),
478        Children::spawn((
479            Spawn((
480                // Checkbox outer
481                Node {
482                    display: Display::Flex,
483                    width: px(16),
484                    height: px(16),
485                    border: UiRect::all(px(2)),
486                    ..default()
487                },
488                BorderColor::all(ELEMENT_OUTLINE), // Border color for the checkbox
489                BorderRadius::all(px(3)),
490                children![
491                    // Checkbox inner
492                    (
493                        Node {
494                            display: Display::Flex,
495                            width: px(8),
496                            height: px(8),
497                            position_type: PositionType::Absolute,
498                            left: px(2),
499                            top: px(2),
500                            ..default()
501                        },
502                        BackgroundColor(ELEMENT_FILL),
503                    ),
504                ],
505            )),
506            Spawn((
507                Text::new(caption),
508                TextFont {
509                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
510                    font_size: 20.0,
511                    ..default()
512                },
513            )),
514        )),
515    )
516}
517
518// Update the element's styles.
519fn update_checkbox_or_radio_style(
520    mut q_checkbox: Query<
521        (Has<Checked>, &Hovered, Has<InteractionDisabled>, &Children),
522        (
523            Or<(With<DemoCheckbox>, With<DemoRadio>)>,
524            Or<(
525                Added<DemoCheckbox>,
526                Changed<Hovered>,
527                Added<Checked>,
528                Added<InteractionDisabled>,
529            )>,
530        ),
531    >,
532    mut q_border_color: Query<
533        (&mut BorderColor, &mut Children),
534        (Without<DemoCheckbox>, Without<DemoRadio>),
535    >,
536    mut q_bg_color: Query<&mut BackgroundColor, (Without<DemoCheckbox>, Without<Children>)>,
537) {
538    for (checked, Hovered(is_hovering), is_disabled, children) in q_checkbox.iter_mut() {
539        let Some(border_id) = children.first() else {
540            continue;
541        };
542
543        let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id) else {
544            continue;
545        };
546
547        let Some(mark_id) = border_children.first() else {
548            warn!("Checkbox does not have a mark entity.");
549            continue;
550        };
551
552        let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else {
553            warn!("Checkbox mark entity lacking a background color.");
554            continue;
555        };
556
557        set_checkbox_or_radio_style(
558            is_disabled,
559            *is_hovering,
560            checked,
561            &mut border_color,
562            &mut mark_bg,
563        );
564    }
565}
566
567fn update_checkbox_or_radio_style2(
568    mut q_checkbox: Query<
569        (Has<Checked>, &Hovered, Has<InteractionDisabled>, &Children),
570        Or<(With<DemoCheckbox>, With<DemoRadio>)>,
571    >,
572    mut q_border_color: Query<
573        (&mut BorderColor, &mut Children),
574        (Without<DemoCheckbox>, Without<DemoRadio>),
575    >,
576    mut q_bg_color: Query<
577        &mut BackgroundColor,
578        (Without<DemoCheckbox>, Without<DemoRadio>, Without<Children>),
579    >,
580    mut removed_checked: RemovedComponents<Checked>,
581    mut removed_disabled: RemovedComponents<InteractionDisabled>,
582) {
583    removed_checked
584        .read()
585        .chain(removed_disabled.read())
586        .for_each(|entity| {
587            if let Ok((checked, Hovered(is_hovering), is_disabled, children)) =
588                q_checkbox.get_mut(entity)
589            {
590                let Some(border_id) = children.first() else {
591                    return;
592                };
593
594                let Ok((mut border_color, border_children)) = q_border_color.get_mut(*border_id)
595                else {
596                    return;
597                };
598
599                let Some(mark_id) = border_children.first() else {
600                    warn!("Checkbox does not have a mark entity.");
601                    return;
602                };
603
604                let Ok(mut mark_bg) = q_bg_color.get_mut(*mark_id) else {
605                    warn!("Checkbox mark entity lacking a background color.");
606                    return;
607                };
608
609                set_checkbox_or_radio_style(
610                    is_disabled,
611                    *is_hovering,
612                    checked,
613                    &mut border_color,
614                    &mut mark_bg,
615                );
616            }
617        });
618}
619
620fn set_checkbox_or_radio_style(
621    disabled: bool,
622    hovering: bool,
623    checked: bool,
624    border_color: &mut BorderColor,
625    mark_bg: &mut BackgroundColor,
626) {
627    let color: Color = if disabled {
628        // If the element is disabled, use a lighter color
629        ELEMENT_OUTLINE.with_alpha(0.2)
630    } else if hovering {
631        // If hovering, use a lighter color
632        ELEMENT_OUTLINE.lighter(0.2)
633    } else {
634        // Default color for the element
635        ELEMENT_OUTLINE
636    };
637
638    // Update the background color of the element
639    border_color.set_all(color);
640
641    let mark_color: Color = match (disabled, checked) {
642        (true, true) => ELEMENT_FILL_DISABLED,
643        (false, true) => ELEMENT_FILL,
644        (_, false) => Srgba::NONE.into(),
645    };
646
647    if mark_bg.0 != mark_color {
648        // Update the color of the element
649        mark_bg.0 = mark_color;
650    }
651}
652
653/// Create a demo radio group
654fn radio_group(asset_server: &AssetServer) -> impl Bundle {
655    (
656        Node {
657            display: Display::Flex,
658            flex_direction: FlexDirection::Column,
659            align_items: AlignItems::Start,
660            column_gap: px(4),
661            ..default()
662        },
663        Name::new("RadioGroup"),
664        RadioGroup,
665        TabIndex::default(),
666        children![
667            (radio(asset_server, TrackClick::Drag, "Slider Drag"),),
668            (radio(asset_server, TrackClick::Step, "Slider Step"),),
669            (radio(asset_server, TrackClick::Snap, "Slider Snap"),)
670        ],
671    )
672}
673
674/// Create a demo radio button
675fn radio(asset_server: &AssetServer, value: TrackClick, caption: &str) -> impl Bundle {
676    (
677        Node {
678            display: Display::Flex,
679            flex_direction: FlexDirection::Row,
680            justify_content: JustifyContent::FlexStart,
681            align_items: AlignItems::Center,
682            align_content: AlignContent::Center,
683            column_gap: px(4),
684            ..default()
685        },
686        Name::new("RadioButton"),
687        Hovered::default(),
688        DemoRadio(value),
689        RadioButton,
690        Children::spawn((
691            Spawn((
692                // Radio outer
693                Node {
694                    display: Display::Flex,
695                    width: px(16),
696                    height: px(16),
697                    border: UiRect::all(px(2)),
698                    ..default()
699                },
700                BorderColor::all(ELEMENT_OUTLINE), // Border color for the radio button
701                BorderRadius::MAX,
702                children![
703                    // Radio inner
704                    (
705                        Node {
706                            display: Display::Flex,
707                            width: px(8),
708                            height: px(8),
709                            position_type: PositionType::Absolute,
710                            left: px(2),
711                            top: px(2),
712                            ..default()
713                        },
714                        BorderRadius::MAX,
715                        BackgroundColor(ELEMENT_FILL),
716                    ),
717                ],
718            )),
719            Spawn((
720                Text::new(caption),
721                TextFont {
722                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
723                    font_size: 20.0,
724                    ..default()
725                },
726            )),
727        )),
728    )
729}
730
731fn toggle_disabled(
732    input: Res<ButtonInput<KeyCode>>,
733    mut interaction_query: Query<
734        (Entity, Has<InteractionDisabled>),
735        Or<(
736            With<Button>,
737            With<Slider>,
738            With<Checkbox>,
739            With<RadioButton>,
740        )>,
741    >,
742    mut commands: Commands,
743) {
744    if input.just_pressed(KeyCode::KeyD) {
745        for (entity, disabled) in &mut interaction_query {
746            if disabled {
747                info!("Widget enabled");
748                commands.entity(entity).remove::<InteractionDisabled>();
749            } else {
750                info!("Widget disabled");
751                commands.entity(entity).insert(InteractionDisabled);
752            }
753        }
754    }
755}