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

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)]