[Settings][Dialog] Implement search for settings by text, description, tooltip, etc. (#7065)

* [Settings] Implement search

Took 38 minutes

Took 8 seconds

Took 9 minutes

Took 5 seconds

Took 44 seconds


Took 25 seconds

* Comments

Took 23 minutes


Took 15 seconds

* Comments

Took 1 hour 14 minutes

* Minor fixes to search

Took 13 minutes

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL
2026-08-08 22:19:21 +02:00
committed by GitHub
parent ba203440af
commit bf6b2a90bc
13 changed files with 1069 additions and 103 deletions

View File

@@ -257,10 +257,13 @@ set(cockatrice_SOURCES
src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_widget.cpp
src/interface/widgets/settings_page/abstract_settings_page.cpp
src/interface/widgets/settings_page/appearance_settings_page.cpp
src/interface/widgets/settings_page/deck_editor_settings_page.cpp
src/interface/widgets/settings_page/general_settings_page.cpp
src/interface/widgets/settings_page/messages_settings_page.cpp
src/interface/widgets/settings_page/settings_search_delegate.cpp
src/interface/widgets/settings_page/settings_search_model.cpp
src/interface/widgets/settings_page/shortcut_settings_page.cpp
src/interface/widgets/settings_page/sound_settings_page.cpp
src/interface/widgets/settings_page/storage_settings_page.cpp

View File

@@ -4,7 +4,6 @@
#include "../../../client/settings/shortcuts_settings.h"
#include "../../deck_loader/card_node_function.h"
#include "../../deck_loader/deck_loader.h"
#include "dlg_settings.h"
#include <QApplication>
#include <QCheckBox>

View File

@@ -1,3 +1,8 @@
/**
* @file dlg_settings.cpp
* @brief Implementation of the main settings dialog
* @ingroup Dialogs
*/
#include "dlg_settings.h"
#include "../../../client/settings/cache_settings.h"
@@ -6,6 +11,8 @@
#include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_settings_page.h"
#include "../settings_page/settings_search_delegate.h"
#include "../settings_page/settings_search_model.h"
#include "../settings_page/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h"
@@ -13,20 +20,36 @@
#include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QAbstractItemView>
#include <QCloseEvent>
#include <QDialogButtonBox>
#include <QDir>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QGuiApplication>
#include <QListWidget>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLineEdit>
#include <QListView>
#include <QMessageBox>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QScreen>
#include <QScrollArea>
#include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QStackedLayout>
#include <QStackedWidget>
#include <QToolButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
/**
* @brief Wraps a widget in a scroll area for long settings pages
* @param widget The widget to wrap
* @return The scroll area containing the widget
*/
static QScrollArea *makeScrollable(QWidget *widget)
{
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
@@ -40,112 +63,355 @@ static QScrollArea *makeScrollable(QWidget *widget)
return scrollArea;
}
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
/**
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
*/
static QStringList pageIconResources()
{
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
}
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
{
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
this->setMinimumSize(qMin(700, rec.width()), qMin(700, rec.height()));
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
contentsWidget = new QListWidget;
contentsWidget->setViewMode(QListView::IconMode);
contentsWidget->setIconSize(QSize(58, 50));
contentsWidget->setMovement(QListView::Static);
contentsWidget->setMinimumHeight(85);
contentsWidget->setMaximumHeight(85);
contentsWidget->setSpacing(5);
pagesWidget = new QStackedWidget;
pagesWidget->addWidget(makeScrollable(new GeneralSettingsPage));
pagesWidget->addWidget(makeScrollable(new AppearanceSettingsPage));
pagesWidget->addWidget(makeScrollable(new UserInterfaceSettingsPage));
pagesWidget->addWidget(new DeckEditorSettingsPage);
pagesWidget->addWidget(makeScrollable(new StorageSettingsPage));
pagesWidget->addWidget(new MessagesSettingsPage);
pagesWidget->addWidget(new SoundSettingsPage);
pagesWidget->addWidget(new ShortcutSettingsPage);
createIcons();
contentsWidget->setCurrentRow(0);
auto *vboxLayout = new QVBoxLayout;
vboxLayout->addWidget(contentsWidget);
vboxLayout->addWidget(pagesWidget);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgSettings::close);
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(vboxLayout);
mainLayout->addSpacing(2);
mainLayout->addWidget(buttonBox);
setLayout(mainLayout);
setupUi();
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi();
searchEdit->setFocus();
adjustSize();
}
void DlgSettings::createIcons()
void DlgSettings::setupUi()
{
generalButton = new QListWidgetItem(contentsWidget);
generalButton->setTextAlignment(Qt::AlignHCenter);
generalButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
generalButton->setIcon(QPixmap("theme:config/general"));
// Search bar
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
appearanceButton = new QListWidgetItem(contentsWidget);
appearanceButton->setTextAlignment(Qt::AlignHCenter);
appearanceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
appearanceButton->setIcon(QPixmap("theme:config/appearance"));
auto *searchLayout = new QHBoxLayout;
searchLayout->addWidget(searchEdit);
userInterfaceButton = new QListWidgetItem(contentsWidget);
userInterfaceButton->setTextAlignment(Qt::AlignHCenter);
userInterfaceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
userInterfaceButton->setIcon(QPixmap("theme:config/interface"));
// Tab bar (built in setupTabBar)
setupTabBar();
deckEditorButton = new QListWidgetItem(contentsWidget);
deckEditorButton->setTextAlignment(Qt::AlignHCenter);
deckEditorButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
deckEditorButton->setIcon(QPixmap("theme:config/deckeditor"));
// Pages stacked widget
pagesWidget = new QStackedWidget;
storageButton = new QListWidgetItem(contentsWidget);
storageButton->setTextAlignment(Qt::AlignHCenter);
storageButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
storageButton->setIcon(QPixmap("theme:config/storage"));
auto *generalPage = new GeneralSettingsPage;
auto *appearancePage = new AppearanceSettingsPage;
auto *userInterfacePage = new UserInterfaceSettingsPage;
auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
messagesButton = new QListWidgetItem(contentsWidget);
messagesButton->setTextAlignment(Qt::AlignHCenter);
messagesButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
messagesButton->setIcon(QPixmap("theme:config/messages"));
pages.append(generalPage);
pages.append(appearancePage);
pages.append(userInterfacePage);
pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
soundButton = new QListWidgetItem(contentsWidget);
soundButton->setTextAlignment(Qt::AlignHCenter);
soundButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
soundButton->setIcon(QPixmap("theme:config/sound"));
pagesWidget->addWidget(makeScrollable(generalPage));
pagesWidget->addWidget(makeScrollable(appearancePage));
pagesWidget->addWidget(makeScrollable(userInterfacePage));
pagesWidget->addWidget(makeScrollable(deckEditorPage));
pagesWidget->addWidget(makeScrollable(storagePage));
pagesWidget->addWidget(messagesPage);
pagesWidget->addWidget(soundPage);
pagesWidget->addWidget(shortcutsPage);
shortcutsButton = new QListWidgetItem(contentsWidget);
shortcutsButton->setTextAlignment(Qt::AlignHCenter);
shortcutsButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
shortcutsButton->setIcon(QPixmap("theme:config/shorcuts"));
Q_ASSERT(pages.size() == NumPages);
connect(contentsWidget, &QListWidget::currentItemChanged, this, &DlgSettings::changePage);
// Search results view (hidden by default)
searchResultsView = new QListView;
searchResultsView->setUniformItemSizes(false);
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
const QStringList icons = pageIconResources();
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
tabButtons[i]->setIcon(QPixmap(icons[i]));
}
searchDelegate->setPageIcons(icons);
searchResultsView->viewport()->update();
});
// Build search index after pages are created
buildSearchIndex();
// Pages container (stacked widget + search results overlay)
pagesContainer = new QWidget;
auto *containerLayout = new QStackedLayout;
containerLayout->setStackingMode(QStackedLayout::StackAll);
containerLayout->addWidget(pagesWidget);
containerLayout->addWidget(searchResultsView);
pagesContainer->setLayout(containerLayout);
// Bottom buttons
auto *buttonBox = new QHBoxLayout;
buttonBox->addStretch();
okButton = new QPushButton;
okButton->setDefault(true);
connect(okButton, &QPushButton::clicked, this, &DlgSettings::close);
buttonBox->addWidget(okButton);
// Main layout
auto *mainLayout = new QVBoxLayout;
mainLayout->addLayout(searchLayout);
mainLayout->addWidget(tabBarWidget);
auto *separator = new QFrame;
separator->setFrameShape(QFrame::HLine);
separator->setFrameShadow(QFrame::Sunken);
mainLayout->addWidget(separator);
mainLayout->addWidget(pagesContainer);
mainLayout->addSpacing(4);
mainLayout->addLayout(buttonBox);
setLayout(mainLayout);
// Keyboard shortcuts
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
int next = (currentTabIndex + 1) % tabButtons.size();
setActiveTab(next);
});
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
setActiveTab(prev);
});
// Initialize to first tab
setActiveTab(0);
}
void DlgSettings::changePage(QListWidgetItem *current, QListWidgetItem *previous)
void DlgSettings::setupTabBar()
{
if (!current) {
current = previous;
tabBarWidget = new QWidget;
auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
const QStringList iconResources = pageIconResources();
for (int i = 0; i < iconResources.size(); ++i) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(iconResources[i]));
tabButton->setIconSize(QSize(48, 48));
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
tabButton->setAutoExclusive(true);
tabButton->setMinimumHeight(85);
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
}
pagesWidget->setCurrentIndex(contentsWidget->row(current));
tabBarWidget->setLayout(tabLayout);
}
void DlgSettings::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
const QStringList pageNames = translatedPageNames();
searchDelegate->setPageNames(pageNames);
searchDelegate->setPageIcons(pageIconResources());
for (int i = 0; i < pages.size(); ++i) {
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
for (auto &entry : pageEntries) {
if (entry.pageIndex == -1) {
entry.pageIndex = i;
}
}
allEntries.append(pageEntries);
}
searchModel->setSourceEntries(allEntries);
}
void DlgSettings::onTabClicked(int index)
{
if (searchActive) {
switchToTabMode();
}
setActiveTab(index);
}
void DlgSettings::setActiveTab(int index)
{
if (index < 0 || index >= tabButtons.size()) {
return;
}
currentTabIndex = index;
pagesWidget->setCurrentIndex(index);
for (int i = 0; i < tabButtons.size(); ++i) {
tabButtons[i]->setChecked(i == index);
}
// Style active tab with a thick accent border + subtle background tint
for (int i = 0; i < tabButtons.size(); ++i) {
if (i == index) {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: palette(window); padding-bottom: 1px; }");
} else {
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
"background: transparent; }");
}
}
}
void DlgSettings::flashWidget(QWidget *widget)
{
auto *overlay = new QWidget(widget);
overlay->setGeometry(widget->rect());
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
QPalette pal = overlay->palette();
QColor flashColor = pal.color(QPalette::Highlight);
flashColor.setAlpha(100);
pal.setBrush(QPalette::Window, flashColor);
overlay->setPalette(pal);
overlay->setAutoFillBackground(true);
auto *effect = new QGraphicsOpacityEffect(overlay);
effect->setOpacity(0.0);
overlay->setGraphicsEffect(effect);
overlay->show();
overlay->raise();
auto *flashIn = new QPropertyAnimation(effect, "opacity");
flashIn->setDuration(120);
flashIn->setStartValue(0.0);
flashIn->setEndValue(0.6);
flashIn->setEasingCurve(QEasingCurve::OutCubic);
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
fadeOut->setDuration(900);
fadeOut->setStartValue(0.6);
fadeOut->setEndValue(0.0);
fadeOut->setEasingCurve(QEasingCurve::InCubic);
auto *group = new QSequentialAnimationGroup(overlay);
group->addAnimation(flashIn);
group->addAnimation(fadeOut);
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
group->start(QAbstractAnimation::DeleteWhenStopped);
}
void DlgSettings::onSearchTextChanged(const QString &text)
{
searchModel->setFilterString(text);
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
if (!searchActive) {
switchToSearchMode();
}
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
} else if (searchActive) {
switchToTabMode();
}
}
void DlgSettings::switchToSearchMode()
{
searchActive = true;
tabBarWidget->setVisible(false);
pagesWidget->setVisible(false);
searchResultsView->setVisible(true);
if (searchModel->rowCount(QModelIndex()) > 0) {
searchResultsView->setCurrentIndex(searchModel->index(0));
}
}
void DlgSettings::switchToTabMode()
{
searchActive = false;
tabBarWidget->setVisible(true);
pagesWidget->setVisible(true);
searchResultsView->setVisible(false);
searchEdit->blockSignals(true);
searchEdit->clear();
searchEdit->blockSignals(false);
setActiveTab(currentTabIndex);
}
void DlgSettings::onSearchResultClicked(const QModelIndex &index)
{
navigateToSearchResult(index);
}
void DlgSettings::navigateToSearchResult(const QModelIndex &index)
{
SettingsSearchEntry entry = searchModel->entryForIndex(index);
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
return;
}
// Switch to the page
switchToTabMode();
setActiveTab(entry.pageIndex);
// Scroll to the widget, focus it, and flash to highlight it
if (entry.widget) {
QWidget *widget = entry.widget;
while (widget) {
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
scrollArea->ensureWidgetVisible(entry.widget);
break;
}
widget = widget->parentWidget();
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
}
void DlgSettings::setTab(int index)
{
if (index <= contentsWidget->count() - 1 && index >= 0) {
changePage(contentsWidget->item(index), contentsWidget->currentItem());
contentsWidget->setCurrentRow(index);
if (index >= 0 && index < tabButtons.size()) {
setActiveTab(index);
}
}
@@ -155,6 +421,49 @@ void DlgSettings::updateLanguage()
installNewTranslator();
}
bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
{
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
auto *keyEvent = static_cast<QKeyEvent *>(event);
if (keyEvent->key() == Qt::Key_Escape) {
if (searchActive) {
switchToTabMode();
return true;
}
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
if (searchActive) {
if (searchResultsView->currentIndex().isValid()) {
navigateToSearchResult(searchResultsView->currentIndex());
}
return true;
}
} else if (keyEvent->key() == Qt::Key_Down) {
if (searchActive) {
int nextRow = searchResultsView->currentIndex().row() + 1;
if (nextRow >= searchModel->rowCount()) {
nextRow = 0;
}
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
searchResultsView->scrollTo(searchModel->index(nextRow));
return true;
}
} else if (keyEvent->key() == Qt::Key_Up) {
if (searchActive) {
int prevRow = searchResultsView->currentIndex().row() - 1;
if (prevRow < 0) {
prevRow = searchModel->rowCount() - 1;
}
if (prevRow >= 0) {
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
searchResultsView->scrollTo(searchModel->index(prevRow));
}
return true;
}
}
}
return QDialog::eventFilter(watched, event);
}
void DlgSettings::closeEvent(QCloseEvent *event)
{
bool showLoadError = true;
@@ -209,7 +518,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() ||
SettingsCache::instance().paths().getDeckPath().isEmpty()) {
//! \todo Prompt to create the deck directory.
if (QMessageBox::critical(
this, tr("Error"),
tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"),
@@ -221,7 +529,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() ||
SettingsCache::instance().paths().getPicsPath().isEmpty()) {
//! \todo Prompt to create the pictures directory.
if (QMessageBox::critical(this, tr("Error"),
tr("The path to your card pictures directory is invalid. Would you like to go back "
"and set the correct path?"),
@@ -236,15 +543,26 @@ void DlgSettings::closeEvent(QCloseEvent *event)
void DlgSettings::retranslateUi()
{
setWindowTitle(tr("Settings"));
retranslateTabNames();
generalButton->setText(tr("General"));
appearanceButton->setText(tr("Appearance"));
userInterfaceButton->setText(tr("User Interface"));
storageButton->setText(tr("Storage"));
deckEditorButton->setText(tr("Card Sources"));
messagesButton->setText(tr("Chat"));
soundButton->setText(tr("Sound"));
shortcutsButton->setText(tr("Shortcuts"));
searchEdit->setPlaceholderText(tr("Search settings..."));
okButton->setText(tr("OK"));
contentsWidget->reset();
// Rebuild search index for translated text
buildSearchIndex();
}
QStringList DlgSettings::translatedPageNames()
{
return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
}
void DlgSettings::retranslateTabNames()
{
const QStringList tabLabels = translatedPageNames();
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
}

View File

@@ -1,43 +1,99 @@
/**
* @file dlg_settings.h
* @brief Main settings dialog for the Cockatrice client
* @ingroup Dialogs
*/
//! \todo Document this file.
#ifndef DLG_SETTINGS_H
#define DLG_SETTINGS_H
#include <QComboBox>
#include <QDialog>
#include <QLoggingCategory>
class QPushButton;
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
class QListWidget;
class QStackedWidget;
class QListWidgetItem;
class QToolButton;
class QListView;
class QLineEdit;
class AbstractSettingsPage;
class SettingsSearchModel;
class SettingsSearchDelegate;
/**
* @brief Main application settings dialog with tabbed navigation and search
*
* Provides a modern settings interface organized into tabbed pages. Users can
* either navigate by clicking tabs or search for specific settings using the
* built-in search bar. Search results are filtered and ranked by relevance.
*/
class DlgSettings : public QDialog
{
Q_OBJECT
public:
/**
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
*
* Use these values instead of raw indices so reordering pages never silently
* breaks external callers like tab_room.cpp.
*/
enum SettingsPage
{
GeneralPage = 0,
AppearancePage,
UserInterfacePage,
DeckEditorPage,
StoragePage,
MessagesPage,
SoundPage,
ShortcutsPage,
NumPages
};
explicit DlgSettings(QWidget *parent = nullptr);
void setTab(int index);
private slots:
void changePage(QListWidgetItem *current, QListWidgetItem *previous);
void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultClicked(const QModelIndex &index);
void updateLanguage();
private:
QListWidget *contentsWidget;
QStackedWidget *pagesWidget;
QListWidgetItem *generalButton, *appearanceButton, *userInterfaceButton, *deckEditorButton, *storageButton,
*messagesButton, *soundButton, *shortcutsButton;
void createIcons();
// UI elements
QLineEdit *searchEdit; ///< Search bar for filtering settings
QWidget *tabBarWidget; ///< Container widget for the tab buttons
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
QPushButton *okButton; ///< Button to close the dialog
// Data
QList<AbstractSettingsPage *> pages; ///< All settings page instances
SettingsSearchModel *searchModel; ///< Model for search results
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
int currentTabIndex; ///< Currently active tab index
bool searchActive; ///< Whether search mode is active
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
static void flashWidget(QWidget *widget);
static QStringList translatedPageNames();
void retranslateUi();
void retranslateTabNames();
protected:
void closeEvent(QCloseEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
};
#endif
#endif // DLG_SETTINGS_H

View File

@@ -0,0 +1,196 @@
#include "abstract_settings_page.h"
#include "settings_search_model.h"
#include <QCheckBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
#include <QPair>
#include <QSpinBox>
/**
* @brief Recursively collects all widgets within a layout
* @param layout The layout to walk
* @param widgets Output list of (widget, containing layout) pairs
*/
static void collectWidgets(QLayout *layout, QList<QPair<QWidget *, QLayout *>> &widgets)
{
for (int i = 0; i < layout->count(); ++i) {
QLayoutItem *item = layout->itemAt(i);
if (!item) {
continue;
}
if (QWidget *widget = item->widget()) {
widgets.append({widget, layout});
} else if (QLayout *subLayout = item->layout()) {
collectWidgets(subLayout, widgets);
}
}
}
/**
* @brief Rejects QLabels that are not setting names
*
* HTML link labels, path values, and excessively long labels are filtered out.
*/
static bool isValidSettingLabel(const QLabel *label)
{
const QString &text = label->text();
if (Qt::mightBeRichText(text)) {
return false;
}
if (text.contains(QLatin1Char('/')) || text.contains(QLatin1Char('\\'))) {
return false;
}
if (text.size() > 60) {
return false;
}
return true;
}
/**
* @brief Finds the control associated with a setting label
*
* Uses the explicit buddy if set, otherwise the widget in the cell (or slot)
* immediately following the label within the same layout. Returns nullptr when
* no obvious control is found.
*/
static QWidget *controlForLabel(QLabel *label, QLayout *containingLayout)
{
if (QWidget *buddy = label->buddy()) {
return buddy;
}
if (auto *grid = qobject_cast<QGridLayout *>(containingLayout)) {
int index = grid->indexOf(label);
if (index != -1) {
int row = 0;
int column = 0;
int rowSpan = 1;
int columnSpan = 1;
grid->getItemPosition(index, &row, &column, &rowSpan, &columnSpan);
if (QLayoutItem *next = grid->itemAtPosition(row, column + columnSpan)) {
if (QWidget *nextWidget = next->widget()) {
return nextWidget;
}
}
}
} else {
int index = containingLayout->indexOf(label);
if (index != -1) {
for (int i = index + 1; i < containingLayout->count(); ++i) {
if (QLayoutItem *next = containingLayout->itemAt(i)) {
if (QWidget *nextWidget = next->widget()) {
return nextWidget;
}
}
}
}
}
return nullptr;
}
/**
* @brief Builds the extended search text for an entry
*
* Combines the group title, label, and any extra searchable text derived from
* the associated control (placeholder, prefix/suffix, tooltip). Combo values
* and numeric tooltips are excluded since they change at runtime and would
* make the search index stale.
*/
static QString buildFullSearchText(const QString &groupTitle, const QString &cleanLabel, QWidget *control)
{
QStringList parts = {groupTitle, cleanLabel};
if (control) {
if (auto *lineEdit = qobject_cast<QLineEdit *>(control)) {
parts.append(lineEdit->placeholderText());
} else if (auto *spinBox = qobject_cast<QSpinBox *>(control)) {
parts.append(spinBox->prefix());
parts.append(spinBox->suffix());
}
if (!control->toolTip().isEmpty()) {
bool isNumeric = false;
control->toolTip().toInt(&isNumeric);
if (!isNumeric) {
parts.append(control->toolTip());
}
}
}
parts.removeAll(QString());
return parts.join(QLatin1Char(' '));
}
QList<SettingsSearchEntry> AbstractSettingsPage::getSearchEntries()
{
return autoDetectSearchEntries(this, -1);
}
QList<SettingsSearchEntry> AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex)
{
QList<SettingsSearchEntry> entries;
const auto children = page->children();
for (QObject *child : children) {
auto *groupBox = qobject_cast<QGroupBox *>(child);
if (!groupBox) {
continue;
}
QString groupTitle = groupBox->title();
if (groupTitle.isEmpty()) {
continue;
}
QLayout *groupLayout = groupBox->layout();
if (!groupLayout) {
continue;
}
QList<QPair<QWidget *, QLayout *>> widgets;
collectWidgets(groupLayout, widgets);
for (const auto &pair : widgets) {
QWidget *widget = pair.first;
QString label;
auto *checkBox = qobject_cast<QCheckBox *>(widget);
if (checkBox) {
label = checkBox->text();
} else {
auto *labelWidget = qobject_cast<QLabel *>(widget);
if (!labelWidget || labelWidget->text().isEmpty() || !isValidSettingLabel(labelWidget)) {
continue;
}
label = labelWidget->text();
}
if (label.isEmpty()) {
continue;
}
// Strip accelerator markers (&) for search
QString cleanLabel = label;
cleanLabel.remove(QLatin1Char('&'));
QWidget *control = widget;
if (auto *labelWidget = qobject_cast<QLabel *>(widget)) {
control = controlForLabel(labelWidget, pair.second);
if (!control) {
continue;
}
}
entries.append(SettingsSearchEntry{.pageIndex = pageIndex,
.groupTitle = groupTitle,
.widgetLabel = cleanLabel,
.fullSearchText = buildFullSearchText(groupTitle, cleanLabel, control),
.widget = control});
}
}
return entries;
}

View File

@@ -1,16 +1,24 @@
#ifndef COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
#define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
#include <QList>
#include <QWidget>
#define WIKI_CUSTOM_PIC_URL "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs"
#define WIKI_CUSTOM_SHORTCUTS "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Keyboard-Shortcuts"
#define WIKI_TRANSLATION_FAQ "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ"
struct SettingsSearchEntry;
class AbstractSettingsPage : public QWidget
{
Q_OBJECT
public:
virtual void retranslateUi() = 0;
virtual QList<SettingsSearchEntry> getSearchEntries();
protected:
static QList<SettingsSearchEntry> autoDetectSearchEntries(QWidget *page, int pageIndex);
};
#endif // COCKATRICE_ABSTRACT_SETTINGS_PAGE_H

View File

@@ -90,6 +90,7 @@ MessagesSettingsPage::MessagesSettingsPage()
highlightNotice->addWidget(&hexHighlightLabel, 1, 2);
highlightNotice->addWidget(customAlertString, 0, 0);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0);
customAlertStringLabel.setBuddy(customAlertString);
highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice);

View File

@@ -0,0 +1,125 @@
/**
* @file settings_search_delegate.cpp
* @brief Implementation of the custom settings search result delegate
* @ingroup Dialogs
*/
#include "settings_search_delegate.h"
#include "settings_search_model.h"
#include <QPainter>
SettingsSearchDelegate::SettingsSearchDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
}
void SettingsSearchDelegate::setPageNames(const QStringList &names)
{
pageNames = names;
}
void SettingsSearchDelegate::setPageIcons(const QStringList &iconResources)
{
pageIcons.clear();
for (const QString &resource : iconResources) {
pageIcons.append(QPixmap(resource));
}
}
void SettingsSearchDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
painter->save();
SettingsSearchEntry entry = index.data(SettingsSearchModel::EntryRole).value<SettingsSearchEntry>();
bool isSelected = option.state & QStyle::State_Selected;
bool isHovered = option.state & QStyle::State_MouseOver;
// Background
QColor bgColor = isSelected ? option.palette.color(QPalette::Highlight)
: isHovered ? option.palette.color(QPalette::Midlight)
: option.palette.color(QPalette::Base);
painter->fillRect(option.rect, bgColor);
if (isSelected) {
// Accent bar on the left to make the selection unmistakable
painter->fillRect(QRect(option.rect.left(), option.rect.top(), 4, option.rect.height()),
option.palette.color(QPalette::Highlight).darker(150));
}
int leftMargin = 12;
int topMargin = 8;
int rightMargin = 12;
int bottomMargin = 4;
QRect contentRect = option.rect.adjusted(leftMargin, topMargin, -rightMargin, -bottomMargin);
int yPos = contentRect.top();
// Icon of the related settings page
const int iconSize = 24;
QPixmap pageIcon =
(entry.pageIndex >= 0 && entry.pageIndex < pageIcons.size()) ? pageIcons.at(entry.pageIndex) : QPixmap();
int iconOffset = pageIcon.isNull() ? 0 : iconSize + 8;
if (!pageIcon.isNull()) {
QRect iconRect(contentRect.left(), contentRect.top() + (contentRect.height() - iconSize) / 2, iconSize,
iconSize);
painter->drawPixmap(iconRect, pageIcon);
}
QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0);
// Breadcrumb: "Page > Group"
QFont breadcrumbFont = option.font;
breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1);
breadcrumbFont.setBold(true);
QColor breadcrumbColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
if (!isSelected) {
breadcrumbColor.setAlpha(180);
}
QString pageName;
if (entry.pageIndex >= 0 && entry.pageIndex < pageNames.size()) {
pageName = pageNames[entry.pageIndex];
} else {
pageName = QString::number(entry.pageIndex);
}
QString breadcrumbText = QStringLiteral("%1 > %2").arg(pageName, entry.groupTitle);
painter->setFont(breadcrumbFont);
painter->setPen(breadcrumbColor);
painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 20), Qt::AlignLeft | Qt::AlignVCenter,
breadcrumbText);
yPos += 20;
// Setting label
QFont labelFont = option.font;
labelFont.setPointSize(labelFont.pointSize() + 1);
labelFont.setBold(isSelected);
QColor labelColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
painter->setFont(labelFont);
painter->setPen(labelColor);
painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 24), Qt::AlignLeft | Qt::AlignVCenter,
entry.widgetLabel);
yPos += 24;
// Bottom separator
QPen separatorPen(option.palette.color(QPalette::Mid), 1);
painter->setPen(separatorPen);
painter->drawLine(option.rect.left() + leftMargin, option.rect.bottom(), option.rect.right() - rightMargin,
option.rect.bottom());
painter->restore();
}
QSize SettingsSearchDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
Q_UNUSED(index);
return QSize(option.rect.width(), 56);
}

