responsive_menu/
responsive_menu.rs

1//! - A simple game menu, with buttons that use a nine-patch system for design (i.e., composed of
2//!   images for the corners and middle segments) and an image to the right of the buttons.
3//! - For normal screen sizes, the menu is centered in the middle of the screen
4//! - For 400px width and lower, the buttons fill the screen width and the image is above the
5//!   buttons.
6
7mod utils;
8use bevy_ui::widget::NodeImageMode;
9use utils::*;
10
11use std::sync::OnceLock;
12
13use bevy::{prelude::*, window::WindowResized};
14use futures_signals::signal::Mutable;
15use haalka::prelude::*;
16
17fn main() {
18    App::new()
19        .add_plugins(examples_plugin)
20        .add_systems(
21            Startup,
22            (setup, |world: &mut World| {
23                ui_root().spawn(world);
24            })
25                .chain(),
26        )
27        .add_systems(Update, on_resize)
28        .run();
29}
30
31const BASE_SIZE: f32 = 600.;
32const GAP: f32 = 10.;
33const FONT_SIZE: f32 = 33.33;
34
35static NINE_SLICE_TEXTURE: OnceLock<Handle<Image>> = OnceLock::new();
36
37fn nine_slice_texture() -> &'static Handle<Image> {
38    NINE_SLICE_TEXTURE
39        .get()
40        .expect("expected NINE_SLICE_TEXTURE_ATLAS to be initialized")
41}
42
43static NINE_SLICE_TEXTURE_ATLAS_LAYOUT: OnceLock<Handle<TextureAtlasLayout>> = OnceLock::new();
44
45fn nine_slice_texture_atlas_layout() -> &'static Handle<TextureAtlasLayout> {
46    NINE_SLICE_TEXTURE_ATLAS_LAYOUT
47        .get()
48        .expect("expected NINE_SLICE_TEXTURE_ATLAS_LAYOUT to be initialized")
49}
50
51static IMAGE: OnceLock<Handle<Image>> = OnceLock::new();
52
53fn image() -> &'static Handle<Image> {
54    IMAGE.get().expect("expected IMAGE to be initialized")
55}
56
57fn nine_slice_el(frame_signal: impl Signal<Item = usize> + Send + 'static) -> El<ImageNode> {
58    El::<ImageNode>::new()
59        .image_node(
60            ImageNode::from_atlas_image(
61                nine_slice_texture().clone(),
62                TextureAtlas {
63                    layout: nine_slice_texture_atlas_layout().clone(),
64                    index: 0,
65                },
66            )
67            .with_mode(NodeImageMode::Sliced(TextureSlicer {
68                border: BorderRect::all(24.0),
69                center_scale_mode: SliceScaleMode::Stretch,
70                sides_scale_mode: SliceScaleMode::Stretch,
71                max_corner_scale: 1.0,
72            })),
73        )
74        .on_signal_with_image_node(frame_signal, move |mut image, frame| {
75            if let Some(atlas) = &mut image.texture_atlas {
76                atlas.index = frame;
77            }
78        })
79}
80
81fn nine_slice_button() -> impl Element {
82    let hovered = Mutable::new(false);
83    let pressed = Mutable::new(false);
84    nine_slice_el(map_ref! {
85        let hovered = hovered.signal(),
86        let pressed = pressed.signal() => {
87            if *pressed {
88                2
89            } else if *hovered {
90                1
91            } else {
92                0
93            }
94        }
95    })
96    .with_node(|mut node| {
97        node.width = Val::Px(100.);
98        node.height = Val::Px(50.);
99    })
100    .hovered_sync(hovered)
101    .pressed_sync(pressed)
102    .cursor(CursorIcon::System(SystemCursorIcon::Pointer))
103}
104
105static WIDTH: LazyLock<Mutable<f32>> = LazyLock::new(default);
106
107fn horizontal() -> impl Element {
108    Row::<Node>::new()
109        .with_node(|mut node| {
110            node.width = Val::Percent(100.);
111            node.height = Val::Percent(100.);
112            node.column_gap = Val::Px(GAP);
113        })
114        .item(
115            Column::<Node>::new()
116                .with_node(|mut node| {
117                    node.width = Val::Percent(50.);
118                    node.height = Val::Percent(100.);
119                    node.row_gap = Val::Px(GAP);
120                })
121                .align_content(Align::center())
122                .items((0..8).map(|_| nine_slice_button())),
123        )
124        .item(El::<ImageNode>::new().image_node(ImageNode::new(image().clone())))
125}
126
127fn vertical() -> impl Element {
128    Column::<Node>::new()
129        .with_node(|mut node| {
130            node.width = Val::Percent(100.);
131            node.height = Val::Percent(100.);
132            node.row_gap = Val::Px(GAP);
133        })
134        .item(El::<ImageNode>::new().image_node(ImageNode::new(image().clone())))
135        .item(
136            Row::<Node>::new()
137                .multiline()
138                .align_content(Align::center())
139                .with_node(|mut node| {
140                    node.width = Val::Percent(100.);
141                    node.height = Val::Percent(50.);
142                    node.column_gap = Val::Px(GAP);
143                })
144                .items((0..8).map(|_| nine_slice_button())),
145        )
146}
147
148fn menu() -> impl Element {
149    nine_slice_el(always(3))
150        .with_node(|mut node| {
151            node.height = Val::Px(BASE_SIZE);
152            node.padding = UiRect::all(Val::Px(GAP));
153        })
154        .on_signal_with_node(
155            WIDTH.signal().map(|width| BASE_SIZE.min(width)).dedupe().map(Val::Px),
156            |mut node, width| node.width = width,
157        )
158        .child_signal(
159            WIDTH
160                .signal()
161                .map(|width| width > 400.)
162                .dedupe()
163                .map_bool(|| horizontal().type_erase(), || vertical().type_erase()),
164        )
165}
166
167fn ui_root() -> impl Element {
168    El::<Node>::new()
169        .with_node(|mut node| {
170            node.width = Val::Percent(100.);
171            node.height = Val::Percent(100.);
172        })
173        .align_content(Align::center())
174        .cursor(CursorIcon::default())
175        .child(
176            Column::<Node>::new()
177                .with_node(|mut node| node.row_gap = Val::Px(GAP))
178                .item(
179                    Row::<Node>::new()
180                        .with_node(|mut node| node.padding.left = Val::Px(GAP))
181                        .item(
182                            El::<Text>::new()
183                                .text_font(TextFont::from_font_size(FONT_SIZE))
184                                .text(Text::new("width: ")),
185                        )
186                        .item(
187                            El::<Text>::new()
188                                .text_font(TextFont::from_font_size(FONT_SIZE))
189                                .text_signal(WIDTH.signal_ref(ToString::to_string).map(Text)),
190                        ),
191                )
192                .item(menu()),
193        )
194}
195
196fn setup(
197    mut commands: Commands,
198    asset_server: Res<AssetServer>,
199    mut texture_atlases: ResMut<Assets<TextureAtlasLayout>>,
200) {
201    NINE_SLICE_TEXTURE
202        .set(asset_server.load("panels.png"))
203        .expect("failed to initialize NINE_SLICE_TEXTURE");
204    NINE_SLICE_TEXTURE_ATLAS_LAYOUT
205        .set(texture_atlases.add(TextureAtlasLayout::from_grid(UVec2::new(32, 32), 4, 1, None, None)))
206        .expect("failed to initialize NINE_SLICE_TEXTURE_ATLAS_LAYOUT");
207    IMAGE
208        .set(asset_server.load("icon.png"))
209        .expect("failed to initialize IMAGE");
210    commands.spawn(Camera2d);
211}
212
213fn on_resize(mut resize_events: EventReader<WindowResized>) {
214    for event in resize_events.read() {
215        WIDTH.set(event.width)
216    }
217}