feat(desktop): GPU rendering, modern GTK4 UI, hotkeys and player 2
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:
2026-03-18 15:12:06 +03:00
parent d113228f1b
commit ad6970d4b5
7 changed files with 545 additions and 147 deletions

148
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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,
}
}

View File

@@ -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: &gtk::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: &gtk::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(&current_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: &gtk::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: &gtk::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: &gtk::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: &gtk::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: &gtk::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: &gtk::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: &gtk::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: &gtk::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: &gtk::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())

View File

@@ -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
);
}

View File

@@ -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: &gtk::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)]