use std::sync::mpsc; use crate::{ app::UserEvent, emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState}, persistence::Persistence, }; use egui::{ ecolor::HexColor, menu, Align2, Button, CentralPanel, Color32, Context, Direction, Frame, Layout, Response, Sense, TopBottomPanel, Ui, Vec2, ViewportBuilder, ViewportCommand, ViewportId, WidgetText, Window, }; use egui_toast::{Toast, Toasts}; use serde::{Deserialize, Serialize}; use winit::event_loop::EventLoopProxy; use super::{ game_screen::{DisplayMode, GameScreen}, AppWindow, }; const COLOR_PRESETS: [[Color32; 2]; 3] = [ [ Color32::from_rgb(0xff, 0x00, 0x00), Color32::from_rgb(0x00, 0xc6, 0xf0), ], [ Color32::from_rgb(0x00, 0xb4, 0x00), Color32::from_rgb(0xc8, 0x00, 0xff), ], [ Color32::from_rgb(0xb4, 0x9b, 0x00), Color32::from_rgb(0x00, 0x00, 0xff), ], ]; pub struct GameWindow { client: EmulatorClient, proxy: EventLoopProxy, persistence: Persistence, sim_id: SimId, config: GameConfig, screen: Option, messages: Option>, color_picker: Option, } impl GameWindow { pub fn new( client: EmulatorClient, proxy: EventLoopProxy, persistence: Persistence, sim_id: SimId, ) -> Self { let config = load_config(&persistence, sim_id); Self { client, proxy, persistence, sim_id, config, screen: None, messages: None, color_picker: None, } } fn show_menu(&mut self, ctx: &Context, ui: &mut Ui) { ui.menu_button("ROM", |ui| { if ui.button("Open ROM").clicked() { let rom = rfd::FileDialog::new() .add_filter("Virtual Boy ROMs", &["vb", "vbrom"]) .pick_file(); if let Some(path) = rom { self.client .send_command(EmulatorCommand::LoadGame(self.sim_id, path)); } ui.close_menu(); } if ui.button("Quit").clicked() { let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id)); } }); ui.menu_button("Emulation", |ui| { let state = self.client.emulator_state(); let is_ready = self.client.sim_state(self.sim_id) == SimState::Ready; let can_pause = is_ready && state == EmulatorState::Running; let can_resume = is_ready && state == EmulatorState::Paused; if state == EmulatorState::Running { if ui.add_enabled(can_pause, Button::new("Pause")).clicked() { self.client.send_command(EmulatorCommand::Pause); ui.close_menu(); } } else if ui.add_enabled(can_resume, Button::new("Resume")).clicked() { self.client.send_command(EmulatorCommand::Resume); ui.close_menu(); } if ui.add_enabled(is_ready, Button::new("Reset")).clicked() { self.client .send_command(EmulatorCommand::Reset(self.sim_id)); ui.close_menu(); } }); ui.menu_button("Options", |ui| self.show_options_menu(ctx, ui)); ui.menu_button("Multiplayer", |ui| { let has_player_2 = self.client.sim_state(SimId::Player2) != SimState::Uninitialized; if self.sim_id == SimId::Player1 && !has_player_2 && ui.button("Open Player 2").clicked() { self.client .send_command(EmulatorCommand::StartSecondSim(None)); self.proxy.send_event(UserEvent::OpenPlayer2).unwrap(); ui.close_menu(); } if has_player_2 { let linked = self.client.are_sims_linked(); if linked && ui.button("Unlink").clicked() { self.client.send_command(EmulatorCommand::Unlink); ui.close_menu(); } if !linked && ui.button("Link").clicked() { self.client.send_command(EmulatorCommand::Link); ui.close_menu(); } } }); ui.menu_button("Tools", |ui| { if ui.button("GDB Server").clicked() { self.proxy .send_event(UserEvent::OpenDebugger(self.sim_id)) .unwrap(); ui.close_menu(); } }); ui.menu_button("About", |ui| { self.proxy.send_event(UserEvent::OpenAbout).unwrap(); ui.close_menu(); }); } fn show_options_menu(&mut self, ctx: &Context, ui: &mut Ui) { ui.menu_button("Video", |ui| { ui.menu_button("Screen Size", |ui| { let current_dims = self.config.dimensions; for scale in 1..=4 { let label = format!("x{scale}"); let scale = scale as f32; let dims = { let Vec2 { x, y } = self.config.display_mode.proportions(); Vec2::new(x * scale, y * scale + 22.0) }; if ui .selectable_button((current_dims - dims).length() < 1.0, label) .clicked() { ctx.send_viewport_cmd(ViewportCommand::InnerSize(dims)); ui.close_menu(); } } }); ui.menu_button("Display Mode", |ui| { let old_proportions = self.config.display_mode.proportions(); let mut changed = false; let mut display_mode = self.config.display_mode; changed |= ui .selectable_option(&mut display_mode, DisplayMode::Anaglyph, "Anaglyph") .clicked(); changed |= ui .selectable_option(&mut display_mode, DisplayMode::LeftEye, "Left Eye") .clicked(); changed |= ui .selectable_option(&mut display_mode, DisplayMode::RightEye, "Right Eye") .clicked(); changed |= ui .selectable_option(&mut display_mode, DisplayMode::SideBySide, "Side by Side") .clicked(); if !changed { return; } let current_dims = self.config.dimensions; let new_proportions = display_mode.proportions(); let scale = new_proportions / old_proportions; if scale != Vec2::new(1.0, 1.0) { ctx.send_viewport_cmd(ViewportCommand::InnerSize(current_dims * scale)); } self.update_config(|c| { c.display_mode = display_mode; c.dimensions = current_dims * scale; }); ui.close_menu(); }); ui.menu_button("Colors", |ui| { for preset in COLOR_PRESETS { if ui.color_pair_button(preset[0], preset[1]).clicked() { self.update_config(|c| c.colors = preset); ui.close_menu(); } } ui.with_layout(ui.layout().with_cross_align(egui::Align::Center), |ui| { if ui.button("Custom").clicked() { let color_str = |color: Color32| { format!("{:02x}{:02x}{:02x}", color.r(), color.g(), color.b()) }; let is_running = self.client.emulator_state() == EmulatorState::Running; if is_running { self.client.send_command(EmulatorCommand::Pause); } let color_codes = [ color_str(self.config.colors[0]), color_str(self.config.colors[1]), ]; self.color_picker = Some(ColorPickerState { color_codes, just_opened: true, unpause_on_close: is_running, }); ui.close_menu(); } }); }); }); ui.menu_button("Audio", |ui| { let p1_enabled = self.client.is_audio_enabled(SimId::Player1); let p2_enabled = self.client.is_audio_enabled(SimId::Player2); if ui.selectable_button(p1_enabled, "Player 1").clicked() { self.client .send_command(EmulatorCommand::SetAudioEnabled(!p1_enabled, p2_enabled)); ui.close_menu(); } if ui.selectable_button(p2_enabled, "Player 2").clicked() { self.client .send_command(EmulatorCommand::SetAudioEnabled(p1_enabled, !p2_enabled)); ui.close_menu(); } }); ui.menu_button("Input", |ui| { if ui.button("Bind Inputs").clicked() { self.proxy.send_event(UserEvent::OpenInput).unwrap(); ui.close_menu(); } }); } fn show_color_picker(&mut self, ui: &mut Ui) { let mut colors = self.config.colors; let Some(state) = self.color_picker.as_mut() else { return; }; let (open, updated) = ui .horizontal(|ui| { let left_color = ui.color_picker(&mut colors[0], &mut state.color_codes[0]); if state.just_opened { left_color.request_focus(); state.just_opened = false; } let right_color = ui.color_picker(&mut colors[1], &mut state.color_codes[1]); let open = left_color.has_focus() || right_color.has_focus(); let updated = left_color.changed() || right_color.changed(); (open, updated) }) .inner; if !open { if state.unpause_on_close { self.client.send_command(EmulatorCommand::Resume); } self.color_picker = None; } if updated { self.update_config(|c| c.colors = colors); } } fn update_config(&mut self, update: impl FnOnce(&mut GameConfig)) { let mut new_config = self.config.clone(); update(&mut new_config); if self.config != new_config { let _ = self .persistence .save_config(config_filename(self.sim_id), &new_config); } self.config = new_config; } } fn config_filename(sim_id: SimId) -> &'static str { match sim_id { SimId::Player1 => "config_p1", SimId::Player2 => "config_p2", } } fn load_config(persistence: &Persistence, sim_id: SimId) -> GameConfig { if let Ok(config) = persistence.load_config(config_filename(sim_id)) { return config; } GameConfig { display_mode: DisplayMode::Anaglyph, colors: COLOR_PRESETS[0], dimensions: DisplayMode::Anaglyph.proportions() + Vec2::new(0.0, 22.0), } } impl AppWindow for GameWindow { fn viewport_id(&self) -> ViewportId { match self.sim_id { SimId::Player1 => ViewportId::ROOT, SimId::Player2 => ViewportId::from_hash_of("Player2"), } } fn sim_id(&self) -> SimId { self.sim_id } fn initial_viewport(&self) -> ViewportBuilder { ViewportBuilder::default() .with_title("Lemur") .with_inner_size(self.config.dimensions) } fn show(&mut self, ctx: &Context) { let dimensions = { let bounds = ctx.available_rect(); bounds.max - bounds.min }; self.update_config(|c| c.dimensions = dimensions); let mut toasts = Toasts::new() .anchor(Align2::LEFT_BOTTOM, (10.0, 10.0)) .direction(Direction::BottomUp); if let Some(messages) = self.messages.as_mut() { while let Ok(toast) = messages.try_recv() { toasts.add(toast); } } TopBottomPanel::top("menubar") .exact_height(22.0) .show(ctx, |ui| { menu::bar(ui, |ui| { self.show_menu(ctx, ui); }); }); if self.color_picker.is_some() { Window::new("Color Picker") .title_bar(false) .resizable(false) .anchor(Align2::CENTER_CENTER, Vec2::ZERO) .show(ctx, |ui| { self.show_color_picker(ui); }); } let frame = Frame::central_panel(&ctx.style()).fill(Color32::BLACK); CentralPanel::default().frame(frame).show(ctx, |ui| { if let Some(screen) = self.screen.as_mut() { screen.update(self.config.display_mode, self.config.colors); ui.add(screen); } }); toasts.show(ctx); } fn on_init(&mut self, render_state: &egui_wgpu::RenderState) { let (screen, sink) = GameScreen::init(render_state); let (message_sink, message_source) = mpsc::channel(); self.client.send_command(EmulatorCommand::ConnectToSim( self.sim_id, sink, message_sink, )); self.screen = Some(screen); self.messages = Some(message_source); } fn on_destroy(&mut self) { if self.sim_id == SimId::Player2 { self.client.send_command(EmulatorCommand::StopSecondSim); } let _ = self.proxy.send_event(UserEvent::Quit(self.sim_id)); } } trait UiExt { fn selectable_button(&mut self, selected: bool, text: impl Into) -> Response; fn selectable_option( &mut self, current_value: &mut T, selected_value: T, text: impl Into, ) -> Response { let response = self.selectable_button(*current_value == selected_value, text); if response.clicked() { *current_value = selected_value; } response } fn color_pair_button(&mut self, left: Color32, right: Color32) -> Response; fn color_picker(&mut self, color: &mut Color32, hex: &mut String) -> Response; } impl UiExt for Ui { fn selectable_button(&mut self, selected: bool, text: impl Into) -> Response { self.style_mut().visuals.widgets.inactive.bg_fill = Color32::TRANSPARENT; self.style_mut().visuals.widgets.hovered.bg_fill = Color32::TRANSPARENT; self.style_mut().visuals.widgets.active.bg_fill = Color32::TRANSPARENT; let mut selected = selected; self.checkbox(&mut selected, text) } fn color_pair_button(&mut self, left: Color32, right: Color32) -> Response { let button_size = Vec2::new(60.0, 20.0); let (rect, response) = self.allocate_at_least(button_size, Sense::click()); let center_x = rect.center().x; let left_rect = rect.with_max_x(center_x); self.painter().rect_filled(left_rect, 0.0, left); let right_rect = rect.with_min_x(center_x); self.painter().rect_filled(right_rect, 0.0, right); let style = self.style().interact(&response); self.painter().rect_stroke(rect, 0.0, style.fg_stroke); response } fn color_picker(&mut self, color: &mut Color32, hex: &mut String) -> Response { self.allocate_ui_with_layout( Vec2::new(100.0, 130.0), Layout::top_down_justified(egui::Align::Center), |ui| { let (rect, _) = ui.allocate_at_least(Vec2::new(100.0, 100.0), Sense::hover()); ui.painter().rect_filled(rect, 0.0, *color); let resp = ui.text_edit_singleline(hex); if resp.changed() { if let Ok(new_color) = HexColor::from_str_without_hash(hex) { *color = new_color.color(); } } resp }, ) .inner } } struct ColorPickerState { color_codes: [String; 2], just_opened: bool, unpause_on_close: bool, } #[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] struct GameConfig { display_mode: DisplayMode, colors: [Color32; 2], dimensions: Vec2, }