app.rsannotatedapp.rssource1535 lines · 59.3 KB · raw
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}