feat(input): add host input remapping

Add compact keyboard and mouse mapping with analog-stick defaults and integrate a global remapping editor into the Qt launcher.

Based on the host input remapping work from PR #109.

Co-authored-by: DistantMyth <thetarun3@gmail.com>
This commit is contained in:
nmzik
2026-08-04 03:24:24 +02:00
parent a3a00464bc
commit 6396dc9a85
21 changed files with 756 additions and 41 deletions
+1
View File
@@ -16,6 +16,7 @@ file(GLOB launcher_forms
add_executable(launcher WIN32
${launcher_src}
${launcher_forms}
resources.qrc
)
@@ -69,6 +69,31 @@
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="input_mapping_button">
<property name="minimumSize">
<size>
<width>36</width>
<height>36</height>
</size>
</property>
<property name="toolTip">
<string>Edit input mapping</string>
</property>
<property name="text">
<string/>
</property>
<property name="iconSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QToolButton" name="edit_button">
<property name="minimumSize">
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<g fill="none" stroke="#e8f1ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M8 4h11l5 5v7M24 9h-5V4M16 27H8a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2"/>
<path d="m15 24 1-4 8-8 4 4-8 8-4 1Z"/>
<path d="m22 14 4 4" opacity=".8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<g fill="none" stroke="#e8f1ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M5 8h3M14 8h13M5 16h13M24 16h3M5 24h6M17 24h10"/>
<circle cx="11" cy="8" r="3"/>
<circle cx="21" cy="16" r="3"/>
<circle cx="14" cy="24" r="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 344 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<g fill="none" stroke="#e8f1ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M10 10h12c3.6 0 5.8 2.3 6.6 6.8l.9 5.1c.4 2.4-.8 4.1-2.8 4.1-1.4 0-2.5-.7-3.6-2.3L21.3 21H10.7l-1.8 2.7C7.8 25.3 6.7 26 5.3 26c-2 0-3.2-1.7-2.8-4.1l.9-5.1C4.2 12.3 6.4 10 10 10Z"/>
<path d="M8 16h6M11 13v6"/>
<circle cx="22" cy="15" r="1" fill="#e8f1ff" stroke="none"/>
<circle cx="25" cy="18" r="1" fill="#e8f1ff" stroke="none"/>
<path d="M13 7h6M16 4v6" opacity=".8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 574 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<g fill="none" stroke="#e8f1ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M6 9h20M12 9V5h8v4M9 9l1 18h12l1-18"/>
<path d="M14 14v8M18 14v8" opacity=".8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 272 B

+5 -1
View File
@@ -99,6 +99,7 @@ public:
QString printf_output_file = "_kyty.txt";
ProfilerDirection profiler_direction = ProfilerDirection::None;
bool renderdoc_enabled = false;
QStringList host_input_mapping;
QString elf = QStringLiteral("eboot.bin");
@@ -118,6 +119,7 @@ public:
printf_output_file = other.printf_output_file;
profiler_direction = other.profiler_direction;
renderdoc_enabled = other.renderdoc_enabled;
host_input_mapping = other.host_input_mapping;
}
void CopyFrom(const Configuration& other) {
@@ -154,6 +156,7 @@ public:
KYTY_CFG_SET(printf_output_file);
KYTY_CFG_SET(profiler_direction);
KYTY_CFG_SET(renderdoc_enabled);
s->setValue("host_input_mapping", host_input_mapping);
KYTY_CFG_SET(elf);
}
@@ -180,7 +183,8 @@ public:
KYTY_CFG_GET(printf_output_file);
KYTY_CFG_GET(profiler_direction);
KYTY_CFG_GET(renderdoc_enabled);
elf = s->value("elf", elf).toString();
host_input_mapping = s->value("host_input_mapping", host_input_mapping).toStringList();
elf = s->value("elf", elf).toString();
}
};
@@ -38,7 +38,10 @@ public:
[[nodiscard]] const QString& GetSettingsFile() const { return m_settings_file; }
[[nodiscard]] const QStringList& GetGameDirectories() const { return m_game_dirs; }
[[nodiscard]] bool CanViewSelectedTrophies() const;
[[nodiscard]] const QStringList& GetHostInputMapping() const {
return m_global_info.host_input_mapping;
}
[[nodiscard]] bool CanViewSelectedTrophies() const;
bool EnsureGameDirectory();
void ScanGameDirectory();
@@ -58,6 +61,7 @@ protected slots:
void edit_configuration();
void delete_configuartion();
void edit_global_settings();
void edit_input_mapping();
void run_configuration();
void list_currentItemChanged(QTreeWidgetItem* current, QTreeWidgetItem* previous);
void list_itemDoubleClicked(QTreeWidgetItem* witem, int column);
+31
View File
@@ -0,0 +1,31 @@
#ifndef LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_
#define LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_
#include <QDialog>
#include <QStringList>
class QPushButton;
class QTreeWidget;
class QTreeWidgetItem;
class QWidget;
class InputMappingDialog final: public QDialog {
public:
explicit InputMappingDialog(const QStringList& mapping, QWidget* parent = nullptr);
[[nodiscard]] QStringList Mapping() const;
private:
void ChangeBinding();
void ClearBinding();
void RestoreDefaults();
void SetBinding(QTreeWidgetItem* item, const QString& binding);
void UpdateButtons();
QTreeWidget* m_bindings = nullptr;
QPushButton* m_change_button = nullptr;
QPushButton* m_clear_button = nullptr;
bool m_customized = false;
};
#endif /* LAUNCHER_INCLUDE_INPUT_MAPPING_DIALOG_H_ */
+8
View File
@@ -0,0 +1,8 @@
<RCC>
<qresource prefix="/icons">
<file alias="edit-configuration.svg">icons/edit-configuration.svg</file>
<file alias="global-settings.svg">icons/global-settings.svg</file>
<file alias="input-mapping.svg">icons/input-mapping.svg</file>
<file alias="remove-configuration.svg">icons/remove-configuration.svg</file>
</qresource>
</RCC>
+17 -3
View File
@@ -6,6 +6,7 @@
#include "configurationEditDialog.h"
#include "configurationItem.h"
#include "gameListTreeWidget.h"
#include "inputMappingDialog.h"
#include "mainDialog.h"
#include "patchesDialog.h"
#include "trophyViewerDialog.h"
@@ -169,6 +170,7 @@ static void ApplyGameListStyle(Ui::ConfigurationListWidget* ui) {
"QToolButton:disabled { background: transparent; }"));
ui->edit_button->setStyleSheet(ui->global_settings_button->styleSheet());
ui->delete_button->setStyleSheet(ui->global_settings_button->styleSheet());
ui->input_mapping_button->setStyleSheet(ui->global_settings_button->styleSheet());
}
static void AddSaveDataDir(QStringList* dirs, QSet<QString>* seen, const QString& root,
@@ -226,10 +228,12 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
m_ui->setupUi(this);
ApplyGameListStyle(m_ui);
m_ui->global_settings_button->setIcon(style()->standardIcon(QStyle::SP_FileDialogDetailedView));
m_ui->global_settings_button->setIcon(QIcon(QStringLiteral(":/icons/global-settings.svg")));
m_ui->global_settings_button->setToolTip(tr("Edit global settings and game folders"));
m_ui->edit_button->setIcon(style()->standardIcon(QStyle::SP_FileIcon));
m_ui->delete_button->setIcon(style()->standardIcon(QStyle::SP_DialogDiscardButton));
m_ui->input_mapping_button->setIcon(QIcon(QStringLiteral(":/icons/input-mapping.svg")));
m_ui->input_mapping_button->setToolTip(tr("Edit global input mapping"));
m_ui->edit_button->setIcon(QIcon(QStringLiteral(":/icons/edit-configuration.svg")));
m_ui->delete_button->setIcon(QIcon(QStringLiteral(":/icons/remove-configuration.svg")));
m_ui->delete_button->setEnabled(false);
m_ui->edit_button->setEnabled(false);
@@ -249,6 +253,8 @@ ConfigurationListWidget::ConfigurationListWidget(QWidget* parent)
connect(m_ui->global_settings_button, &QToolButton::clicked, this,
&ConfigurationListWidget::edit_global_settings);
connect(m_ui->input_mapping_button, &QToolButton::clicked, this,
&ConfigurationListWidget::edit_input_mapping);
connect(m_ui->edit_button, &QToolButton::clicked, this,
&ConfigurationListWidget::edit_configuration);
connect(m_ui->delete_button, &QToolButton::clicked, this,
@@ -717,6 +723,14 @@ void ConfigurationListWidget::edit_global_settings() {
}
}
void ConfigurationListWidget::edit_input_mapping() {
InputMappingDialog dialog(m_global_info.host_input_mapping, this);
if (dialog.exec() == QDialog::Accepted) {
m_global_info.host_input_mapping = dialog.Mapping();
WriteSettings();
}
}
void ConfigurationListWidget::ClearCustomSettings(ConfigurationItem* item) {
delete m_custom_infos.take(item->GetInfo().game_path);
}
+311
View File
@@ -0,0 +1,311 @@
#include "inputMappingDialog.h"
#include <QAbstractItemView>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QHash>
#include <QHeaderView>
#include <QKeyEvent>
#include <QKeySequence>
#include <QLabel>
#include <QMouseEvent>
#include <QPushButton>
#include <QStringList>
#include <QTreeWidget>
#include <QVBoxLayout>
namespace {
constexpr int BINDING_COLUMN = 1;
struct PadButton {
const char* id;
const char* label;
const char* default_binding;
};
constexpr PadButton PAD_BUTTONS[] = {
{"Up", "D-pad Up", "Up"},
{"Down", "D-pad Down", "Down"},
{"Left", "D-pad Left", "Left"},
{"Right", "D-pad Right", "Right"},
{"Triangle", "Triangle", "I"},
{"Circle", "Circle", "L"},
{"Cross", "Cross", "J"},
{"Square", "Square", "K"},
{"L1", "L1", "Q"},
{"R1", "R1", "E"},
{"L2", "L2", ""},
{"R2", "R2", ""},
{"L3", "L3", "Left Shift"},
{"R3", "R3", "Left Ctrl"},
{"Options", "Options", "Return"},
{"TouchPad", "Touch pad", "Backspace"},
};
QString KeypadName(int key) {
if (key >= Qt::Key_0 && key <= Qt::Key_9) {
return QStringLiteral("Keypad %1").arg(key - Qt::Key_0);
}
switch (key) {
case Qt::Key_Return:
case Qt::Key_Enter: return QStringLiteral("Keypad Enter");
case Qt::Key_Slash: return QStringLiteral("Keypad /");
case Qt::Key_Asterisk: return QStringLiteral("Keypad *");
case Qt::Key_Minus: return QStringLiteral("Keypad -");
case Qt::Key_Plus: return QStringLiteral("Keypad +");
case Qt::Key_Period: return QStringLiteral("Keypad .");
case Qt::Key_Equal: return QStringLiteral("Keypad =");
case Qt::Key_Comma: return QStringLiteral("Keypad ,");
default: return {};
}
}
QString KeyName(const QKeyEvent& event) {
const int key = event.key();
if (event.modifiers().testFlag(Qt::KeypadModifier)) {
return KeypadName(key);
}
if (key >= Qt::Key_A && key <= Qt::Key_Z) {
return QChar(key);
}
if (key >= Qt::Key_0 && key <= Qt::Key_9) {
return QChar(key);
}
if (key >= Qt::Key_F1 && key <= Qt::Key_F24) {
return QStringLiteral("F%1").arg(key - Qt::Key_F1 + 1);
}
switch (key) {
case Qt::Key_Return:
case Qt::Key_Enter: return QStringLiteral("Return");
case Qt::Key_Backspace: return QStringLiteral("Backspace");
case Qt::Key_Tab: return QStringLiteral("Tab");
case Qt::Key_Shift: return QStringLiteral("Left Shift");
case Qt::Key_Control: return QStringLiteral("Left Ctrl");
case Qt::Key_Alt: return QStringLiteral("Left Alt");
case Qt::Key_Meta: return QStringLiteral("Left GUI");
case Qt::Key_Insert: return QStringLiteral("Insert");
case Qt::Key_Delete: return QStringLiteral("Delete");
case Qt::Key_Home: return QStringLiteral("Home");
case Qt::Key_End: return QStringLiteral("End");
case Qt::Key_PageUp: return QStringLiteral("PageUp");
case Qt::Key_PageDown: return QStringLiteral("PageDown");
case Qt::Key_Left: return QStringLiteral("Left");
case Qt::Key_Right: return QStringLiteral("Right");
case Qt::Key_Up: return QStringLiteral("Up");
case Qt::Key_Down: return QStringLiteral("Down");
case Qt::Key_CapsLock: return QStringLiteral("CapsLock");
case Qt::Key_NumLock: return QStringLiteral("Numlock");
case Qt::Key_ScrollLock: return QStringLiteral("ScrollLock");
case Qt::Key_Pause: return QStringLiteral("Pause");
case Qt::Key_Print: return QStringLiteral("PrintScreen");
default: break;
}
if (event.modifiers() != Qt::NoModifier) {
return {};
}
const auto name = QKeySequence(key).toString(QKeySequence::PortableText);
return name.size() == 1 ? name : QString();
}
class InputCaptureDialog final: public QDialog {
public:
explicit InputCaptureDialog(QWidget* parent): QDialog(parent) {
setWindowTitle(tr("Set Binding"));
setModal(true);
setMinimumWidth(360);
auto* layout = new QVBoxLayout(this);
m_label = new QLabel(
tr("Press a key or mouse button.\nSpace and F1 are reserved; Esc cancels."), this);
m_label->setAlignment(Qt::AlignCenter);
layout->addWidget(m_label);
}
[[nodiscard]] const QString& Binding() const { return m_binding; }
protected:
void keyPressEvent(QKeyEvent* event) override {
if (event->isAutoRepeat()) {
return;
}
if (event->key() == Qt::Key_Escape) {
reject();
return;
}
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_F1) {
m_label->setText(tr("That key is reserved by the emulator."));
return;
}
m_binding = KeyName(*event);
if (!m_binding.isEmpty()) {
accept();
} else {
m_label->setText(tr("That key is not supported."));
}
}
void mousePressEvent(QMouseEvent* event) override {
switch (event->button()) {
case Qt::LeftButton: m_binding = QStringLiteral("Mouse:Left"); break;
case Qt::RightButton: m_binding = QStringLiteral("Mouse:Right"); break;
case Qt::MiddleButton: m_binding = QStringLiteral("Mouse:Middle"); break;
case Qt::BackButton: m_binding = QStringLiteral("Mouse:X1"); break;
case Qt::ForwardButton: m_binding = QStringLiteral("Mouse:X2"); break;
default: return;
}
accept();
}
private:
QLabel* m_label = nullptr;
QString m_binding;
};
QHash<QString, QString> ParseMapping(const QStringList& mapping) {
QHash<QString, QString> result;
for (const auto& entry: mapping) {
const auto separator = entry.indexOf(QLatin1Char('='));
if (separator > 0 && separator + 1 < entry.size()) {
const auto binding = entry.mid(separator + 1);
for (auto item = result.begin(); item != result.end();) {
if (item.value().compare(binding, Qt::CaseInsensitive) == 0) {
item = result.erase(item);
} else {
++item;
}
}
result.insert(entry.left(separator), binding);
}
}
return result;
}
} // namespace
InputMappingDialog::InputMappingDialog(const QStringList& mapping, QWidget* parent)
: QDialog(parent), m_customized(!mapping.isEmpty()) {
setWindowTitle(tr("Input Mapping"));
resize(440, 520);
auto* layout = new QVBoxLayout(this);
layout->addWidget(new QLabel(tr("Map keyboard or mouse input to DualSense buttons.\n"
"Defaults: left stick W/A/S/D, right stick T/F/G/H."),
this));
m_bindings = new QTreeWidget(this);
m_bindings->setColumnCount(2);
m_bindings->setHeaderLabels({tr("DualSense button"), tr("Host input")});
m_bindings->setRootIsDecorated(false);
m_bindings->setSelectionMode(QAbstractItemView::SingleSelection);
m_bindings->header()->setSectionResizeMode(0, QHeaderView::Stretch);
m_bindings->header()->setSectionResizeMode(1, QHeaderView::Stretch);
layout->addWidget(m_bindings);
const auto parsed = ParseMapping(mapping);
for (const auto& button: PAD_BUTTONS) {
auto* item = new QTreeWidgetItem(m_bindings);
item->setText(0, tr(button.label));
item->setData(0, Qt::UserRole, QString::fromLatin1(button.id));
SetBinding(item, m_customized ? parsed.value(QString::fromLatin1(button.id))
: QString::fromLatin1(button.default_binding));
}
m_bindings->setCurrentItem(m_bindings->topLevelItem(0));
auto* controls = new QHBoxLayout;
m_change_button = new QPushButton(tr("Change..."), this);
m_clear_button = new QPushButton(tr("Clear"), this);
auto* defaults = new QPushButton(tr("Defaults"), this);
controls->addWidget(m_change_button);
controls->addWidget(m_clear_button);
controls->addWidget(defaults);
controls->addStretch();
layout->addLayout(controls);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
layout->addWidget(buttons);
connect(m_bindings, &QTreeWidget::itemDoubleClicked, this,
[this](QTreeWidgetItem*, int) { ChangeBinding(); });
connect(m_bindings, &QTreeWidget::itemSelectionChanged, this, [this]() { UpdateButtons(); });
connect(m_change_button, &QPushButton::clicked, this, [this]() { ChangeBinding(); });
connect(m_clear_button, &QPushButton::clicked, this, [this]() { ClearBinding(); });
connect(defaults, &QPushButton::clicked, this, [this]() { RestoreDefaults(); });
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
UpdateButtons();
}
QStringList InputMappingDialog::Mapping() const {
QStringList result;
if (!m_customized) {
return result;
}
for (int index = 0; index < m_bindings->topLevelItemCount(); index++) {
const auto* item = m_bindings->topLevelItem(index);
const auto binding = item->data(BINDING_COLUMN, Qt::UserRole).toString();
if (!binding.isEmpty()) {
result.append(item->data(0, Qt::UserRole).toString() + QLatin1Char('=') + binding);
}
}
return result;
}
void InputMappingDialog::ChangeBinding() {
auto* item = m_bindings->currentItem();
if (item == nullptr) {
return;
}
InputCaptureDialog dialog(this);
if (dialog.exec() != QDialog::Accepted) {
return;
}
for (int index = 0; index < m_bindings->topLevelItemCount(); index++) {
auto* other = m_bindings->topLevelItem(index);
if (other != item && other->data(BINDING_COLUMN, Qt::UserRole)
.toString()
.compare(dialog.Binding(), Qt::CaseInsensitive) == 0) {
SetBinding(other, {});
}
}
SetBinding(item, dialog.Binding());
m_customized = true;
}
void InputMappingDialog::ClearBinding() {
SetBinding(m_bindings->currentItem(), {});
m_customized = true;
}
void InputMappingDialog::RestoreDefaults() {
for (int index = 0; index < m_bindings->topLevelItemCount(); index++) {
SetBinding(m_bindings->topLevelItem(index),
QString::fromLatin1(PAD_BUTTONS[index].default_binding));
}
m_customized = false;
}
void InputMappingDialog::SetBinding(QTreeWidgetItem* item, const QString& binding) {
if (item == nullptr) {
return;
}
item->setData(BINDING_COLUMN, Qt::UserRole, binding);
item->setText(BINDING_COLUMN, binding.isEmpty() ? tr("None") : binding);
UpdateButtons();
}
void InputMappingDialog::UpdateButtons() {
if (m_change_button == nullptr) {
return;
}
const auto* item = m_bindings->currentItem();
m_change_button->setEnabled(item != nullptr);
m_clear_button->setEnabled(item != nullptr &&
!item->data(BINDING_COLUMN, Qt::UserRole).toString().isEmpty());
}
+7 -1
View File
@@ -218,6 +218,9 @@ static QStringList CreateEmulatorArgs(const Configuration& info) {
args << "--printf-output-file" << info.printf_output_file;
args << "--profiler-direction" << EnumToText(info.profiler_direction);
args << "--spirv-debug-printf" << "false";
for (const auto& binding: info.host_input_mapping) {
args << "--keymap" << binding;
}
if (info.renderdoc_enabled) {
args << "--rd";
}
@@ -428,7 +431,10 @@ void MainDialogPrivate::Run() {
m_running_item->SetRunning(true);
m_main_dialog->RunInterpreter(&m_process, m_running_item->GetInfo());
Configuration info;
info.CopyFrom(m_running_item->GetInfo());
info.host_input_mapping = m_ui->widget->GetHostInputMapping();
m_main_dialog->RunInterpreter(&m_process, info);
Update();
}