1use crate::{
2 input::{
3 components::{InputController, InputDeviceAssignment, InputEvent},
4 device::{AvailableInputDevices, InputDevice},
5 },
6 theme::KonnektorenTheme,
7 ui::{
8 responsive::{ResponsiveFontSize, ResponsiveInfo, ResponsiveSpacing},
9 widgets::{ResponsiveText, ThemedButton},
10 },
11};
12use bevy::prelude::*;
13use bevy_egui::{
14 egui::{self, Id, Widget},
15 EguiContexts,
16};
17
18pub struct InputConfigurationPlugin;
20
21impl Plugin for InputConfigurationPlugin {
22 fn build(&self, app: &mut App) {
23 app.add_event::<InputConfigurationEvent>()
24 .add_systems(
25 Update,
26 (
27 handle_input_configuration_events,
28 cleanup_input_configuration,
29 ),
30 )
31 .add_systems(bevy_egui::EguiContextPass, render_input_configuration_ui);
32 }
33}
34
35#[derive(Event)]
37pub enum InputConfigurationEvent {
38 Open,
40 Close,
42 DeviceAssigned { player_id: u32, device: InputDevice },
44 DeviceUnassigned { player_id: u32 },
46}
47
48#[derive(Component)]
50pub struct ActiveInputConfiguration {
51 pub max_players: u32,
52 pub current_players: u32,
53}
54
55pub fn handle_input_configuration_events(
57 mut config_events: EventReader<InputConfigurationEvent>,
58 assignment: Option<ResMut<InputDeviceAssignment>>,
59 mut input_events: EventWriter<InputEvent>,
60 available_devices: Option<Res<AvailableInputDevices>>,
61) {
62 let (mut assignment, available_devices) = match (assignment, available_devices) {
64 (Some(assignment), Some(available_devices)) => (assignment, available_devices),
65 _ => {
66 for event in config_events.read() {
68 match event {
69 InputConfigurationEvent::Open => {
70 warn!("Input configuration opened but InputPlugin not loaded");
71 }
72 InputConfigurationEvent::Close => {
73 info!("Closing input configuration");
74 }
75 _ => {
76 warn!("Input configuration event received but InputPlugin not loaded");
77 }
78 }
79 }
80 return;
81 }
82 };
83
84 for event in config_events.read() {
85 match event {
86 InputConfigurationEvent::Open => {
87 info!("Opening input configuration");
88 }
89 InputConfigurationEvent::Close => {
90 info!("Closing input configuration");
91 }
92 InputConfigurationEvent::DeviceAssigned { player_id, device } => {
93 if !device.is_available(&available_devices) {
94 warn!("Device {} is not available", device.name());
95 continue;
96 }
97
98 if assignment.is_device_assigned(device)
99 && assignment.get_player_for_device(device) != Some(*player_id)
100 {
101 warn!(
102 "Device {} is already assigned to another player",
103 device.name()
104 );
105 continue;
106 }
107
108 assignment.assign_device(*player_id, device.clone());
109 input_events.write(InputEvent::DeviceAssigned {
110 player_id: *player_id,
111 device: device.clone(),
112 });
113
114 info!("Assigned {} to player {}", device.name(), player_id + 1);
115 }
116 InputConfigurationEvent::DeviceUnassigned { player_id } => {
117 assignment.unassign_player(*player_id);
118 input_events.write(InputEvent::DeviceUnassigned {
119 player_id: *player_id,
120 });
121
122 info!("Unassigned device from player {}", player_id + 1);
123 }
124 }
125 }
126}
127
128#[allow(clippy::too_many_arguments)]
130pub fn render_input_configuration_ui(
131 mut contexts: EguiContexts,
132 theme: Res<KonnektorenTheme>,
133 responsive: Res<ResponsiveInfo>,
134 query: Query<(Entity, &ActiveInputConfiguration)>,
135 assignment: Option<Res<InputDeviceAssignment>>,
136 available_devices: Option<Res<AvailableInputDevices>>,
137 mut config_events: EventWriter<InputConfigurationEvent>,
138 input: Res<ButtonInput<KeyCode>>,
139) {
140 if query.is_empty() {
141 return;
142 }
143
144 let Ok((_screen_entity, config)) = query.single() else {
145 return;
146 };
147
148 let (assignment, available_devices) = match (assignment, available_devices) {
150 (Some(assignment), Some(available_devices)) => (assignment, available_devices),
151 _ => {
152 render_input_unavailable_ui(
154 &mut contexts,
155 &theme,
156 &responsive,
157 &mut config_events,
158 &input,
159 );
160 return;
161 }
162 };
163
164 let ctx = contexts.ctx_mut();
165
166 if input.just_pressed(KeyCode::Escape) {
168 config_events.write(InputConfigurationEvent::Close);
169 return;
170 }
171
172 egui::CentralPanel::default()
173 .frame(egui::Frame::NONE.fill(theme.base_100))
174 .show(ctx, |ui| {
175 render_input_configuration_content(
176 ui,
177 config,
178 &theme,
179 &responsive,
180 &assignment,
181 &available_devices,
182 &mut config_events,
183 );
184 });
185}
186
187fn render_input_unavailable_ui(
189 contexts: &mut EguiContexts,
190 theme: &KonnektorenTheme,
191 responsive: &ResponsiveInfo,
192 config_events: &mut EventWriter<InputConfigurationEvent>,
193 input: &Res<ButtonInput<KeyCode>>,
194) {
195 let ctx = contexts.ctx_mut();
196
197 if input.just_pressed(KeyCode::Escape) {
199 config_events.write(InputConfigurationEvent::Close);
200 return;
201 }
202
203 egui::CentralPanel::default()
204 .frame(egui::Frame::NONE.fill(theme.base_100))
205 .show(ctx, |ui| {
206 ui.vertical_centered(|ui| {
207 let max_width = if responsive.is_mobile() {
208 ui.available_width() * 0.95
209 } else {
210 600.0_f32.min(ui.available_width() * 0.9)
211 };
212
213 ui.set_max_width(max_width);
214
215 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
217 ResponsiveText::new(
218 "Input Configuration Not Available",
219 ResponsiveFontSize::Header,
220 theme.error,
221 )
222 .responsive(responsive)
223 .strong()
224 .ui(ui);
225
226 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
227
228 ResponsiveText::new(
230 "Input configuration is not available because the InputPlugin is not loaded.",
231 ResponsiveFontSize::Medium,
232 theme.base_content,
233 )
234 .responsive(responsive)
235 .ui(ui);
236
237 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
238
239 ResponsiveText::new(
240 "To enable input configuration, add InputPlugin to your app.",
241 ResponsiveFontSize::Medium,
242 theme.accent,
243 )
244 .responsive(responsive)
245 .ui(ui);
246
247 ui.add_space(responsive.spacing(ResponsiveSpacing::XLarge));
248
249 ui.scope(|ui| {
251 ui.push_id(Id::new("input_unavailable_back_button"), |ui| {
252 let back_button = ThemedButton::new("← Back to Settings", theme)
253 .responsive(responsive)
254 .width(if responsive.is_mobile() { 200.0 } else { 180.0 });
255
256 if ui.add(back_button).clicked() {
257 config_events.write(InputConfigurationEvent::Close);
258 }
259 });
260 });
261
262 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
263 });
264 });
265}
266
267fn render_input_configuration_content(
268 ui: &mut egui::Ui,
269 config: &ActiveInputConfiguration,
270 theme: &KonnektorenTheme,
271 responsive: &ResponsiveInfo,
272 assignment: &InputDeviceAssignment,
273 available_devices: &AvailableInputDevices,
274 config_events: &mut EventWriter<InputConfigurationEvent>,
275) {
276 ui.vertical_centered(|ui| {
277 let max_width = if responsive.is_mobile() {
278 ui.available_width() * 0.95
279 } else {
280 900.0_f32.min(ui.available_width() * 0.9)
281 };
282
283 ui.set_max_width(max_width);
284
285 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
287 ResponsiveText::new(
288 "Configure Input Devices",
289 ResponsiveFontSize::Header,
290 theme.primary,
291 )
292 .responsive(responsive)
293 .strong()
294 .ui(ui);
295
296 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
297
298 ResponsiveText::new(
300 "Assign input devices to players. Each device can only be used by one player.",
301 ResponsiveFontSize::Medium,
302 theme.base_content,
303 )
304 .responsive(responsive)
305 .ui(ui);
306
307 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
308
309 ui.scope(|ui| {
311 ui.push_id("device_status_section", |ui| {
312 render_device_status_section(ui, theme, responsive, available_devices);
313 });
314 });
315
316 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
317
318 let scroll_height = ui.available_height() - 120.0;
320 egui::ScrollArea::vertical()
321 .id_salt("input_config_scroll")
322 .max_height(scroll_height)
323 .show(ui, |ui| {
324 ui.scope(|ui| {
325 ui.push_id("player_config_grid", |ui| {
326 render_player_configuration_grid(
327 ui,
328 config,
329 theme,
330 responsive,
331 assignment,
332 available_devices,
333 config_events,
334 );
335 });
336 });
337 });
338
339 ui.add_space(responsive.spacing(ResponsiveSpacing::Large));
341
342 ui.scope(|ui| {
343 ui.push_id("input_config_back_button", |ui| {
344 let back_button = ThemedButton::new("← Back to Settings", theme)
345 .responsive(responsive)
346 .width(if responsive.is_mobile() { 200.0 } else { 180.0 });
347
348 if ui.add(back_button).clicked() {
349 config_events.write(InputConfigurationEvent::Close);
350 }
351 });
352 });
353
354 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
355 });
356}
357
358fn render_device_status_section(
359 ui: &mut egui::Ui,
360 theme: &KonnektorenTheme,
361 responsive: &ResponsiveInfo,
362 available_devices: &AvailableInputDevices,
363) {
364 let frame = egui::Frame {
365 inner_margin: responsive.margin_all(crate::ui::responsive::ResponsiveMargin::Medium),
366 corner_radius: egui::CornerRadius::same(8),
367 fill: theme.base_200,
368 stroke: egui::Stroke::new(1.0, theme.accent.linear_multiply(0.3)),
369 ..Default::default()
370 };
371
372 frame.show(ui, |ui| {
373 ResponsiveText::new(
374 "Available Devices",
375 ResponsiveFontSize::Large,
376 theme.secondary,
377 )
378 .responsive(responsive)
379 .strong()
380 .ui(ui);
381
382 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
383
384 let devices = available_devices.get_available_devices();
386 let mut categories = std::collections::HashMap::new();
387
388 for device in devices {
389 let category = device.category();
390 categories
391 .entry(category)
392 .or_insert_with(Vec::new)
393 .push(device);
394 }
395
396 let mut sorted_categories: Vec<_> = categories.into_iter().collect();
398 sorted_categories.sort_by_key(|(category, _)| category.order());
399
400 for (category, devices_in_category) in sorted_categories {
402 ui.horizontal(|ui| {
403 ResponsiveText::new(
405 &format!("{} {}", category.icon(), category.name()),
406 ResponsiveFontSize::Medium,
407 theme.primary,
408 )
409 .responsive(responsive)
410 .strong()
411 .ui(ui);
412
413 ResponsiveText::new(
414 &format!("({})", devices_in_category.len()),
415 ResponsiveFontSize::Small,
416 theme.base_content,
417 )
418 .responsive(responsive)
419 .ui(ui);
420 });
421
422 ui.add_space(responsive.spacing(ResponsiveSpacing::XSmall));
423
424 ui.horizontal_wrapped(|ui| {
426 for (device_index, device) in devices_in_category.iter().enumerate() {
427 let is_available = device.is_available(available_devices);
428 let (bg_color, text_color) = if is_available {
429 (theme.success.linear_multiply(0.2), theme.success)
430 } else {
431 (theme.error.linear_multiply(0.2), theme.error)
432 };
433
434 let device_frame = egui::Frame {
435 inner_margin: egui::Margin::symmetric(6, 3),
436 corner_radius: egui::CornerRadius::same(4),
437 fill: bg_color,
438 ..Default::default()
439 };
440
441 ui.push_id(
442 format!("device_status_{}_{}", category.name(), device_index),
443 |ui| {
444 device_frame.show(ui, |ui| {
445 ResponsiveText::new(
446 &device.name(),
447 ResponsiveFontSize::Small,
448 text_color,
449 )
450 .responsive(responsive)
451 .ui(ui);
452 });
453 },
454 );
455
456 ui.add_space(responsive.spacing(ResponsiveSpacing::XSmall));
457 }
458 });
459
460 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
461 }
462 });
463}
464
465fn render_player_configuration_grid(
466 ui: &mut egui::Ui,
467 config: &ActiveInputConfiguration,
468 theme: &KonnektorenTheme,
469 responsive: &ResponsiveInfo,
470 assignment: &InputDeviceAssignment,
471 available_devices: &AvailableInputDevices,
472 config_events: &mut EventWriter<InputConfigurationEvent>,
473) {
474 let panel_width = if responsive.is_mobile() {
475 ui.available_width() * 0.95
476 } else {
477 380.0
478 };
479
480 let mut current_player = 0;
481
482 while current_player < config.current_players {
483 ui.push_id(format!("player_row_{}", current_player / 2), |ui| {
485 if responsive.is_mobile() {
486 ui.push_id(format!("player_panel_mobile_{}", current_player), |ui| {
488 render_player_panel(
489 ui,
490 current_player,
491 panel_width,
492 theme,
493 responsive,
494 assignment,
495 available_devices,
496 config_events,
497 );
498 });
499 current_player += 1;
500 } else {
501 ui.horizontal(|ui| {
503 ui.push_id(format!("player_panel_left_{}", current_player), |ui| {
504 render_player_panel(
505 ui,
506 current_player,
507 panel_width,
508 theme,
509 responsive,
510 assignment,
511 available_devices,
512 config_events,
513 );
514 });
515
516 if current_player + 1 < config.current_players {
517 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
518 ui.push_id(format!("player_panel_right_{}", current_player + 1), |ui| {
519 render_player_panel(
520 ui,
521 current_player + 1,
522 panel_width,
523 theme,
524 responsive,
525 assignment,
526 available_devices,
527 config_events,
528 );
529 });
530 current_player += 2;
531 } else {
532 current_player += 1;
533 }
534 });
535 }
536 });
537
538 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
539 }
540}
541
542#[allow(clippy::too_many_arguments)]
543fn render_player_panel(
544 ui: &mut egui::Ui,
545 player_id: u32,
546 width: f32,
547 theme: &KonnektorenTheme,
548 responsive: &ResponsiveInfo,
549 assignment: &InputDeviceAssignment,
550 available_devices: &AvailableInputDevices,
551 config_events: &mut EventWriter<InputConfigurationEvent>,
552) {
553 let current_device = assignment.get_device_for_player(player_id);
554
555 let frame = egui::Frame {
556 inner_margin: responsive.margin_all(crate::ui::responsive::ResponsiveMargin::Medium),
557 corner_radius: egui::CornerRadius::same(8),
558 fill: theme.base_200,
559 stroke: egui::Stroke::new(2.0, theme.primary.linear_multiply(0.5)),
560 ..Default::default()
561 };
562
563 ui.push_id(format!("player_panel_content_{}", player_id), |ui| {
565 frame.show(ui, |ui| {
566 ui.set_min_width(width);
567
568 ui.vertical(|ui| {
569 ResponsiveText::new(
571 &format!("Player {}", player_id + 1),
572 ResponsiveFontSize::Large,
573 theme.primary,
574 )
575 .responsive(responsive)
576 .strong()
577 .ui(ui);
578
579 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
580
581 ui.push_id(format!("current_device_display_{}", player_id), |ui| {
583 let (device_text, device_desc) = if let Some(device) = current_device {
584 (device.name(), device.description())
585 } else {
586 (
587 "No device assigned".to_string(),
588 "Select a device below".to_string(),
589 )
590 };
591
592 let device_color = if current_device.is_some() {
593 theme.success
594 } else {
595 theme.error
596 };
597
598 ResponsiveText::new(
599 &format!("Current: {}", device_text),
600 ResponsiveFontSize::Medium,
601 device_color,
602 )
603 .responsive(responsive)
604 .ui(ui);
605
606 if current_device.is_some() {
607 ResponsiveText::new(
608 &device_desc,
609 ResponsiveFontSize::Small,
610 theme.base_content,
611 )
612 .responsive(responsive)
613 .ui(ui);
614 }
615 });
616
617 ui.add_space(responsive.spacing(ResponsiveSpacing::Medium));
618
619 ui.push_id(format!("device_selection_{}", player_id), |ui| {
621 ResponsiveText::new(
622 "Available Devices:",
623 ResponsiveFontSize::Medium,
624 theme.base_content,
625 )
626 .responsive(responsive)
627 .ui(ui);
628
629 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
630
631 render_device_categories_for_player(
632 ui,
633 player_id,
634 width,
635 theme,
636 responsive,
637 assignment,
638 available_devices,
639 config_events,
640 );
641 });
642
643 if current_device.is_some() {
645 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
646
647 ui.push_id(format!("unassign_section_{}", player_id), |ui| {
648 let unassign_button = ThemedButton::new("Unassign Device", theme)
649 .responsive(responsive)
650 .width(width - 40.0)
651 .with_style(|btn| btn.fill(theme.accent));
652
653 if ui.add(unassign_button).clicked() {
654 config_events
655 .write(InputConfigurationEvent::DeviceUnassigned { player_id });
656 }
657 });
658 }
659 });
660 });
661 });
662}
663
664fn render_device_categories_for_player(
665 ui: &mut egui::Ui,
666 player_id: u32,
667 width: f32,
668 theme: &KonnektorenTheme,
669 responsive: &ResponsiveInfo,
670 assignment: &InputDeviceAssignment,
671 available_devices: &AvailableInputDevices,
672 config_events: &mut EventWriter<InputConfigurationEvent>,
673) {
674 let devices = available_devices.get_available_devices();
675
676 let mut categories = std::collections::HashMap::new();
678 for (index, device) in devices.iter().enumerate() {
679 let category = device.category();
680 categories
681 .entry(category)
682 .or_insert_with(Vec::new)
683 .push((index, device));
684 }
685
686 let mut sorted_categories: Vec<_> = categories.into_iter().collect();
688 sorted_categories.sort_by_key(|(category, _)| category.order());
689
690 for (category, devices_in_category) in sorted_categories {
692 ui.push_id(
693 format!("category_{}_{}", category.name(), player_id),
694 |ui| {
695 ResponsiveText::new(
697 &format!("{} {}", category.icon(), category.name()),
698 ResponsiveFontSize::Small,
699 theme.secondary,
700 )
701 .responsive(responsive)
702 .ui(ui);
703
704 ui.add_space(responsive.spacing(ResponsiveSpacing::XSmall));
705
706 ui.push_id(
708 format!("devices_in_category_{}_{}", category.name(), player_id),
709 |ui| {
710 for (device_index, device) in devices_in_category {
711 let is_selected =
712 assignment.get_device_for_player(player_id) == Some(device);
713 let is_available = device.is_available(available_devices);
714 let is_used_by_other = assignment.is_device_assigned(device)
715 && assignment.get_player_for_device(device) != Some(player_id);
716
717 let device_name = device.name();
718 let mut button = ThemedButton::new(&device_name, theme)
719 .responsive(responsive)
720 .width(width - 40.0);
721
722 if is_selected {
724 button = button.with_style(|btn| btn.fill(theme.success));
725 } else if !is_available || is_used_by_other {
726 button = button.enabled(false).opacity(0.5);
727 }
728
729 let button_id = format!(
731 "device_btn_p{}_cat{}_dev{}_idx{}",
732 player_id,
733 category.name(),
734 device_index,
735 device.name().replace(' ', "_")
736 );
737
738 ui.push_id(button_id, |ui| {
739 if ui.add(button).clicked() && is_available && !is_used_by_other {
740 config_events.write(InputConfigurationEvent::DeviceAssigned {
741 player_id,
742 device: device.clone(),
743 });
744 }
745 });
746
747 ui.add_space(responsive.spacing(ResponsiveSpacing::XSmall));
748 }
749 },
750 );
751
752 ui.add_space(responsive.spacing(ResponsiveSpacing::Small));
753 },
754 );
755 }
756}
757
758pub fn cleanup_input_configuration(
760 mut commands: Commands,
761 mut config_events: EventReader<InputConfigurationEvent>,
762 config_query: Query<Entity, With<ActiveInputConfiguration>>,
763) {
764 for event in config_events.read() {
765 if matches!(event, InputConfigurationEvent::Close) {
766 for entity in config_query.iter() {
767 commands.entity(entity).despawn();
768 }
769 }
770 }
771}
772
773pub fn spawn_input_configuration_screen(
775 commands: &mut Commands,
776 max_players: u32,
777 controllers: &Query<&InputController>,
778) -> Entity {
779 let current_players = controllers.iter().map(|c| c.player_id).max().unwrap_or(0) + 1;
780
781 commands
782 .spawn((
783 Name::new("Input Configuration Screen"),
784 ActiveInputConfiguration {
785 max_players,
786 current_players,
787 },
788 ))
789 .id()
790}