From c5ec4bd7f964bf1cc4077b99c428dd20561dfc1d Mon Sep 17 00:00:00 2001 From: collecting Date: Wed, 4 Feb 2026 08:29:10 -0500 Subject: [PATCH] feat(hotkeys): Introduce Hotkey_Profile_Manager --- src/citron/CMakeLists.txt | 2 + src/citron/configuration/configure_dialog.cpp | 183 ++++++----- .../configuration/configure_hotkeys.cpp | 305 ++++++++++++++---- src/citron/configuration/configure_hotkeys.h | 28 +- src/citron/configuration/configure_hotkeys.ui | 82 ++++- src/citron/hotkey_profile_manager.cpp | 239 ++++++++++++++ src/citron/hotkey_profile_manager.h | 69 ++++ 7 files changed, 760 insertions(+), 148 deletions(-) create mode 100644 src/citron/hotkey_profile_manager.cpp create mode 100644 src/citron/hotkey_profile_manager.h diff --git a/src/citron/CMakeLists.txt b/src/citron/CMakeLists.txt index cb028c618..da419989d 100644 --- a/src/citron/CMakeLists.txt +++ b/src/citron/CMakeLists.txt @@ -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 diff --git a/src/citron/configuration/configure_dialog.cpp b/src/citron/configuration/configure_dialog.cpp index fe8a679b8..e1f425d6b 100644 --- a/src/citron/configuration/configure_dialog.cpp +++ b/src/citron/configuration/configure_dialog.cpp @@ -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 #include #include @@ -20,17 +19,12 @@ #include #include #include -#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(system_.HIDCore(), this)}, + hotkeys_tab{std::make_unique(registry, system_.HIDCore(), this)}, input_tab{std::make_unique(system_, this)}, network_tab{std::make_unique(system_, this)}, profile_tab{std::make_unique(system_, this)}, @@ -103,8 +103,8 @@ ConfigureDialog::ConfigureDialog(QWidget* parent, HotkeyRegistry& registry_, web_tab{std::make_unique(this)} { if (auto* main_window = qobject_cast(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(&QButtonGroup::idClicked), this, &ConfigureDialog::AnimateTabSwitch); + connect(tab_button_group.get(), qOverload(&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(ui->buttonBox->graphicsEffect()); + auto* button_opacity_effect = + qobject_cast(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()) { diff --git a/src/citron/configuration/configure_hotkeys.cpp b/src/citron/configuration/configure_hotkeys.cpp index ac08b7527..1a18a099f 100644 --- a/src/citron/configuration/configure_hotkeys.cpp +++ b/src/citron/configuration/configure_hotkeys.cpp @@ -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 +#include #include #include #include @@ -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()), +ConfigureHotkeys::ConfigureHotkeys(HotkeyRegistry& registry_, Core::HID::HIDCore& hid_core, + QWidget* parent) + : QWidget(parent), ui(std::make_unique()), registry(registry_), + controller(new Core::HID::EmulatedController(Core::HID::NpadIdType::Player1)), timeout_timer(std::make_unique()), poll_timer(std::make_unique()) { 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(&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 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, 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(143); +} + +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,67 @@ std::pair 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)`? + // Or we can just use the internal map if we were friends. + // Reconstructing BackendShortcuts from UI + std::vector 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 + // 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); + } + } + + profile_manager.SetProfileShortcuts(current_profile_name, new_shortcuts); + profile_manager.Save(); } void ConfigureHotkeys::RestoreDefaults() { @@ -339,11 +537,10 @@ 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. 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; } diff --git a/src/citron/configuration/configure_hotkeys.h b/src/citron/configuration/configure_hotkeys.h index 20ea3b515..834b18c43 100644 --- a/src/citron/configuration/configure_hotkeys.h +++ b/src/citron/configuration/configure_hotkeys.h @@ -1,11 +1,7 @@ -// SPDX-FileCopyrightText: 2017 Citra Emulator Project -// SPDX-License-Identifier: GPL-2.0-or-later - -#pragma once - #include #include #include +#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; + Hotkey::ProfileManager profile_manager; + HotkeyRegistry& registry; QStandardItemModel* model; diff --git a/src/citron/configuration/configure_hotkeys.ui b/src/citron/configuration/configure_hotkeys.ui index a6902a5d8..c8945769f 100644 --- a/src/citron/configuration/configure_hotkeys.ui +++ b/src/citron/configuration/configure_hotkeys.ui @@ -18,16 +18,65 @@ - + - + - Double-click on a binding to change it. + Hotkey Profile: - + + + + 0 + 0 + + + + + + + + + + + + New + + + + + + + Delete + + + + + + + Rename + + + + + + + Import + + + + + + + Export + + + + + Qt::Horizontal @@ -48,6 +97,24 @@ + + + 0 + 0 + + + + + 139 + 0 + + + + + 139 + 16777215 + + Restore Defaults @@ -55,6 +122,13 @@ + + + + Double-click on a binding to change it. + + + diff --git a/src/citron/hotkey_profile_manager.cpp b/src/citron/hotkey_profile_manager.cpp new file mode 100644 index 000000000..ffd72d738 --- /dev/null +++ b/src/citron/hotkey_profile_manager.cpp @@ -0,0 +1,239 @@ +// SPDX-FileCopyrightText: 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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& 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 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 diff --git a/src/citron/hotkey_profile_manager.h b/src/citron/hotkey_profile_manager.h new file mode 100644 index 000000000..631d159ae --- /dev/null +++ b/src/citron/hotkey_profile_manager.h @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 citron Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#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> 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& 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