From 5f0b13ac5730eee17761b638378987aca9adaa2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valdis=20Bogd=C4=81ns?= Date: Fri, 5 Sep 2025 09:50:59 +0300 Subject: [PATCH] Log presets feature (#3509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Log presets feature - Add “Load Presets…” button in Logger → Log Filter (settings_dialog.ui) - Implement LogPresetsDialog (table: Comment, Filter) - Support add (+), multi-remove (−), load via button or double-click - Persist presets via QSettings at logger_presets/entries (qt_ui.ini) - Wire button to open dialog and set logFilterLineEdit - Update CMakeLists.txt to include new dialog sources * CLang fix * CLang fix again * Log Presets: checkbox selection + tri-state header - Added first column with per-row checkboxes - Implemented header checkbox with tri-state; click toggles select-all/none - Synced checkboxes and row selection in both directions - Header toggle also updates row selection to match - Remove operates on checked rows; falls back to selected rows if none checked - Load uses first checked row; falls back to first selected row - Double-click row triggers the same action as Load - Load button visible only when exactly one row is selected - Remove button visible only when at least one row is selected - Dialog opens with no selected rows and no focused buttons; focus set to table - New rows start editing in the Comment column - Serialization updated for new column indices; checkbox state not persisted - Cleanups: removed unused include and empty helper; pruned temporary comments * Clang * Fix: tri‑state header checkbox visible and aligned - Overlay real QCheckBox in header section 0 (tri‑state) - Align with row checkboxes using SE_ItemViewItemCheckIndicator - Reposition on header resize and geometry changes - Header click + checkbox click toggle select‑all/none - Tri‑state reflects per‑row checkbox states * CLang --------- Co-authored-by: w1naenator --- CMakeLists.txt | 2 + src/qt_gui/log_presets_dialog.cpp | 414 ++++++++++++++++++++++++++++++ src/qt_gui/log_presets_dialog.h | 54 ++++ src/qt_gui/settings_dialog.cpp | 9 + src/qt_gui/settings_dialog.ui | 7 + 5 files changed, 486 insertions(+) create mode 100644 src/qt_gui/log_presets_dialog.cpp create mode 100644 src/qt_gui/log_presets_dialog.h diff --git a/CMakeLists.txt b/CMakeLists.txt index aa1712d5e..5bf6acd4c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1098,6 +1098,8 @@ set(QT_GUI src/qt_gui/about_dialog.cpp src/qt_gui/kbm_help_dialog.h src/qt_gui/main_window_themes.cpp src/qt_gui/main_window_themes.h + src/qt_gui/log_presets_dialog.cpp + src/qt_gui/log_presets_dialog.h src/qt_gui/settings_dialog.cpp src/qt_gui/settings_dialog.h src/qt_gui/settings_dialog.ui diff --git a/src/qt_gui/log_presets_dialog.cpp b/src/qt_gui/log_presets_dialog.cpp new file mode 100644 index 000000000..049b043f3 --- /dev/null +++ b/src/qt_gui/log_presets_dialog.cpp @@ -0,0 +1,414 @@ +// SPDX-FileCopyrightText: Copyright 2025 shadPS4 Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "log_presets_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr const char* kPresetsGroup = "logger_presets"; +constexpr const char* kPresetsKey = "entries"; + +inline QString MakeEntry(const QString& comment, const QString& filter) { + return comment + "\t" + filter; +} + +inline void SplitEntry(const QString& entry, QString& comment, QString& filter) { + const int idx = entry.indexOf('\t'); + if (idx < 0) { + comment = entry; + filter = QString(); + } else { + comment = entry.left(idx); + filter = entry.mid(idx + 1); + } +} +} // namespace + +LogPresetsDialog::LogPresetsDialog(std::shared_ptr gui_settings, QWidget* parent) + : QDialog(parent), m_gui_settings(std::move(gui_settings)) { + setWindowTitle(tr("Log Filter Presets")); + resize(640, 360); + + auto* root = new QVBoxLayout(this); + + m_table = new QTableWidget(this); + m_table->setColumnCount(3); + m_table->horizontalHeader()->setSectionsClickable(true); + m_table->horizontalHeader()->setHighlightSections(false); + QStringList headers; + headers << QString() << tr("Comment") << tr("Filter"); + m_table->setHorizontalHeaderLabels(headers); + m_table->horizontalHeader()->setStretchLastSection(true); + m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeMode::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeMode::Interactive); + m_table->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeMode::Stretch); + m_table->setSelectionBehavior(QAbstractItemView::SelectionBehavior::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SelectionMode::ExtendedSelection); + m_table->setEditTriggers(QAbstractItemView::EditTrigger::DoubleClicked | + QAbstractItemView::EditTrigger::SelectedClicked | + QAbstractItemView::EditTrigger::EditKeyPressed); + + connect(m_table, &QTableWidget::cellDoubleClicked, this, + [this](int /*row*/, int /*col*/) { LoadSelected(); }); + + connect(m_table, &QTableWidget::itemChanged, this, [this](QTableWidgetItem* item) { + if (m_updating_checks) + return; + if (item && item->column() == 0) { + m_updating_checks = true; + const int row = item->row(); + auto* sm = m_table->selectionModel(); + const auto idx = m_table->model()->index(row, 0); + const bool check = (item->checkState() == Qt::Checked); + sm->select(idx, (check ? QItemSelectionModel::Select : QItemSelectionModel::Deselect) | + QItemSelectionModel::Rows); + m_updating_checks = false; + UpdateHeaderCheckState(); + UpdateLoadButtonEnabled(); + } + }); + + connect(m_table->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection& selected, const QItemSelection& deselected) { + if (m_updating_checks) + return; + m_updating_checks = true; + QSet selRows; + for (const auto& idx : selected.indexes()) + selRows.insert(idx.row()); + QSet deselRows; + for (const auto& idx : deselected.indexes()) + deselRows.insert(idx.row()); + for (int r : selRows) { + auto* it = m_table->item(r, 0); + if (!it) { + it = new QTableWidgetItem(); + it->setFlags((QTableWidgetItem().flags() | Qt::ItemIsUserCheckable) & + ~Qt::ItemIsEditable); + m_table->setItem(r, 0, it); + } + it->setCheckState(Qt::Checked); + } + for (int r : deselRows) { + auto* it = m_table->item(r, 0); + if (it) + it->setCheckState(Qt::Unchecked); + } + m_updating_checks = false; + UpdateHeaderCheckState(); + UpdateLoadButtonEnabled(); + }); + + connect(m_table->horizontalHeader(), &QHeaderView::sectionClicked, this, [this](int section) { + if (section != 0) + return; + const auto next = (m_header_checkbox && m_header_checkbox->checkState() == Qt::Checked) + ? Qt::Unchecked + : Qt::Checked; + if (m_header_checkbox) + m_header_checkbox->setCheckState(next); + SetAllCheckStates(next); + UpdateHeaderCheckState(); + UpdateLoadButtonEnabled(); + }); + + m_header_checkbox = new QCheckBox(m_table->horizontalHeader()); + m_header_checkbox->setTristate(true); + m_header_checkbox->setText(QString()); + m_header_checkbox->setFocusPolicy(Qt::NoFocus); + PositionHeaderCheckbox(); + QObject::connect(m_table->horizontalHeader(), &QHeaderView::geometriesChanged, this, + [this]() { PositionHeaderCheckbox(); }); + QObject::connect(m_table->horizontalHeader(), &QHeaderView::sectionResized, this, + [this](int, int, int) { PositionHeaderCheckbox(); }); + QObject::connect(m_header_checkbox, &QCheckBox::clicked, this, [this](bool checked) { + SetAllCheckStates(checked ? Qt::Checked : Qt::Unchecked); + UpdateHeaderCheckState(); + UpdateLoadButtonEnabled(); + }); + + root->addWidget(m_table); + + auto* buttons_layout = new QHBoxLayout(); + m_add_btn = new QPushButton(tr("+"), this); + m_remove_btn = new QPushButton(tr("-"), this); + m_load_btn = new QPushButton(tr("Load"), this); + m_close_btn = new QPushButton(tr("Close"), this); + + m_add_btn->setToolTip(tr("Add a new preset after the selected row")); + m_remove_btn->setToolTip(tr("Remove selected presets")); + m_load_btn->setToolTip(tr("Load the selected preset")); + + buttons_layout->addWidget(m_add_btn); + buttons_layout->addWidget(m_remove_btn); + buttons_layout->addStretch(); + buttons_layout->addWidget(m_load_btn); + buttons_layout->addWidget(m_close_btn); + root->addLayout(buttons_layout); + + m_add_btn->setAutoDefault(false); + m_remove_btn->setAutoDefault(false); + m_load_btn->setAutoDefault(false); + m_close_btn->setAutoDefault(false); + m_add_btn->setDefault(false); + m_remove_btn->setDefault(false); + m_load_btn->setDefault(false); + m_close_btn->setDefault(false); + + connect(m_add_btn, &QPushButton::clicked, this, [this]() { AddAfterSelection(); }); + connect(m_remove_btn, &QPushButton::clicked, this, [this]() { RemoveSelected(); }); + connect(m_load_btn, &QPushButton::clicked, this, [this]() { LoadSelected(); }); + connect(m_close_btn, &QPushButton::clicked, this, [this]() { reject(); }); + + LoadFromSettings(); + m_updating_checks = true; + m_table->clearSelection(); + m_table->setCurrentItem(nullptr); + m_updating_checks = false; + m_table->setFocus(Qt::OtherFocusReason); + UpdateLoadButtonEnabled(); +} + +void LogPresetsDialog::accept() { + SaveToSettings(); + QDialog::accept(); +} + +void LogPresetsDialog::reject() { + SaveToSettings(); + QDialog::reject(); +} + +void LogPresetsDialog::LoadFromSettings() { + if (!m_gui_settings) + return; + const auto var = m_gui_settings->GetValue(kPresetsGroup, kPresetsKey, QVariant()); + QList list; + if (var.isValid()) { + list = m_gui_settings->Var2List(var); + } + PopulateFromList(list); +} + +void LogPresetsDialog::SaveToSettings() { + if (!m_gui_settings) + return; + const auto list = SerializeTable(); + m_gui_settings->SetValue(kPresetsGroup, kPresetsKey, m_gui_settings->List2Var(list)); +} + +QList LogPresetsDialog::SerializeTable() const { + QList list; + const int rows = m_table->rowCount(); + for (int r = 0; r < rows; ++r) { + const auto* comment = m_table->item(r, 1); + const auto* filter = m_table->item(r, 2); + const QString c = comment ? comment->text() : QString(); + const QString f = filter ? filter->text() : QString(); + if (!c.isEmpty() || !f.isEmpty()) { + list.push_back(MakeEntry(c, f)); + } + } + return list; +} + +void LogPresetsDialog::PopulateFromList(const QList& list) { + m_table->setRowCount(0); + for (const auto& entry : list) { + QString c, f; + SplitEntry(entry, c, f); + const int row = m_table->rowCount(); + m_table->insertRow(row); + auto* chk = new QTableWidgetItem(); + chk->setFlags((chk->flags() | Qt::ItemIsUserCheckable) & ~Qt::ItemIsEditable); + chk->setCheckState(Qt::Unchecked); + m_table->setItem(row, 0, chk); + + m_table->setItem(row, 1, new QTableWidgetItem(c)); + m_table->setItem(row, 2, new QTableWidgetItem(f)); + } + m_updating_checks = true; + m_table->clearSelection(); + m_table->setCurrentItem(nullptr); + m_updating_checks = false; + UpdateHeaderCheckState(); + UpdateLoadButtonEnabled(); +} + +void LogPresetsDialog::AddAfterSelection() { + int insert_row = m_table->rowCount(); + const auto selected = m_table->selectionModel()->selectedRows(); + if (!selected.isEmpty()) { + // place after the last selected row + insert_row = selected.last().row() + 1; + } + m_table->insertRow(insert_row); + auto* chk = new QTableWidgetItem(); + chk->setFlags((chk->flags() | Qt::ItemIsUserCheckable) & ~Qt::ItemIsEditable); + chk->setCheckState(Qt::Unchecked); + m_table->setItem(insert_row, 0, chk); + m_table->setItem(insert_row, 1, new QTableWidgetItem("")); + m_table->setItem(insert_row, 2, new QTableWidgetItem("")); + UpdateHeaderCheckState(); + m_table->setCurrentCell(insert_row, 1); + m_table->editItem(m_table->item(insert_row, 1)); + UpdateLoadButtonEnabled(); +} + +void LogPresetsDialog::RemoveSelected() { + // Prefer checked rows; fall back to selected rows if none checked + QList to_remove = GetCheckedRows(); + if (to_remove.isEmpty()) { + const auto selected = m_table->selectionModel()->selectedRows(); + for (const auto& idx : selected) + to_remove.push_back(idx.row()); + } + if (to_remove.isEmpty()) + return; + std::sort(to_remove.begin(), to_remove.end(), [](int a, int b) { return a > b; }); + for (int row : to_remove) + m_table->removeRow(row); + UpdateHeaderCheckState(); + UpdateLoadButtonEnabled(); +} + +void LogPresetsDialog::LoadSelected() { + QList rows = GetCheckedRows(); + if (rows.isEmpty()) { + const auto selected = m_table->selectionModel()->selectedRows(); + if (selected.isEmpty()) + return; + rows.push_back(selected.first().row()); + } + const int row = rows.first(); + const auto* item = m_table->item(row, 2); + if (item) { + emit PresetChosen(item->text()); + accept(); + } +} + +void LogPresetsDialog::UpdateHeaderCheckState() { + const int rows = m_table->rowCount(); + if (rows == 0) { + if (m_header_checkbox) + m_header_checkbox->setCheckState(Qt::Unchecked); + return; + } + int checked = 0; + for (int r = 0; r < rows; ++r) { + auto* it = m_table->item(r, 0); + if (it && it->checkState() == Qt::Checked) + ++checked; + } + const auto state = (checked == 0) ? Qt::Unchecked + : (checked == rows) ? Qt::Checked + : Qt::PartiallyChecked; + if (m_header_checkbox) + m_header_checkbox->setCheckState(state); +} + +void LogPresetsDialog::SetAllCheckStates(Qt::CheckState state) { + const QSignalBlocker blocker(m_table); + m_updating_checks = true; + const int rows = m_table->rowCount(); + auto* sm = m_table->selectionModel(); + for (int r = 0; r < rows; ++r) { + auto* it = m_table->item(r, 0); + if (!it) { + it = new QTableWidgetItem(); + it->setFlags((QTableWidgetItem().flags() | Qt::ItemIsUserCheckable) & + ~Qt::ItemIsEditable); + m_table->setItem(r, 0, it); + } + it->setCheckState(state); + const auto idx = m_table->model()->index(r, 0); + sm->select(idx, ((state == Qt::Checked) ? QItemSelectionModel::Select + : QItemSelectionModel::Deselect) | + QItemSelectionModel::Rows); + } + m_updating_checks = false; +} + +QList LogPresetsDialog::GetCheckedRows() const { + QList rows; + const int count = m_table->rowCount(); + for (int r = 0; r < count; ++r) { + const auto* it = m_table->item(r, 0); + if (it && it->checkState() == Qt::Checked) + rows.push_back(r); + } + return rows; +} + +void LogPresetsDialog::UpdateLoadButtonEnabled() { + if (!m_table || !m_table->selectionModel()) + return; + const int count = m_table->selectionModel()->selectedRows().size(); + + if (m_load_btn) { + const bool showLoad = (count == 1); + m_load_btn->setVisible(showLoad); + m_load_btn->setEnabled(showLoad); + } + + if (m_remove_btn) { + const bool showRemove = (count >= 1); + m_remove_btn->setVisible(showRemove); + m_remove_btn->setEnabled(showRemove); + } +} + +void LogPresetsDialog::PositionHeaderCheckbox() { + if (!m_header_checkbox) + return; + auto* hdr = m_table->horizontalHeader(); + const int section = 0; + const int x = hdr->sectionViewportPosition(section); + const int w = hdr->sectionSize(section); + const int h = hdr->height(); + const QSize sz = m_header_checkbox->sizeHint(); + + // Try to align horizontally with cell checkbox indicator for this column + int left_in_section = 0; + if (m_table->model()->rowCount() > 0) { + QStyleOptionViewItem opt; + opt.initFrom(m_table); + opt.features = QStyleOptionViewItem::HasCheckIndicator; + // Use a representative cell rect width (section width) and a typical row height + const int cell_h = m_table->rowHeight(0) > 0 ? m_table->rowHeight(0) : sz.height(); + opt.rect = QRect(0, 0, w, cell_h); + const QRect ind = + m_table->style()->subElementRect(QStyle::SE_ItemViewItemCheckIndicator, &opt, m_table); + left_in_section = ind.left(); + // Guard against styles that return empty rects + if (ind.isEmpty()) { + left_in_section = + m_table->style()->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, m_table); + } + } else { + left_in_section = + m_table->style()->pixelMetric(QStyle::PM_FocusFrameHMargin, nullptr, m_table); + } + + int cx = x + left_in_section; + // Center vertically in header + const int cy = (h - sz.height()) / 2; + // Ensure checkbox fully visible within the section bounds + if (cx + sz.width() > x + w) + cx = x + std::max(0, w - sz.width()); + m_header_checkbox->setGeometry(QRect(QPoint(cx, cy), sz)); + m_header_checkbox->show(); +} diff --git a/src/qt_gui/log_presets_dialog.h b/src/qt_gui/log_presets_dialog.h new file mode 100644 index 000000000..163136703 --- /dev/null +++ b/src/qt_gui/log_presets_dialog.h @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright 2025 shadPS4 Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +class QTableWidget; +class QPushButton; +class QTableWidgetItem; +class QCheckBox; + +#include "gui_settings.h" + +class LogPresetsDialog : public QDialog { + Q_OBJECT + +public: + explicit LogPresetsDialog(std::shared_ptr gui_settings, + QWidget* parent = nullptr); + ~LogPresetsDialog() override = default; + +signals: + void PresetChosen(const QString& filter); + +protected: + void accept() override; + void reject() override; + +private: + void LoadFromSettings(); + void SaveToSettings(); + void AddAfterSelection(); + void RemoveSelected(); + void LoadSelected(); + void UpdateHeaderCheckState(); + void SetAllCheckStates(Qt::CheckState state); + QList GetCheckedRows() const; + void UpdateLoadButtonEnabled(); + void PositionHeaderCheckbox(); + + QList SerializeTable() const; + void PopulateFromList(const QList& list); + +private: + std::shared_ptr m_gui_settings; + QTableWidget* m_table = nullptr; + QCheckBox* m_header_checkbox = nullptr; + QPushButton* m_add_btn = nullptr; + QPushButton* m_remove_btn = nullptr; + QPushButton* m_load_btn = nullptr; + QPushButton* m_close_btn = nullptr; + bool m_updating_checks = false; +}; diff --git a/src/qt_gui/settings_dialog.cpp b/src/qt_gui/settings_dialog.cpp index aedb1823e..c46e2b8e3 100644 --- a/src/qt_gui/settings_dialog.cpp +++ b/src/qt_gui/settings_dialog.cpp @@ -26,6 +26,7 @@ #include "background_music_player.h" #include "common/logging/backend.h" #include "common/logging/filter.h" +#include "log_presets_dialog.h" #include "settings_dialog.h" #include "ui_settings_dialog.h" #include "video_core/renderer_vulkan/vk_instance.h" @@ -386,6 +387,14 @@ SettingsDialog::SettingsDialog(std::shared_ptr gui_settings, Common::FS::GetUserPath(Common::FS::PathType::LogDir)); QDesktopServices::openUrl(QUrl::fromLocalFile(userPath)); }); + + // Log presets popup button + connect(ui->logPresetsButton, &QPushButton::clicked, this, [this]() { + auto dlg = new LogPresetsDialog(m_gui_settings, this); + connect(dlg, &LogPresetsDialog::PresetChosen, this, + [this](const QString& filter) { ui->logFilterLineEdit->setText(filter); }); + dlg->exec(); + }); } // GRAPHICS TAB diff --git a/src/qt_gui/settings_dialog.ui b/src/qt_gui/settings_dialog.ui index 30aa5acae..01dd1604a 100644 --- a/src/qt_gui/settings_dialog.ui +++ b/src/qt_gui/settings_dialog.ui @@ -2217,6 +2217,13 @@ + + + + Load Presets... + + +