konnektoren_bevy/screens/settings/
input_configuration.rs

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
18/// Plugin for input configuration within settings
19pub 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/// Events for input configuration
36#[derive(Event)]
37pub enum InputConfigurationEvent {
38    /// Open input configuration
39    Open,
40    /// Close input configuration
41    Close,
42    /// Device button clicked
43    DeviceAssigned { player_id: u32, device: InputDevice },
44    /// Player device unassigned
45    DeviceUnassigned { player_id: u32 },
46}
47
48/// Component marking an active input configuration screen
49#[derive(Component)]
50pub struct ActiveInputConfiguration {
51    pub max_players: u32,
52    pub current_players: u32,
53}
54
55/// System to handle input configuration events
56pub 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    // Early return if input resources aren't available
63    let (mut assignment, available_devices) = match (assignment, available_devices) {
64        (Some(assignment), Some(available_devices)) => (assignment, available_devices),
65        _ => {
66            // If input resources aren't available, just consume events and warn
67            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/// System to render input configuration UI
129#[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    // Check if input resources are available
149    let (assignment, available_devices) = match (assignment, available_devices) {
150        (Some(assignment), Some(available_devices)) => (assignment, available_devices),
151        _ => {
152            // If input resources aren't available, show error message
153            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    // Handle escape to close
167    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
187/// Render UI when input resources are not available
188fn 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    // Handle escape to close
198    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                // Header
216                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                // Error message
229                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                // Back button with unique ID using scope and push_id
250                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        // Header
286        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        // Instructions
299        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        // Device status section with unique ID
310        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        // Player configuration grid with unique scroll area ID
319        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        // Footer with back button
340        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        // Group devices by category
385        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        // Sort categories by their display order
397        let mut sorted_categories: Vec<_> = categories.into_iter().collect();
398        sorted_categories.sort_by_key(|(category, _)| category.order());
399
400        // Display devices by category
401        for (category, devices_in_category) in sorted_categories {
402            ui.horizontal(|ui| {
403                // Category icon and name
404                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            // Show individual devices in this category
425            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        // Add unique ID salt for each row of players
484        ui.push_id(format!("player_row_{}", current_player / 2), |ui| {
485            if responsive.is_mobile() {
486                // Mobile: one column
487                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                // Desktop: two columns
502                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    // Use player-specific ID for the entire panel
564    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                // Player header
570                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                // Current device display with unique ID
582                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                // Device selection section with unique ID
620                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                // Unassign button with unique ID
644                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    // Group devices by category for organized display
677    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    // Sort categories by their display order
687    let mut sorted_categories: Vec<_> = categories.into_iter().collect();
688    sorted_categories.sort_by_key(|(category, _)| category.order());
689
690    // Render each category with unique IDs
691    for (category, devices_in_category) in sorted_categories {
692        ui.push_id(
693            format!("category_{}_{}", category.name(), player_id),
694            |ui| {
695                // Category header
696                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                // Devices in this category with unique IDs
707                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                            // Style the button based on state
723                            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                            // Use comprehensive unique ID for each device button
730                            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
758/// System to cleanup input configuration
759pub 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
773/// Helper function to spawn input configuration screen
774pub 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}