1mod cheats; 2mod common; 3mod gb; 4mod gba; 5mod genesis; 6mod input; 7mod inputcollect; 8mod nes; 9mod pce; 10mod romlist; 11mod smsgg; 12mod snes; 13mod widgets; 14 15use crate::app::cheats::{CheatConsole, CheatWindowState}; 16use crate::app::genesis::{GenesisVolumeState, S32XPriorityState}; 17use crate::app::input::InputMappingSet; 18use crate::app::nes::{NesPaletteState, OverscanState}; 19use crate::app::romlist::{RomListThreadHandle, RomMetadata}; 20use crate::app::snes::HandledError; 21use crate::app::widgets::RenderErrorEffect; 22use crate::emurunner::{ 23 EmuRunnerCommand, EmuRunnerStatus, EmulatorRunInput, GuiEmulatorRunnerHandle, 24}; 25use egui::{ 26 Align, Button, CentralPanel, Color32, Context, Key, KeyboardShortcut, LayerId, Layout, 27 Modifiers, Order, Panel, TextEdit, ThemePreference, Ui, UiKind, Vec2, ViewportCommand, Widget, 28 Window, 29}; 30use egui_extras::{Column, TableBuilder}; 31use emath::Pos2; 32use jgenesis_native_config::paths::{ConfigDirType, ConfigDirs}; 33use jgenesis_native_config::{AppConfig, EguiTheme, ListFilters, RecentOpen, RomSearchDirectory}; 34use jgenesis_native_driver::extensions::Console; 35use jgenesis_native_driver::{NativeEmulatorError, SdlSubsystems, extensions}; 36use nes_config::NesPalette; 37use rfd::FileDialog; 38use std::cell::RefCell; 39use std::collections::{HashMap, HashSet}; 40use std::fs; 41use std::path::{Path, PathBuf}; 42use std::rc::Rc; 43use std::str::FromStr; 44use std::sync::{Arc, Mutex}; 45use time::{OffsetDateTime, UtcOffset, format_description}; 46 47use crate::app::inputcollect::InputCollectionState; 48use cdrom::reader::CdRomFileFormat; 49pub(crate) use cheats::ActiveCheats; 50pub(crate) use input::GenericButton; 51use jgenesis_native_driver::input::Joysticks; 52 53const RESERVED_HELP_TEXT_HEIGHT: f32 = 150.0; 54 55trait ListFiltersExt { 56 fn to_console_vec(&self) -> Vec<Console>; 57 58 fn apply<'metadata>( 59 &self, 60 rom_list: &'metadata [RomMetadata], 61 title_match: &'metadata str, 62 ) -> impl Iterator<Item = &'metadata RomMetadata> + 'metadata; 63} 64 65impl ListFiltersExt for ListFilters { 66 fn to_console_vec(&self) -> Vec<Console> { 67 [ 68 self.master_system.then_some(Console::MasterSystem), 69 self.game_gear.then_some(Console::GameGear), 70 self.sg_1000.then_some(Console::Sg1000), 71 self.genesis.then_some(Console::Genesis), 72 self.sega_cd.then_some(Console::SegaCd), 73 self.sega_32x.then_some(Console::Sega32X), 74 (self.sega_cd || self.sega_32x).then_some(Console::SegaCd32X), 75 self.nes.then_some(Console::Nes), 76 self.snes.then_some(Console::Snes), 77 self.game_boy.then_some(Console::GameBoy), 78 self.game_boy_color.then_some(Console::GameBoyColor), 79 self.game_boy_advance.then_some(Console::GameBoyAdvance), 80 self.pc_engine.then_some(Console::PcEngine), 81 ] 82 .into_iter() 83 .flatten() 84 .collect() 85 } 86 87 fn apply<'metadata>( 88 &self, 89 rom_list: &'metadata [RomMetadata], 90 title_match_lowercase: &'metadata str, 91 ) -> impl Iterator<Item = &'metadata RomMetadata> + 'metadata { 92 debug_assert!( 93 title_match_lowercase.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) 94 ); 95 96 let filters = self.to_console_vec(); 97 rom_list.iter().filter(move |metadata| { 98 filters.contains(&metadata.console) 99 && (title_match_lowercase.is_empty() 100 || metadata.file_name_no_ext.to_lowercase().contains(title_match_lowercase)) 101 }) 102 } 103} 104 105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 106enum OpenWindow { 107 SmsGgGeneral, 108 GenesisGeneral, 109 NesGeneral, 110 SnesGeneral, 111 GameBoyGeneral, 112 GbaGeneral, 113 PceGeneral, 114 Synchronization, 115 Paths, 116 Interface, 117 CommonVideo, 118 CommonFilter, 119 SmsGgVideo, 120 GenesisVideo, 121 NesVideo, 122 SnesVideo, 123 GameBoyVideo, 124 GbaVideo, 125 PceVideo, 126 CommonAudio, 127 SmsGgAudio, 128 GenesisAudio, 129 NesAudio, 130 SnesAudio, 131 GameBoyAudio, 132 GbaAudio, 133 PceAudio, 134 GeneralInput, 135 SmsGgInput, 136 GenesisInput, 137 NesInput, 138 NesPeripherals, 139 SnesInput, 140 GameBoyInput, 141 GbaInput, 142 GbaPeripherals, 143 PceInput, 144 Hotkeys, 145 Cheats, 146 SmsGgOverclock, 147 GenesisOverclock, 148 SnesOverclock, 149 PceOverclock, 150 About, 151} 152 153impl OpenWindow { 154 fn title(self) -> &'static str { 155 match self { 156 OpenWindow::SmsGgGeneral => "SMS/GG General Settings", 157 OpenWindow::GenesisGeneral => "Genesis General Settings", 158 OpenWindow::NesGeneral => "NES General Settings", 159 OpenWindow::SnesGeneral => "SNES General Settings", 160 OpenWindow::GameBoyGeneral => "Game Boy General Settings", 161 OpenWindow::GbaGeneral => "GBA General Settings", 162 OpenWindow::PceGeneral => "PC Engine General Settings", 163 OpenWindow::Synchronization => "Synchronization Settings", 164 OpenWindow::Paths => "Path Settings", 165 OpenWindow::Interface => "Interface Settings", 166 OpenWindow::CommonVideo => "General Video Settings", 167 OpenWindow::CommonFilter => "Video Filtering Settings", 168 OpenWindow::SmsGgVideo => "SMS/GG Video Settings", 169 OpenWindow::GenesisVideo => "Genesis Video Settings", 170 OpenWindow::NesVideo => "NES Video Settings", 171 OpenWindow::SnesVideo => "SNES Video Settings", 172 OpenWindow::GameBoyVideo => "Game Boy Video Settings", 173 OpenWindow::GbaVideo => "GBA Video Settings", 174 OpenWindow::PceVideo => "PC Engine Video Settings", 175 OpenWindow::CommonAudio => "General Audio Settings", 176 OpenWindow::SmsGgAudio => "SMS/GG Audio Settings", 177 OpenWindow::GenesisAudio => "Genesis Audio Settings", 178 OpenWindow::NesAudio => "NES Audio Settings", 179 OpenWindow::SnesAudio => "SNES Audio Settings", 180 OpenWindow::GameBoyAudio => "Game Boy Audio Settings", 181 OpenWindow::GbaAudio => "GBA Audio Settings", 182 OpenWindow::PceAudio => "PC Engine Audio Settings", 183 OpenWindow::GeneralInput => "General Input Settings", 184 OpenWindow::SmsGgInput => "SMS/GG Input Settings", 185 OpenWindow::GenesisInput => "Genesis Input Settings", 186 OpenWindow::NesInput => "NES Input Settings", 187 OpenWindow::NesPeripherals => "NES Peripheral Settings", 188 OpenWindow::SnesInput => "SNES Input Settings", 189 OpenWindow::GameBoyInput => "Game Boy Input Settings", 190 OpenWindow::GbaInput => "GBA Input Settings", 191 OpenWindow::GbaPeripherals => "GBA Peripheral Settings", 192 OpenWindow::PceInput => "PC Engine Input Settings", 193 OpenWindow::Hotkeys => "Hotkey Settings", 194 OpenWindow::Cheats => "Cheats", 195 OpenWindow::SmsGgOverclock => "SMS/GG Overclocking Settings", 196 OpenWindow::GenesisOverclock => "Genesis Overclocking Settings", 197 OpenWindow::SnesOverclock => "SNES Overclocking Settings", 198 OpenWindow::PceOverclock => "PCE Overclocking Settings", 199 OpenWindow::About => "About", 200 } 201 } 202} 203 204#[derive(Debug, Clone, Copy)] 205struct HelpText { 206 heading: &'static str, 207 text: &'static [&'static str], 208} 209 210struct AppState { 211 cheats: CheatWindowState, 212 current_file_path: PathBuf, 213 current_secondary_file_paths: Vec<PathBuf>, 214 open_windows: HashSet<OpenWindow>, 215 help_text: HashMap<OpenWindow, HelpText>, 216 input_mapping_sets: HashMap<OpenWindow, InputMappingSet>, 217 pce_selected_player_idx: usize, 218 error_window_open: bool, 219 prescale_width_raw: u32, 220 prescale_height_raw: u32, 221 ff_multiplier_text: String, 222 ff_multiplier_invalid: bool, 223 rewind_buffer_len_text: String, 224 rewind_buffer_len_invalid: bool, 225 audio_buffer_size_text: String, 226 audio_buffer_size_invalid: bool, 227 audio_hardware_queue_size_text: String, 228 audio_hardware_queue_size_invalid: bool, 229 audio_gain_text: String, 230 audio_gain_invalid: bool, 231 nes_palette: NesPaletteState, 232 genesis_volume: GenesisVolumeState, 233 s32x_priority: S32XPriorityState, 234 overscan: OverscanState, 235 input_collection: Option<InputCollectionState>, 236 rom_list: Arc<Mutex<Vec<RomMetadata>>>, 237 filtered_rom_list: Rc<[RomMetadata]>, 238 rom_list_refresh_needed: bool, 239 recent_open_list: Vec<Option<RomMetadata>>, 240 disc_change_options: Vec<(String, PathBuf)>, 241 title_match: String, 242 title_match_lowercase: Rc<str>, 243 rendered_first_frame: bool, 244 close_on_emulator_exit: bool, 245} 246 247impl AppState { 248 fn new(ctx: &Context) -> Self { 249 Self { 250 cheats: CheatWindowState::new(), 251 current_file_path: PathBuf::new(), 252 current_secondary_file_paths: vec![], 253 open_windows: HashSet::new(), 254 help_text: HashMap::new(), 255 input_mapping_sets: HashMap::new(), 256 pce_selected_player_idx: 0, 257 error_window_open: false, 258 prescale_width_raw: 1, 259 prescale_height_raw: 1, 260 ff_multiplier_text: String::new(), 261 ff_multiplier_invalid: false, 262 rewind_buffer_len_text: String::new(), 263 rewind_buffer_len_invalid: false, 264 audio_buffer_size_text: String::new(), 265 audio_buffer_size_invalid: false, 266 audio_hardware_queue_size_text: String::new(), 267 audio_hardware_queue_size_invalid: false, 268 audio_gain_text: String::new(), 269 audio_gain_invalid: false, 270 nes_palette: NesPaletteState::create(ctx, &NesPalette::default()), 271 genesis_volume: GenesisVolumeState::default(), 272 s32x_priority: S32XPriorityState::default(), 273 overscan: OverscanState::default(), 274 input_collection: None, 275 rom_list: Arc::new(Mutex::new(vec![])), 276 filtered_rom_list: vec![].into(), 277 rom_list_refresh_needed: true, 278 title_match: String::new(), 279 title_match_lowercase: Rc::from(String::new()), 280 recent_open_list: vec![], 281 disc_change_options: Vec::new(), 282 rendered_first_frame: false, 283 close_on_emulator_exit: false, 284 } 285 } 286 287 fn from_config(config: &AppConfig, ctx: &Context) -> Self { 288 let mut state = Self::new(ctx); 289 state.update_config_derived_fields(config, ctx); 290 291 state 292 } 293 294 fn update_config_derived_fields(&mut self, config: &AppConfig, ctx: &Context) { 295 self.prescale_width_raw = config.common.prescale_width.get(); 296 self.prescale_height_raw = config.common.prescale_height.get(); 297 self.ff_multiplier_text = config.common.fast_forward_multiplier.to_string(); 298 self.ff_multiplier_invalid = false; 299 self.rewind_buffer_len_text = config.common.rewind_buffer_length_seconds.to_string(); 300 self.rewind_buffer_len_invalid = false; 301 self.audio_buffer_size_text = config.common.audio_buffer_size.to_string(); 302 self.audio_buffer_size_invalid = false; 303 self.audio_hardware_queue_size_text = config.common.audio_hardware_queue_size.to_string(); 304 self.audio_hardware_queue_size_invalid = false; 305 self.audio_gain_text = format!("{:.1}", config.common.audio_gain_db); 306 self.audio_gain_invalid = false; 307 self.nes_palette = NesPaletteState::create(ctx, &config.nes.palette); 308 self.genesis_volume = GenesisVolumeState::from_config(config); 309 self.s32x_priority = S32XPriorityState::from_config(&config.sega_32x); 310 self.overscan = config.nes.overscan().into(); 311 self.recent_open_list = romlist::from_recent_opens(&config.recent_open_list); 312 } 313 314 fn open_window(&mut self, ctx: &Context, window: OpenWindow) { 315 self.open_windows.insert(window); 316 ctx.move_to_top(LayerId::new(Order::Middle, window.title().into())); 317 } 318} 319 320#[derive(Debug, Clone)] 321pub struct LoadAtStartup { 322 pub file_paths: Vec<PathBuf>, 323 pub console: Option<Console>, 324 pub load_state_slot: Option<usize>, 325 pub config_overrides: Vec<String>, 326} 327 328#[derive(Debug, Clone)] 329pub struct ConfigInfo { 330 pub initial_config: AppConfig, 331 pub config_path: PathBuf, 332 pub config_dirs: ConfigDirs, 333 pub config_dir_type: ConfigDirType, 334} 335 336pub struct App { 337 config: AppConfig, 338 last_saved_config: AppConfig, 339 state: AppState, 340 config_path: PathBuf, 341 config_dirs: ConfigDirs, 342 config_dir_type: ConfigDirType, 343 emu_runner: GuiEmulatorRunnerHandle, 344 rom_list_thread: RomListThreadHandle, 345 load_at_startup: Option<LoadAtStartup>, 346 config_overrides: Vec<String>, 347 joysticks: Rc<RefCell<Joysticks>>, 348 cjk_fonts_loaded: bool, 349} 350 351impl App { 352 #[must_use] 353 pub fn new( 354 config_info: ConfigInfo, 355 load_at_startup: Option<LoadAtStartup>, 356 ctx: Context, 357 sdl: &SdlSubsystems, 358 emu_runner: GuiEmulatorRunnerHandle, 359 ) -> Self { 360 let config = config_info.initial_config; 361 362 let state = AppState::from_config(&config, &ctx); 363 364 let rom_list_thread = RomListThreadHandle::spawn(Arc::clone(&state.rom_list), ctx); 365 rom_list_thread.request_scan(config.rom_search_dirs.clone()); 366 367 let last_saved_config = config.clone(); 368 369 Self { 370 config, 371 last_saved_config, 372 state, 373 config_path: config_info.config_path, 374 config_dirs: config_info.config_dirs, 375 config_dir_type: config_info.config_dir_type, 376 emu_runner, 377 rom_list_thread, 378 load_at_startup, 379 config_overrides: vec![], 380 joysticks: Rc::clone(&sdl.joysticks), 381 cjk_fonts_loaded: false, 382 } 383 } 384 385 fn open_file(&mut self, console: Option<Console>) { 386 let mut file_dialog = FileDialog::new(); 387 388 match console { 389 Some(console) => { 390 for extensions in console.supported_extensions() { 391 let label = extensions.label.unwrap_or(console.display_str()); 392 file_dialog = file_dialog.add_filter(label, &extensions.to_list()); 393 } 394 } 395 None => { 396 file_dialog = 397 file_dialog.add_filter("Supported Files", &extensions::ALL_PLUS_ARCHIVES); 398 } 399 } 400 401 file_dialog = file_dialog.add_filter("All Files", &["*"]); 402 403 if let Some(dir) = self.config.rom_search_dirs.first() { 404 file_dialog = file_dialog.set_directory(&dir.path); 405 } 406 let Some(path) = file_dialog.pick_file() else { return }; 407 408 match console { 409 Some(console @ (Console::SegaCd | Console::SegaCd32X)) => { 410 let mut secondary_paths = vec![]; 411 if CdRomFileFormat::from_file_path(&path).is_none() { 412 let disc_path = Self::open_sega_cd_secondary_path_dialog(&path); 413 if let Some(disc_path) = disc_path { 414 secondary_paths.push(disc_path); 415 } 416 } 417 418 self.launch_emulator(path, secondary_paths, console); 419 } 420 Some(console) => self.launch_emulator(path, vec![], console), 421 None => self.launch_emulator_auto(path, None), 422 } 423 } 424 425 fn open_recent_file(&mut self, index: usize) { 426 let Some(recent_open) = self.config.recent_open_list.get(index) else { return }; 427 let Ok(console) = Console::from_str(&recent_open.console) else { return }; 428 429 self.launch_emulator( 430 recent_open.path.clone(), 431 recent_open.secondary_paths.clone(), 432 console, 433 ); 434 } 435 436 fn open_sega_cd_secondary_path_dialog(primary_path: &Path) -> Option<PathBuf> { 437 // Sega CD is attached but the main file is not a CD-ROM image; prompt to load a disc 438 let mut file_dialog = FileDialog::new() 439 .set_title("Sega CD Disc Image") 440 .add_filter("cue/chd", extensions::SEGA_CD); 441 442 if let Some(primary_parent) = primary_path.parent() { 443 file_dialog = file_dialog.set_directory(primary_parent); 444 } 445 446 file_dialog.pick_file() 447 } 448 449 fn launch_emulator_auto(&mut self, path: PathBuf, console: Option<Console>) { 450 let console = match console { 451 Some(console) => console, 452 None => { 453 let Some(metadata) = romlist::read_metadata(Path::new(&path)) else { 454 log::error!("Unable to detect compatible file at path: '{}'", path.display()); 455 self.emu_runner.clear_waiting_for_first_command(); 456 return; 457 }; 458 metadata.console 459 } 460 }; 461 462 let mut secondary_paths = vec![]; 463 if matches!(console, Console::SegaCd | Console::SegaCd32X) 464 && CdRomFileFormat::from_file_path(&path).is_none() 465 && let Some(secondary_path) = Self::open_sega_cd_secondary_path_dialog(&path) 466 { 467 secondary_paths.push(secondary_path); 468 } 469 470 self.launch_emulator(path, secondary_paths, console); 471 } 472 473 fn launch_emulator(&mut self, path: PathBuf, secondary_paths: Vec<PathBuf>, console: Console) { 474 self.state.current_file_path.clone_from(&path); 475 self.state.current_secondary_file_paths.clone_from(&secondary_paths); 476 477 // Update Open Recent contents 478 let console_str = console.to_string(); 479 self.config 480 .recent_open_list 481 .retain(|open| open.path != path || open.console != console_str); 482 self.config.recent_open_list.insert( 483 0, 484 RecentOpen { 485 console: console_str, 486 path: path.clone(), 487 secondary_paths: secondary_paths.clone(), 488 }, 489 ); 490 self.config.recent_open_list.truncate(10); 491 self.state.recent_open_list = romlist::from_recent_opens(&self.config.recent_open_list); 492 493 self.state.disc_change_options = romlist::find_all_disc_paths(&path); 494 495 self.load_cheats_for_game(console, &path); 496 497 let mut config = self.config.clone(); 498 if let Err(err) = config.apply_overrides(&self.config_overrides) { 499 log::error!("Error applying config overrides: {err}"); 500 } 501 502 self.emu_runner.push_command(EmuRunnerCommand::Run { 503 console, 504 config: Box::new(config), 505 cheats: Arc::clone(self.active_cheats()), 506 input: EmulatorRunInput::OpenFile { file_path: path, secondary_paths }, 507 }); 508 } 509 510 fn add_rom_search_directory(&mut self) { 511 let Some(dir) = FileDialog::new().pick_folder() else { return }; 512 513 self.config.rom_search_dirs.push(RomSearchDirectory::new(dir)); 514 self.request_rom_list_scan(); 515 } 516 517 fn request_rom_list_scan(&mut self) { 518 self.rom_list_thread.request_scan(self.config.rom_search_dirs.clone()); 519 self.state.rom_list_refresh_needed = true; 520 } 521 522 fn render_about(&mut self, ctx: &Context) { 523 let mut open = true; 524 Window::new(OpenWindow::About.title()).open(&mut open).resizable(false).show(ctx, |ui| { 525 ui.heading("jgenesis"); 526 527 ui.add_space(10.0); 528 ui.label(format!("Version: {}", env!("CARGO_PKG_VERSION"))); 529 530 ui.add_space(15.0); 531 ui.label("Copyright © 2023-2026 James Groth"); 532 533 ui.add_space(10.0); 534 ui.horizontal(|ui| { 535 ui.label("Source code:"); 536 ui.hyperlink("https://github.com/jsgroth/jgenesis"); 537 }); 538 }); 539 if !open { 540 self.state.open_windows.remove(&OpenWindow::About); 541 } 542 } 543 544 fn render_menu(&mut self, ui: &mut Ui) { 545 Panel::top("top_bottom_panel").show_inside(ui, |ui| { 546 egui::MenuBar::new().ui(ui, |ui| { 547 ui.add_enabled_ui(!self.state.error_window_open, |ui| { 548 self.render_file_menu(ui); 549 self.render_emulation_menu(ui); 550 self.render_settings_menu(ui); 551 self.render_video_menu(ui); 552 self.render_audio_menu(ui); 553 self.render_input_menu(ui); 554 self.render_cheats_menu_button(ui); 555 self.render_overclock_menu(ui); 556 self.render_help_menu(ui); 557 }); 558 }); 559 }); 560 } 561 562 fn render_file_menu(&mut self, ui: &mut Ui) { 563 let open_shortcut = KeyboardShortcut::new(Modifiers::CTRL, Key::O); 564 if ui.input_mut(|input| input.consume_shortcut(&open_shortcut)) { 565 self.open_file(None); 566 } 567 568 let open_most_recent_shortcut = KeyboardShortcut::new(Modifiers::NONE, Key::F5); 569 if ui.input_mut(|input| input.consume_shortcut(&open_most_recent_shortcut)) { 570 self.open_recent_file(0); 571 } 572 573 let quit_shortcut = KeyboardShortcut::new(Modifiers::CTRL, Key::Q); 574 if ui.input_mut(|input| input.consume_shortcut(&quit_shortcut)) { 575 ui.send_viewport_cmd(ViewportCommand::Close); 576 } 577 578 ui.menu_button("File", |ui| { 579 ui.add_enabled_ui(!self.state.recent_open_list.is_empty(), |ui| { 580 ui.menu_button("Open Recent", |ui| { 581 ui.set_min_width(300.0); 582 ui.set_max_width(500.0); 583 584 for (i, recent_open) in 585 self.state.recent_open_list.clone().into_iter().enumerate() 586 { 587 let Some(recent_open) = recent_open else { continue }; 588 589 let label = format!( 590 "{} [{}]", 591 recent_open.file_name_no_ext, 592 recent_open.console.display_str() 593 ); 594 if ui.button(label).clicked() { 595 self.open_recent_file(i); 596 ui.close_kind(UiKind::Menu); 597 } 598 599 ui.add_space(5.0); 600 } 601 602 ui.separator(); 603 604 if ui.button("Clear List").clicked() { 605 self.config.recent_open_list.clear(); 606 self.state.recent_open_list.clear(); 607 ui.close_kind(UiKind::Menu); 608 } 609 }); 610 611 let open_most_recent_button = Button::new("Open Most Recent") 612 .shortcut_text(ui.format_shortcut(&open_most_recent_shortcut)); 613 if ui.add(open_most_recent_button).clicked() { 614 self.open_recent_file(0); 615 ui.close_kind(UiKind::Menu); 616 } 617 }); 618 619 ui.add_space(10.0); 620 621 ui.menu_button("Open Using", |ui| { 622 for console in Console::ALL { 623 if console == Console::GameBoyColor { 624 // Don't allow Open Using > GBC until the GB/GBC forcing logic is less awful 625 continue; 626 } 627 628 self.render_open_using_button(console, ui); 629 } 630 }); 631 632 ui.add_space(10.0); 633 634 ui.menu_button("Run BIOS", |ui| { 635 for (label, console, has_bios) in [ 636 ( 637 "Master System", 638 Console::MasterSystem, 639 self.config.smsgg.sms_bios_path.is_some(), 640 ), 641 ("Sega CD", Console::SegaCd, self.config.sega_cd.bios_path.is_some()), 642 ("Sega CD 32X", Console::SegaCd32X, self.config.sega_cd.bios_path.is_some()), 643 ] { 644 ui.add_enabled_ui(has_bios, |ui| { 645 if ui.button(label).clicked() { 646 self.emu_runner.push_command(EmuRunnerCommand::Run { 647 console, 648 config: Box::new(self.config.clone()), 649 cheats: Arc::new(ActiveCheats::None), 650 input: EmulatorRunInput::RunBios, 651 }); 652 self.state.current_file_path.clear(); 653 ui.close_kind(UiKind::Menu); 654 } 655 }); 656 } 657 }); 658 659 ui.add_space(10.0); 660 661 let open_button = Button::new("Open").shortcut_text(ui.format_shortcut(&open_shortcut)); 662 if open_button.ui(ui).clicked() { 663 self.open_file(None); 664 ui.close_kind(UiKind::Menu); 665 } 666 667 let quit_button = Button::new("Quit").shortcut_text(ui.format_shortcut(&quit_shortcut)); 668 if quit_button.ui(ui).clicked() { 669 ui.send_viewport_cmd(ViewportCommand::Close); 670 } 671 }); 672 } 673 674 fn render_open_using_button(&mut self, console: Console, ui: &mut Ui) { 675 if ui.button(console.display_str()).clicked() { 676 self.open_file(Some(console)); 677 ui.close_kind(UiKind::Menu); 678 } 679 } 680 681 fn render_emulation_menu(&mut self, ui: &mut Ui) { 682 ui.menu_button("Emulation", |ui| { 683 ui.add_enabled_ui(self.emu_runner.status().is_running(), |ui| { 684 let save_state_metadata = self.emu_runner.save_state_metadata(); 685 686 ui.menu_button("Load State", |ui| { 687 ui.set_min_width(200.0); 688 689 for slot in 0..jgenesis_native_driver::SAVE_STATE_SLOTS { 690 match save_state_metadata.times_nanos[slot] { 691 Some(time_nanos) => { 692 let formatted_time = format_time_nanos(time_nanos) 693 .unwrap_or_else(|| "Unknown".into()); 694 let label = format!("Slot {slot} - {formatted_time}"); 695 if ui.button(label).clicked() { 696 self.emu_runner 697 .push_command(EmuRunnerCommand::LoadState { slot }); 698 ui.close_kind(UiKind::Menu); 699 } 700 } 701 None => { 702 ui.add_enabled_ui(false, |ui| { 703 let _ = ui.button(format!("Slot {slot} - Empty")); 704 }); 705 } 706 } 707 } 708 }); 709 710 ui.menu_button("Save State", |ui| { 711 ui.set_min_width(200.0); 712 713 for slot in 0..jgenesis_native_driver::SAVE_STATE_SLOTS { 714 let label = match save_state_metadata.times_nanos[slot] { 715 Some(time_nanos) => { 716 let formatted_time = format_time_nanos(time_nanos) 717 .unwrap_or_else(|| "Unknown".into()); 718 format!("Slot {slot} - {formatted_time}") 719 } 720 None => format!("Slot {slot} - Empty"), 721 }; 722 723 if ui.button(label).clicked() { 724 self.emu_runner.push_command(EmuRunnerCommand::SaveState { slot }); 725 ui.close_kind(UiKind::Menu); 726 } 727 } 728 }); 729 730 ui.add_space(15.0); 731 732 if ui.button("Open Memory Viewer").clicked() { 733 self.emu_runner.push_command(EmuRunnerCommand::OpenMemoryViewer); 734 ui.close_kind(UiKind::Menu); 735 } 736 737 ui.add_space(15.0); 738 739 let emu_runner_status = self.emu_runner.status(); 740 let supports_soft_reset = !(emu_runner_status.is_running_handheld() 741 || emu_runner_status == EmuRunnerStatus::RunningPcEngine); 742 ui.add_enabled_ui(supports_soft_reset, |ui| { 743 if ui.button("Soft Reset").clicked() { 744 self.emu_runner.push_command(EmuRunnerCommand::SoftReset); 745 ui.close_kind(UiKind::Menu); 746 } 747 }); 748 749 if ui.button("Hard Reset").clicked() { 750 self.emu_runner.push_command(EmuRunnerCommand::HardReset); 751 ui.close_kind(UiKind::Menu); 752 } 753 754 if ui.button("Power Off").clicked() { 755 self.emu_runner.push_command(EmuRunnerCommand::StopEmulator); 756 ui.close_kind(UiKind::Menu); 757 } 758 759 ui.add_space(15.0); 760 761 ui.add_enabled_ui(emu_runner_status.is_running_disc_based(), |ui| { 762 ui.menu_button("Change Disc", |ui| { 763 if !self.state.disc_change_options.is_empty() { 764 for (name, path) in &self.state.disc_change_options { 765 let enabled = path != &self.state.current_file_path; 766 ui.add_enabled_ui(enabled, |ui| { 767 if ui.button(name).clicked() { 768 self.state.current_file_path.clone_from(path); 769 self.emu_runner.push_command( 770 EmuRunnerCommand::SegaCdChangeDisc(path.clone()), 771 ); 772 ui.close_kind(UiKind::Menu); 773 } 774 }); 775 } 776 777 ui.separator(); 778 } 779 780 if ui.button("Select file...").clicked() { 781 if let Some(path) = 782 FileDialog::new().add_filter("cue/chd", &["cue", "chd"]).pick_file() 783 { 784 self.state.current_file_path.clone_from(&path); 785 self.emu_runner 786 .push_command(EmuRunnerCommand::SegaCdChangeDisc(path)); 787 } 788 789 ui.close_kind(UiKind::Menu); 790 } 791 }); 792 793 if ui.button("Remove Disc").clicked() { 794 self.emu_runner.push_command(EmuRunnerCommand::SegaCdRemoveDisc); 795 self.state.current_file_path.clear(); 796 ui.close_kind(UiKind::Menu); 797 } 798 }); 799 }); 800 }); 801 } 802 803 fn render_settings_menu(&mut self, ui: &mut Ui) { 804 ui.menu_button("Settings", |ui| { 805 for (label, window) in [ 806 ("SMS / Game Gear / SG", OpenWindow::SmsGgGeneral), 807 ("Genesis / Sega CD / 32X", OpenWindow::GenesisGeneral), 808 ("NES", OpenWindow::NesGeneral), 809 ("SNES", OpenWindow::SnesGeneral), 810 ("Game Boy", OpenWindow::GameBoyGeneral), 811 ("Game Boy Advance", OpenWindow::GbaGeneral), 812 ("PC Engine", OpenWindow::PceGeneral), 813 ] { 814 if ui.button(label).clicked() { 815 self.state.open_window(ui.ctx(), window); 816 ui.close_kind(UiKind::Menu); 817 } 818 } 819 820 ui.separator(); 821 822 if ui.button("Synchronization").clicked() { 823 self.state.open_window(ui.ctx(), OpenWindow::Synchronization); 824 ui.close_kind(UiKind::Menu); 825 } 826 827 if ui.button("Paths").clicked() { 828 self.state.open_window(ui.ctx(), OpenWindow::Paths); 829 ui.close_kind(UiKind::Menu); 830 } 831 832 if ui.button("Interface").clicked() { 833 self.state.open_window(ui.ctx(), OpenWindow::Interface); 834 ui.close_kind(UiKind::Menu); 835 } 836 }); 837 } 838 839 fn render_video_menu(&mut self, ui: &mut Ui) { 840 ui.menu_button("Video", |ui| { 841 if ui.button("General").clicked() { 842 self.state.open_window(ui.ctx(), OpenWindow::CommonVideo); 843 ui.close_kind(UiKind::Menu); 844 } 845 846 if ui.button("Filtering").clicked() { 847 self.state.open_window(ui.ctx(), OpenWindow::CommonFilter); 848 ui.close_kind(UiKind::Menu); 849 } 850 851 ui.separator(); 852 853 for (label, window) in [ 854 ("SMS / Game Gear / SG", OpenWindow::SmsGgVideo), 855 ("Genesis / Sega CD / 32X", OpenWindow::GenesisVideo), 856 ("NES", OpenWindow::NesVideo), 857 ("SNES", OpenWindow::SnesVideo), 858 ("Game Boy", OpenWindow::GameBoyVideo), 859 ("Game Boy Advance", OpenWindow::GbaVideo), 860 ("PC Engine", OpenWindow::PceVideo), 861 ] { 862 if ui.button(label).clicked() { 863 self.state.open_window(ui.ctx(), window); 864 ui.close_kind(UiKind::Menu); 865 } 866 } 867 }); 868 } 869 870 fn render_audio_menu(&mut self, ui: &mut Ui) { 871 ui.menu_button("Audio", |ui| { 872 if ui.button("General").clicked() { 873 self.state.open_window(ui.ctx(), OpenWindow::CommonAudio); 874 ui.close_kind(UiKind::Menu); 875 } 876 877 ui.separator(); 878 879 for (label, window) in [ 880 ("SMS / Game Gear / SG", OpenWindow::SmsGgAudio), 881 ("Genesis / Sega CD / 32X", OpenWindow::GenesisAudio), 882 ("NES", OpenWindow::NesAudio), 883 ("SNES", OpenWindow::SnesAudio), 884 ("Game Boy", OpenWindow::GameBoyAudio), 885 ("Game Boy Advance", OpenWindow::GbaAudio), 886 ("PC Engine", OpenWindow::PceAudio), 887 ] { 888 if ui.button(label).clicked() { 889 self.state.open_window(ui.ctx(), window); 890 ui.close_kind(UiKind::Menu); 891 } 892 } 893 }); 894 } 895 896 fn render_input_menu(&mut self, ui: &mut Ui) { 897 ui.menu_button("Input", |ui| { 898 if ui.button("General").clicked() { 899 self.state.open_window(ui.ctx(), OpenWindow::GeneralInput); 900 ui.close_kind(UiKind::Menu); 901 } 902 903 ui.separator(); 904 905 if ui.button("SMS / Game Gear / SG").clicked() { 906 self.state.open_window(ui.ctx(), OpenWindow::SmsGgInput); 907 ui.close_kind(UiKind::Menu); 908 } 909 910 if ui.button("Genesis / Sega CD / 32X").clicked() { 911 self.state.open_window(ui.ctx(), OpenWindow::GenesisInput); 912 ui.close_kind(UiKind::Menu); 913 } 914 915 ui.menu_button("NES", |ui| { 916 if ui.button("Gamepads").clicked() { 917 self.state.open_window(ui.ctx(), OpenWindow::NesInput); 918 ui.close_kind(UiKind::Menu); 919 } 920 921 if ui.button("Peripherals").clicked() { 922 self.state.open_window(ui.ctx(), OpenWindow::NesPeripherals); 923 ui.close_kind(UiKind::Menu); 924 } 925 }); 926 927 if ui.button("SNES").clicked() { 928 self.state.open_window(ui.ctx(), OpenWindow::SnesInput); 929 ui.close_kind(UiKind::Menu); 930 } 931 932 if ui.button("Game Boy").clicked() { 933 self.state.open_window(ui.ctx(), OpenWindow::GameBoyInput); 934 ui.close_kind(UiKind::Menu); 935 } 936 937 ui.menu_button("Game Boy Advance", |ui| { 938 if ui.button("Gamepad").clicked() { 939 self.state.open_window(ui.ctx(), OpenWindow::GbaInput); 940 ui.close_kind(UiKind::Menu); 941 } 942 943 if ui.button("Peripherals").clicked() { 944 self.state.open_window(ui.ctx(), OpenWindow::GbaPeripherals); 945 ui.close_kind(UiKind::Menu); 946 } 947 }); 948 949 if ui.button("PC Engine").clicked() { 950 self.state.open_window(ui.ctx(), OpenWindow::PceInput); 951 ui.close_kind(UiKind::Menu); 952 } 953 954 ui.separator(); 955 956 if ui.button("Hotkeys").clicked() { 957 self.state.open_window(ui.ctx(), OpenWindow::Hotkeys); 958 ui.close_kind(UiKind::Menu); 959 } 960 }); 961 } 962 963 fn render_cheats_menu_button(&mut self, ui: &mut Ui) { 964 let cheats_supported = self 965 .emu_runner 966 .status() 967 .running_console() 968 .is_none_or(|console| CheatConsole::from_console(console).is_some()); 969 970 ui.add_enabled_ui(cheats_supported, |ui| { 971 if ui.button("Cheats").clicked() { 972 self.state.open_window(ui.ctx(), OpenWindow::Cheats); 973 } 974 }); 975 } 976 977 fn render_overclock_menu(&mut self, ui: &mut Ui) { 978 ui.menu_button("Overclocking", |ui| { 979 for (label, window) in [ 980 ("SMS / Game Gear / SG", OpenWindow::SmsGgOverclock), 981 ("Genesis / Sega CD / 32X", OpenWindow::GenesisOverclock), 982 ("SNES", OpenWindow::SnesOverclock), 983 ("PC Engine", OpenWindow::PceOverclock), 984 ] { 985 if ui.button(label).clicked() { 986 self.state.open_window(ui.ctx(), window); 987 ui.close_kind(UiKind::Menu); 988 } 989 } 990 }); 991 } 992 993 fn render_help_menu(&mut self, ui: &mut Ui) { 994 ui.menu_button("Help", |ui| { 995 if ui.button("About").clicked() { 996 self.state.open_window(ui.ctx(), OpenWindow::About); 997 ui.close_kind(UiKind::Menu); 998 } 999 }); 1000 } 1001 1002 fn render_central_panel(&mut self, ui: &mut Ui) { 1003 CentralPanel::default().show_inside(ui, |ui| { 1004 ui.add_enabled_ui(!self.state.error_window_open, |ui| { 1005 if self.rom_list_thread.any_scans_in_progress() { 1006 ui.centered_and_justified(|ui| { 1007 ui.label("Scanning search directories..."); 1008 }); 1009 } else if self.state.rom_list.lock().unwrap().is_empty() { 1010 ui.centered_and_justified(|ui| { 1011 if ui.selectable_label(false, "Configure ROM search directory").clicked() { 1012 self.add_rom_search_directory(); 1013 } 1014 }); 1015 } else { 1016 ui.add_enabled_ui(self.state.input_collection.is_none(), |ui| { 1017 self.render_central_panel_filters(ui); 1018 1019 ui.add_space(15.0); 1020 1021 TableBuilder::new(ui) 1022 .auto_shrink([false; 2]) 1023 .striped(true) 1024 .max_scroll_height(3000.0) 1025 .cell_layout(Layout::left_to_right(Align::Center)) 1026 .column(Column::auto().at_least(300.0).at_most(400.0)) 1027 .column(Column::auto().at_least(125.0)) 1028 .column(Column::auto().at_least(50.0)) 1029 .column(Column::remainder()) 1030 .header(30.0, |mut row| { 1031 row.col(|ui| { 1032 ui.vertical_centered(|ui| { 1033 ui.heading("Name"); 1034 }); 1035 }); 1036 1037 row.col(|ui| { 1038 ui.vertical_centered(|ui| { 1039 ui.heading("Console"); 1040 }); 1041 }); 1042 1043 row.col(|ui| { 1044 ui.vertical_centered(|ui| { 1045 ui.heading("Size"); 1046 }); 1047 }); 1048 1049 // Blank column to make stripes extend to the right 1050 row.col(|_ui| {}); 1051 }) 1052 .body(|body| { 1053 let rom_list = Rc::clone(&self.state.filtered_rom_list); 1054 body.rows(40.0, rom_list.len(), |mut row| { 1055 let metadata = &rom_list[row.index()]; 1056 1057 row.col(|ui| { 1058 if Button::new(&metadata.file_name_no_ext) 1059 .min_size(Vec2::new(300.0, 30.0)) 1060 .wrap() 1061 .ui(ui) 1062 .clicked() 1063 { 1064 self.launch_emulator_auto( 1065 metadata.full_path.clone(), 1066 Some(metadata.console), 1067 ); 1068 } 1069 }); 1070 1071 row.col(|ui| { 1072 ui.centered_and_justified(|ui| { 1073 ui.label(metadata.console.display_str()); 1074 }); 1075 }); 1076 1077 row.col(|ui| { 1078 ui.centered_and_justified(|ui| { 1079 if metadata.file_size < 1024 * 1024 { 1080 let file_size_kb = metadata.file_size / 1024; 1081 ui.label(format!("{file_size_kb}KB")); 1082 } else { 1083 let file_size_mb = metadata.file_size / 1024 / 1024; 1084 ui.label(format!("{file_size_mb}MB")); 1085 } 1086 }); 1087 }); 1088 1089 // Blank column to make stripes extend to the right 1090 row.col(|_ui| {}); 1091 }); 1092 }); 1093 }); 1094 } 1095 }); 1096 }); 1097 } 1098 1099 fn render_central_panel_filters(&mut self, ui: &mut Ui) { 1100 ui.horizontal(|ui| { 1101 let prev_list_filters = self.config.list_filters.clone(); 1102 1103 ui.checkbox(&mut self.config.list_filters.genesis, "Genesis"); 1104 ui.checkbox(&mut self.config.list_filters.sega_cd, "Sega CD"); 1105 ui.checkbox(&mut self.config.list_filters.sega_32x, "32X"); 1106 ui.checkbox(&mut self.config.list_filters.master_system, "SMS"); 1107 ui.checkbox(&mut self.config.list_filters.game_gear, "Game Gear"); 1108 ui.checkbox(&mut self.config.list_filters.sg_1000, "SG-1000"); 1109 ui.checkbox(&mut self.config.list_filters.nes, "NES"); 1110 ui.checkbox(&mut self.config.list_filters.snes, "SNES"); 1111 ui.checkbox(&mut self.config.list_filters.game_boy, "GB"); 1112 ui.checkbox(&mut self.config.list_filters.game_boy_color, "GBC"); 1113 ui.checkbox(&mut self.config.list_filters.game_boy_advance, "GBA"); 1114 ui.checkbox(&mut self.config.list_filters.pc_engine, "PCE"); 1115 1116 ui.add_space(10.0); 1117 1118 if ui.button("All").clicked() { 1119 self.config.list_filters = ListFilters::ALL; 1120 } 1121 1122 if ui.button("None").clicked() { 1123 self.config.list_filters = ListFilters::NONE; 1124 } 1125 1126 if prev_list_filters != self.config.list_filters { 1127 self.refresh_filtered_rom_list(ui); 1128 } 1129 }); 1130 1131 ui.add_space(5.0); 1132 1133 ui.horizontal(|ui| { 1134 let textedit = TextEdit::singleline(&mut self.state.title_match) 1135 .hint_text("Filter by name") 1136 .desired_width(350.0); 1137 if ui.add(textedit).changed() { 1138 self.state.title_match_lowercase = Rc::from(self.state.title_match.to_lowercase()); 1139 self.refresh_filtered_rom_list(ui); 1140 } 1141 1142 if ui.button("Clear").clicked() { 1143 self.state.title_match.clear(); 1144 self.state.title_match_lowercase = Rc::from(String::new()); 1145 self.refresh_filtered_rom_list(ui); 1146 } 1147 }); 1148 } 1149 1150 fn render_windows(&mut self, ctx: &Context) { 1151 let open_windows: Vec<_> = self.state.open_windows.iter().copied().collect(); 1152 for open_window in open_windows { 1153 match open_window { 1154 OpenWindow::SmsGgGeneral => self.render_smsgg_general_settings(ctx), 1155 OpenWindow::GenesisGeneral => self.render_genesis_general_settings(ctx), 1156 OpenWindow::NesGeneral => self.render_nes_general_settings(ctx), 1157 OpenWindow::SnesGeneral => self.render_snes_general_settings(ctx), 1158 OpenWindow::GameBoyGeneral => self.render_gb_general_settings(ctx), 1159 OpenWindow::GbaGeneral => self.render_gba_general_settings(ctx), 1160 OpenWindow::PceGeneral => self.render_pce_general_settings(ctx), 1161 OpenWindow::Synchronization => self.render_sync_settings(ctx), 1162 OpenWindow::Paths => self.render_path_settings(ctx), 1163 OpenWindow::Interface => self.render_interface_settings(ctx), 1164 OpenWindow::CommonVideo => self.render_common_video_settings(ctx), 1165 OpenWindow::CommonFilter => self.render_video_filtering_settings(ctx), 1166 OpenWindow::SmsGgVideo => self.render_smsgg_video_settings(ctx), 1167 OpenWindow::GenesisVideo => self.render_genesis_video_settings(ctx), 1168 OpenWindow::NesVideo => self.render_nes_video_settings(ctx), 1169 OpenWindow::SnesVideo => self.render_snes_video_settings(ctx), 1170 OpenWindow::GameBoyVideo => self.render_gb_video_settings(ctx), 1171 OpenWindow::GbaVideo => self.render_gba_video_settings(ctx), 1172 OpenWindow::PceVideo => self.render_pce_video_settings(ctx), 1173 OpenWindow::CommonAudio => self.render_common_audio_settings(ctx), 1174 OpenWindow::SmsGgAudio => self.render_smsgg_audio_settings(ctx), 1175 OpenWindow::GenesisAudio => self.render_genesis_audio_settings(ctx), 1176 OpenWindow::NesAudio => self.render_nes_audio_settings(ctx), 1177 OpenWindow::SnesAudio => self.render_snes_audio_settings(ctx), 1178 OpenWindow::GameBoyAudio => self.render_gb_audio_settings(ctx), 1179 OpenWindow::GbaAudio => self.render_gba_audio_settings(ctx), 1180 OpenWindow::PceAudio => self.render_pce_audio_settings(ctx), 1181 OpenWindow::GeneralInput => self.render_general_input_settings(ctx), 1182 OpenWindow::SmsGgInput => self.render_smsgg_input_settings(ctx), 1183 OpenWindow::GenesisInput => self.render_genesis_input_settings(ctx), 1184 OpenWindow::NesInput => self.render_nes_input_settings(ctx), 1185 OpenWindow::NesPeripherals => self.render_nes_peripheral_settings(ctx), 1186 OpenWindow::SnesInput => self.render_snes_input_settings(ctx), 1187 OpenWindow::GameBoyInput => self.render_gb_input_settings(ctx), 1188 OpenWindow::GbaInput => self.render_gba_input_settings(ctx), 1189 OpenWindow::GbaPeripherals => self.render_gba_peripheral_settings(ctx), 1190 OpenWindow::PceInput => self.render_pce_input_settings(ctx), 1191 OpenWindow::Hotkeys => self.render_hotkey_settings(ctx), 1192 OpenWindow::Cheats => self.render_cheats_window(ctx), 1193 OpenWindow::SmsGgOverclock => self.render_smsgg_overclock_settings(ctx), 1194 OpenWindow::GenesisOverclock => self.render_genesis_overclock_settings(ctx), 1195 OpenWindow::SnesOverclock => self.render_snes_overclock_settings(ctx), 1196 OpenWindow::PceOverclock => self.render_pce_overclock_settings(ctx), 1197 OpenWindow::About => self.render_about(ctx), 1198 } 1199 } 1200 } 1201 1202 fn render_help_text(&mut self, ui: &mut Ui, window: OpenWindow) { 1203 ui.separator(); 1204 1205 ui.scope(|ui| { 1206 ui.set_min_size([0.0, RESERVED_HELP_TEXT_HEIGHT].into()); 1207 1208 let Some(help_text) = self.state.help_text.get(&window) else { return }; 1209 1210 ui.heading(help_text.heading); 1211 1212 for text in help_text.text { 1213 ui.add_space(7.0); 1214 ui.label(*text); 1215 } 1216 }); 1217 } 1218 1219 fn check_emulator_error(&mut self, ctx: &Context) { 1220 let emulator_error = self.emu_runner.emulator_error(); 1221 let mut emulator_error = emulator_error.borrow_mut(); 1222 1223 self.state.error_window_open = emulator_error.is_some(); 1224 1225 if let Some(err) = &*emulator_error { 1226 let mut open = true; 1227 let render_effect = match err { 1228 NativeEmulatorError::SmsNoBios => self.render_sms_bios_error(ctx, &mut open), 1229 NativeEmulatorError::GgNoBios => self.render_gg_bios_error(ctx, &mut open), 1230 &NativeEmulatorError::SegaCdNoBios(region) => { 1231 self.render_scd_bios_error(ctx, &mut open, region) 1232 } 1233 NativeEmulatorError::GbNoDmgBootRom => { 1234 self.render_dmg_boot_rom_error(ctx, &mut open) 1235 } 1236 NativeEmulatorError::GbNoCgbBootRom => { 1237 self.render_cgb_boot_rom_error(ctx, &mut open) 1238 } 1239 NativeEmulatorError::SnesLoad(snes_load_err) => { 1240 match self.render_snes_load_error(ctx, snes_load_err, &mut open) { 1241 HandledError::Yes(effect) => effect, 1242 HandledError::No => Self::render_generic_error_window(ctx, err, &mut open), 1243 } 1244 } 1245 NativeEmulatorError::GbaNoBios => self.render_gba_bios_error(ctx, &mut open), 1246 _ => Self::render_generic_error_window(ctx, err, &mut open), 1247 }; 1248 1249 if !open { 1250 *emulator_error = None; 1251 } 1252 1253 match render_effect { 1254 RenderErrorEffect::LaunchEmulator(console) => { 1255 self.launch_emulator( 1256 self.state.current_file_path.clone(), 1257 self.state.current_secondary_file_paths.clone(), 1258 console, 1259 ); 1260 } 1261 RenderErrorEffect::None => {} 1262 } 1263 } 1264 } 1265 1266 fn render_generic_error_window( 1267 ctx: &Context, 1268 err: &NativeEmulatorError, 1269 open: &mut bool, 1270 ) -> RenderErrorEffect { 1271 Window::new("Emulator Error").open(open).resizable(false).show(ctx, |ui| { 1272 ui.label("Emulator terminated with error:"); 1273 ui.add_space(10.0); 1274 ui.colored_label(Color32::RED, err.to_string()); 1275 }); 1276 1277 RenderErrorEffect::None 1278 } 1279 1280 fn check_input_collection(&mut self, ctx: &Context) { 1281 let Some(input_collection) = &mut self.state.input_collection else { return }; 1282 1283 input_collection.show_window(ctx, &self.joysticks.borrow()); 1284 1285 if input_collection.done() { 1286 self.state.input_collection = None; 1287 } 1288 } 1289 1290 fn check_for_close_on_emu_exit(&mut self, ctx: &Context) { 1291 if self.state.close_on_emulator_exit { 1292 let status = self.emu_runner.status(); 1293 if !status.is_running() && status != EmuRunnerStatus::WaitingForFirstCommand { 1294 ctx.send_viewport_cmd(ViewportCommand::Close); 1295 } 1296 } 1297 } 1298 1299 fn update_egui_theme(&mut self, ctx: &Context) { 1300 ctx.set_theme(match self.config.egui_theme { 1301 EguiTheme::SystemDefault => ThemePreference::System, 1302 EguiTheme::Dark => ThemePreference::Dark, 1303 EguiTheme::Light => ThemePreference::Light, 1304 }); 1305 } 1306 1307 fn reload_config(&mut self) { 1308 let mut config = self.config.clone(); 1309 if let Err(err) = config.apply_overrides(&self.config_overrides) { 1310 log::error!("Error applying config overrides: {err}"); 1311 } 1312 1313 self.emu_runner.push_command(EmuRunnerCommand::ReloadConfig( 1314 Box::new(config), 1315 Arc::clone(self.active_cheats()), 1316 )); 1317 } 1318 1319 fn refresh_filtered_rom_list(&mut self, ctx: &Context) { 1320 { 1321 let rom_list = self.state.rom_list.lock().unwrap(); 1322 1323 self.state.filtered_rom_list = self 1324 .config 1325 .list_filters 1326 .apply(&rom_list, &self.state.title_match_lowercase) 1327 .cloned() 1328 .collect::<Vec<_>>() 1329 .into(); 1330 } 1331 1332 self.check_rom_list_for_cjk(ctx); 1333 } 1334 1335 fn update_window_size_in_config(&mut self, ctx: &Context) { 1336 ctx.viewport(|vp| { 1337 let Pos2 { x: width, y: height } = vp.input.viewport_rect().max; 1338 self.config.gui_window_width = width; 1339 self.config.gui_window_height = height; 1340 }); 1341 } 1342 1343 #[allow(clippy::missing_panics_doc)] 1344 pub fn ui(&mut self, ui: &mut Ui) { 1345 if self.emu_runner.exit_signal() { 1346 ui.send_viewport_cmd(ViewportCommand::Close); 1347 return; 1348 } 1349 1350 if self.state.rom_list_refresh_needed && !self.rom_list_thread.any_scans_in_progress() { 1351 self.state.rom_list_refresh_needed = false; 1352 self.refresh_filtered_rom_list(ui); 1353 } 1354 1355 if self.state.rendered_first_frame { 1356 self.check_load_at_startup(); 1357 } 1358 1359 let gui_focused = ui.input(|input| input.raw.focused); 1360 self.emu_runner.update_gui_focused(gui_focused); 1361 1362 self.check_emulator_error(ui); 1363 self.check_input_collection(ui); 1364 self.check_for_close_on_emu_exit(ui); 1365 1366 self.update_egui_theme(ui); 1367 1368 self.render_menu(ui); 1369 self.render_central_panel(ui); 1370 1371 self.render_windows(ui); 1372 1373 self.update_window_size_in_config(ui); 1374 1375 if self.config != self.last_saved_config { 1376 if should_reload_config(&self.config, &self.last_saved_config) { 1377 self.reload_config(); 1378 } 1379 1380 let config_str = toml::to_string_pretty(&self.config).unwrap(); 1381 if let Err(err) = fs::write(&self.config_path, config_str) { 1382 log::error!("Error serializing app config: {err}"); 1383 } 1384 1385 nes::update_palette_textures(ui, &self.state.nes_palette, &self.config.nes.palette); 1386 1387 self.last_saved_config = self.config.clone(); 1388 } 1389 1390 self.check_recent_opens_for_cjk(ui); 1391 self.check_cheat_names_for_cjk(ui); 1392 1393 self.state.rendered_first_frame = true; 1394 } 1395 1396 pub fn handle_sdl_event(&mut self, event: &sdl3::event::Event, ctx: &Context, window_id: u32) { 1397 if let Some(input_collection) = &mut self.state.input_collection { 1398 input_collection.handle_sdl_event( 1399 event, 1400 ctx, 1401 window_id, 1402 &mut self.joysticks.borrow_mut(), 1403 &mut self.config.input, 1404 ); 1405 } 1406 } 1407 1408 fn check_load_at_startup(&mut self) { 1409 let Some(load_at_startup) = self.load_at_startup.take() else { return }; 1410 let Some(primary_path) = load_at_startup.file_paths.first() else { return }; 1411 1412 self.config_overrides.clone_from(&load_at_startup.config_overrides); 1413 1414 let console = load_at_startup 1415 .console 1416 .or_else(|| Console::from_file(primary_path).map(|console| console.console)) 1417 .unwrap_or_else(|| { 1418 log::error!( 1419 "Unable to guess hardware for path '{}'; defaulting to Genesis", 1420 primary_path.display() 1421 ); 1422 Console::Genesis 1423 }); 1424 1425 let secondary_paths = &load_at_startup.file_paths[1..]; 1426 self.launch_emulator(primary_path.clone(), secondary_paths.to_vec(), console); 1427 1428 if let Some(load_state_slot) = load_at_startup.load_state_slot { 1429 self.emu_runner.push_command(EmuRunnerCommand::LoadState { slot: load_state_slot }); 1430 } 1431 1432 self.state.close_on_emulator_exit = true; 1433 } 1434 1435 fn check_rom_list_for_cjk(&mut self, ctx: &Context) { 1436 self.load_cjk_fonts_if_needed(ctx, |app, predicate| { 1437 app.state 1438 .filtered_rom_list 1439 .iter() 1440 .find_map(|metadata| predicate(&metadata.file_name_no_ext)) 1441 }); 1442 } 1443 1444 fn check_recent_opens_for_cjk(&mut self, ctx: &Context) { 1445 self.load_cjk_fonts_if_needed(ctx, |app, predicate| { 1446 app.state 1447 .recent_open_list 1448 .iter() 1449 .filter_map(Option::as_ref) 1450 .find_map(|metadata| predicate(&metadata.file_name_no_ext)) 1451 }); 1452 } 1453 1454 fn load_cjk_fonts_if_needed( 1455 &mut self, 1456 ctx: &Context, 1457 // Not ideal API but avoids lifetime issues that arise with a function that takes a &Self 1458 // and returns an iterator 1459 find_cjk_string: impl FnOnce(&Self, fn(&str) -> Option<String>) -> Option<String>, 1460 ) { 1461 if self.cjk_fonts_loaded { 1462 return; 1463 } 1464 1465 let any_cjk_string = 1466 find_cjk_string(self, |s| s.chars().any(is_cjk_character).then(|| s.into())); 1467 1468 if let Some(cjk_string) = any_cjk_string { 1469 log::info!("Loading CJK fonts due to displayed CJK string: '{cjk_string}'"); 1470 1471 egui_system_fonts::add_auto(ctx, egui_system_fonts::FontStyle::Sans); 1472 self.cjk_fonts_loaded = true; 1473 } 1474 } 1475} 1476 1477fn should_reload_config(prev_config: &AppConfig, new_config: &AppConfig) -> bool { 1478 // UI-only settings changes should not trigger emulator config reloads 1479 1480 let prev_no_ui_settings = AppConfig { 1481 list_filters: ListFilters::default(), 1482 rom_search_dirs: vec![], 1483 recent_open_list: vec![], 1484 gui_window_width: jgenesis_native_config::DEFAULT_GUI_WIDTH, 1485 gui_window_height: jgenesis_native_config::DEFAULT_GUI_HEIGHT, 1486 ..prev_config.clone() 1487 }; 1488 1489 let new_no_ui_settings = AppConfig { 1490 list_filters: ListFilters::default(), 1491 rom_search_dirs: vec![], 1492 recent_open_list: vec![], 1493 gui_window_width: jgenesis_native_config::DEFAULT_GUI_WIDTH, 1494 gui_window_height: jgenesis_native_config::DEFAULT_GUI_HEIGHT, 1495 ..new_config.clone() 1496 }; 1497 1498 prev_no_ui_settings != new_no_ui_settings 1499} 1500 1501fn format_time_nanos(time_nanos: u128) -> Option<String> { 1502 let utc_date_time = OffsetDateTime::from_unix_timestamp_nanos(time_nanos as i128) 1503 .unwrap_or(OffsetDateTime::UNIX_EPOCH); 1504 1505 let local_offset = UtcOffset::current_local_offset().ok()?; 1506 let local_date_time = utc_date_time.checked_to_offset(local_offset)?; 1507 1508 let format = 1509 format_description::parse_borrowed::<2>("[year]-[month]-[day] [hour]:[minute]:[second]") 1510 .unwrap(); 1511 local_date_time.format(&format).ok() 1512} 1513 1514fn is_cjk_character(c: char) -> bool { 1515 const RANGES: &[(char, char)] = &[ 1516 ('\u{2E80}', '\u{D7FF}'), // Various CJK character and punctuation sets (mostly) 1517 ('\u{F900}', '\u{FAFF}'), // CJK Compatibility Ideographs 1518 ('\u{FE30}', '\u{FE4F}'), // CJK Compatibility Forms 1519 ('\u{1AFF0}', '\u{1B16F}'), // Kana Extensions/Supplements 1520 ('\u{20000}', '\u{2FA1F}'), // CJK Unified Ideographs Extensions, CJK Compatibility Ideographs Supplement 1521 ('\u{30000}', '\u{33479}'), // CJK Unified Ideographs Extensions 1522 ]; 1523 1524 RANGES.iter().any(|&(start, end)| (start..=end).contains(&c)) 1525} 1526 1527#[cfg(test)] 1528mod tests { 1529 use super::*; 1530 1531 #[test] 1532 fn time_nanos_format_is_valid() { 1533 assert!(format_time_nanos(1_000_000_000).is_some()); 1534 } 1535}