feat(desktop): GPU rendering, modern GTK4 UI, hotkeys and player 2
Some checks failed
CI / rust (push) Has been cancelled
Some checks failed
CI / rust (push) Has been cancelled
- Replace Cairo DrawingArea with custom NesScreen widget using GskTextureScaleNode for GPU-accelerated nearest-neighbor rendering - Migrate from FileChooserNative to FileDialog (GTK 4.10+) - Add AlertDialog for error display, structured logging via env_logger - Add FPS counter (F3), NTSC/PAL toggle (F7), fullscreen (F11), Esc to quit, save/load state (Ctrl+S/L), volume slider - Add player 2 keyboard input support - Fix window proportions by compensating for header bar height
This commit is contained in:
148
Cargo.lock
generated
148
Cargo.lock
generated
@@ -33,6 +33,56 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
@@ -159,6 +209,12 @@ dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -230,6 +286,29 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -604,6 +683,12 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.13.0"
|
||||
@@ -613,6 +698,30 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -759,9 +868,10 @@ dependencies = [
|
||||
name = "nesemu-desktop"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cairo-rs",
|
||||
"cpal",
|
||||
"env_logger",
|
||||
"gtk4",
|
||||
"log",
|
||||
"nesemu",
|
||||
]
|
||||
|
||||
@@ -846,6 +956,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.19.8"
|
||||
@@ -882,6 +998,21 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
@@ -1156,6 +1287,12 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
@@ -1312,6 +1449,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
|
||||
@@ -5,6 +5,8 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
nesemu = { path = "../.." }
|
||||
gtk4 = "0.8"
|
||||
cairo-rs = "0.19"
|
||||
gtk4 = { version = "0.8", features = ["v4_10"] }
|
||||
|
||||
cpal = "0.15"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -13,18 +13,22 @@ use crate::SAMPLE_RATE;
|
||||
|
||||
pub(crate) struct DesktopApp {
|
||||
session: Option<ClientRuntime<Box<dyn FrameClock>>>,
|
||||
input: InputState,
|
||||
input_p1: InputState,
|
||||
input_p2: InputState,
|
||||
audio: CpalAudioSink,
|
||||
video: BufferedVideo,
|
||||
save_slot: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl DesktopApp {
|
||||
pub(crate) fn new(volume: Arc<AtomicU32>) -> Self {
|
||||
Self {
|
||||
session: None,
|
||||
input: InputState::default(),
|
||||
input_p1: InputState::default(),
|
||||
input_p2: InputState::default(),
|
||||
audio: CpalAudioSink::new(volume),
|
||||
video: BufferedVideo::new(),
|
||||
save_slot: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,12 +78,18 @@ impl DesktopApp {
|
||||
return;
|
||||
};
|
||||
|
||||
match session.tick(&mut self.input, &mut self.video, &mut self.audio) {
|
||||
Ok(_) => {}
|
||||
Err(err) => {
|
||||
eprintln!("Frame execution error: {err}");
|
||||
session.pause();
|
||||
}
|
||||
// Set player 2 buttons before the frame tick.
|
||||
use nesemu::InputProvider;
|
||||
let p2_buttons = self.input_p2.poll_buttons();
|
||||
session
|
||||
.host_mut()
|
||||
.runtime_mut()
|
||||
.bus_mut()
|
||||
.set_joypad2_buttons(p2_buttons);
|
||||
|
||||
if let Err(err) = session.tick(&mut self.input_p1, &mut self.video, &mut self.audio) {
|
||||
log::error!("Frame execution error: {err}");
|
||||
session.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +104,49 @@ impl DesktopApp {
|
||||
.unwrap_or_else(|| VideoMode::Ntsc.frame_duration())
|
||||
}
|
||||
|
||||
pub(crate) fn input_mut(&mut self) -> &mut InputState {
|
||||
&mut self.input
|
||||
pub(crate) fn input_p1_mut(&mut self) -> &mut InputState {
|
||||
&mut self.input_p1
|
||||
}
|
||||
|
||||
pub(crate) fn input_p2_mut(&mut self) -> &mut InputState {
|
||||
&mut self.input_p2
|
||||
}
|
||||
|
||||
pub(crate) fn save_state(&mut self) {
|
||||
if let Some(session) = self.session.as_ref() {
|
||||
self.save_slot = Some(session.host().runtime().save_state());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_state(&mut self) {
|
||||
let Some(data) = self.save_slot.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
if let Err(err) = session.host_mut().runtime_mut().load_state(data) {
|
||||
log::error!("Failed to load state: {err}");
|
||||
}
|
||||
self.audio.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn video_mode(&self) -> VideoMode {
|
||||
self.session
|
||||
.as_ref()
|
||||
.map(|s| s.host().runtime().video_mode())
|
||||
.unwrap_or(VideoMode::Ntsc)
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_video_mode(&mut self) -> Option<VideoMode> {
|
||||
let session = self.session.as_mut()?;
|
||||
let current = session.host().runtime().video_mode();
|
||||
let next = match current {
|
||||
VideoMode::Ntsc => VideoMode::Pal,
|
||||
_ => VideoMode::Ntsc,
|
||||
};
|
||||
session.host_mut().runtime_mut().set_video_mode(next);
|
||||
log::info!("Video mode: {next:?}");
|
||||
Some(next)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -7,18 +7,7 @@ pub(crate) struct InputState {
|
||||
}
|
||||
|
||||
impl InputState {
|
||||
pub(crate) fn set_key_state(&mut self, key: gdk::Key, pressed: bool) {
|
||||
let button = match key {
|
||||
gdk::Key::Up => JoypadButton::Up,
|
||||
gdk::Key::Down => JoypadButton::Down,
|
||||
gdk::Key::Left => JoypadButton::Left,
|
||||
gdk::Key::Right => JoypadButton::Right,
|
||||
gdk::Key::x | gdk::Key::X => JoypadButton::A,
|
||||
gdk::Key::z | gdk::Key::Z => JoypadButton::B,
|
||||
gdk::Key::Return => JoypadButton::Start,
|
||||
gdk::Key::Shift_L | gdk::Key::Shift_R => JoypadButton::Select,
|
||||
_ => return,
|
||||
};
|
||||
pub(crate) fn set_button(&mut self, button: JoypadButton, pressed: bool) {
|
||||
set_button_pressed(&mut self.buttons, button, pressed);
|
||||
}
|
||||
}
|
||||
@@ -28,3 +17,31 @@ impl InputProvider for InputState {
|
||||
self.buttons
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key_to_p1_button(key: gdk::Key) -> Option<JoypadButton> {
|
||||
match key {
|
||||
gdk::Key::Up => Some(JoypadButton::Up),
|
||||
gdk::Key::Down => Some(JoypadButton::Down),
|
||||
gdk::Key::Left => Some(JoypadButton::Left),
|
||||
gdk::Key::Right => Some(JoypadButton::Right),
|
||||
gdk::Key::x | gdk::Key::X => Some(JoypadButton::A),
|
||||
gdk::Key::z | gdk::Key::Z => Some(JoypadButton::B),
|
||||
gdk::Key::Return => Some(JoypadButton::Start),
|
||||
gdk::Key::Shift_L | gdk::Key::Shift_R => Some(JoypadButton::Select),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn key_to_p2_button(key: gdk::Key) -> Option<JoypadButton> {
|
||||
match key {
|
||||
gdk::Key::w | gdk::Key::W => Some(JoypadButton::Up),
|
||||
gdk::Key::s | gdk::Key::S => Some(JoypadButton::Down),
|
||||
gdk::Key::a | gdk::Key::A => Some(JoypadButton::Left),
|
||||
gdk::Key::d | gdk::Key::D => Some(JoypadButton::Right),
|
||||
gdk::Key::k | gdk::Key::K => Some(JoypadButton::A),
|
||||
gdk::Key::j | gdk::Key::J => Some(JoypadButton::B),
|
||||
gdk::Key::i | gdk::Key::I => Some(JoypadButton::Start),
|
||||
gdk::Key::u | gdk::Key::U => Some(JoypadButton::Select),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod input;
|
||||
mod scheduling;
|
||||
mod video;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
@@ -17,10 +17,11 @@ use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
use gtk4 as gtk;
|
||||
use nesemu::prelude::EmulationState;
|
||||
use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH};
|
||||
use nesemu::{FRAME_HEIGHT, FRAME_WIDTH};
|
||||
|
||||
use app::DesktopApp;
|
||||
use scheduling::DesktopFrameScheduler;
|
||||
use video::NesScreen;
|
||||
|
||||
const APP_ID: &str = "org.nesemu.desktop";
|
||||
const TITLE: &str = "NES Emulator";
|
||||
@@ -28,11 +29,7 @@ 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");
|
||||
}
|
||||
}
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let app = gtk::Application::builder().application_id(APP_ID).build();
|
||||
|
||||
@@ -53,7 +50,7 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
.application(app)
|
||||
.title(TITLE)
|
||||
.default_width((FRAME_WIDTH as i32) * SCALE)
|
||||
.default_height((FRAME_HEIGHT as i32) * SCALE)
|
||||
.default_height((FRAME_HEIGHT as i32) * SCALE + 45)
|
||||
.build();
|
||||
|
||||
// --- Header bar ---
|
||||
@@ -108,45 +105,48 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
|
||||
window.set_titlebar(Some(&header));
|
||||
|
||||
// --- Drawing area ---
|
||||
let drawing_area = gtk::DrawingArea::new();
|
||||
drawing_area.set_hexpand(true);
|
||||
drawing_area.set_vexpand(true);
|
||||
// --- NES screen widget (GPU-accelerated, nearest-neighbor scaling) ---
|
||||
let screen = NesScreen::new();
|
||||
screen.set_size_request(FRAME_WIDTH as i32, FRAME_HEIGHT as i32);
|
||||
screen.set_hexpand(true);
|
||||
screen.set_vexpand(true);
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
overlay.set_child(Some(&drawing_area));
|
||||
overlay.set_child(Some(&screen));
|
||||
|
||||
let drop_label = gtk::Label::builder()
|
||||
.label("Drop a .nes ROM here\nor press Ctrl+O to open")
|
||||
.label("Drop a .nes ROM here\nor press Ctrl+O to open\n\nF3 — FPS counter\nF7 — NTSC / PAL\nF11 — Fullscreen\nEsc — Quit")
|
||||
.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);
|
||||
overlay.set_measure_overlay(&drop_label, false);
|
||||
|
||||
let fps_label = gtk::Label::new(None);
|
||||
fps_label.set_halign(gtk::Align::End);
|
||||
fps_label.set_valign(gtk::Align::Start);
|
||||
fps_label.set_margin_top(4);
|
||||
fps_label.set_margin_end(4);
|
||||
fps_label.add_css_class("monospace");
|
||||
fps_label.set_visible(false);
|
||||
overlay.add_overlay(&fps_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 current_rom_name: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
|
||||
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();
|
||||
let current_rom_name = Rc::clone(¤t_rom_name);
|
||||
move |app_state: &DesktopApp, rom_name: Option<&str>| {
|
||||
let loaded = app_state.is_loaded();
|
||||
pause_button.set_sensitive(loaded);
|
||||
@@ -162,7 +162,11 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
}
|
||||
|
||||
if let Some(name) = rom_name {
|
||||
window.set_title(Some(&format!("{TITLE} — {name}")));
|
||||
*current_rom_name.borrow_mut() = Some(name.to_string());
|
||||
}
|
||||
if let Some(name) = current_rom_name.borrow().as_deref() {
|
||||
let mode = app_state.video_mode();
|
||||
window.set_title(Some(&format!("{TITLE} — {name} [{mode:?}]")));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -172,12 +176,17 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
{
|
||||
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));
|
||||
match app_state.load_rom_from_path(&path) {
|
||||
Ok(()) => {
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to load ROM '{}': {err}", path.display());
|
||||
sync_ui(&app_state, None);
|
||||
drop(app_state);
|
||||
show_error(&window, &format!("Failed to load ROM: {err}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sync_ui(&app_state, None);
|
||||
@@ -191,42 +200,48 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
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 filters = gio::ListStore::new::<gtk::FileFilter>();
|
||||
filters.append(&nes_filter);
|
||||
filters.append(&all_filter);
|
||||
|
||||
let dialog = gtk::FileDialog::builder()
|
||||
.title("Open NES ROM")
|
||||
.modal(true)
|
||||
.filters(&filters)
|
||||
.default_filter(&nes_filter)
|
||||
.build();
|
||||
|
||||
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 {
|
||||
let parent = window.clone();
|
||||
let error_window = window.clone();
|
||||
dialog.open(Some(&parent), gio::Cancellable::NONE, move |result| {
|
||||
let file = match result {
|
||||
Ok(file) => file,
|
||||
Err(_) => return, // user cancelled
|
||||
};
|
||||
let Some(path) = file.path() else { return };
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
match app_state.load_rom_from_path(&path) {
|
||||
Ok(()) => {
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
}
|
||||
Err(err) => {
|
||||
drop(app_state);
|
||||
show_error(&error_window, &format!("Failed to load ROM: {err}"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chooser.show();
|
||||
})
|
||||
};
|
||||
|
||||
@@ -307,6 +322,76 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
window.add_action(&action_reset);
|
||||
app.set_accels_for_action("win.reset", &["<Ctrl>r"]);
|
||||
|
||||
let action_save = gio::SimpleAction::new("save-state", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
action_save.connect_activate(move |_, _| {
|
||||
desktop.borrow_mut().save_state();
|
||||
});
|
||||
}
|
||||
window.add_action(&action_save);
|
||||
app.set_accels_for_action("win.save-state", &["<Ctrl>s"]);
|
||||
|
||||
let action_load = gio::SimpleAction::new("load-state", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
action_load.connect_activate(move |_, _| {
|
||||
desktop.borrow_mut().load_state();
|
||||
});
|
||||
}
|
||||
window.add_action(&action_load);
|
||||
app.set_accels_for_action("win.load-state", &["<Ctrl>l"]);
|
||||
|
||||
let action_fullscreen = gio::SimpleAction::new("toggle-fullscreen", None);
|
||||
{
|
||||
let window = window.clone();
|
||||
action_fullscreen.connect_activate(move |_, _| {
|
||||
if window.is_fullscreen() {
|
||||
window.unfullscreen();
|
||||
} else {
|
||||
window.fullscreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
window.add_action(&action_fullscreen);
|
||||
app.set_accels_for_action("win.toggle-fullscreen", &["F11"]);
|
||||
|
||||
let action_fps = gio::SimpleAction::new("toggle-fps", None);
|
||||
{
|
||||
let fps_label = fps_label.clone();
|
||||
action_fps.connect_activate(move |_, _| {
|
||||
fps_label.set_visible(!fps_label.is_visible());
|
||||
});
|
||||
}
|
||||
window.add_action(&action_fps);
|
||||
app.set_accels_for_action("win.toggle-fps", &["F3"]);
|
||||
|
||||
let action_quit = gio::SimpleAction::new("quit", None);
|
||||
{
|
||||
let window = window.clone();
|
||||
action_quit.connect_activate(move |_, _| {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
window.add_action(&action_quit);
|
||||
app.set_accels_for_action("win.quit", &["Escape"]);
|
||||
|
||||
let action_video_mode = gio::SimpleAction::new("cycle-video-mode", None);
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
action_video_mode.connect_activate(move |_, _| {
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if app_state.cycle_video_mode().is_some() {
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
sync_ui(&app_state, None);
|
||||
}
|
||||
});
|
||||
}
|
||||
window.add_action(&action_video_mode);
|
||||
app.set_accels_for_action("win.cycle-video-mode", &["F7"]);
|
||||
|
||||
// --- Keyboard controller for joypad input ---
|
||||
{
|
||||
let desktop = Rc::clone(&desktop);
|
||||
@@ -315,12 +400,23 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
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);
|
||||
if let Some(btn) = input::key_to_p1_button(key) {
|
||||
app_state.input_p1_mut().set_button(btn, true);
|
||||
}
|
||||
if let Some(btn) = input::key_to_p2_button(key) {
|
||||
app_state.input_p2_mut().set_button(btn, true);
|
||||
}
|
||||
gtk::glib::Propagation::Proceed
|
||||
});
|
||||
|
||||
key_controller.connect_key_released(move |_, key, _, _| {
|
||||
desktop.borrow_mut().input_mut().set_key_state(key, false);
|
||||
let mut app_state = desktop.borrow_mut();
|
||||
if let Some(btn) = input::key_to_p1_button(key) {
|
||||
app_state.input_p1_mut().set_button(btn, false);
|
||||
}
|
||||
if let Some(btn) = input::key_to_p2_button(key) {
|
||||
app_state.input_p2_mut().set_button(btn, false);
|
||||
}
|
||||
});
|
||||
|
||||
window.add_controller(key_controller);
|
||||
@@ -331,32 +427,43 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
let desktop = Rc::clone(&desktop);
|
||||
let scheduler = Rc::clone(&scheduler);
|
||||
let sync_ui = Rc::clone(&sync_ui);
|
||||
let window = window.clone();
|
||||
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;
|
||||
match app_state.load_rom_from_path(&path) {
|
||||
Ok(()) => {
|
||||
scheduler.borrow_mut().reset_timing();
|
||||
let name = rom_filename(&path);
|
||||
sync_ui(&app_state, Some(&name));
|
||||
return true;
|
||||
}
|
||||
Err(err) => {
|
||||
log::error!("Failed to load ROM '{}': {err}", path.display());
|
||||
drop(app_state);
|
||||
show_error(&window, &format!("Failed to load ROM: {err}"));
|
||||
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);
|
||||
screen.add_controller(drop_target);
|
||||
}
|
||||
|
||||
// --- FPS counter state ---
|
||||
let fps_state = Rc::new(FpsCounter::new());
|
||||
|
||||
// --- Game loop ---
|
||||
{
|
||||
schedule_game_loop(
|
||||
Rc::clone(&desktop),
|
||||
drawing_area.clone(),
|
||||
Rc::clone(&frame_for_draw),
|
||||
screen.clone(),
|
||||
fps_label.clone(),
|
||||
Rc::clone(&fps_state),
|
||||
Rc::clone(&scheduler),
|
||||
);
|
||||
}
|
||||
@@ -364,16 +471,42 @@ fn build_ui(app: >k::Application, initial_rom: Option<PathBuf>) {
|
||||
window.present();
|
||||
}
|
||||
|
||||
struct FpsCounter {
|
||||
frame_count: Cell<u32>,
|
||||
last_update: Cell<Instant>,
|
||||
}
|
||||
|
||||
impl FpsCounter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
frame_count: Cell::new(0),
|
||||
last_update: Cell::new(Instant::now()),
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&self, label: >k::Label) {
|
||||
self.frame_count.set(self.frame_count.get() + 1);
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last_update.get());
|
||||
if elapsed.as_secs_f64() >= 1.0 {
|
||||
let fps = self.frame_count.get() as f64 / elapsed.as_secs_f64();
|
||||
label.set_label(&format!("{fps:.1} FPS"));
|
||||
self.frame_count.set(0);
|
||||
self.last_update.set(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_game_loop(
|
||||
desktop: Rc<RefCell<DesktopApp>>,
|
||||
drawing_area: gtk::DrawingArea,
|
||||
frame_for_draw: Rc<RefCell<Vec<u8>>>,
|
||||
screen: NesScreen,
|
||||
fps_label: gtk::Label,
|
||||
fps_state: Rc<FpsCounter>,
|
||||
scheduler: Rc<RefCell<DesktopFrameScheduler>>,
|
||||
) {
|
||||
let interval = desktop.borrow().frame_interval();
|
||||
let delay = scheduler
|
||||
.borrow_mut()
|
||||
.delay_until_next_frame(Instant::now(), interval);
|
||||
.delay_until_next_frame(Instant::now());
|
||||
|
||||
glib::timeout_add_local_once(delay, move || {
|
||||
{
|
||||
@@ -383,17 +516,27 @@ fn schedule_game_loop(
|
||||
|
||||
scheduler.borrow_mut().mark_frame_complete(now, interval);
|
||||
app_state.tick();
|
||||
screen.set_frame(app_state.frame_rgba());
|
||||
|
||||
frame_for_draw
|
||||
.borrow_mut()
|
||||
.copy_from_slice(app_state.frame_rgba());
|
||||
drawing_area.queue_draw();
|
||||
if fps_label.is_visible() {
|
||||
fps_state.tick(&fps_label);
|
||||
}
|
||||
}
|
||||
|
||||
schedule_game_loop(desktop, drawing_area, frame_for_draw, scheduler);
|
||||
schedule_game_loop(desktop, screen, fps_label, fps_state, scheduler);
|
||||
});
|
||||
}
|
||||
|
||||
fn show_error(window: >k::ApplicationWindow, message: &str) {
|
||||
log::error!("{message}");
|
||||
let dialog = gtk::AlertDialog::builder()
|
||||
.modal(true)
|
||||
.message("Error")
|
||||
.detail(message)
|
||||
.build();
|
||||
dialog.show(Some(window));
|
||||
}
|
||||
|
||||
fn rom_filename(path: &Path) -> String {
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
|
||||
@@ -15,11 +15,7 @@ impl DesktopFrameScheduler {
|
||||
self.next_deadline = None;
|
||||
}
|
||||
|
||||
pub(crate) fn delay_until_next_frame(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
_interval: Duration,
|
||||
) -> Duration {
|
||||
pub(crate) fn delay_until_next_frame(&mut self, now: Instant) -> Duration {
|
||||
match self.next_deadline {
|
||||
None => {
|
||||
self.next_deadline = Some(now);
|
||||
@@ -50,16 +46,16 @@ mod tests {
|
||||
let interval = Duration::from_micros(16_639);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
scheduler.delay_until_next_frame(start),
|
||||
Duration::ZERO
|
||||
);
|
||||
scheduler.mark_frame_complete(start, interval);
|
||||
assert!(
|
||||
scheduler.delay_until_next_frame(start + Duration::from_millis(1), interval)
|
||||
scheduler.delay_until_next_frame(start + Duration::from_millis(1))
|
||||
> Duration::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start + interval, interval),
|
||||
scheduler.delay_until_next_frame(start + interval),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
@@ -71,15 +67,15 @@ mod tests {
|
||||
let interval = Duration::from_micros(16_639);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
scheduler.delay_until_next_frame(start),
|
||||
Duration::ZERO
|
||||
);
|
||||
scheduler.mark_frame_complete(start, interval);
|
||||
assert!(scheduler.delay_until_next_frame(start, interval) > Duration::ZERO);
|
||||
assert!(scheduler.delay_until_next_frame(start) > Duration::ZERO);
|
||||
|
||||
scheduler.reset_timing();
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
scheduler.delay_until_next_frame(start),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
@@ -91,13 +87,13 @@ mod tests {
|
||||
let interval = Duration::from_micros(16_639);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start, interval),
|
||||
scheduler.delay_until_next_frame(start),
|
||||
Duration::ZERO
|
||||
);
|
||||
scheduler.mark_frame_complete(start, interval);
|
||||
|
||||
assert_eq!(
|
||||
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2), interval),
|
||||
scheduler.delay_until_next_frame(start + interval + Duration::from_millis(2)),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use gtk::gdk;
|
||||
use gtk::glib;
|
||||
use gtk::gsk;
|
||||
use gtk::prelude::*;
|
||||
use gtk::subclass::prelude::*;
|
||||
use gtk4 as gtk;
|
||||
use nesemu::{FRAME_HEIGHT, FRAME_RGBA_BYTES, FRAME_WIDTH, VideoOutput};
|
||||
|
||||
pub(crate) struct BufferedVideo {
|
||||
@@ -25,48 +33,82 @@ impl VideoOutput for BufferedVideo {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn draw_frame(frame: &[u8], cr: &cairo::Context, width: i32, height: i32) {
|
||||
let stride = cairo::Format::ARgb32
|
||||
.stride_for_width(FRAME_WIDTH as u32)
|
||||
.unwrap();
|
||||
let mut argb = vec![0u8; stride as usize * FRAME_HEIGHT];
|
||||
for y in 0..FRAME_HEIGHT {
|
||||
for x in 0..FRAME_WIDTH {
|
||||
let src = (y * FRAME_WIDTH + x) * 4;
|
||||
let dst = y * stride as usize + x * 4;
|
||||
let r = frame[src];
|
||||
let g = frame[src + 1];
|
||||
let b = frame[src + 2];
|
||||
let a = frame[src + 3];
|
||||
argb[dst] = b;
|
||||
argb[dst + 1] = g;
|
||||
argb[dst + 2] = r;
|
||||
argb[dst + 3] = a;
|
||||
// ---------------------------------------------------------------------------
|
||||
// NesScreen — a custom GTK widget that renders a NES frame buffer on the GPU
|
||||
// with nearest-neighbor (pixel-perfect) scaling via GskTextureScaleNode.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct NesScreen {
|
||||
pub(super) texture: RefCell<Option<gdk::Texture>>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for NesScreen {
|
||||
const NAME: &'static str = "NesScreen";
|
||||
type Type = super::NesScreen;
|
||||
type ParentType = gtk::Widget;
|
||||
}
|
||||
|
||||
impl ObjectImpl for NesScreen {}
|
||||
|
||||
impl WidgetImpl for NesScreen {
|
||||
fn snapshot(&self, snapshot: >k::Snapshot) {
|
||||
let Some(texture) = self.texture.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let widget = self.obj();
|
||||
let w = widget.width() as f32;
|
||||
let h = widget.height() as f32;
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute scale that fits the frame inside the widget, preserving
|
||||
// aspect ratio.
|
||||
let scale_x = w / FRAME_WIDTH as f32;
|
||||
let scale_y = h / FRAME_HEIGHT as f32;
|
||||
let scale = scale_x.min(scale_y);
|
||||
|
||||
let scaled_w = FRAME_WIDTH as f32 * scale;
|
||||
let scaled_h = FRAME_HEIGHT as f32 * scale;
|
||||
let offset_x = (w - scaled_w) / 2.0;
|
||||
let offset_y = (h - scaled_h) / 2.0;
|
||||
|
||||
let bounds = gtk::graphene::Rect::new(offset_x, offset_y, scaled_w, scaled_h);
|
||||
snapshot.append_scaled_texture(&texture, gsk::ScalingFilter::Nearest, &bounds);
|
||||
}
|
||||
}
|
||||
let surface = cairo::ImageSurface::create_for_data(
|
||||
argb,
|
||||
cairo::Format::ARgb32,
|
||||
FRAME_WIDTH as i32,
|
||||
FRAME_HEIGHT as i32,
|
||||
stride,
|
||||
)
|
||||
.expect("Failed to create Cairo surface");
|
||||
}
|
||||
|
||||
cr.set_source_rgb(0.0, 0.0, 0.0);
|
||||
let _ = cr.paint();
|
||||
glib::wrapper! {
|
||||
pub struct NesScreen(ObjectSubclass<imp::NesScreen>)
|
||||
@extends gtk::Widget;
|
||||
}
|
||||
|
||||
let sx = width as f64 / FRAME_WIDTH as f64;
|
||||
let sy = height as f64 / FRAME_HEIGHT as f64;
|
||||
let scale = sx.min(sy);
|
||||
let offset_x = (width as f64 - FRAME_WIDTH as f64 * scale) / 2.0;
|
||||
let offset_y = (height as f64 - FRAME_HEIGHT as f64 * scale) / 2.0;
|
||||
impl NesScreen {
|
||||
pub(crate) fn new() -> Self {
|
||||
glib::Object::builder().build()
|
||||
}
|
||||
|
||||
cr.translate(offset_x, offset_y);
|
||||
cr.scale(scale, scale);
|
||||
let _ = cr.set_source_surface(&surface, 0.0, 0.0);
|
||||
cr.source().set_filter(cairo::Filter::Nearest);
|
||||
let _ = cr.paint();
|
||||
pub(crate) fn set_frame(&self, frame: &[u8]) {
|
||||
let bytes = glib::Bytes::from(frame);
|
||||
let stride = FRAME_WIDTH * 4;
|
||||
let texture: gdk::Texture = gdk::MemoryTexture::new(
|
||||
FRAME_WIDTH as i32,
|
||||
FRAME_HEIGHT as i32,
|
||||
gdk::MemoryFormat::R8g8b8a8,
|
||||
&bytes,
|
||||
stride,
|
||||
)
|
||||
.upcast();
|
||||
*self.imp().texture.borrow_mut() = Some(texture);
|
||||
self.queue_draw();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user