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}