Skip to main content

basic_controls/
basic_controls.rs

1use beuvy_runtime::input::InputType;
2use beuvy_runtime::input::InputValueChangedMessage;
3use beuvy_runtime::scroll_container_node;
4use beuvy_runtime::text::FontResource;
5use beuvy_runtime::text::set_plain_text;
6use beuvy_runtime::{AddButton, AddInput, AddSelect, AddSelectOption, AddText, UiKitPlugin};
7use bevy::prelude::*;
8use bevy::text::TextLayout;
9
10#[derive(Component)]
11struct SliderValueText;
12
13fn main() {
14    App::new()
15        .add_plugins(DefaultPlugins.set(WindowPlugin {
16            primary_window: Some(Window {
17                title: "beuvy-runtime basic controls".to_string(),
18                resolution: (1280, 860).into(),
19                ..default()
20            }),
21            ..default()
22        }))
23        .add_plugins(UiKitPlugin)
24        .add_systems(Startup, setup)
25        .add_systems(Update, sync_slider_value_label)
26        .run();
27}
28
29fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
30    commands.spawn(Camera2d);
31    commands.insert_resource(FontResource::from_handle(
32        asset_server.load("fonts/SarasaFixedSC-Regular.ttf"),
33    ));
34
35    commands
36        .spawn((
37            scroll_container_node(Node {
38                width: Val::Percent(100.0),
39                height: Val::Percent(100.0),
40                padding: UiRect::all(Val::Px(24.0)),
41                column_gap: Val::Px(20.0),
42                align_items: AlignItems::Start,
43                ..default()
44            }),
45            ScrollPosition::default(),
46            BackgroundColor(Color::srgb_u8(245, 247, 250)),
47        ))
48        .with_children(|parent| {
49            spawn_column(parent, |parent| {
50                spawn_panel(parent, "Text", |parent| {
51                    spawn_text(parent, "Display Title", 22.0, Color::srgb_u8(15, 23, 42));
52                    spawn_text(
53                        parent,
54                        "Body copy rendered through the runtime text builder.",
55                        15.0,
56                        Color::srgb_u8(30, 41, 59),
57                    );
58                    spawn_text(
59                        parent,
60                        "Secondary hint text for dense tool UIs.",
61                        13.0,
62                        Color::srgb_u8(100, 116, 139),
63                    );
64                });
65
66                spawn_panel(parent, "Text Inputs", |parent| {
67                    parent.spawn(AddInput {
68                        name: "pilot_name".to_string(),
69                        placeholder: "Pilot name".to_string(),
70                        size_chars: Some(24),
71                        ..default()
72                    });
73                    parent.spawn(AddInput {
74                        name: "callsign".to_string(),
75                        value: "ALPHA-7".to_string(),
76                        size_chars: Some(16),
77                        ..default()
78                    });
79                    parent.spawn(AddInput {
80                        name: "long_note".to_string(),
81                        value: "Long single-line value for caret scrolling and selection checks"
82                            .to_string(),
83                        placeholder: "Long note".to_string(),
84                        size_chars: Some(28),
85                        ..default()
86                    });
87                    parent.spawn(AddInput {
88                        name: "multiline_note".to_string(),
89                        input_type: InputType::Textarea,
90                        value: "Textarea value for native multiline wrapping, caret movement,\nand segmented selection checks."
91                            .to_string(),
92                        placeholder: "Multiline note".to_string(),
93                        size_chars: Some(28),
94                        rows: Some(4),
95                        ..default()
96                    });
97                    parent.spawn(AddInput {
98                        name: "ime_text".to_string(),
99                        placeholder: "中文 IME input".to_string(),
100                        size_chars: Some(20),
101                        ..default()
102                    });
103                    parent.spawn(AddInput {
104                        name: "ime_textarea".to_string(),
105                        input_type: InputType::Textarea,
106                        placeholder: "中文 IME textarea".to_string(),
107                        size_chars: Some(28),
108                        rows: Some(3),
109                        ..default()
110                    });
111                    parent.spawn(AddInput {
112                        name: "disabled_text".to_string(),
113                        value: "Locked field".to_string(),
114                        size_chars: Some(18),
115                        disabled: true,
116                        ..default()
117                    });
118                });
119
120                spawn_panel(parent, "Numeric Inputs", |parent| {
121                    spawn_row(parent, |parent| {
122                        parent.spawn(AddInput {
123                            name: "count".to_string(),
124                            input_type: InputType::Number,
125                            value: "12".to_string(),
126                            min: Some(0.0),
127                            max: Some(64.0),
128                            step: Some(1.0),
129                            size_chars: Some(8),
130                            ..default()
131                        });
132                        parent.spawn(AddInput {
133                            name: "threshold".to_string(),
134                            input_type: InputType::Number,
135                            value: ".".to_string(),
136                            min: Some(0.0),
137                            max: Some(1.0),
138                            step: Some(0.05),
139                            size_chars: Some(8),
140                            ..default()
141                        });
142                    });
143                    parent.spawn((
144                        SliderValueText,
145                        Node {
146                            width: Val::Percent(100.0),
147                            ..default()
148                        },
149                        TextLayout::default(),
150                        AddText {
151                            text: "Volume: 45".to_string(),
152                            size: 14.0,
153                            color: Color::srgb_u8(71, 85, 105),
154                            ..default()
155                        },
156                    ));
157                    parent.spawn(AddInput {
158                        name: "volume".to_string(),
159                        input_type: InputType::Range,
160                        value: "45".to_string(),
161                        min: Some(0.0),
162                        max: Some(100.0),
163                        step: Some(5.0),
164                        ..default()
165                    });
166                });
167
168                spawn_panel(parent, "Form Toggles", |parent| {
169                    spawn_labeled_toggle_row(
170                        parent,
171                        "Checkbox input",
172                        AddInput {
173                            name: "enable_audio".to_string(),
174                            input_type: InputType::Checkbox,
175                            checked: true,
176                            ..default()
177                        },
178                    );
179                    spawn_labeled_toggle_row(
180                        parent,
181                        "Radio input (easy)",
182                        AddInput {
183                            name: "mode".to_string(),
184                            input_type: InputType::Radio,
185                            value: "easy".to_string(),
186                            checked: true,
187                            ..default()
188                        },
189                    );
190                    spawn_labeled_toggle_row(
191                        parent,
192                        "Radio input (hard)",
193                        AddInput {
194                            name: "mode".to_string(),
195                            input_type: InputType::Radio,
196                            value: "hard".to_string(),
197                            ..default()
198                        },
199                    );
200                    spawn_field(parent, "Password input", |parent| {
201                        parent.spawn(AddInput {
202                            name: "secret".to_string(),
203                            input_type: InputType::Password,
204                            value: "hunter2".to_string(),
205                            placeholder: "Password".to_string(),
206                            size_chars: Some(20),
207                            ..default()
208                        });
209                    });
210                });
211            });
212
213            spawn_column(parent, |parent| {
214                spawn_panel(parent, "Selects", |parent| {
215                    parent.spawn(AddSelect {
216                        name: "difficulty".to_string(),
217                        value: "normal".to_string(),
218                        options: vec![
219                            option("difficulty_easy", "easy", "Easy"),
220                            option("difficulty_normal", "normal", "Normal"),
221                            option("difficulty_hard", "hard", "Hard"),
222                        ],
223                        ..default()
224                    });
225                    parent.spawn(AddSelect {
226                        name: "region".to_string(),
227                        value: "us-east".to_string(),
228                        options: vec![
229                            option("region_use1", "us-east", "US East"),
230                            option("region_euw1", "eu-west", "EU West"),
231                            AddSelectOption {
232                                name: "region_apac".to_string(),
233                                value: "apac".to_string(),
234                                text: "APAC (disabled)".to_string(),
235                                localized_text: None,
236                                localized_text_format: None,
237                                disabled: true,
238                            },
239                        ],
240                        ..default()
241                    });
242                });
243
244                spawn_panel(parent, "Buttons", |parent| {
245                    spawn_field_label(parent, "Default");
246                    spawn_row(parent, |parent| {
247                        parent.spawn(AddButton {
248                            name: "default_primary".to_string(),
249                            text: "Primary Action".to_string(),
250                            class: Some("button-root w-[180px]".to_string()),
251                            ..default()
252                        });
253                        parent.spawn(AddButton {
254                            name: "default_secondary".to_string(),
255                            text: "Secondary Action".to_string(),
256                            class: Some("button-root w-[180px]".to_string()),
257                            ..default()
258                        });
259                    });
260
261                    spawn_field_label(parent, "Sizing");
262                    spawn_row(parent, |parent| {
263                        parent.spawn(AddButton {
264                            name: "compact".to_string(),
265                            text: "Compact".to_string(),
266                            class: Some(
267                                "button-root min-h-[30px] w-[120px] px-[8px] py-[4px]".to_string(),
268                            ),
269                            ..default()
270                        });
271                        parent.spawn(AddButton {
272                            name: "wide".to_string(),
273                            text: "Wide Button".to_string(),
274                            class: Some("button-root min-h-[48px] w-[220px]".to_string()),
275                            ..default()
276                        });
277                    });
278
279                    spawn_field_label(parent, "Disabled");
280                    spawn_row(parent, |parent| {
281                        parent.spawn(AddButton {
282                            name: "disabled_default".to_string(),
283                            text: "Disabled".to_string(),
284                            disabled: true,
285                            ..default()
286                        });
287                        parent.spawn(AddButton {
288                            name: "disabled_wide".to_string(),
289                            text: "Disabled Wide".to_string(),
290                            disabled: true,
291                            class: Some("button-root min-h-[48px] w-[220px]".to_string()),
292                            ..default()
293                        });
294                    });
295                });
296            });
297        });
298}
299
300fn sync_slider_value_label(
301    mut commands: Commands,
302    mut events: MessageReader<InputValueChangedMessage>,
303    labels: Query<Entity, With<SliderValueText>>,
304) {
305    let Some(label) = labels.iter().next() else {
306        return;
307    };
308    for event in events.read() {
309        if event.name == "volume" {
310            set_plain_text(&mut commands, label, format!("Volume: {}", event.value));
311        }
312    }
313}
314
315fn spawn_column(
316    parent: &mut ChildSpawnerCommands,
317    children: impl FnOnce(&mut ChildSpawnerCommands),
318) {
319    parent
320        .spawn(Node {
321            width: Val::Px(600.0),
322            row_gap: Val::Px(20.0),
323            flex_direction: FlexDirection::Column,
324            overflow: Overflow::visible(),
325            ..default()
326        })
327        .with_children(children);
328}
329
330fn spawn_panel(
331    parent: &mut ChildSpawnerCommands,
332    title: &str,
333    children: impl FnOnce(&mut ChildSpawnerCommands),
334) {
335    let mut panel = parent.spawn(Node {
336        width: Val::Percent(100.0),
337        min_height: Val::Px(200.0),
338        padding: UiRect::all(Val::Px(16.0)),
339        row_gap: Val::Px(12.0),
340        flex_direction: FlexDirection::Column,
341        overflow: Overflow::visible(),
342        border_radius: BorderRadius::all(Val::Px(12.0)),
343        ..default()
344    });
345    panel.insert(BorderColor::all(Color::srgb_u8(209, 213, 219)));
346    panel.insert(BackgroundColor(Color::WHITE));
347    panel.with_children(|parent| {
348        spawn_text(parent, title, 18.0, Color::srgb_u8(15, 23, 42));
349        children(parent);
350    });
351}
352
353fn spawn_field(
354    parent: &mut ChildSpawnerCommands,
355    label: &str,
356    children: impl FnOnce(&mut ChildSpawnerCommands),
357) {
358    parent
359        .spawn(Node {
360            width: Val::Percent(100.0),
361            row_gap: Val::Px(10.0),
362            flex_direction: FlexDirection::Column,
363            ..default()
364        })
365        .with_children(|parent| {
366            spawn_field_label(parent, label);
367            children(parent);
368        });
369}
370
371fn spawn_labeled_toggle_row(parent: &mut ChildSpawnerCommands, label: &str, input: AddInput) {
372    parent
373        .spawn(Node {
374            column_gap: Val::Px(12.0),
375            align_items: AlignItems::Center,
376            ..default()
377        })
378        .with_children(|row| {
379            row.spawn(input);
380            spawn_inline_label(row, label);
381        });
382}
383
384fn spawn_row(parent: &mut ChildSpawnerCommands, children: impl FnOnce(&mut ChildSpawnerCommands)) {
385    parent
386        .spawn(Node {
387            column_gap: Val::Px(10.0),
388            row_gap: Val::Px(10.0),
389            flex_wrap: FlexWrap::Wrap,
390            overflow: Overflow::visible(),
391            ..default()
392        })
393        .with_children(children);
394}
395
396fn spawn_field_label(parent: &mut ChildSpawnerCommands, text: &str) {
397    parent.spawn((
398        Node::default(),
399        TextLayout::default(),
400        AddText {
401            text: text.to_string(),
402            size: 13.0,
403            color: Color::srgb_u8(75, 85, 99),
404            ..default()
405        },
406    ));
407}
408
409fn spawn_inline_label(parent: &mut ChildSpawnerCommands, text: &str) {
410    parent.spawn((
411        Node::default(),
412        TextLayout::default(),
413        AddText {
414            text: text.to_string(),
415            size: 15.0,
416            color: Color::srgb_u8(31, 41, 55),
417            ..default()
418        },
419    ));
420}
421
422fn spawn_text(parent: &mut ChildSpawnerCommands, text: &str, size: f32, color: Color) {
423    parent.spawn((
424        Node {
425            width: Val::Percent(100.0),
426            ..default()
427        },
428        TextLayout::default(),
429        AddText {
430            text: text.to_string(),
431            size,
432            color,
433            ..default()
434        },
435    ));
436}
437
438fn option(name: &str, value: &str, text: &str) -> AddSelectOption {
439    AddSelectOption {
440        name: name.to_string(),
441        value: value.to_string(),
442        text: text.to_string(),
443        localized_text: None,
444        localized_text_format: None,
445        disabled: false,
446    }
447}