diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d8aba7e..0e522bdb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -733,13 +733,13 @@ if(NOT BUILD_HYDRA_CORE AND NOT BUILD_LIBRETRO_CORE) set(FRONTEND_SOURCE_FILES src/panda_qt/main.cpp src/panda_qt/screen.cpp src/panda_qt/main_window.cpp src/panda_qt/about_window.cpp src/panda_qt/config_window.cpp src/panda_qt/zep.cpp src/panda_qt/text_editor.cpp src/panda_qt/cheats_window.cpp src/panda_qt/mappings.cpp src/panda_qt/patch_window.cpp src/panda_qt/elided_label.cpp src/panda_qt/shader_editor.cpp src/panda_qt/translations.cpp - src/panda_qt/thread_debugger.cpp src/panda_qt/cpu_debugger.cpp src/panda_qt/dsp_debugger.cpp + src/panda_qt/thread_debugger.cpp src/panda_qt/cpu_debugger.cpp src/panda_qt/dsp_debugger.cpp src/panda_qt/input_window.cpp ) set(FRONTEND_HEADER_FILES include/panda_qt/screen.hpp include/panda_qt/main_window.hpp include/panda_qt/about_window.hpp include/panda_qt/config_window.hpp include/panda_qt/text_editor.hpp include/panda_qt/cheats_window.hpp include/panda_qt/patch_window.hpp include/panda_qt/elided_label.hpp include/panda_qt/shader_editor.hpp include/panda_qt/thread_debugger.hpp include/panda_qt/cpu_debugger.hpp include/panda_qt/dsp_debugger.hpp - include/panda_qt/disabled_widget_overlay.hpp + include/panda_qt/disabled_widget_overlay.hpp include/panda_qt/input_window.hpp ) source_group("Source Files\\Qt" FILES ${FRONTEND_SOURCE_FILES}) @@ -787,6 +787,7 @@ if(NOT BUILD_HYDRA_CORE AND NOT BUILD_LIBRETRO_CORE) docs/img/settings_icon.png docs/img/display_icon.png docs/img/speaker_icon.png docs/img/sparkling_icon.png docs/img/battery_icon.png docs/img/sdcard_icon.png docs/img/rnap_icon.png docs/img/rcow_icon.png docs/img/skyemu_icon.png docs/img/runpog_icon.png + docs/img/gamepad_icon.png ) # Translation files in Qt's .ts format. Will be converted into binary files and embedded into the executable diff --git a/docs/img/gamepad_icon.png b/docs/img/gamepad_icon.png new file mode 100644 index 00000000..e4a59f1f Binary files /dev/null and b/docs/img/gamepad_icon.png differ diff --git a/include/input_mappings.hpp b/include/input_mappings.hpp index 177f1d51..a17cb1de 100644 --- a/include/input_mappings.hpp +++ b/include/input_mappings.hpp @@ -1,5 +1,10 @@ #pragma once +#include +#include +#include +#include +#include #include #include "helpers.hpp" @@ -15,8 +20,108 @@ struct InputMappings { } void setMapping(Scancode scancode, u32 key) { container[scancode] = key; } + + template + void serialize(const std::filesystem::path& path, const std::string& frontend, ScancodeToString scancodeToString) const { + toml::basic_value data; + + std::error_code error; + if (std::filesystem::exists(path, error)) { + try { + data = toml::parse(path); + } catch (const std::exception& ex) { + Helpers::warn("Exception trying to parse mappings file. Exception: %s\n", ex.what()); + return; + } + } else { + if (error) { + Helpers::warn("Filesystem error accessing %s (error: %s)\n", path.string().c_str(), error.message().c_str()); + } + printf("Saving new mappings file %s\n", path.string().c_str()); + } + + data["Metadata"]["Name"] = name.empty() ? "Unnamed Mappings" : name; + data["Metadata"]["Device"] = device.empty() ? "Unknown Device" : device; + data["Metadata"]["Frontend"] = frontend; + + data["Mappings"] = toml::table{}; + + for (const auto& [scancode, key] : container) { + const std::string& keyName = HID::Keys::keyToName(key); + if (!data["Mappings"].contains(keyName)) { + data["Mappings"][keyName] = toml::array{}; + } + data["Mappings"][keyName].push_back(scancodeToString(scancode)); + } + + std::ofstream file(path, std::ios::out); + file << data; + } + + template + static std::optional deserialize( + const std::filesystem::path& path, const std::string& wantFrontend, ScancodeFromString stringToScancode + ) { + toml::basic_value data; + std::error_code error; + + if (!std::filesystem::exists(path, error)) { + if (error) { + Helpers::warn("Filesystem error accessing %s (error: %s)\n", path.string().c_str(), error.message().c_str()); + } + + return std::nullopt; + } + + InputMappings mappings; + + try { + data = toml::parse(path); + + const auto metadata = toml::find(data, "Metadata"); + mappings.name = toml::find_or(metadata, "Name", "Unnamed Mappings"); + mappings.device = toml::find_or(metadata, "Device", "Unknown Device"); + + std::string haveFrontend = toml::find_or(metadata, "Frontend", "Unknown Frontend"); + + bool equal = std::equal(haveFrontend.begin(), haveFrontend.end(), wantFrontend.begin(), wantFrontend.end(), [](char a, char b) { + return std::tolower(a) == std::tolower(b); + }); + + if (!equal) { + Helpers::warn( + "Mappings file %s was created for frontend %s, but we are using frontend %s\n", path.string().c_str(), haveFrontend.c_str(), + wantFrontend.c_str() + ); + + return std::nullopt; + } + } catch (const std::exception& ex) { + Helpers::warn("Exception trying to parse config file. Exception: %s\n", ex.what()); + return std::nullopt; + } + + const auto& mappingsTable = toml::find_or(data, "Mappings", toml::table{}); + for (const auto& [keyName, scancodes] : mappingsTable) { + for (const auto& scancodeVal : scancodes.as_array()) { + std::string scancodeStr = scancodeVal.as_string(); + mappings.setMapping(stringToScancode(scancodeStr), HID::Keys::nameToKey(keyName)); + } + } + + return mappings; + } + static InputMappings defaultKeyboardMappings(); + auto begin() { return container.begin(); } + auto end() { return container.end(); } + + auto begin() const { return container.begin(); } + auto end() const { return container.end(); } + private: Container container; + std::string name; + std::string device; }; diff --git a/include/panda_qt/config_window.hpp b/include/panda_qt/config_window.hpp index 1d37a8ca..795a6c8c 100644 --- a/include/panda_qt/config_window.hpp +++ b/include/panda_qt/config_window.hpp @@ -16,6 +16,8 @@ #include "emulator.hpp" #include "frontend_settings.hpp" +#include "input_mappings.hpp" +#include "panda_qt/input_window.hpp" class ConfigWindow : public QDialog { Q_OBJECT @@ -30,8 +32,9 @@ class ConfigWindow : public QDialog { QTextEdit* helpText = nullptr; QListWidget* widgetList = nullptr; QStackedWidget* widgetContainer = nullptr; + InputWindow* inputWindow = nullptr; - static constexpr size_t settingWidgetCount = 6; + static constexpr size_t settingWidgetCount = 7; std::array helpTexts; // The config class holds a copy of the emulator config which it edits and sends @@ -52,6 +55,7 @@ class ConfigWindow : public QDialog { ~ConfigWindow(); EmulatorConfig& getConfig() { return config; } + InputWindow* getInputWindow() { return inputWindow; } private: Emulator* emu; diff --git a/include/panda_qt/input_window.hpp b/include/panda_qt/input_window.hpp new file mode 100644 index 00000000..7fe33912 --- /dev/null +++ b/include/panda_qt/input_window.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +#include "input_mappings.hpp" + +class InputWindow : public QDialog { + Q_OBJECT + + public: + InputWindow(QWidget* parent = nullptr); + + void loadFromMappings(const InputMappings& mappings); + void applyToMappings(InputMappings& mappings) const; + + signals: + void mappingsChanged(); + + protected: + bool eventFilter(QObject* obj, QEvent* event) override; + + private: + QMap buttonMap; + QMap keyMappings; + + QString waitingForAction; + + void startKeyCapture(const QString& action); +}; diff --git a/include/panda_qt/main_window.hpp b/include/panda_qt/main_window.hpp index 80b4a7f1..7bdf6b96 100644 --- a/include/panda_qt/main_window.hpp +++ b/include/panda_qt/main_window.hpp @@ -43,7 +43,6 @@ class MainWindow : public QMainWindow { Pause, Resume, TogglePause, - DumpRomFS, PressKey, ReleaseKey, SetCirclePadX, @@ -134,6 +133,9 @@ class MainWindow : public QMainWindow { void dispatchMessage(const EmulatorMessage& message); void loadTranslation(); + void loadKeybindings(); + void saveKeybindings(); + // Tracks whether we are using an OpenGL-backed renderer or a Vulkan-backed renderer bool usingGL = false; bool usingVk = false; @@ -145,6 +147,9 @@ class MainWindow : public QMainWindow { bool keyboardAnalogX = false; bool keyboardAnalogY = false; + // Tracks if keybindings changed, in which case we should update the keybindings file when closing the emulator + bool keybindingsChanged = false; + public: MainWindow(QApplication* app, QWidget* parent = nullptr); ~MainWindow(); diff --git a/include/services/hid.hpp b/include/services/hid.hpp index 91bc16da..33e61a35 100644 --- a/include/services/hid.hpp +++ b/include/services/hid.hpp @@ -1,6 +1,7 @@ #pragma once #include #include +#include #include "helpers.hpp" #include "kernel_types.hpp" @@ -38,7 +39,10 @@ namespace HID::Keys { CirclePadUp = 1 << 30, // Y >= 41 CirclePadDown = 1u << 31 // Y <= -41 }; -} + + const char* keyToName(u32 key); + u32 nameToKey(std::string name); +} // namespace HID::Keys // Circular dependency because we need HID to spawn events class Kernel; diff --git a/src/core/services/hid.cpp b/src/core/services/hid.cpp index ce927316..695e0329 100644 --- a/src/core/services/hid.cpp +++ b/src/core/services/hid.cpp @@ -1,6 +1,8 @@ #include "services/hid.hpp" -#include +#include +#include +#include #include "ipc.hpp" #include "kernel.hpp" @@ -242,4 +244,61 @@ void HIDService::updateInputs(u64 currentTick) { kernel.signalEvent(e.value()); } } -} \ No newline at end of file +} + +// Key serialization helpers +namespace HID::Keys { + const char* keyToName(u32 key) { + static std::unordered_map keyMap = { + {A, "A"}, + {B, "B"}, + {Select, "Select"}, + {Start, "Start"}, + {Right, "D-Pad Right"}, + {Left, "D-Pad Left"}, + {Up, "D-Pad Up"}, + {Down, "D-Pad Down"}, + {R, "R"}, + {L, "L"}, + {X, "X"}, + {Y, "Y"}, + {ZL, "ZL"}, + {ZR, "ZR"}, + {CirclePadRight, "CirclePad Right"}, + {CirclePadLeft, "CirclePad Left"}, + {CirclePadUp, "CirclePad Up"}, + {CirclePadDown, "CirclePad Down"}, + }; + + auto it = keyMap.find(key); + return it != keyMap.end() ? it->second : "Unknown key"; + } + + u32 nameToKey(std::string name) { + static std::unordered_map keyMap = { + {"a", A}, + {"b", B}, + {"select", Select}, + {"start", Start}, + {"d-pad right", Right}, + {"d-pad left", Left}, + {"d-pad up", Up}, + {"d-pad down", Down}, + {"r", R}, + {"l", L}, + {"x", X}, + {"y", Y}, + {"zl", ZL}, + {"zr", ZR}, + {"circlepad right", CirclePadRight}, + {"circlepad left", CirclePadLeft}, + {"circlepad up", CirclePadUp}, + {"circlepad down", CirclePadDown}, + }; + + std::transform(name.begin(), name.end(), name.begin(), [](char c) { return std::tolower(c); }); + auto it = keyMap.find(name); + + return it != keyMap.end() ? it->second : HID::Keys::Null; + } +} // namespace HID::Keys diff --git a/src/panda_qt/config_window.cpp b/src/panda_qt/config_window.cpp index aecefbc5..14b33156 100644 --- a/src/panda_qt/config_window.cpp +++ b/src/panda_qt/config_window.cpp @@ -284,8 +284,8 @@ ConfigWindow::ConfigWindow(ConfigCallback configCallback, MainWindowCallback win gpuLayout->addRow(tr("Light threshold for forcing shadergen"), lightShadergenThreshold); // Audio settings - QGroupBox* spuGroupBox = new QGroupBox(tr("Audio Settings"), this); - QFormLayout* audioLayout = new QFormLayout(spuGroupBox); + QGroupBox* dspGroupBox = new QGroupBox(tr("Audio Settings"), this); + QFormLayout* audioLayout = new QFormLayout(dspGroupBox); audioLayout->setHorizontalSpacing(20); audioLayout->setVerticalSpacing(10); @@ -344,6 +344,8 @@ ConfigWindow::ConfigWindow(ConfigCallback configCallback, MainWindowCallback win volumeLayout->addWidget(volumeLabel); audioLayout->addRow(tr("Audio device volume"), volumeLayout); + inputWindow = new InputWindow(this); + // Battery settings QGroupBox* batGroupBox = new QGroupBox(tr("Battery Settings"), this); QFormLayout* batLayout = new QFormLayout(batGroupBox); @@ -381,7 +383,8 @@ ConfigWindow::ConfigWindow(ConfigCallback configCallback, MainWindowCallback win addWidget(guiGroupBox, tr("Interface"), ":/docs/img/sparkling_icon.png", tr("User Interface settings")); addWidget(genGroupBox, tr("General"), ":/docs/img/settings_icon.png", tr("General emulator settings")); addWidget(gpuGroupBox, tr("Graphics"), ":/docs/img/display_icon.png", tr("Graphics emulation and output settings")); - addWidget(spuGroupBox, tr("Audio"), ":/docs/img/speaker_icon.png", tr("Audio emulation and output settings")); + addWidget(dspGroupBox, tr("Audio"), ":/docs/img/speaker_icon.png", tr("Audio emulation and output settings")); + addWidget(inputWindow, tr("Input"), ":/docs/img/gamepad_icon.png", tr("Keyboard & controller input settings")); addWidget(batGroupBox, tr("Battery"), ":/docs/img/battery_icon.png", tr("Battery emulation settings")); addWidget(sdcGroupBox, tr("SD Card"), ":/docs/img/sdcard_icon.png", tr("SD Card emulation settings")); diff --git a/src/panda_qt/input_window.cpp b/src/panda_qt/input_window.cpp new file mode 100644 index 00000000..99d5a58e --- /dev/null +++ b/src/panda_qt/input_window.cpp @@ -0,0 +1,127 @@ +#include "panda_qt/input_window.hpp" + +#include +#include +#include +#include +#include + +#include "input_mappings.hpp" +#include "services/hid.hpp" + +InputWindow::InputWindow(QWidget* parent) : QDialog(parent) { + auto mainLayout = new QVBoxLayout(this); + + QStringList actions = { + "A", + "B", + "X", + "Y", + "L", + "R", + "ZL", + "ZR", + "Start", + "Select", + "D-Pad Up", + "D-Pad Down", + "D-Pad Left", + "D-Pad Right", + "CirclePad Up", + "CirclePad Down", + "CirclePad Left", + "CirclePad Right", + }; + + for (const QString& action : actions) { + auto row = new QHBoxLayout(); + row->addWidget(new QLabel(action)); + + auto button = new QPushButton(tr("Not set")); + buttonMap[action] = button; + keyMappings[action] = QKeySequence(); + + connect(button, &QPushButton::clicked, this, [=, this]() { startKeyCapture(action); }); + + row->addWidget(button); + mainLayout->addLayout(row); + } + + auto resetButton = new QPushButton(tr("Reset Defaults")); + connect(resetButton, &QPushButton::pressed, this, [&]() { + // Restore the keymappings to the default ones for Qt + auto defaultMappings = InputMappings::defaultKeyboardMappings(); + loadFromMappings(defaultMappings); + + emit mappingsChanged(); + }); + + mainLayout->addWidget(resetButton); + installEventFilter(this); +} + +void InputWindow::startKeyCapture(const QString& action) { + waitingForAction = action; + grabKeyboard(); +} + +bool InputWindow::eventFilter(QObject* obj, QEvent* event) { + // If we're waiting for a button to be inputted, handle the keypress + if (!waitingForAction.isEmpty() && event->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(event); + QKeySequence key(keyEvent->key()); + + // If this key is already bound to something else, unbind it + for (auto it = keyMappings.begin(); it != keyMappings.end(); ++it) { + if (it.key() != waitingForAction && it.value() == key) { + it.value() = QKeySequence(); + buttonMap[it.key()]->setText(tr("Not set")); + break; + } + } + + keyMappings[waitingForAction] = key; + buttonMap[waitingForAction]->setText(key.toString()); + + releaseKeyboard(); + waitingForAction.clear(); + + emit mappingsChanged(); + return true; + } + + return false; +} + +void InputWindow::loadFromMappings(const InputMappings& mappings) { + for (const auto& action : buttonMap.keys()) { + u32 key = HID::Keys::nameToKey(action.toStdString()); + + for (const auto& [scancode, mappedKey] : mappings) { + if (mappedKey == key) { + QKeySequence qkey(scancode); + keyMappings[action] = qkey; + buttonMap[action]->setText(qkey.toString()); + break; + } + } + } +} + +void InputWindow::applyToMappings(InputMappings& mappings) const { + // Clear existing keyboard mappings before mapping the buttons + mappings = InputMappings(); + + for (const auto& action : keyMappings.keys()) { + const QKeySequence& qkey = keyMappings[action]; + + if (!qkey.isEmpty()) { + InputMappings::Scancode scancode = qkey[0].key(); + u32 key = HID::Keys::nameToKey(action.toStdString()); + + if (key != HID::Keys::Null) { + mappings.setMapping(scancode, key); + } + } + } +} \ No newline at end of file diff --git a/src/panda_qt/main.cpp b/src/panda_qt/main.cpp index 4ab737b0..0574ac68 100644 --- a/src/panda_qt/main.cpp +++ b/src/panda_qt/main.cpp @@ -1,7 +1,6 @@ #include #include "panda_qt/main_window.hpp" -#include "panda_qt/screen.hpp" int main(int argc, char *argv[]) { QApplication app(argc, argv); diff --git a/src/panda_qt/main_window.cpp b/src/panda_qt/main_window.cpp index e8971878..b592226b 100644 --- a/src/panda_qt/main_window.cpp +++ b/src/panda_qt/main_window.cpp @@ -14,7 +14,7 @@ #include "services/dsp.hpp" #include "version.hpp" -MainWindow::MainWindow(QApplication* app, QWidget* parent) : QMainWindow(parent), keyboardMappings(InputMappings::defaultKeyboardMappings()) { +MainWindow::MainWindow(QApplication* app, QWidget* parent) : QMainWindow(parent), keyboardMappings(InputMappings()) { emu = new Emulator(); loadTranslation(); @@ -115,6 +115,13 @@ MainWindow::MainWindow(QApplication* app, QWidget* parent) : QMainWindow(parent) [&]() { return this; }, emu->getConfig(), this ); + loadKeybindings(); + + connect(configWindow->getInputWindow(), &InputWindow::mappingsChanged, this, [&]() { + keybindingsChanged = true; + configWindow->getInputWindow()->applyToMappings(keyboardMappings); + }); + auto args = QCoreApplication::arguments(); if (args.size() > 1) { auto romPath = std::filesystem::current_path() / args.at(1).toStdU16String(); @@ -274,6 +281,10 @@ void MainWindow::closeEvent(QCloseEvent* event) { // Cleanup when the main window closes MainWindow::~MainWindow() { + if (keybindingsChanged) { + saveKeybindings(); + } + delete emu; delete menuBar; delete aboutWindow; @@ -766,3 +777,23 @@ void MainWindow::setupControllerSensors(SDL_GameController* controller) { SDL_GameControllerSetSensorEnabled(controller, SDL_SENSOR_ACCEL, SDL_TRUE); } } + +void MainWindow::loadKeybindings() { + auto mappings = InputMappings::deserialize(emu->getAppDataRoot() / "controls_qt.toml", "Qt", [](const std::string& name) { + return InputMappings::Scancode(QKeySequence(QString::fromStdString(name))[0].key()); + }); + + if (mappings.has_value()) { + keyboardMappings = *mappings; + } else { + keyboardMappings = InputMappings::defaultKeyboardMappings(); + } + + configWindow->getInputWindow()->loadFromMappings(keyboardMappings); +} + +void MainWindow::saveKeybindings() { + keyboardMappings.serialize(emu->getAppDataRoot() / "controls_qt.toml", "Qt", [](InputMappings::Scancode scancode) { + return QKeySequence(scancode).toString().toStdString(); + }); +} diff --git a/src/panda_qt/mappings.cpp b/src/panda_qt/mappings.cpp index 99b98107..b41debee 100644 --- a/src/panda_qt/mappings.cpp +++ b/src/panda_qt/mappings.cpp @@ -1,4 +1,5 @@ #include +#include #include "input_mappings.hpp" diff --git a/src/panda_sdl/mappings.cpp b/src/panda_sdl/mappings.cpp index 58d139c4..7e982978 100644 --- a/src/panda_sdl/mappings.cpp +++ b/src/panda_sdl/mappings.cpp @@ -1,7 +1,7 @@ -#include "input_mappings.hpp" - #include +#include "input_mappings.hpp" + InputMappings InputMappings::defaultKeyboardMappings() { InputMappings mappings; mappings.setMapping(SDLK_l, HID::Keys::A);