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