mod app; mod audio; mod input; mod scheduling; mod video; use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; use std::time::Instant; use gtk::gdk; use gtk::gio; use gtk::glib; use gtk::prelude::*; use gtk4 as gtk; use nesemu::prelude::EmulationState; use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH}; use app::DesktopApp; use scheduling::DesktopFrameScheduler; const APP_ID: &str = "org.nesemu.desktop"; const TITLE: &str = "NES Emulator"; const SCALE: i32 = 3; const SAMPLE_RATE: u32 = 48_000; fn main() { if std::env::var_os("GSK_RENDERER").is_none() { unsafe { std::env::set_var("GSK_RENDERER", "cairo"); } } let app = gtk::Application::builder().application_id(APP_ID).build(); let initial_rom: Rc>> = Rc::new(RefCell::new(std::env::args().nth(1).map(PathBuf::from))); let initial_rom_for_activate = Rc::clone(&initial_rom); app.connect_activate(move |app| { let rom = initial_rom_for_activate.borrow_mut().take(); build_ui(app, rom); }); app.run_with_args::<&str>(&[]); } fn build_ui(app: >k::Application, initial_rom: Option) { let window = gtk::ApplicationWindow::builder() .application(app) .title(TITLE) .default_width((FRAME_WIDTH as i32) * SCALE) .default_height((FRAME_HEIGHT as i32) * SCALE) .build(); // --- Header bar --- let header = gtk::HeaderBar::new(); let open_button = gtk::Button::builder() .icon_name("document-open-symbolic") .tooltip_text("Open ROM (Ctrl+O)") .focusable(false) .build(); let pause_button = gtk::Button::builder() .icon_name("media-playback-pause-symbolic") .tooltip_text("Pause / Resume (P)") .focusable(false) .sensitive(false) .build(); let reset_button = gtk::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text("Reset (Ctrl+R)") .focusable(false) .sensitive(false) .build(); let volume = Arc::new(AtomicU32::new(f32::to_bits(0.75))); let volume_scale = gtk::Scale::with_range(gtk::Orientation::Horizontal, 0.0, 1.0, 0.05); volume_scale.set_value(0.75); volume_scale.set_draw_value(false); volume_scale.set_width_request(100); volume_scale.set_tooltip_text(Some("Volume")); volume_scale.set_focusable(false); { let volume = Arc::clone(&volume); volume_scale.connect_value_changed(move |scale| { let val = scale.value() as f32; volume.store(f32::to_bits(val), AtomicOrdering::Relaxed); }); } header.pack_start(&open_button); header.pack_start(&pause_button); header.pack_start(&reset_button); let volume_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); let volume_icon = gtk::Image::from_icon_name("audio-volume-high-symbolic"); volume_box.append(&volume_icon); volume_box.append(&volume_scale); header.pack_end(&volume_box); window.set_titlebar(Some(&header)); // --- Drawing area --- let drawing_area = gtk::DrawingArea::new(); drawing_area.set_hexpand(true); drawing_area.set_vexpand(true); let overlay = gtk::Overlay::new(); overlay.set_child(Some(&drawing_area)); let drop_label = gtk::Label::builder() .label("Drop a .nes ROM here\nor press Ctrl+O to open") .justify(gtk::Justification::Center) .css_classes(["dim-label"]) .build(); drop_label.set_halign(gtk::Align::Center); drop_label.set_valign(gtk::Align::Center); overlay.add_overlay(&drop_label); window.set_child(Some(&overlay)); // --- State --- let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume)))); let frame_for_draw: Rc>> = Rc::new(RefCell::new(vec![0u8; FRAME_RGBA_BYTES])); let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new())); // --- Draw function (pixel-perfect nearest-neighbor) --- { let frame_for_draw = Rc::clone(&frame_for_draw); drawing_area.set_draw_func(move |_da, cr, width, height| { let frame = frame_for_draw.borrow(); video::draw_frame(&frame, cr, width, height); }); } // --- Helper to sync UI with emulation state --- let sync_ui = { let pause_button = pause_button.clone(); let reset_button = reset_button.clone(); let drop_label = drop_label.clone(); let window = window.clone(); move |app_state: &DesktopApp, rom_name: Option<&str>| { let loaded = app_state.is_loaded(); pause_button.set_sensitive(loaded); reset_button.set_sensitive(loaded); drop_label.set_visible(!loaded); if app_state.state() == EmulationState::Running { pause_button.set_icon_name("media-playback-pause-symbolic"); pause_button.set_tooltip_text(Some("Pause (P)")); } else { pause_button.set_icon_name("media-playback-start-symbolic"); pause_button.set_tooltip_text(Some("Resume (P)")); } if let Some(name) = rom_name { window.set_title(Some(&format!("{TITLE} — {name}"))); } } }; let sync_ui = Rc::new(sync_ui); // --- Load initial ROM --- { let mut app_state = desktop.borrow_mut(); if let Some(path) = initial_rom { if let Err(err) = app_state.load_rom_from_path(&path) { eprintln!("Failed to load ROM '{}': {err}", path.display()); sync_ui(&app_state, None); } else { let name = rom_filename(&path); sync_ui(&app_state, Some(&name)); } } else { sync_ui(&app_state, None); } } // --- Open ROM handler --- let do_open_rom = { let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); let window = window.clone(); Rc::new(move || { let chooser = gtk::FileChooserNative::new( Some("Open NES ROM"), Some(&window), gtk::FileChooserAction::Open, Some("Open"), Some("Cancel"), ); let nes_filter = gtk::FileFilter::new(); nes_filter.set_name(Some("NES ROMs")); nes_filter.add_pattern("*.nes"); chooser.add_filter(&nes_filter); let all_filter = gtk::FileFilter::new(); all_filter.set_name(Some("All files")); all_filter.add_pattern("*"); chooser.add_filter(&all_filter); let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); chooser.connect_response(move |dialog, response| { if response == gtk::ResponseType::Accept && let Some(path) = dialog.file().and_then(|f| f.path()) { let mut app_state = desktop.borrow_mut(); if let Err(err) = app_state.load_rom_from_path(&path) { eprintln!("Failed to load ROM '{}': {err}", path.display()); } else { scheduler.borrow_mut().reset_timing(); let name = rom_filename(&path); sync_ui(&app_state, Some(&name)); } } }); chooser.show(); }) }; // --- Button handlers --- { let do_open_rom = Rc::clone(&do_open_rom); open_button.connect_clicked(move |_| { do_open_rom(); }); } { let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); pause_button.connect_clicked(move |_| { let mut app_state = desktop.borrow_mut(); app_state.toggle_pause(); scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); }); } { let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); reset_button.connect_clicked(move |_| { let mut app_state = desktop.borrow_mut(); app_state.reset(); scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); }); } // --- Keyboard shortcuts via actions --- let action_open = gio::SimpleAction::new("open", None); { let do_open_rom = Rc::clone(&do_open_rom); action_open.connect_activate(move |_, _| { do_open_rom(); }); } window.add_action(&action_open); app.set_accels_for_action("win.open", &["o"]); let action_pause = gio::SimpleAction::new("toggle-pause", None); { let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); action_pause.connect_activate(move |_, _| { let mut app_state = desktop.borrow_mut(); if app_state.is_loaded() { app_state.toggle_pause(); scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); } }); } window.add_action(&action_pause); app.set_accels_for_action("win.toggle-pause", &["p"]); let action_reset = gio::SimpleAction::new("reset", None); { let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); action_reset.connect_activate(move |_, _| { let mut app_state = desktop.borrow_mut(); if app_state.is_loaded() { app_state.reset(); scheduler.borrow_mut().reset_timing(); sync_ui(&app_state, None); } }); } window.add_action(&action_reset); app.set_accels_for_action("win.reset", &["r"]); // --- Keyboard controller for joypad input --- { let desktop = Rc::clone(&desktop); let key_controller = gtk::EventControllerKey::new(); let desktop_for_press = Rc::clone(&desktop); key_controller.connect_key_pressed(move |_, key, _, _| { let mut app_state = desktop_for_press.borrow_mut(); app_state.input_mut().set_key_state(key, true); gtk::glib::Propagation::Proceed }); key_controller.connect_key_released(move |_, key, _, _| { desktop.borrow_mut().input_mut().set_key_state(key, false); }); window.add_controller(key_controller); } // --- Drag-and-drop --- { let desktop = Rc::clone(&desktop); let scheduler = Rc::clone(&scheduler); let sync_ui = Rc::clone(&sync_ui); let drop_target = gtk::DropTarget::new(gio::File::static_type(), gdk::DragAction::COPY); drop_target.connect_drop(move |_, value, _, _| { if let Ok(file) = value.get::() && let Some(path) = file.path() { let mut app_state = desktop.borrow_mut(); if let Err(err) = app_state.load_rom_from_path(&path) { eprintln!("Failed to load ROM '{}': {err}", path.display()); return false; } scheduler.borrow_mut().reset_timing(); let name = rom_filename(&path); sync_ui(&app_state, Some(&name)); return true; } false }); drawing_area.add_controller(drop_target); } // --- Game loop --- { schedule_game_loop( Rc::clone(&desktop), drawing_area.clone(), Rc::clone(&frame_for_draw), Rc::clone(&scheduler), ); } window.present(); } fn schedule_game_loop( desktop: Rc>, drawing_area: gtk::DrawingArea, frame_for_draw: Rc>>, scheduler: Rc>, ) { let interval = desktop.borrow().frame_interval(); let delay = scheduler .borrow_mut() .delay_until_next_frame(Instant::now(), interval); glib::timeout_add_local_once(delay, move || { { let mut app_state = desktop.borrow_mut(); let now = Instant::now(); let interval = app_state.frame_interval(); scheduler.borrow_mut().mark_frame_complete(now, interval); app_state.tick(); frame_for_draw .borrow_mut() .copy_from_slice(app_state.frame_rgba()); drawing_area.queue_draw(); } schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler); }); } fn rom_filename(path: &Path) -> String { path.file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "Unknown".into()) }