diff --git a/pcsx2-qt/CMakeLists.txt b/pcsx2-qt/CMakeLists.txt index ce069f59fe..c1ecf9e9c1 100644 --- a/pcsx2-qt/CMakeLists.txt +++ b/pcsx2-qt/CMakeLists.txt @@ -35,6 +35,9 @@ target_sources(pcsx2-qt PRIVATE SetupWizardDialog.h SetupWizardDialog.ui Themes.cpp + ToolbarCustomizationDialog.cpp + ToolbarCustomizationDialog.h + ToolbarCustomizationDialog.ui Translations.cpp QtHost.cpp QtHost.h diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index 905291c597..edd1634f17 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -20,6 +20,7 @@ #include "Settings/MemoryCardCreateDialog.h" #include "Tools/InputRecording/InputRecordingViewer.h" #include "Tools/InputRecording/NewInputRecordingDlg.h" +#include "ToolbarCustomizationDialog.h" #if !defined(__APPLE__) #include "ShortcutCreationDialog.h" @@ -92,6 +93,14 @@ const char* MainWindow::DISC_IMAGE_FILTER = QT_TRANSLATE_NOOP("MainWindow", "All "GZ Images (*.gz);;" "Block Dumps (*.dump)"); +const char* MainWindow::DEFAULT_TOOLBAR_LAYOUT = + "start_file,start_disc,start_bios,fullscreen_ui,," + "power_off,reset,pause,change_disc,," + "screenshot,video_capture,," + "load_state,save_state,," + "fullscreen,," + "settings,controller_settings,hotkey_settings"; + MainWindow* g_main_window = nullptr; // UI thread VM validity. @@ -208,7 +217,8 @@ void MainWindow::setupAdditionalUi() const bool toolbars_locked = Host::GetBaseBoolSettingValue("UI", "LockToolbar", false); m_ui.actionViewLockToolbar->setChecked(toolbars_locked); m_ui.toolBar->setMovable(!toolbars_locked); - m_ui.toolBar->setContextMenuPolicy(Qt::PreventContextMenu); + m_ui.toolBar->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_ui.toolBar, &QToolBar::customContextMenuRequested, this, &MainWindow::onToolbarContextMenuRequested); m_settings_toolbar_menu = new QMenu(m_ui.toolBar); m_settings_toolbar_menu->addAction(m_ui.actionSettings); @@ -230,6 +240,8 @@ void MainWindow::setupAdditionalUi() m_ui.actionGridViewShowFullTitles->setChecked(m_game_list_widget->getShowGridFullCoverTitles()); m_ui.mainContainer->addWidget(m_game_list_widget); + rebuildToolbar(); + updateEmulationActions(false, false, false); updateDisplayRelatedActions(false, false, false); @@ -529,6 +541,7 @@ void MainWindow::connectSignals() connect(m_ui.actionRescanAllGames, &QAction::triggered, [this]() { refreshGameList(true, true); }); connect(m_ui.actionViewToolbar, &QAction::toggled, this, &MainWindow::onViewToolbarActionToggled); connect(m_ui.actionViewLockToolbar, &QAction::toggled, this, &MainWindow::onViewLockToolbarActionToggled); + connect(m_ui.actionCustomizeToolbar, &QAction::triggered, this, &MainWindow::onCustomizeToolbarTriggered); connect(m_ui.actionViewStatusBar, &QAction::toggled, this, &MainWindow::onViewStatusBarActionToggled); connect(m_ui.actionViewGameList, &QAction::triggered, this, &MainWindow::onViewGameListActionTriggered); connect(m_ui.actionViewGameGrid, &QAction::triggered, this, &MainWindow::onViewGameGridActionTriggered); @@ -2038,6 +2051,83 @@ void MainWindow::onViewLockToolbarActionToggled(bool checked) m_ui.toolBar->setMovable(!checked); } +static std::vector getToolbarActionRegistry(const Ui::MainWindow& ui) +{ + return { + {"start_file", ui.actionToolbarStartFile}, + {"start_disc", ui.actionToolbarStartDisc}, + {"start_bios", ui.actionToolbarStartBios}, + {"fullscreen_ui", ui.actionToolbarStartFullscreenUI}, + {"power_off", ui.actionToolbarPowerOff}, + {"reset", ui.actionToolbarReset}, + {"pause", ui.actionToolbarPause}, + {"change_disc", ui.actionToolbarChangeDisc}, + {"screenshot", ui.actionToolbarScreenshot}, + {"video_capture", ui.actionVideoCapture}, + {"load_state", ui.actionToolbarLoadState}, + {"save_state", ui.actionToolbarSaveState}, + {"fullscreen", ui.actionToolbarFullscreen}, + {"settings", ui.actionToolbarSettings}, + {"game_settings", ui.actionViewGameProperties}, + {"controller_settings", ui.actionToolbarControllerSettings}, + {"hotkey_settings", ui.actionToolbarHotkeySettings}, + {"debugger", ui.actionDebugger}, + }; +} + +void MainWindow::rebuildToolbar() +{ + std::string layout_str = Host::GetBaseStringSettingValue("UI", "ToolbarItems", DEFAULT_TOOLBAR_LAYOUT); + if (layout_str.empty()) + layout_str = DEFAULT_TOOLBAR_LAYOUT; + + const auto registry = getToolbarActionRegistry(m_ui); + QHash action_map; + for (const auto& item : registry) + { + if (item.action) + action_map.insert(QString::fromUtf8(item.id), item.action); + } + + m_ui.toolBar->clear(); + + const QStringList items = QString::fromStdString(layout_str).split(QLatin1Char(','), Qt::KeepEmptyParts); + for (const QString& item_id : items) + { + const QString trimmed_id = item_id.trimmed(); + if (trimmed_id.isEmpty()) + { + m_ui.toolBar->addSeparator(); + } + else + { + if (QAction* act = action_map.value(trimmed_id, nullptr); act != nullptr) + { + m_ui.toolBar->addAction(act); + } + } + } + + updateEmulationActions(s_vm_valid, s_vm_valid, false); +} + +void MainWindow::onCustomizeToolbarTriggered() +{ + ToolbarCustomizationDialog dlg(this, getToolbarActionRegistry(m_ui)); + if (dlg.exec() == QDialog::Accepted) + rebuildToolbar(); +} + +void MainWindow::onToolbarContextMenuRequested(const QPoint& pos) +{ + QMenu menu(m_ui.toolBar); + + menu.addAction(m_ui.actionViewLockToolbar); + menu.addAction(m_ui.actionCustomizeToolbar); + + menu.exec(m_ui.toolBar->mapToGlobal(pos)); +} + void MainWindow::onViewStatusBarActionToggled(bool checked) { Host::SetBaseBoolSettingValue("UI", "ShowStatusBar", checked); diff --git a/pcsx2-qt/MainWindow.h b/pcsx2-qt/MainWindow.h index 26ffb1ec95..0c3a799bb5 100644 --- a/pcsx2-qt/MainWindow.h +++ b/pcsx2-qt/MainWindow.h @@ -83,11 +83,15 @@ public: /// Default filter for opening a disc image. static const char* DISC_IMAGE_FILTER; + /// Default layout for the toolbar. + static const char* DEFAULT_TOOLBAR_LAYOUT; + public: MainWindow(); ~MainWindow(); void initialize(); + void rebuildToolbar(); void connectVMThreadSignals(EmuThread* thread); void startupUpdateCheck(); void resetSettings(bool ui); @@ -175,6 +179,8 @@ private Q_SLOTS: void onFullscreenUIStateChange(bool running); void onViewToolbarActionToggled(bool checked); void onViewLockToolbarActionToggled(bool checked); + void onCustomizeToolbarTriggered(); + void onToolbarContextMenuRequested(const QPoint& pos); void onViewStatusBarActionToggled(bool checked); void onViewGameListActionTriggered(); void onViewGameGridActionTriggered(); diff --git a/pcsx2-qt/MainWindow.ui b/pcsx2-qt/MainWindow.ui index c284fd2a83..9bf5d4e1af 100644 --- a/pcsx2-qt/MainWindow.ui +++ b/pcsx2-qt/MainWindow.ui @@ -164,6 +164,7 @@ + @@ -735,6 +736,11 @@ Loc&k Toolbar + + + &Customize Toolbar... + + true diff --git a/pcsx2-qt/ToolbarCustomizationDialog.cpp b/pcsx2-qt/ToolbarCustomizationDialog.cpp new file mode 100644 index 0000000000..da65384bdc --- /dev/null +++ b/pcsx2-qt/ToolbarCustomizationDialog.cpp @@ -0,0 +1,210 @@ +// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +#include "ToolbarCustomizationDialog.h" +#include "MainWindow.h" +#include "QtHost.h" + +ToolbarCustomizationDialog::ToolbarCustomizationDialog(QWidget* parent, std::vector action_registry) + : QDialog(parent) + , m_action_registry(std::move(action_registry)) +{ + m_ui.setupUi(this); + setWindowIcon(QtHost::GetAppIcon()); + + m_ui.currentList->setDragDropMode(QAbstractItemView::InternalMove); + m_ui.currentList->setDefaultDropAction(Qt::MoveAction); + + std::string layout_str = Host::GetBaseStringSettingValue("UI", "ToolbarItems", MainWindow::DEFAULT_TOOLBAR_LAYOUT); + if (layout_str.empty()) + layout_str = MainWindow::DEFAULT_TOOLBAR_LAYOUT; + populateFromLayout(QString::fromStdString(layout_str)); + + connect(m_ui.addButton, &QPushButton::clicked, this, &ToolbarCustomizationDialog::onAddClicked); + connect(m_ui.removeButton, &QPushButton::clicked, this, &ToolbarCustomizationDialog::onRemoveClicked); + connect(m_ui.moveUpButton, &QPushButton::clicked, this, &ToolbarCustomizationDialog::onMoveUpClicked); + connect(m_ui.moveDownButton, &QPushButton::clicked, this, &ToolbarCustomizationDialog::onMoveDownClicked); + connect(m_ui.addSeparatorButton, &QPushButton::clicked, this, &ToolbarCustomizationDialog::onAddSeparatorClicked); + connect(m_ui.buttonBox->button(QDialogButtonBox::RestoreDefaults), &QPushButton::clicked, this, + &ToolbarCustomizationDialog::onRestoreDefaultsClicked); + + connect(m_ui.availableList, &QListWidget::itemDoubleClicked, this, &ToolbarCustomizationDialog::onAddClicked); + connect(m_ui.currentList, &QListWidget::itemDoubleClicked, this, &ToolbarCustomizationDialog::onRemoveClicked); + + connect(m_ui.availableList, &QListWidget::itemSelectionChanged, this, &ToolbarCustomizationDialog::updateButtonStates); + connect(m_ui.currentList, &QListWidget::itemSelectionChanged, this, &ToolbarCustomizationDialog::updateButtonStates); + + connect(m_ui.buttonBox, &QDialogButtonBox::accepted, this, &ToolbarCustomizationDialog::onAccepted); + connect(m_ui.buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + updateButtonStates(); +} + +ToolbarCustomizationDialog::~ToolbarCustomizationDialog() = default; + +void ToolbarCustomizationDialog::populateFromLayout(const QString& layout_str) +{ + m_ui.availableList->clear(); + m_ui.currentList->clear(); + + const QStringList items = layout_str.split(QLatin1Char(','), Qt::KeepEmptyParts); + QSet added_ids; + + QHash registry_map; + for (const ToolbarActionInfo& info : m_action_registry) + registry_map.insert(QString::fromUtf8(info.id), &info); + + for (const QString& item_id : items) + { + const QString trimmed = item_id.trimmed(); + if (trimmed.isEmpty()) + { + QListWidgetItem* item = new QListWidgetItem(tr("--- Separator ---"), m_ui.currentList); + item->setData(Qt::UserRole, QString()); + } + else + { + auto it = registry_map.find(trimmed); + if (it != registry_map.end()) + { + const ToolbarActionInfo* info = it.value(); + const QIcon icon = info->action ? info->action->icon() : QIcon(); + const QString text = info->action ? info->action->iconText() : QString(); + QListWidgetItem* item = new QListWidgetItem(icon, text, m_ui.currentList); + item->setData(Qt::UserRole, QString::fromUtf8(info->id)); + added_ids.insert(QString::fromUtf8(info->id)); + } + } + } + + for (const ToolbarActionInfo& info : m_action_registry) + { + if (!added_ids.contains(QString::fromUtf8(info.id))) + { + const QIcon icon = info.action ? info.action->icon() : QIcon(); + const QString text = info.action ? info.action->iconText() : QString(); + QListWidgetItem* item = new QListWidgetItem(icon, text, m_ui.availableList); + item->setData(Qt::UserRole, QString::fromUtf8(info.id)); + } + } + + updateButtonStates(); +} + +void ToolbarCustomizationDialog::onRestoreDefaultsClicked() +{ + populateFromLayout(QString::fromUtf8(MainWindow::DEFAULT_TOOLBAR_LAYOUT)); +} + +void ToolbarCustomizationDialog::onAddClicked() +{ + QListWidgetItem* taken = m_ui.availableList->takeItem(m_ui.availableList->currentRow()); + if (!taken) + return; + + int target_row = m_ui.currentList->currentRow(); + if (target_row < 0) + target_row = m_ui.currentList->count(); + else + target_row++; + + m_ui.currentList->insertItem(target_row, taken); + m_ui.currentList->setCurrentRow(target_row); + updateButtonStates(); +} + +void ToolbarCustomizationDialog::onRemoveClicked() +{ + QListWidgetItem* current = m_ui.currentList->currentItem(); + if (!current) + return; + + const int row = m_ui.currentList->row(current); + const QString id = current->data(Qt::UserRole).toString(); + + if (id.isEmpty()) + { + delete m_ui.currentList->takeItem(row); + } + else + { + QListWidgetItem* taken = m_ui.currentList->takeItem(row); + m_ui.availableList->addItem(taken); + m_ui.availableList->setCurrentItem(taken); + } + + updateButtonStates(); +} + +void ToolbarCustomizationDialog::onMoveUpClicked() +{ + const int row = m_ui.currentList->currentRow(); + if (row <= 0) + return; + + QListWidgetItem* item = m_ui.currentList->takeItem(row); + m_ui.currentList->insertItem(row - 1, item); + m_ui.currentList->setCurrentRow(row - 1); + updateButtonStates(); +} + +void ToolbarCustomizationDialog::onMoveDownClicked() +{ + const int row = m_ui.currentList->currentRow(); + if (row < 0 || row >= m_ui.currentList->count() - 1) + return; + + QListWidgetItem* item = m_ui.currentList->takeItem(row); + m_ui.currentList->insertItem(row + 1, item); + m_ui.currentList->setCurrentRow(row + 1); + updateButtonStates(); +} + +void ToolbarCustomizationDialog::onAddSeparatorClicked() +{ + int target_row = m_ui.currentList->currentRow(); + if (target_row < 0) + target_row = m_ui.currentList->count(); + else + target_row++; + + QListWidgetItem* item = new QListWidgetItem(tr("--- Separator ---")); + item->setData(Qt::UserRole, QString()); + m_ui.currentList->insertItem(target_row, item); + m_ui.currentList->setCurrentRow(target_row); + updateButtonStates(); +} + +void ToolbarCustomizationDialog::onAccepted() +{ + QStringList items; + for (int i = 0; i < m_ui.currentList->count(); ++i) + { + const QListWidgetItem* item = m_ui.currentList->item(i); + items.append(item->data(Qt::UserRole).toString()); + } + + const QString layout_str = items.join(QLatin1Char(',')); + if (layout_str == QLatin1StringView(MainWindow::DEFAULT_TOOLBAR_LAYOUT)) + Host::RemoveBaseSettingValue("UI", "ToolbarItems"); + else + Host::SetBaseStringSettingValue("UI", "ToolbarItems", layout_str.toUtf8().constData()); + + Host::CommitBaseSettingChanges(); + accept(); +} + +void ToolbarCustomizationDialog::updateButtonStates() +{ + const bool has_available_selection = (m_ui.availableList->currentItem() != nullptr); + const bool has_current_selection = (m_ui.currentList->currentItem() != nullptr); + const int current_row = m_ui.currentList->currentRow(); + const int current_count = m_ui.currentList->count(); + + m_ui.addButton->setEnabled(has_available_selection); + m_ui.removeButton->setEnabled(has_current_selection); + m_ui.moveUpButton->setEnabled(has_current_selection && current_row > 0); + m_ui.moveDownButton->setEnabled(has_current_selection && current_row >= 0 && current_row < current_count - 1); +} + +#include "moc_ToolbarCustomizationDialog.cpp" diff --git a/pcsx2-qt/ToolbarCustomizationDialog.h b/pcsx2-qt/ToolbarCustomizationDialog.h new file mode 100644 index 0000000000..fc8f6ddeea --- /dev/null +++ b/pcsx2-qt/ToolbarCustomizationDialog.h @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2002-2026 PCSX2 Dev Team +// SPDX-License-Identifier: GPL-3.0+ + +#pragma once + +#include "ui_ToolbarCustomizationDialog.h" + +#include + +class QAction; + +struct ToolbarActionInfo +{ + const char* id = nullptr; + QAction* action = nullptr; +}; + +class ToolbarCustomizationDialog final : public QDialog +{ + Q_OBJECT + +public: + explicit ToolbarCustomizationDialog(QWidget* parent, std::vector action_registry); + ~ToolbarCustomizationDialog() override; + +private Q_SLOTS: + void onAddClicked(); + void onRemoveClicked(); + void onMoveUpClicked(); + void onMoveDownClicked(); + void onAddSeparatorClicked(); + void onRestoreDefaultsClicked(); + void onAccepted(); + void updateButtonStates(); + +private: + void populateFromLayout(const QString& layout_str); + + std::vector m_action_registry; + Ui::ToolbarCustomizationDialog m_ui; +}; diff --git a/pcsx2-qt/ToolbarCustomizationDialog.ui b/pcsx2-qt/ToolbarCustomizationDialog.ui new file mode 100644 index 0000000000..11900728df --- /dev/null +++ b/pcsx2-qt/ToolbarCustomizationDialog.ui @@ -0,0 +1,196 @@ + + + ToolbarCustomizationDialog + + + + 0 + 0 + 600 + 420 + + + + Customize Toolbar + + + + + + Select actions to display on the toolbar and arrange their order. + + + + + + + + + + + Available Actions: + + + availableList + + + + + + + + 24 + 24 + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Add + + + + + + + + + + Remove + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + Current Toolbar: + + + currentList + + + + + + + + 24 + 24 + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + Move Up + + + + + + + + + + Move Down + + + + + + + + + + Add Separator + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok|QDialogButtonBox::StandardButton::RestoreDefaults + + + + + + + + + + diff --git a/pcsx2-qt/pcsx2-qt.vcxproj b/pcsx2-qt/pcsx2-qt.vcxproj index 6ac1266572..802a1e9578 100644 --- a/pcsx2-qt/pcsx2-qt.vcxproj +++ b/pcsx2-qt/pcsx2-qt.vcxproj @@ -110,6 +110,7 @@ + @@ -189,6 +190,7 @@ + @@ -295,6 +297,7 @@ + diff --git a/pcsx2-qt/pcsx2-qt.vcxproj.filters b/pcsx2-qt/pcsx2-qt.vcxproj.filters index 4e439927cb..5d3b2a836f 100644 --- a/pcsx2-qt/pcsx2-qt.vcxproj.filters +++ b/pcsx2-qt/pcsx2-qt.vcxproj.filters @@ -180,6 +180,7 @@ + Settings @@ -435,6 +436,7 @@ + Settings @@ -785,6 +787,7 @@ + Debugger diff --git a/pcsx2-qt/resources/icons/black/svg/arrow-down-line.svg b/pcsx2-qt/resources/icons/black/svg/arrow-down-line.svg new file mode 100644 index 0000000000..ecbcb5032b --- /dev/null +++ b/pcsx2-qt/resources/icons/black/svg/arrow-down-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/black/svg/arrow-up-line.svg b/pcsx2-qt/resources/icons/black/svg/arrow-up-line.svg new file mode 100644 index 0000000000..dd398895b5 --- /dev/null +++ b/pcsx2-qt/resources/icons/black/svg/arrow-up-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/white/svg/arrow-down-line.svg b/pcsx2-qt/resources/icons/white/svg/arrow-down-line.svg new file mode 100644 index 0000000000..7b61fd70cd --- /dev/null +++ b/pcsx2-qt/resources/icons/white/svg/arrow-down-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/white/svg/arrow-up-line.svg b/pcsx2-qt/resources/icons/white/svg/arrow-up-line.svg new file mode 100644 index 0000000000..d169b4cd09 --- /dev/null +++ b/pcsx2-qt/resources/icons/white/svg/arrow-up-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/pcsx2-qt/resources/resources.qrc b/pcsx2-qt/resources/resources.qrc index 8271ad9298..149cfdb475 100644 --- a/pcsx2-qt/resources/resources.qrc +++ b/pcsx2-qt/resources/resources.qrc @@ -2,6 +2,8 @@ icons/AppIcon64.png icons/black/index.theme + icons/black/svg/arrow-up-line.svg + icons/black/svg/arrow-down-line.svg icons/black/svg/arrow-left-line.svg icons/black/svg/arrow-left-right-line.svg icons/black/svg/arrow-right-line.svg @@ -115,6 +117,8 @@ icons/QT.png icons/update.png icons/white/index.theme + icons/white/svg/arrow-up-line.svg + icons/white/svg/arrow-down-line.svg icons/white/svg/arrow-left-line.svg icons/white/svg/arrow-left-right-line.svg icons/white/svg/arrow-right-line.svg