View File

@@ -0,0 +1,38 @@
/**
* @file settings_search_delegate.h
* @brief Custom delegate for rendering settings search results
* @ingroup Dialogs
*/
#ifndef COCKATRICE_SETTINGS_SEARCH_DELEGATE_H
#define COCKATRICE_SETTINGS_SEARCH_DELEGATE_H
#include <QPixmap>
#include <QStyledItemDelegate>
/**
* @brief Custom paint delegate for settings search result items
*
* Renders each search result with a breadcrumb line ("Page > Group"),
* the setting label, and a subtle separator. Supports selected/hovered states.
*/
class SettingsSearchDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit SettingsSearchDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
/** @brief Sets the translated page names for breadcrumb display */
void setPageNames(const QStringList &names);
/** @brief Sets the icons shown in front of results, indexed by page position */
void setPageIcons(const QStringList &iconResources);
private:
QStringList pageNames; ///< Translated page names indexed by page position
QList<QPixmap> pageIcons; ///< Icons of the related settings pages
};
#endif // COCKATRICE_SETTINGS_SEARCH_DELEGATE_H

View File

@@ -0,0 +1,145 @@
/**
* @file settings_search_model.cpp
* @brief Implementation of the settings search list model
* @ingroup Dialogs
*/
#include "settings_search_model.h"
#include <QPair>
#include <algorithm>
SettingsSearchModel::SettingsSearchModel(QObject *parent) : QAbstractListModel(parent)
{
}
void SettingsSearchModel::setSourceEntries(const QList<SettingsSearchEntry> &entries)
{
beginResetModel();
sourceEntries = entries;
endResetModel();
rebuildFilter();
}
void SettingsSearchModel::setFilterString(const QString &text)
{
filterActive = !text.trimmed().isEmpty();
if (filterActive) {
filterQuery = text.trimmed();
filterRegex =
QRegularExpression(QRegularExpression::escape(filterQuery), QRegularExpression::CaseInsensitiveOption);
}
rebuildFilter();
}
bool SettingsSearchModel::isFilterActive() const
{
return filterActive;
}
/**
* @brief Calculates a relevance score for a single entry against the query
*
* Scoring priorities (highest to lowest):
* 1. Label starts with query -> 100
* 2. Label contains query -> 80
* 3. Group title starts with query -> 60
* 4. Group title contains query -> 40
* 5. Full text regex match -> 20
* 6. No match -> 0 (excluded from results)
*/
static int relevanceScore(const SettingsSearchEntry &entry, const QString &query, const QRegularExpression &regex)
{
QString lowerQuery = query.toLower();
// Label matches are most relevant
QString label = entry.widgetLabel.toLower();
if (label.startsWith(lowerQuery)) {
return 100;
}
if (label.contains(lowerQuery)) {
return 80;
}
// Group title matches are next
QString group = entry.groupTitle.toLower();
if (group.startsWith(lowerQuery)) {
return 60;
}
if (group.contains(lowerQuery)) {
return 40;
}
// Full text match is least relevant
if (entry.fullSearchText.contains(regex)) {
return 20;
}
return 0;
}
void SettingsSearchModel::rebuildFilter()
{
beginResetModel();
filteredIndices.clear();
if (!filterActive) {
for (int i = 0; i < sourceEntries.size(); ++i) {
filteredIndices.append(i);
}
} else {
QList<QPair<int, int>> scored; // <score, index>
for (int i = 0; i < sourceEntries.size(); ++i) {
const SettingsSearchEntry &entry = sourceEntries[i];
// Skip conditional settings that are currently disabled or hidden
if (entry.widget && (!entry.widget->isEnabled() || entry.widget->isHidden())) {
continue;
}
int score = relevanceScore(entry, filterQuery, filterRegex);
if (score > 0) {
scored.append({-score, i}); // negative for descending sort
}
}
std::sort(scored.begin(), scored.end());
for (const auto &pair : scored) {
filteredIndices.append(pair.second);
}
}
endResetModel();
}
int SettingsSearchModel::rowCount(const QModelIndex &parent) const
{
if (parent.isValid()) {
return 0;
}
return filteredIndices.size();
}
QVariant SettingsSearchModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid() || index.row() >= filteredIndices.size()) {
return {};
}
const SettingsSearchEntry &entry = sourceEntries[filteredIndices[index.row()]];
switch (role) {
case EntryRole:
return QVariant::fromValue(entry);
case Qt::DisplayRole:
return entry.widgetLabel;
case Qt::ToolTipRole:
return QStringLiteral("%1 > %2 > %3")
.arg(QString::number(entry.pageIndex), entry.groupTitle, entry.widgetLabel);
default:
return {};
}
}
SettingsSearchEntry SettingsSearchModel::entryForIndex(const QModelIndex &index) const
{
if (!index.isValid() || index.row() >= filteredIndices.size()) {
return {};
}
return sourceEntries[filteredIndices[index.row()]];
}

