feat(desktop): add input configuration dialog with key remapping
Some checks failed
CI / rust (push) Has been cancelled

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.
This commit is contained in:
2026-03-18 15:40:52 +03:00
parent ad6970d4b5
commit badbe0979f
3 changed files with 472 additions and 33 deletions

View File

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

@@ -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<gdk::Key, JoypadButton>,
}
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<JoypadButton> {
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<gdk::Key, JoypadButton> {
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<JoypadButton> {
self.p1.lookup(key)
}
pub(crate) fn lookup_p2(&self, key: gdk::Key) -> Option<JoypadButton> {
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: &gtk::ApplicationWindow,
config: Rc<RefCell<InputConfig>>,
) {
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<RefCell<Option<(usize, JoypadButton)>>> = Rc::new(RefCell::new(None));
// Store all key buttons for updating labels
let key_buttons: Rc<RefCell<Vec<Vec<gtk::Button>>>> =
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));
}
}

View File

@@ -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: &gtk::Application, initial_rom: Option<PathBuf>) {
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: &gtk::Application, initial_rom: Option<PathBuf>) {
// --- 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<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
@@ -277,6 +287,14 @@ fn build_ui(app: &gtk::Application, initial_rom: Option<PathBuf>) {
});
}
{
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: &gtk::Application, initial_rom: Option<PathBuf>) {
// --- 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);
}
});