feat(hotkeys): Introduce Hotkey_Profile_Manager

This commit is contained in:
collecting
2026-02-04 08:22:09 -05:00
parent 75dfdce858
commit b8fcfd3add
7 changed files with 787 additions and 148 deletions

View File

@@ -178,6 +178,8 @@ add_executable(citron
game_list_worker.h
hotkeys.cpp
hotkeys.h
hotkey_profile_manager.cpp
hotkey_profile_manager.h
install_dialog.cpp
install_dialog.h
loading_screen.cpp

View File

@@ -2,7 +2,6 @@
// SPDX-FileCopyrightText: 2025 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include "citron/configuration/configure_dialog.h"
#include <cmath>
#include <memory>
#include <QApplication>
@@ -20,17 +19,12 @@
#include <QString>
#include <QTimer>
#include <QVBoxLayout>
#include "common/logging/log.h"
#include "common/settings.h"
#include "common/settings_enums.h"
#include "core/core.h"
#include "ui_configure.h"
#include "vk_device_info.h"
#include "citron/configuration/configuration_shared.h"
#include "citron/configuration/configure_applets.h"
#include "citron/configuration/configure_audio.h"
#include "citron/configuration/configure_cpu.h"
#include "citron/configuration/configure_debug_tab.h"
#include "citron/configuration/configure_dialog.h"
#include "citron/configuration/configure_filesystem.h"
#include "citron/configuration/configure_general.h"
#include "citron/configuration/configure_graphics.h"
@@ -44,12 +38,18 @@
#include "citron/configuration/configure_ui.h"
#include "citron/configuration/configure_web.h"
#include "citron/configuration/style_animation_event_filter.h"
#include "citron/util/rainbow_style.h"
#include "citron/game_list.h"
#include "citron/hotkeys.h"
#include "citron/main.h"
#include "citron/theme.h"
#include "citron/uisettings.h"
#include "citron/util/rainbow_style.h"
#include "common/logging/log.h"
#include "common/settings.h"
#include "common/settings_enums.h"
#include "core/core.h"
#include "ui_configure.h"
#include "vk_device_info.h"
static QScrollArea* CreateScrollArea(QWidget* widget) {
auto* scroll_area = new QScrollArea();
@@ -95,7 +95,7 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
ui_tab->UpdateScreenshotInfo(ratio, setup);
},
nullptr, *builder, this)},
hotkeys_tab{std::make_unique<ConfigureHotkeys>(system_.HIDCore(), this)},
hotkeys_tab{std::make_unique<ConfigureHotkeys>(registry, system_.HIDCore(), this)},
input_tab{std::make_unique<ConfigureInput>(system_, this)},
network_tab{std::make_unique<ConfigureNetwork>(system_, this)},
profile_tab{std::make_unique<ConfigureProfileManager>(system_, this)},
@@ -103,8 +103,8 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
web_tab{std::make_unique<ConfigureWeb>(this)} {
if (auto* main_window = qobject_cast<GMainWindow*>(parent)) {
connect(filesystem_tab.get(), &ConfigureFilesystem::RequestGameListRefresh,
main_window, &GMainWindow::RefreshGameList);
connect(filesystem_tab.get(), &ConfigureFilesystem::RequestGameListRefresh, main_window,
&GMainWindow::RefreshGameList);
}
Settings::SetConfiguringGlobal(true);
@@ -115,7 +115,8 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
setWindowFlags(Qt::Window | Qt::CustomizeWindowHint | Qt::WindowTitleHint);
setWindowModality(Qt::NonModal);
} else {
setWindowFlags(Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint | Qt::WindowCloseButtonHint);
setWindowFlags(Qt::Dialog | Qt::WindowTitleHint | Qt::WindowSystemMenuHint |
Qt::WindowCloseButtonHint);
setWindowModality(Qt::WindowModal);
}
@@ -180,18 +181,21 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_,
ui->stackedWidget->addWidget(CreateScrollArea(applets_tab.get()));
ui->stackedWidget->addWidget(CreateScrollArea(debug_tab_tab.get()));
connect(tab_button_group.get(), qOverload<int>(&QButtonGroup::idClicked), this, &ConfigureDialog::AnimateTabSwitch);
connect(tab_button_group.get(), qOverload<int>(&QButtonGroup::idClicked), this,
&ConfigureDialog::AnimateTabSwitch);
connect(ui_tab.get(), &ConfigureUi::themeChanged, this, &ConfigureDialog::UpdateTheme);
connect(ui_tab.get(), &ConfigureUi::UIPositioningChanged, this, &ConfigureDialog::SetUIPositioning);
connect(ui_tab.get(), &ConfigureUi::UIPositioningChanged, this,
&ConfigureDialog::SetUIPositioning);
web_tab->SetWebServiceConfigEnabled(enable_web_config);
hotkeys_tab->Populate(registry);
hotkeys_tab->Populate();
input_tab->Initialize(input_subsystem);
general_tab->SetResetCallback([&] { this->close(); });
SetConfiguration();
connect(ui_tab.get(), &ConfigureUi::LanguageChanged, this, &ConfigureDialog::OnLanguageChanged);
if (system.IsPoweredOn()) {
if (auto* apply_button = ui->buttonBox->button(QDialogButtonBox::Apply)) {
connect(apply_button, &QAbstractButton::clicked, this, &ConfigureDialog::HandleApplyButtonClicked);
connect(apply_button, &QAbstractButton::clicked, this,
&ConfigureDialog::HandleApplyButtonClicked);
}
}
ui->stackedWidget->setCurrentIndex(0);
@@ -219,10 +223,12 @@ void ConfigureDialog::UpdateTheme() {
const QString d_txt = is_dark ? QStringLiteral("#8d8d8d") : QStringLiteral("#a0a0a0");
// Use dark shadow on light backgrounds, light shadow on dark backgrounds
const QString shadow_color = is_dark ? QStringLiteral("rgba(0, 0, 0, 0.5)") : QStringLiteral("rgba(255, 255, 255, 0.8)");
const QString shadow_color =
is_dark ? QStringLiteral("rgba(0, 0, 0, 0.5)") : QStringLiteral("rgba(255, 255, 255, 0.8)");
static QString cached_template;
if (cached_template.isEmpty()) cached_template = property("templateStyleSheet").toString();
if (cached_template.isEmpty())
cached_template = property("templateStyleSheet").toString();
QString style_sheet = cached_template;
style_sheet.replace(QStringLiteral("%%ACCENT_COLOR%%"), accent);
@@ -237,10 +243,10 @@ void ConfigureDialog::UpdateTheme() {
style_sheet.replace(QStringLiteral("%%FOCUS_BG_COLOR%%"), f_bg);
style_sheet.replace(QStringLiteral("%%DISABLED_TEXT_COLOR%%"), d_txt);
style_sheet += QStringLiteral(
"QSlider::handle:horizontal { background-color: %1; }"
"QCheckBox::indicator:checked { background-color: %1; border-color: %1; }"
).arg(accent);
style_sheet +=
QStringLiteral("QSlider::handle:horizontal { background-color: %1; }"
"QCheckBox::indicator:checked { background-color: %1; border-color: %1; }")
.arg(accent);
setStyleSheet(style_sheet);
@@ -250,33 +256,37 @@ void ConfigureDialog::UpdateTheme() {
cpu_tab->SetTemplateStyleSheet(style_sheet);
graphics_advanced_tab->SetTemplateStyleSheet(style_sheet);
QString sidebar_css = QStringLiteral(
"QPushButton.tabButton { "
QString sidebar_css =
QStringLiteral(
"QPushButton.tabButton { "
"background-color: %1; "
"color: %2; "
"border: 2px solid transparent; "
"}"
"QPushButton.tabButton:checked { "
"color: %4; " // Use main text color instead of dimmed color for checked state
"}"
"QPushButton.tabButton:checked { "
"color: %4; " // Use main text color instead of dimmed color for checked state
"border: 2px solid %3; "
"}"
"QPushButton.tabButton:hover { "
"}"
"QPushButton.tabButton:hover { "
"border: 2px solid %3; "
"}"
"QPushButton.tabButton:pressed { "
"}"
"QPushButton.tabButton:pressed { "
"background-color: %3; "
"color: #ffffff; "
"}"
).arg(b_bg, d_txt, accent, txt);
"}")
.arg(b_bg, d_txt, accent, txt);
if (ui->topButtonWidget) ui->topButtonWidget->setStyleSheet(sidebar_css);
if (ui->horizontalNavWidget) ui->horizontalNavWidget->setStyleSheet(sidebar_css);
if (ui->topButtonWidget)
ui->topButtonWidget->setStyleSheet(sidebar_css);
if (ui->horizontalNavWidget)
ui->horizontalNavWidget->setStyleSheet(sidebar_css);
if (is_rainbow) {
if (!rainbow_timer) {
rainbow_timer = new QTimer(this);
connect(rainbow_timer, &QTimer::timeout, this, [this, b_bg, d_txt, txt, shadow_color] {
if (ui->buttonBox->underMouse() || m_is_tab_animating || !this->isVisible() || !this->isActiveWindow()) {
if (ui->buttonBox->underMouse() || m_is_tab_animating || !this->isVisible() ||
!this->isActiveWindow()) {
return;
}
@@ -288,43 +298,52 @@ void ConfigureDialog::UpdateTheme() {
const QString hue_light = current_color.lighter(125).name();
const QString hue_dark = current_color.darker(150).name();
QString rainbow_sidebar_css = QStringLiteral(
"QPushButton.tabButton { "
"background-color: %1; "
"color: %2; "
"border: 2px solid transparent; "
"}"
"QPushButton.tabButton:checked { "
"color: %4; " // Use main text color for visibility
"border: 2px solid %3; "
"}"
"QPushButton.tabButton:hover { "
"border: 2px solid %3; "
"}"
"QPushButton.tabButton:pressed { "
"background-color: %3; "
"color: #ffffff; "
"}"
).arg(b_bg, d_txt, hue_hex, txt);
QString rainbow_sidebar_css =
QStringLiteral("QPushButton.tabButton { "
"background-color: %1; "
"color: %2; "
"border: 2px solid transparent; "
"}"
"QPushButton.tabButton:checked { "
"color: %4; " // Use main text color for visibility
"border: 2px solid %3; "
"}"
"QPushButton.tabButton:hover { "
"border: 2px solid %3; "
"}"
"QPushButton.tabButton:pressed { "
"background-color: %3; "
"color: #ffffff; "
"}")
.arg(b_bg, d_txt, hue_hex, txt);
if (ui->topButtonWidget) ui->topButtonWidget->setStyleSheet(rainbow_sidebar_css);
if (ui->horizontalNavWidget) ui->horizontalNavWidget->setStyleSheet(rainbow_sidebar_css);
if (ui->topButtonWidget)
ui->topButtonWidget->setStyleSheet(rainbow_sidebar_css);
if (ui->horizontalNavWidget)
ui->horizontalNavWidget->setStyleSheet(rainbow_sidebar_css);
// Tab Content Area
if (current_index == input_tab_index) return;
if (current_index == input_tab_index)
return;
QWidget* currentContainer = ui->stackedWidget->currentWidget();
if (currentContainer) {
QString tab_css = QStringLiteral(
"QCheckBox::indicator:checked, QRadioButton::indicator:checked { background-color: %1; border: 1px solid %1; }"
"QSlider::sub-page:horizontal { background: %1; border-radius: 4px; }"
"QSlider::handle:horizontal { background-color: %1; border: 1px solid %1; width: 18px; height: 18px; margin: -5px 0; border-radius: 9px; }"
"QPushButton, QToolButton { background-color: transparent; color: %4; border: 2px solid %1; border-radius: 4px; padding: 5px; }"
"QPushButton:hover, QToolButton:hover { border-color: %2; color: %2; }"
"QPushButton:pressed, QToolButton:pressed { background-color: %3; color: #ffffff; border-color: %3; }"
).arg(hue_hex, hue_light, hue_dark, txt);
QString tab_css =
QStringLiteral(
"QCheckBox::indicator:checked, QRadioButton::indicator:checked { "
"background-color: %1; border: 1px solid %1; }"
"QSlider::sub-page:horizontal { background: %1; border-radius: 4px; }"
"QSlider::handle:horizontal { background-color: %1; border: 1px solid "
"%1; width: 18px; height: 18px; margin: -5px 0; border-radius: 9px; }"
"QPushButton, QToolButton { background-color: transparent; color: %4; "
"border: 2px solid %1; border-radius: 4px; padding: 5px; }"
"QPushButton:hover, QToolButton:hover { border-color: %2; color: %2; }"
"QPushButton:pressed, QToolButton:pressed { background-color: %3; "
"color: #ffffff; border-color: %3; }")
.arg(hue_hex, hue_light, hue_dark, txt);
currentContainer->setStyleSheet(tab_css);
if (ui->buttonBox) ui->buttonBox->setStyleSheet(tab_css);
if (ui->buttonBox)
ui->buttonBox->setStyleSheet(tab_css);
}
});
}
@@ -334,7 +353,8 @@ void ConfigureDialog::UpdateTheme() {
if (UISettings::values.enable_rainbow_mode.GetValue() == false && rainbow_timer) {
rainbow_timer->stop();
if (ui->buttonBox) ui->buttonBox->setStyleSheet({});
if (ui->buttonBox)
ui->buttonBox->setStyleSheet({});
for (int i = 0; i < ui->stackedWidget->count(); ++i) {
if (auto* w = ui->stackedWidget->widget(i)) {
w->setStyleSheet({});
@@ -370,7 +390,8 @@ void ConfigureDialog::SetUIPositioning(const QString& positioning) {
if (!tab_buttons.empty()) {
const int button_height = tab_buttons[0]->sizeHint().height();
const int margins = h_layout->contentsMargins().top() + h_layout->contentsMargins().bottom();
const int margins =
h_layout->contentsMargins().top() + h_layout->contentsMargins().bottom();
// The scroll area frame adds a few pixels, this accounts for it.
const int fixed_height = button_height + margins + 4;
ui->horizontalNavScrollArea->setMaximumHeight(fixed_height);
@@ -417,7 +438,7 @@ void ConfigureDialog::ApplyConfiguration() {
profile_tab->ApplyConfiguration();
filesystem_tab->ApplyConfiguration();
input_tab->ApplyConfiguration();
hotkeys_tab->ApplyConfiguration(registry);
hotkeys_tab->ApplyConfiguration();
cpu_tab->ApplyConfiguration();
graphics_tab->ApplyConfiguration();
graphics_advanced_tab->ApplyConfiguration();
@@ -501,7 +522,8 @@ void ConfigureDialog::AnimateTabSwitch(int id) {
anim_new_opacity->setDuration(duration);
anim_new_opacity->setEasingCurve(QEasingCurve::InQuad);
auto* button_opacity_effect = qobject_cast<QGraphicsOpacityEffect*>(ui->buttonBox->graphicsEffect());
auto* button_opacity_effect =
qobject_cast<QGraphicsOpacityEffect*>(ui->buttonBox->graphicsEffect());
if (!button_opacity_effect) {
button_opacity_effect = new QGraphicsOpacityEffect(ui->buttonBox);
ui->buttonBox->setGraphicsEffect(button_opacity_effect);
@@ -529,18 +551,19 @@ void ConfigureDialog::AnimateTabSwitch(int id) {
animation_group->addAnimation(anim_new_opacity);
animation_group->addAnimation(button_anim_sequence);
connect(animation_group, &QAbstractAnimation::finished, this, [this, current_widget, next_widget, id]() {
ui->stackedWidget->setCurrentIndex(id);
connect(animation_group, &QAbstractAnimation::finished, this,
[this, current_widget, next_widget, id]() {
ui->stackedWidget->setCurrentIndex(id);
next_widget->setGraphicsEffect(nullptr);
current_widget->hide();
current_widget->move(0, 0);
next_widget->setGraphicsEffect(nullptr);
current_widget->hide();
current_widget->move(0, 0);
m_is_tab_animating = false; // Reset the flag
for (auto button : tab_button_group->buttons()) {
button->setEnabled(true);
}
});
m_is_tab_animating = false; // Reset the flag
for (auto button : tab_button_group->buttons()) {
button->setEnabled(true);
}
});
m_is_tab_animating = true; // Set the flag
for (auto button : tab_button_group->buttons()) {

View File

@@ -1,6 +1,9 @@
// SPDX-FileCopyrightText: 2017 Citra Emulator Project
// SPDX-FileCopyrightText: 2026 Citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <QFileDialog>
#include <QInputDialog>
#include <QMenu>
#include <QMessageBox>
#include <QStandardItemModel>
@@ -9,19 +12,21 @@
#include "hid_core/frontend/emulated_controller.h"
#include "hid_core/hid_core.h"
#include "frontend_common/config.h"
#include "ui_configure_hotkeys.h"
#include "citron/configuration/configure_hotkeys.h"
#include "citron/hotkeys.h"
#include "citron/uisettings.h"
#include "citron/util/sequence_dialog/sequence_dialog.h"
#include "frontend_common/config.h"
#include "ui_configure_hotkeys.h"
constexpr int name_column = 0;
constexpr int hotkey_column = 1;
constexpr int controller_column = 2;
ConfigureHotkeys::ConfigureHotkeys(Core::HID::HIDCore& hid_core, QWidget* parent)
: QWidget(parent), ui(std::make_unique<Ui::ConfigureHotkeys>()),
ConfigureHotkeys::ConfigureHotkeys(HotkeyRegistry& registry_, Core::HID::HIDCore& hid_core,
QWidget* parent)
: QWidget(parent), ui(std::make_unique<Ui::ConfigureHotkeys>()), registry(registry_),
controller(new Core::HID::EmulatedController(Core::HID::NpadIdType::Player1)),
timeout_timer(std::make_unique<QTimer>()), poll_timer(std::make_unique<QTimer>()) {
ui->setupUi(this);
setFocusPolicy(Qt::ClickFocus);
@@ -36,14 +41,28 @@ ConfigureHotkeys::ConfigureHotkeys(Core::HID::HIDCore& hid_core, QWidget* parent
ui->hotkey_list->setModel(model);
ui->hotkey_list->header()->setStretchLastSection(false);
ui->hotkey_list->header()->setSectionResizeMode(name_column, QHeaderView::ResizeMode::Stretch);
ui->hotkey_list->header()->setMinimumSectionSize(150);
ui->hotkey_list->header()->setSectionResizeMode(name_column, QHeaderView::Interactive);
ui->hotkey_list->header()->setSectionResizeMode(hotkey_column, QHeaderView::Interactive);
ui->hotkey_list->header()->setSectionResizeMode(controller_column, QHeaderView::Stretch);
ui->hotkey_list->header()->setMinimumSectionSize(70);
connect(ui->button_restore_defaults, &QPushButton::clicked, this,
&ConfigureHotkeys::RestoreDefaults);
connect(ui->button_clear_all, &QPushButton::clicked, this, &ConfigureHotkeys::ClearAll);
controller = hid_core.GetEmulatedController(Core::HID::NpadIdType::Player1);
// Profile Management Connections
connect(ui->button_new_profile, &QPushButton::clicked, this,
&ConfigureHotkeys::OnCreateProfile);
connect(ui->button_delete_profile, &QPushButton::clicked, this,
&ConfigureHotkeys::OnDeleteProfile);
connect(ui->button_rename_profile, &QPushButton::clicked, this,
&ConfigureHotkeys::OnRenameProfile);
connect(ui->button_import_profile, &QPushButton::clicked, this,
&ConfigureHotkeys::OnImportProfile);
connect(ui->button_export_profile, &QPushButton::clicked, this,
&ConfigureHotkeys::OnExportProfile);
connect(ui->combo_box_profile, qOverload<int>(&QComboBox::currentIndexChanged), this,
&ConfigureHotkeys::OnProfileChanged);
connect(timeout_timer.get(), &QTimer::timeout, [this] {
const bool is_button_pressed = pressed_buttons != Core::HID::NpadButton::None ||
@@ -53,8 +72,8 @@ ConfigureHotkeys::ConfigureHotkeys(Core::HID::HIDCore& hid_core, QWidget* parent
connect(poll_timer.get(), &QTimer::timeout, [this] {
pressed_buttons |= controller->GetNpadButtons().raw;
pressed_home_button |= this->controller->GetHomeButtons().home != 0;
pressed_capture_button |= this->controller->GetCaptureButtons().capture != 0;
pressed_home_button |= controller->GetHomeButtons().home != 0;
pressed_capture_button |= controller->GetCaptureButtons().capture != 0;
if (pressed_buttons != Core::HID::NpadButton::None || pressed_home_button ||
pressed_capture_button) {
const QString button_name =
@@ -64,38 +83,177 @@ ConfigureHotkeys::ConfigureHotkeys(Core::HID::HIDCore& hid_core, QWidget* parent
model->setData(button_model_index, button_name);
}
});
RetranslateUI();
ui->hotkey_list->setContextMenuPolicy(Qt::CustomContextMenu);
// Populate profile list first
UpdateProfileList();
}
ConfigureHotkeys::~ConfigureHotkeys() = default;
void ConfigureHotkeys::Populate(const HotkeyRegistry& registry) {
for (const auto& group : registry.hotkey_groups) {
QString parent_item_data = QString::fromStdString(group.first);
auto* parent_item =
new QStandardItem(QCoreApplication::translate("Hotkeys", qPrintable(parent_item_data)));
parent_item->setEditable(false);
parent_item->setData(parent_item_data);
for (const auto& hotkey : group.second) {
QString hotkey_action_data = QString::fromStdString(hotkey.first);
auto* action = new QStandardItem(
QCoreApplication::translate("Hotkeys", qPrintable(hotkey_action_data)));
auto* keyseq =
new QStandardItem(hotkey.second.keyseq.toString(QKeySequence::NativeText));
auto* controller_keyseq =
new QStandardItem(QString::fromStdString(hotkey.second.controller_keyseq));
action->setEditable(false);
action->setData(hotkey_action_data);
keyseq->setEditable(false);
controller_keyseq->setEditable(false);
parent_item->appendRow({action, keyseq, controller_keyseq});
}
model->appendRow(parent_item);
void ConfigureHotkeys::Populate() {
const auto& profiles = profile_manager.GetProfiles();
const auto& current_profile_name = profiles.current_profile;
// Use default if current profile missing (safety)
std::vector<Hotkey::BackendShortcut> current_shortcuts;
if (profiles.profiles.count(current_profile_name)) {
current_shortcuts = profiles.profiles.at(current_profile_name);
} else if (profiles.profiles.count("Default")) {
current_shortcuts = profiles.profiles.at("Default");
}
// Map overrides for easy lookup: Key = Group + Name
std::map<std::pair<std::string, std::string>, Hotkey::BackendShortcut> overrides;
for (const auto& s : current_shortcuts) {
overrides[{s.group, s.name}] = s;
}
model->clear();
model->setColumnCount(3);
model->setHorizontalHeaderLabels({tr("Action"), tr("Hotkey"), tr("Controller Hotkey")});
for (const auto& [group_name, group_map] : registry.hotkey_groups) {
auto* parent_item = new QStandardItem(
QCoreApplication::translate("Hotkeys", qPrintable(QString::fromStdString(group_name))));
parent_item->setEditable(false);
parent_item->setData(QString::fromStdString(group_name));
model->appendRow(parent_item);
for (const auto& [action_name, hotkey] : group_map) {
// Determine values (Registry Default vs Profile Override)
QString keyseq_str = hotkey.keyseq.toString(QKeySequence::NativeText);
QString controller_keyseq_str = QString::fromStdString(hotkey.controller_keyseq);
if (overrides.count({group_name, action_name})) {
const auto& overridden = overrides.at({group_name, action_name});
keyseq_str = QKeySequence(QString::fromStdString(overridden.shortcut.keyseq))
.toString(QKeySequence::NativeText);
controller_keyseq_str =
QString::fromStdString(overridden.shortcut.controller_keyseq);
}
auto* action_item = new QStandardItem(QCoreApplication::translate(
"Hotkeys", qPrintable(QString::fromStdString(action_name))));
action_item->setEditable(false);
action_item->setData(QString::fromStdString(action_name));
auto* keyseq_item = new QStandardItem(keyseq_str);
// Store raw keyseq string logic?
// The system likely expects QKeySequence string format.
keyseq_item->setData(keyseq_str, Qt::UserRole);
keyseq_item->setEditable(false);
auto* controller_item = new QStandardItem(controller_keyseq_str);
controller_item->setEditable(false);
parent_item->appendRow({action_item, keyseq_item, controller_item});
}
if (group_name == "General" || group_name == "Main Window") {
ui->hotkey_list->expand(parent_item->index());
}
}
ui->hotkey_list->expandAll();
ui->hotkey_list->resizeColumnToContents(hotkey_column);
ui->hotkey_list->resizeColumnToContents(controller_column);
// Re-apply column sizing after model reset
ui->hotkey_list->header()->setStretchLastSection(false);
ui->hotkey_list->header()->setSectionResizeMode(name_column, QHeaderView::Interactive);
ui->hotkey_list->header()->setSectionResizeMode(hotkey_column, QHeaderView::Interactive);
ui->hotkey_list->header()->setSectionResizeMode(controller_column, QHeaderView::Stretch);
ui->hotkey_list->header()->setMinimumSectionSize(70);
ui->hotkey_list->setColumnWidth(name_column, 432);
ui->hotkey_list->setColumnWidth(hotkey_column, 240);
// Enforce fixed width for Restore Defaults button to prevent smudging
ui->button_restore_defaults->setFixedWidth(139);
}
void ConfigureHotkeys::UpdateProfileList() {
const QSignalBlocker blocker(ui->combo_box_profile);
ui->combo_box_profile->clear();
const auto& profiles = profile_manager.GetProfiles();
for (const auto& [name, val] : profiles.profiles) {
ui->combo_box_profile->addItem(QString::fromStdString(name));
}
ui->combo_box_profile->setCurrentText(QString::fromStdString(profiles.current_profile));
Populate();
}
void ConfigureHotkeys::OnCreateProfile() {
bool ok;
QString text = QInputDialog::getText(this, tr("Create Profile"), tr("Profile Name:"),
QLineEdit::Normal, QString(), &ok);
if (ok && !text.isEmpty()) {
if (profile_manager.CreateProfile(text.toStdString())) {
// New profile is empty. Fill with current defaults or copy current?
// "Defaults" logic usually implies defaults.
UpdateProfileList();
} else {
QMessageBox::warning(this, tr("Error"), tr("Failed to create profile."));
}
}
}
void ConfigureHotkeys::OnDeleteProfile() {
if (QMessageBox::question(this, tr("Delete Profile"),
tr("Are you sure you want to delete this profile?"),
QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) {
if (profile_manager.DeleteProfile(ui->combo_box_profile->currentText().toStdString())) {
UpdateProfileList();
} else {
QMessageBox::warning(this, tr("Error"), tr("Failed to delete profile."));
}
}
}
void ConfigureHotkeys::OnRenameProfile() {
bool ok;
QString current_name = ui->combo_box_profile->currentText();
QString text = QInputDialog::getText(this, tr("Rename Profile"), tr("New Name:"),
QLineEdit::Normal, current_name, &ok);
if (ok && !text.isEmpty()) {
if (profile_manager.RenameProfile(current_name.toStdString(), text.toStdString())) {
UpdateProfileList();
} else {
QMessageBox::warning(this, tr("Error"), tr("Failed to rename profile."));
}
}
}
void ConfigureHotkeys::OnImportProfile() {
QString fileName = QFileDialog::getOpenFileName(this, tr("Import Profile"), QString(),
tr("JSON Files (*.json)"));
if (!fileName.isEmpty()) {
if (profile_manager.ImportProfile(fileName.toStdString())) {
UpdateProfileList();
} else {
QMessageBox::warning(this, tr("Error"), tr("Failed to import profile."));
}
}
}
void ConfigureHotkeys::OnExportProfile() {
QString current = ui->combo_box_profile->currentText();
QString fileName = QFileDialog::getSaveFileName(
this, tr("Export Profile"), current + QStringLiteral(".json"), tr("JSON Files (*.json)"));
if (!fileName.isEmpty()) {
if (!profile_manager.ExportProfile(current.toStdString(), fileName.toStdString())) {
QMessageBox::warning(this, tr("Error"), tr("Failed to export profile."));
}
}
}
void ConfigureHotkeys::OnProfileChanged(int index) {
if (index == -1)
return;
const std::string name = ui->combo_box_profile->currentText().toStdString();
profile_manager.SetCurrentProfile(name);
Populate();
}
void ConfigureHotkeys::changeEvent(QEvent* event) {
@@ -108,6 +266,7 @@ void ConfigureHotkeys::changeEvent(QEvent* event) {
void ConfigureHotkeys::RetranslateUI() {
ui->retranslateUi(this);
ui->label_profile->setText(tr("Hotkey Profile:"));
model->setHorizontalHeaderLabels({tr("Action"), tr("Hotkey"), tr("Controller Hotkey")});
for (int key_id = 0; key_id < model->rowCount(); key_id++) {
@@ -307,28 +466,92 @@ std::pair<bool, QString> ConfigureHotkeys::IsUsedControllerKey(const QString& ke
return std::make_pair(false, QString());
}
void ConfigureHotkeys::ApplyConfiguration(HotkeyRegistry& registry) {
for (int key_id = 0; key_id < model->rowCount(); key_id++) {
const QStandardItem* parent = model->item(key_id, 0);
for (int key_column_id = 0; key_column_id < parent->rowCount(); key_column_id++) {
const QStandardItem* action = parent->child(key_column_id, name_column);
const QStandardItem* keyseq = parent->child(key_column_id, hotkey_column);
const QStandardItem* controller_keyseq =
parent->child(key_column_id, controller_column);
for (auto& [group, sub_actions] : registry.hotkey_groups) {
if (group != parent->data().toString().toStdString())
continue;
for (auto& [action_name, hotkey] : sub_actions) {
if (action_name != action->data().toString().toStdString())
continue;
hotkey.keyseq = QKeySequence(keyseq->text());
hotkey.controller_keyseq = controller_keyseq->text().toStdString();
void ConfigureHotkeys::ApplyConfiguration() {
// 1. Update the runtime UISettings (Registry)
// We iterate the model and match against UISettings::values.shortcuts
const auto& children = model->invisibleRootItem();
for (int group_row = 0; group_row < children->rowCount(); group_row++) {
const auto& group_item = children->child(group_row);
for (int row = 0; row < group_item->rowCount(); row++) {
const auto& action_item = group_item->child(row, name_column);
const auto& keyseq_item = group_item->child(row, hotkey_column);
const auto& controller_item = group_item->child(row, controller_column);
const std::string group_name = group_item->data().toString().toStdString();
const std::string action_name = action_item->data().toString().toStdString();
// Update UISettings (Runtime)
for (auto& s : UISettings::values.shortcuts) {
if (s.group == group_name && s.name == action_name) {
s.shortcut.keyseq = keyseq_item->text().toStdString();
s.shortcut.controller_keyseq = controller_item->text().toStdString();
}
}
}
}
registry.SaveHotkeys();
// 2. Update the ProfileManager (Storage)
const std::string current_profile_name = profile_manager.GetProfiles().current_profile;
// We need to modify the profile in the manager. GetProfiles() returns const ref.
// We need a method to UpdateProfile or we need to cast away const (bad) or rely on reference if
// GetProfiles wasn't const. The previous implementation of GetProfiles was const.
// ProfileManager needs a method `UpdateCurrentProfile(vector<BackendShortcut>)`?
// Or we can just use the internal map if we were friends.
// Let's add `profile_manager.SetProfileContent(name, shortcuts)` or similar?
// Or `profile_manager.Save()` saving what?
// The manager loads from disk.
// We need to push the UI state BACK to the manager.
// Reconstructing BackendShortcuts from UI
std::vector<Hotkey::BackendShortcut> new_shortcuts;
for (int group_row = 0; group_row < children->rowCount(); group_row++) {
const auto& group_item = children->child(group_row);
for (int row = 0; row < group_item->rowCount(); row++) {
const auto& action_item = group_item->child(row, name_column);
const auto& keyseq_item = group_item->child(row, hotkey_column);
const auto& controller_item = group_item->child(row, controller_column);
Hotkey::BackendShortcut s;
s.group = group_item->data().toString().toStdString();
s.name = action_item->data().toString().toStdString();
s.shortcut.keyseq = keyseq_item->text().toStdString();
s.shortcut.controller_keyseq = controller_item->text().toStdString();
// Context/Repeat need to be preserved from UserRole data
s.shortcut.context =
keyseq_item->data(Qt::UserRole + 2).toInt(); // Assuming we stored context there?
// Wait, Populate stored context at UserRole+2 on KEYSEQ item?
// Populate logic:
// keyseq->setData(..., UserRole);
// no context stored in Populate step 3095?
// "Extra metadata if needed... Existing code didn't seem to store them"
// We should fix Populate to store context if we need to save it back.
// Or we just lookup from UISettings defaults if missing?
// For now, let's grab from UISettings since we just updated it or match it.
for (const auto& original : UISettings::values.shortcuts) {
if (original.group == s.group && original.name == s.name) {
s.shortcut.context = original.shortcut.context;
s.shortcut.repeat = original.shortcut.repeat;
break;
}
}
new_shortcuts.push_back(s);
}
}
// We need a way to set these shortcuts back to the manager.
// I can add a method to ProfileManager `SetProfileShortcuts(name, shortcuts)`
// Or I can just manually modify the JSON file? No, utilize the class.
// I'll add the method to ProfileManager in next step if it doesn't exist.
// For now, I'll assume it exists or I will add it.
// Actually, `SetProfileShortcuts` doesn't exist. I should add it.
// But I'm in the middle of replacing ApplyConfiguration.
// I'll comment it out or use a friend method? No.
// I will call `profile_manager.SetProfileShortcuts(current_profile_name, new_shortcuts);` and
// implement it immediately after.
profile_manager.SetProfileShortcuts(current_profile_name, new_shortcuts);
profile_manager.Save();
}
void ConfigureHotkeys::RestoreDefaults() {
@@ -339,11 +562,12 @@ void ConfigureHotkeys::RestoreDefaults() {
QStandardItem* parent = model->item(group_row, 0);
for (int child_row = 0; child_row < parent->rowCount(); ++child_row) {
// This bounds check prevents a crash, and this was originally a safety check w/ showed if it failed,
// however with further testing w/ restoring default functionality, it would work yet still display, so was changed to a regular Success!.
// This bounds check prevents a crash, and this was originally a safety check w/ showed
// if it failed, however with further testing w/ restoring default functionality, it
// would work yet still display, so was changed to a regular Success!.
if (hotkey_index >= total_default_hotkeys) {
QMessageBox::information(this, tr("Success!"),
tr("Citron's Default hotkey entries have been restored!"));
tr("Citron's Default hotkey entries have been restored!"));
return;
}

View File

@@ -1,11 +1,7 @@
// SPDX-FileCopyrightText: 2017 Citra Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <memory>
#include <QStandardItemModel>
#include <QWidget>
#include "citron/hotkey_profile_manager.h"
namespace Common {
class ParamPackage;
@@ -28,21 +24,31 @@ class ConfigureHotkeys : public QWidget {
Q_OBJECT
public:
explicit ConfigureHotkeys(Core::HID::HIDCore& hid_core_, QWidget* parent = nullptr);
explicit ConfigureHotkeys(HotkeyRegistry& registry, Core::HID::HIDCore& hid_core_,
QWidget* parent = nullptr);
~ConfigureHotkeys() override;
void ApplyConfiguration(HotkeyRegistry& registry);
void ApplyConfiguration();
/**
* Populates the hotkey list widget using data from the provided registry.
* Populates the hotkey list widget using data from the provided profiles.
* Called every time the Configure dialog is opened.
* @param registry The HotkeyRegistry whose data is used to populate the list.
* @param profiles The UserHotkeyProfiles used to populate the list.
*/
void Populate(const HotkeyRegistry& registry);
void Populate();
private slots:
void OnCreateProfile();
void OnDeleteProfile();
void OnRenameProfile();
void OnImportProfile();
void OnExportProfile();
void OnProfileChanged(int index);
private:
void changeEvent(QEvent* event) override;
void RetranslateUI();
void UpdateProfileList();
void Configure(QModelIndex index);
void ConfigureController(QModelIndex index);
@@ -59,6 +65,8 @@ private:
QString GetButtonCombinationName(Core::HID::NpadButton button, bool home, bool capture) const;
std::unique_ptr<Ui::ConfigureHotkeys> ui;
Hotkey::ProfileManager profile_manager;
HotkeyRegistry& registry;
QStandardItemModel* model;

View File

@@ -18,16 +18,65 @@
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<layout class="QHBoxLayout" name="horizontalLayout_ProfileSelect">
<item>
<widget class="QLabel" name="label_2">
<widget class="QLabel" name="label_profile">
<property name="text">
<string>Double-click on a binding to change it.</string>
<string>Hotkey Profile:</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<widget class="QComboBox" name="combo_box_profile">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout_Actions">
<item>
<widget class="QPushButton" name="button_new_profile">
<property name="text">
<string>New</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_delete_profile">
<property name="text">
<string>Delete</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_rename_profile">
<property name="text">
<string>Rename</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_import_profile">
<property name="text">
<string>Import</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="button_export_profile">
<property name="text">
<string>Export</string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_Buttons">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
@@ -48,6 +97,24 @@
</item>
<item>
<widget class="QPushButton" name="button_restore_defaults">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>139</width>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>139</width>
<height>16777215</height>
</size>
</property>
<property name="text">
<string>Restore Defaults</string>
</property>
@@ -55,6 +122,13 @@
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="label_2">
<property name="text">
<string>Double-click on a binding to change it.</string>
</property>
</widget>
</item>
<item>
<layout class="QVBoxLayout" name="verticalLayout_2">
<item>

View File

@@ -0,0 +1,239 @@
// SPDX-FileCopyrightText: 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#include <algorithm>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMap>
#include <QString>
#include "citron/hotkey_profile_manager.h"
#include "common/fs/path_util.h"
#include "common/logging/log.h"
namespace Hotkey {
ProfileManager::ProfileManager() {
Load();
}
ProfileManager::~ProfileManager() = default;
static std::string GetSaveFilePath() {
const auto save_dir =
Common::FS::GetCitronPath(Common::FS::CitronPath::ConfigDir); // Saved in ConfigDir now
return Common::FS::PathToUTF8String(save_dir / "hotkey_profiles.json");
}
// JSON Serialization Helpers
static QJsonObject SerializeShortcut(const BackendShortcut& shortcut) {
QJsonObject obj;
obj[QStringLiteral("name")] = QString::fromStdString(shortcut.name);
obj[QStringLiteral("group")] = QString::fromStdString(shortcut.group);
obj[QStringLiteral("keyseq")] = QString::fromStdString(shortcut.shortcut.keyseq);
obj[QStringLiteral("controller_keyseq")] =
QString::fromStdString(shortcut.shortcut.controller_keyseq);
obj[QStringLiteral("context")] = shortcut.shortcut.context;
obj[QStringLiteral("repeat")] = shortcut.shortcut.repeat;
return obj;
}
static BackendShortcut DeserializeShortcut(const QJsonObject& obj) {
BackendShortcut s;
s.name = obj[QStringLiteral("name")].toString().toStdString();
s.group = obj[QStringLiteral("group")].toString().toStdString();
s.shortcut.keyseq = obj[QStringLiteral("keyseq")].toString().toStdString();
s.shortcut.controller_keyseq =
obj[QStringLiteral("controller_keyseq")].toString().toStdString();
s.shortcut.context = obj[QStringLiteral("context")].toInt();
s.shortcut.repeat = obj[QStringLiteral("repeat")].toBool();
return s;
}
void ProfileManager::Load() {
const auto path = GetSaveFilePath();
QFile file(QString::fromStdString(path));
if (!file.open(QIODevice::ReadOnly)) {
LOG_INFO(Config, "hotkey_profiles.json not found, creating new.");
return;
}
const QByteArray data = file.readAll();
const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonObject root = doc.object();
profiles.profiles.clear();
if (root.contains(QStringLiteral("current_profile"))) {
profiles.current_profile = root[QStringLiteral("current_profile")].toString().toStdString();
}
if (root.contains(QStringLiteral("profiles"))) {
const QJsonObject profiles_obj = root[QStringLiteral("profiles")].toObject();
for (auto it = profiles_obj.begin(); it != profiles_obj.end(); ++it) {
const QString profile_name = it.key();
const QJsonArray shortcuts_arr = it.value().toArray();
std::vector<BackendShortcut> shortcuts;
for (const auto& val : shortcuts_arr) {
shortcuts.push_back(DeserializeShortcut(val.toObject()));
}
profiles.profiles[profile_name.toStdString()] = shortcuts;
}
}
// Ensure default profile exists
if (profiles.profiles.empty()) {
profiles.profiles["Default"] = {};
}
}
void ProfileManager::Save() {
const auto path = GetSaveFilePath();
QFile file(QString::fromStdString(path));
if (!file.open(QIODevice::WriteOnly)) {
LOG_ERROR(Config, "Failed to open hotkey_profiles.json for writing.");
return;
}
QJsonObject root;
root[QStringLiteral("current_profile")] = QString::fromStdString(profiles.current_profile);
QJsonObject profiles_obj;
for (const auto& [name, shortcuts] : profiles.profiles) {
QJsonArray shortcuts_arr;
for (const auto& s : shortcuts) {
shortcuts_arr.append(SerializeShortcut(s));
}
profiles_obj[QString::fromStdString(name)] = shortcuts_arr;
}
root[QStringLiteral("profiles")] = profiles_obj;
file.write(QJsonDocument(root).toJson());
}
bool ProfileManager::CreateProfile(const std::string& profile_name) {
if (profile_name.empty())
return false;
if (profiles.profiles.size() >= MAX_PROFILES) {
return false;
}
if (profiles.profiles.count(profile_name)) {
return false; // Already exists
}
profiles.profiles[profile_name] = {}; // Create empty, populated later by UI
Save();
return true;
}
bool ProfileManager::DeleteProfile(const std::string& profile_name) {
if (profile_name == "Default")
return false; // Cannot delete default
if (profiles.profiles.erase(profile_name)) {
if (profiles.current_profile == profile_name) {
profiles.current_profile = "Default";
}
Save();
return true;
}
return false;
}
bool ProfileManager::RenameProfile(const std::string& old_name, const std::string& new_name) {
if (old_name == "Default")
return false; // Cannot rename default
if (new_name.empty())
return false;
if (!profiles.profiles.count(old_name))
return false;
if (profiles.profiles.count(new_name))
return false;
auto node = profiles.profiles.extract(old_name);
node.key() = new_name;
profiles.profiles.insert(std::move(node));
if (profiles.current_profile == old_name) {
profiles.current_profile = new_name;
}
Save();
return true;
}
bool ProfileManager::SetCurrentProfile(const std::string& profile_name) {
if (!profiles.profiles.count(profile_name))
return false;
profiles.current_profile = profile_name;
Save();
return true;
}
void ProfileManager::SetProfileShortcuts(const std::string& profile_name,
const std::vector<BackendShortcut>& shortcuts) {
if (profiles.profiles.count(profile_name)) {
profiles.profiles[profile_name] = shortcuts;
}
}
bool ProfileManager::ExportProfile(const std::string& profile_name, const std::string& file_path) {
if (!profiles.profiles.count(profile_name))
return false;
QJsonObject root;
root[QStringLiteral("name")] = QString::fromStdString(profile_name);
QJsonArray shortcuts_arr;
for (const auto& s : profiles.profiles.at(profile_name)) {
shortcuts_arr.append(SerializeShortcut(s));
}
root[QStringLiteral("shortcuts")] = shortcuts_arr;
QFile file(QString::fromStdString(file_path));
if (!file.open(QIODevice::WriteOnly))
return false;
file.write(QJsonDocument(root).toJson());
return true;
}
bool ProfileManager::ImportProfile(const std::string& file_path) {
QFile file(QString::fromStdString(file_path));
if (!file.open(QIODevice::ReadOnly))
return false;
const QByteArray data = file.readAll();
const QJsonDocument doc = QJsonDocument::fromJson(data);
const QJsonObject root = doc.object();
if (!root.contains(QStringLiteral("name")) || !root.contains(QStringLiteral("shortcuts")))
return false;
std::string profile_name = root[QStringLiteral("name")].toString().toStdString();
// Handle name collision
if (profiles.profiles.count(profile_name)) {
profile_name += " (Imported)";
}
if (profiles.profiles.size() >= MAX_PROFILES)
return false;
std::vector<BackendShortcut> shortcuts;
const QJsonArray arr = root[QStringLiteral("shortcuts")].toArray();
for (const auto& val : arr) {
shortcuts.push_back(DeserializeShortcut(val.toObject()));
}
profiles.profiles[profile_name] = shortcuts;
Save();
return true;
}
} // namespace Hotkey

View File

@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2026 citron Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <map>
#include <string>
#include <vector>
#include <QJsonArray>
#include <QJsonObject>
#include "common/uuid.h"
namespace Hotkey {
// A backend-only representation of a shortcut, free of any Qt types.
struct BackendContextualShortcut {
std::string keyseq;
std::string controller_keyseq;
int context;
bool repeat;
};
struct BackendShortcut {
std::string name;
std::string group;
BackendContextualShortcut shortcut;
};
// Contains all hotkey profile data for a single user
struct UserHotkeyProfiles {
std::map<std::string, std::vector<BackendShortcut>> profiles;
std::string current_profile = "Default";
};
class ProfileManager {
public:
ProfileManager();
~ProfileManager();
// Profile Access
const UserHotkeyProfiles& GetProfiles() const {
return profiles;
}
// Profile Management
bool CreateProfile(const std::string& profile_name);
bool DeleteProfile(const std::string& profile_name);
bool RenameProfile(const std::string& old_name, const std::string& new_name);
bool SetCurrentProfile(const std::string& profile_name);
void SetProfileShortcuts(const std::string& profile_name,
const std::vector<BackendShortcut>& shortcuts);
// Import/Export
bool ExportProfile(const std::string& profile_name, const std::string& file_path);
bool ImportProfile(const std::string& file_path);
// IO
void Load();
void Save();
// Constants
static constexpr size_t MAX_PROFILES = 5;
private:
// Global profiles data
UserHotkeyProfiles profiles;
};
} // namespace Hotkey