View File

@@ -0,0 +1,76 @@
/**
* @file settings_search_model.h
* @brief Data model for the settings search feature
* @ingroup Dialogs
*/
#ifndef COCKATRICE_SETTINGS_SEARCH_MODEL_H
#define COCKATRICE_SETTINGS_SEARCH_MODEL_H
#include <QAbstractListModel>
#include <QList>
#include <QRegularExpression>
#include <QWidget>
/**
* @brief Represents a single searchable setting entry
*
* Each settings page provides a list of these entries via getSearchEntries().
* The model uses them for filtering, relevance scoring, and display.
*/
struct SettingsSearchEntry
{
int pageIndex; ///< Index of the settings page this entry belongs to
QString groupTitle; ///< Title of the group/section within the page
QString widgetLabel; ///< Display label for the setting widget
QString fullSearchText; ///< Extended search text (label, control text, tooltip) for full-text matching
QWidget *widget; ///< Pointer to the setting widget for focus/scrolling
};
/**
* @brief List model providing filtered, ranked search results
*
* Manages a list of SettingsSearchEntry items. When a filter string is set,
* entries are scored by relevance and sorted so the best matches appear first.
* Supports custom roles for accessing entry fields from views and delegates.
*/
class SettingsSearchModel : public QAbstractListModel
{
Q_OBJECT
public:
/**
* @brief Custom data role for accessing the full entry
*/
enum Roles
{
EntryRole = Qt::UserRole + 1, ///< Full SettingsSearchEntry object
};
explicit SettingsSearchModel(QObject *parent = nullptr);
/** @brief Replaces the source entries and rebuilds the filter */
void setSourceEntries(const QList<SettingsSearchEntry> &entries);
/** @brief Sets the filter string and recalculates the results */
void setFilterString(const QString &text);
/** @brief Whether a non-empty filter is currently active */
bool isFilterActive() const;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
/** @brief Returns the full entry for a given model index */
SettingsSearchEntry entryForIndex(const QModelIndex &index) const;
private:
QList<SettingsSearchEntry> sourceEntries; ///< Complete unfiltered entry list
QList<int> filteredIndices; ///< Indices into sourceEntries matching the filter
QRegularExpression filterRegex; ///< Compiled regex for the current filter
QString filterQuery; ///< Current filter query string
bool filterActive = false; ///< Whether filtering is active
/** @brief Recalculates the filtered index list and ranking */
void rebuildFilter();
};
Q_DECLARE_METATYPE(SettingsSearchEntry)
#endif // COCKATRICE_SETTINGS_SEARCH_MODEL_H

View File

@@ -123,6 +123,7 @@ void ShortcutSettingsPage::retranslateUi()
currentActionGroupLabel->setText(tr("Section:"));
currentActionLabel->setText(tr("Action:"));
currentShortcutLabel->setText(tr("Shortcut:"));
editShortcutGroupBox->setTitle(tr("Shortcut editor"));
editTextBox->retranslateUi();
faqLabel->setText(QString("<a href='%1'>%2</a>").arg(WIKI_CUSTOM_SHORTCUTS).arg(tr("How to set custom shortcuts")));
btnResetAll->setText(tr("Restore all default shortcuts"));

View File

@@ -242,7 +242,7 @@ void TabRoom::actClearChat()
void TabRoom::actOpenChatSettings()
{
DlgSettings settings(this);
settings.setTab(4);
settings.setTab(DlgSettings::MessagesPage);
settings.exec();
}