Some checks failed
CI / rust (push) Has been cancelled
Split DesktopApp into input, audio, video, scheduling, and app modules. Migrate DesktopApp from manual pause/resume logic to library ClientRuntime.
402 lines
13 KiB
Rust
402 lines
13 KiB
Rust
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<RefCell<Option<PathBuf>>> =
|
|
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<PathBuf>) {
|
|
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<RefCell<Vec<u8>>> = 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", &["<Ctrl>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", &["<Ctrl>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::<gio::File>()
|
|
&& 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<RefCell<DesktopApp>>,
|
|
drawing_area: gtk::DrawingArea,
|
|
frame_for_draw: Rc<RefCell<Vec<u8>>>,
|
|
scheduler: Rc<RefCell<DesktopFrameScheduler>>,
|
|
) {
|
|
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())
|
|
}
|