From badbe0979fc1a9200c8d9ee71527d0738b6c3872 Mon Sep 17 00:00:00 2001 From: "se.cherkasov" Date: Wed, 18 Mar 2026 15:40:52 +0300 Subject: [PATCH] feat(desktop): add input configuration dialog with key remapping Add modal controls dialog (header bar button) with per-player keyboard remapping, key capture mode, duplicate protection, reset to defaults, and a gamepad tab stub for future Xbox/DualSense support. --- crates/nesemu-desktop/src/input.rs | 29 -- crates/nesemu-desktop/src/input_config.rs | 446 ++++++++++++++++++++++ crates/nesemu-desktop/src/main.rs | 30 +- 3 files changed, 472 insertions(+), 33 deletions(-) create mode 100644 crates/nesemu-desktop/src/input_config.rs diff --git a/crates/nesemu-desktop/src/input.rs b/crates/nesemu-desktop/src/input.rs index bf1625f..5a8e080 100644 --- a/crates/nesemu-desktop/src/input.rs +++ b/crates/nesemu-desktop/src/input.rs @@ -1,4 +1,3 @@ -use gtk4::gdk; use nesemu::{InputProvider, JoypadButton, JoypadButtons, set_button_pressed}; #[derive(Default)] @@ -17,31 +16,3 @@ impl InputProvider for InputState { self.buttons } } - -pub(crate) fn key_to_p1_button(key: gdk::Key) -> Option { - 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 { - 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, - } -} diff --git a/crates/nesemu-desktop/src/input_config.rs b/crates/nesemu-desktop/src/input_config.rs new file mode 100644 index 0000000..64959e4 --- /dev/null +++ b/crates/nesemu-desktop/src/input_config.rs @@ -0,0 +1,446 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use gtk::gdk; +use gtk::glib::translate::IntoGlib; +use gtk::prelude::*; +use gtk4 as gtk; +use nesemu::{JOYPAD_BUTTONS_COUNT, JOYPAD_BUTTON_ORDER, JoypadButton}; + +// --------------------------------------------------------------------------- +// KeyBindings — maps each JoypadButton to a gdk::Key for one player +// --------------------------------------------------------------------------- + +#[derive(Clone)] +pub(crate) struct KeyBindings { + keys: [gdk::Key; JOYPAD_BUTTONS_COUNT], + reverse: HashMap, +} + +impl KeyBindings { + fn new(keys: [gdk::Key; JOYPAD_BUTTONS_COUNT]) -> Self { + let reverse = Self::build_reverse(&keys); + Self { keys, reverse } + } + + pub(crate) fn default_p1() -> Self { + Self::new([ + gdk::Key::Up, + gdk::Key::Down, + gdk::Key::Left, + gdk::Key::Right, + gdk::Key::x, + gdk::Key::z, + gdk::Key::Return, + gdk::Key::Shift_L, + ]) + } + + pub(crate) fn default_p2() -> Self { + Self::new([ + gdk::Key::w, + gdk::Key::s, + gdk::Key::a, + gdk::Key::d, + gdk::Key::k, + gdk::Key::j, + gdk::Key::i, + gdk::Key::u, + ]) + } + + pub(crate) fn lookup(&self, key: gdk::Key) -> Option { + let normalized = normalize_key(key); + self.reverse.get(&normalized).copied() + } + + pub(crate) fn key_for(&self, button: JoypadButton) -> gdk::Key { + self.keys[button.index()] + } + + pub(crate) fn set_key(&mut self, button: JoypadButton, key: gdk::Key) { + let normalized = normalize_key(key); + // Clear duplicate: if another button has this key, unbind it + if let Some(&old_button) = self.reverse.get(&normalized) { + if old_button != button { + self.keys[old_button.index()] = gdk::Key::VoidSymbol; + } + } + // Remove old reverse entry for the button being rebound + let old_key = self.keys[button.index()]; + self.reverse.remove(&old_key); + // Set new binding + self.keys[button.index()] = normalized; + self.rebuild_reverse(); + } + + fn rebuild_reverse(&mut self) { + self.reverse = Self::build_reverse(&self.keys); + } + + fn build_reverse(keys: &[gdk::Key; JOYPAD_BUTTONS_COUNT]) -> HashMap { + let mut map = HashMap::with_capacity(JOYPAD_BUTTONS_COUNT); + for &button in &JOYPAD_BUTTON_ORDER { + let key = keys[button.index()]; + if key != gdk::Key::VoidSymbol { + map.insert(key, button); + } + } + map + } +} + +// --------------------------------------------------------------------------- +// InputConfig — shared state for both players +// --------------------------------------------------------------------------- + +pub(crate) struct InputConfig { + pub(crate) p1: KeyBindings, + pub(crate) p2: KeyBindings, +} + +impl InputConfig { + pub(crate) fn new() -> Self { + Self { + p1: KeyBindings::default_p1(), + p2: KeyBindings::default_p2(), + } + } + + pub(crate) fn lookup_p1(&self, key: gdk::Key) -> Option { + self.p1.lookup(key) + } + + pub(crate) fn lookup_p2(&self, key: gdk::Key) -> Option { + self.p2.lookup(key) + } +} + +// --------------------------------------------------------------------------- +// Key normalization & display +// --------------------------------------------------------------------------- + +fn normalize_key(key: gdk::Key) -> gdk::Key { + let lower = key.to_lower(); + if lower != gdk::Key::VoidSymbol { + lower + } else { + key + } +} + +fn display_key_name(key: gdk::Key) -> String { + if key == gdk::Key::VoidSymbol { + return "—".to_string(); + } + match key { + gdk::Key::Return => "Enter".to_string(), + gdk::Key::Shift_L => "LShift".to_string(), + gdk::Key::Shift_R => "RShift".to_string(), + gdk::Key::Control_L => "LCtrl".to_string(), + gdk::Key::Control_R => "RCtrl".to_string(), + gdk::Key::Alt_L => "LAlt".to_string(), + gdk::Key::Alt_R => "RAlt".to_string(), + gdk::Key::space => "Space".to_string(), + gdk::Key::BackSpace => "Backspace".to_string(), + gdk::Key::Tab => "Tab".to_string(), + gdk::Key::Escape => "Escape".to_string(), + gdk::Key::Up => "↑".to_string(), + gdk::Key::Down => "↓".to_string(), + gdk::Key::Left => "←".to_string(), + gdk::Key::Right => "→".to_string(), + other => { + if let Some(name) = other.name() { + let s = name.to_string(); + if s.len() == 1 { + s.to_uppercase() + } else { + s + } + } else { + format!("0x{:04x}", other.into_glib()) + } + } + } +} + +fn button_display_name(button: JoypadButton) -> &'static str { + match button { + JoypadButton::Up => "Up", + JoypadButton::Down => "Down", + JoypadButton::Left => "Left", + JoypadButton::Right => "Right", + JoypadButton::A => "A", + JoypadButton::B => "B", + JoypadButton::Start => "Start", + JoypadButton::Select => "Select", + } +} + +// --------------------------------------------------------------------------- +// Dialog +// --------------------------------------------------------------------------- + +pub(crate) fn show_input_config_dialog( + parent: >k::ApplicationWindow, + config: Rc>, +) { + let dialog = gtk::Window::builder() + .title("Controls") + .modal(true) + .transient_for(parent) + .resizable(false) + .default_width(340) + .build(); + + let root_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + root_box.set_margin_top(12); + root_box.set_margin_bottom(12); + root_box.set_margin_start(12); + root_box.set_margin_end(12); + + // --- Top-level notebook: Keyboard / Gamepad --- + let top_notebook = gtk::Notebook::new(); + + // --- Keyboard tab --- + let keyboard_box = gtk::Box::new(gtk::Orientation::Vertical, 8); + + let player_notebook = gtk::Notebook::new(); + + // State for key capture mode + let capturing: Rc>> = Rc::new(RefCell::new(None)); + // Store all key buttons for updating labels + let key_buttons: Rc>>> = + Rc::new(RefCell::new(vec![Vec::new(), Vec::new()])); + + for player_idx in 0..2 { + let page_box = gtk::Box::new(gtk::Orientation::Vertical, 4); + page_box.set_margin_top(8); + page_box.set_margin_bottom(8); + page_box.set_margin_start(8); + page_box.set_margin_end(8); + + for &button in &JOYPAD_BUTTON_ORDER { + let row = gtk::Box::new(gtk::Orientation::Horizontal, 0); + row.set_hexpand(true); + + let label = gtk::Label::new(Some(button_display_name(button))); + label.set_halign(gtk::Align::Start); + label.set_hexpand(true); + label.set_width_chars(8); + + let bindings = if player_idx == 0 { + &config.borrow().p1 + } else { + &config.borrow().p2 + }; + let key_name = display_key_name(bindings.key_for(button)); + + let key_button = gtk::Button::with_label(&key_name); + key_button.set_width_request(100); + key_button.set_halign(gtk::Align::End); + + // On click: enter capture mode + { + let capturing = Rc::clone(&capturing); + let key_buttons = Rc::clone(&key_buttons); + key_button.connect_clicked(move |btn| { + // Reset any previous capturing button label + if let Some((prev_player, prev_btn)) = *capturing.borrow() { + let buttons = key_buttons.borrow(); + let prev_widget = &buttons[prev_player][prev_btn.index()]; + // Restore its label — we'll just mark it needs refresh + prev_widget.set_label("—"); + } + *capturing.borrow_mut() = Some((player_idx, button)); + btn.set_label("Press a key..."); + }); + } + + row.append(&label); + row.append(&key_button); + page_box.append(&row); + + key_buttons.borrow_mut()[player_idx].push(key_button); + } + + let tab_label = gtk::Label::new(Some(if player_idx == 0 { + "Player 1" + } else { + "Player 2" + })); + player_notebook.append_page(&page_box, Some(&tab_label)); + } + + keyboard_box.append(&player_notebook); + + // --- Reset to Defaults button --- + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + button_box.set_halign(gtk::Align::Center); + button_box.set_margin_top(8); + + let reset_button = gtk::Button::with_label("Reset to Defaults"); + { + let config = Rc::clone(&config); + let player_notebook = player_notebook.clone(); + let key_buttons = Rc::clone(&key_buttons); + reset_button.connect_clicked(move |_| { + let current_page = player_notebook.current_page().unwrap_or(0) as usize; + let mut cfg = config.borrow_mut(); + if current_page == 0 { + cfg.p1 = KeyBindings::default_p1(); + } else { + cfg.p2 = KeyBindings::default_p2(); + } + // Refresh button labels for the reset player + let bindings = if current_page == 0 { &cfg.p1 } else { &cfg.p2 }; + let buttons = key_buttons.borrow(); + for &btn in &JOYPAD_BUTTON_ORDER { + let label = display_key_name(bindings.key_for(btn)); + buttons[current_page][btn.index()].set_label(&label); + } + }); + } + + let close_button = gtk::Button::with_label("Close"); + { + let dialog = dialog.clone(); + close_button.connect_clicked(move |_| { + dialog.close(); + }); + } + + button_box.append(&reset_button); + button_box.append(&close_button); + keyboard_box.append(&button_box); + + let keyboard_tab_label = gtk::Label::new(Some("Keyboard")); + top_notebook.append_page(&keyboard_box, Some(&keyboard_tab_label)); + + // --- Gamepad tab (stub) --- + let gamepad_box = gtk::Box::new(gtk::Orientation::Vertical, 0); + gamepad_box.set_valign(gtk::Align::Center); + gamepad_box.set_halign(gtk::Align::Center); + gamepad_box.set_vexpand(true); + gamepad_box.set_margin_top(32); + gamepad_box.set_margin_bottom(32); + + let gamepad_label = gtk::Label::new(Some( + "Gamepad support coming soon\nXbox / DualSense", + )); + gamepad_label.set_justify(gtk::Justification::Center); + gamepad_label.add_css_class("dim-label"); + gamepad_box.append(&gamepad_label); + + let gamepad_tab_label = gtk::Label::new(Some("Gamepad")); + top_notebook.append_page(&gamepad_box, Some(&gamepad_tab_label)); + + root_box.append(&top_notebook); + dialog.set_child(Some(&root_box)); + + // --- Key capture via EventControllerKey on the dialog window --- + { + let config = Rc::clone(&config); + let capturing = Rc::clone(&capturing); + let key_buttons = Rc::clone(&key_buttons); + + let key_controller = gtk::EventControllerKey::new(); + key_controller.connect_key_pressed(move |_, keyval, _, _| { + let Some((player_idx, button)) = *capturing.borrow() else { + return gtk::glib::Propagation::Proceed; + }; + + // Escape cancels capture + if keyval == gdk::Key::Escape { + // Restore the label to current binding + let cfg = config.borrow(); + let bindings = if player_idx == 0 { &cfg.p1 } else { &cfg.p2 }; + let label = display_key_name(bindings.key_for(button)); + key_buttons.borrow()[player_idx][button.index()].set_label(&label); + *capturing.borrow_mut() = None; + return gtk::glib::Propagation::Stop; + } + + // Assign the key + { + let mut cfg = config.borrow_mut(); + let bindings = if player_idx == 0 { + &mut cfg.p1 + } else { + &mut cfg.p2 + }; + bindings.set_key(button, keyval); + } + + // Refresh all button labels for this player (duplicate might have been cleared) + { + let cfg = config.borrow(); + let bindings = if player_idx == 0 { &cfg.p1 } else { &cfg.p2 }; + let buttons = key_buttons.borrow(); + for &btn in &JOYPAD_BUTTON_ORDER { + let label = display_key_name(bindings.key_for(btn)); + buttons[player_idx][btn.index()].set_label(&label); + } + } + + *capturing.borrow_mut() = None; + gtk::glib::Propagation::Stop + }); + + dialog.add_controller(key_controller); + } + + dialog.present(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_bindings_lookup() { + let config = InputConfig::new(); + assert_eq!(config.lookup_p1(gdk::Key::x), Some(JoypadButton::A)); + assert_eq!(config.lookup_p1(gdk::Key::z), Some(JoypadButton::B)); + assert_eq!(config.lookup_p1(gdk::Key::Up), Some(JoypadButton::Up)); + assert_eq!(config.lookup_p2(gdk::Key::w), Some(JoypadButton::Up)); + assert_eq!(config.lookup_p2(gdk::Key::k), Some(JoypadButton::A)); + } + + #[test] + fn case_insensitive_lookup() { + let config = InputConfig::new(); + // X (uppercase) should also match since we normalize to lowercase + assert_eq!(config.lookup_p1(gdk::Key::X), Some(JoypadButton::A)); + assert_eq!(config.lookup_p2(gdk::Key::W), Some(JoypadButton::Up)); + } + + #[test] + fn set_key_clears_duplicate() { + let mut bindings = KeyBindings::default_p1(); + // Bind Up to 'x' — should clear A's binding to 'x' + bindings.set_key(JoypadButton::Up, gdk::Key::x); + assert_eq!(bindings.key_for(JoypadButton::Up), gdk::Key::x); + assert_eq!(bindings.key_for(JoypadButton::A), gdk::Key::VoidSymbol); + assert_eq!(bindings.lookup(gdk::Key::x), Some(JoypadButton::Up)); + } + + #[test] + fn display_key_names() { + assert_eq!(display_key_name(gdk::Key::Return), "Enter"); + assert_eq!(display_key_name(gdk::Key::Up), "↑"); + assert_eq!(display_key_name(gdk::Key::Shift_L), "LShift"); + assert_eq!(display_key_name(gdk::Key::VoidSymbol), "—"); + } + + #[test] + fn reset_to_defaults() { + let mut config = InputConfig::new(); + config.p1.set_key(JoypadButton::A, gdk::Key::q); + assert_eq!(config.lookup_p1(gdk::Key::q), Some(JoypadButton::A)); + config.p1 = KeyBindings::default_p1(); + assert_eq!(config.lookup_p1(gdk::Key::q), None); + assert_eq!(config.lookup_p1(gdk::Key::x), Some(JoypadButton::A)); + } +} diff --git a/crates/nesemu-desktop/src/main.rs b/crates/nesemu-desktop/src/main.rs index 2a8da8b..cca93cb 100644 --- a/crates/nesemu-desktop/src/main.rs +++ b/crates/nesemu-desktop/src/main.rs @@ -1,6 +1,7 @@ mod app; mod audio; mod input; +mod input_config; mod scheduling; mod video; @@ -20,6 +21,7 @@ use nesemu::prelude::EmulationState; use nesemu::{FRAME_HEIGHT, FRAME_WIDTH}; use app::DesktopApp; +use input_config::InputConfig; use scheduling::DesktopFrameScheduler; use video::NesScreen; @@ -103,6 +105,13 @@ fn build_ui(app: >k::Application, initial_rom: Option) { volume_box.append(&volume_scale); header.pack_end(&volume_box); + let controls_button = gtk::Button::builder() + .icon_name("preferences-system-symbolic") + .tooltip_text("Controls") + .focusable(false) + .build(); + header.pack_end(&controls_button); + window.set_titlebar(Some(&header)); // --- NES screen widget (GPU-accelerated, nearest-neighbor scaling) --- @@ -138,6 +147,7 @@ fn build_ui(app: >k::Application, initial_rom: Option) { // --- State --- let desktop = Rc::new(RefCell::new(DesktopApp::new(Arc::clone(&volume)))); let scheduler = Rc::new(RefCell::new(DesktopFrameScheduler::new())); + let input_config = Rc::new(RefCell::new(InputConfig::new())); // --- Helper to sync UI with emulation state --- let current_rom_name: Rc>> = Rc::new(RefCell::new(None)); @@ -277,6 +287,14 @@ fn build_ui(app: >k::Application, initial_rom: Option) { }); } + { + let input_config = Rc::clone(&input_config); + let window = window.clone(); + controls_button.connect_clicked(move |_| { + input_config::show_input_config_dialog(&window, Rc::clone(&input_config)); + }); + } + // --- Keyboard shortcuts via actions --- let action_open = gio::SimpleAction::new("open", None); { @@ -395,26 +413,30 @@ fn build_ui(app: >k::Application, initial_rom: Option) { // --- Keyboard controller for joypad input --- { let desktop = Rc::clone(&desktop); + let input_config = Rc::clone(&input_config); let key_controller = gtk::EventControllerKey::new(); let desktop_for_press = Rc::clone(&desktop); + let config_for_press = Rc::clone(&input_config); key_controller.connect_key_pressed(move |_, key, _, _| { + let config = config_for_press.borrow(); let mut app_state = desktop_for_press.borrow_mut(); - if let Some(btn) = input::key_to_p1_button(key) { + if let Some(btn) = config.lookup_p1(key) { app_state.input_p1_mut().set_button(btn, true); } - if let Some(btn) = input::key_to_p2_button(key) { + if let Some(btn) = config.lookup_p2(key) { app_state.input_p2_mut().set_button(btn, true); } gtk::glib::Propagation::Proceed }); key_controller.connect_key_released(move |_, key, _, _| { + let config = input_config.borrow(); let mut app_state = desktop.borrow_mut(); - if let Some(btn) = input::key_to_p1_button(key) { + if let Some(btn) = config.lookup_p1(key) { app_state.input_p1_mut().set_button(btn, false); } - if let Some(btn) = input::key_to_p2_button(key) { + if let Some(btn) = config.lookup_p2(key) { app_state.input_p2_mut().set_button(btn, false); } });