[Settings] Implement search

Took 38 minutes

Took 8 seconds

Took 9 minutes

Took 5 seconds

Took 44 seconds


Took 25 seconds
This commit is contained in:
Lukas Brübach 2026-07-28 18:27:23 +02:00
parent c4316c40e9
commit 0195be8922
11 changed files with 954 additions and 104 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_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_widget.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/appearance_settings_page.cpp
src/interface/widgets/settings_page/deck_editor_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/general_settings_page.cpp
src/interface/widgets/settings_page/messages_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/shortcut_settings_page.cpp
src/interface/widgets/settings_page/sound_settings_page.cpp src/interface/widgets/settings_page/sound_settings_page.cpp
src/interface/widgets/settings_page/storage_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 "../../../client/settings/shortcuts_settings.h"
#include "../../deck_loader/card_node_function.h" #include "../../deck_loader/card_node_function.h"
#include "../../deck_loader/deck_loader.h" #include "../../deck_loader/deck_loader.h"
#include "dlg_settings.h"
#include <QApplication> #include <QApplication>
#include <QCheckBox> #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 "dlg_settings.h"
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
@ -6,28 +11,46 @@
#include "../settings_page/deck_editor_settings_page.h" #include "../settings_page/deck_editor_settings_page.h"
#include "../settings_page/general_settings_page.h" #include "../settings_page/general_settings_page.h"
#include "../settings_page/messages_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/shortcut_settings_page.h"
#include "../settings_page/sound_settings_page.h" #include "../settings_page/sound_settings_page.h"
#include "../settings_page/storage_settings_page.h" #include "../settings_page/storage_settings_page.h"
#include "../settings_page/user_interface_settings_page.h" #include "../settings_page/user_interface_settings_page.h"
#include "../utility/custom_line_edit.h"
#include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_loader.h"
#include "libcockatrice/card/database/card_database_manager.h" #include "libcockatrice/card/database/card_database_manager.h"
#include <QAbstractItemView>
#include <QCloseEvent> #include <QCloseEvent>
#include <QDialogButtonBox>
#include <QDir> #include <QDir>
#include <QFrame>
#include <QGraphicsOpacityEffect>
#include <QGuiApplication> #include <QGuiApplication>
#include <QListWidget> #include <QHBoxLayout>
#include <QKeyEvent>
#include <QListView>
#include <QMessageBox> #include <QMessageBox>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QScreen> #include <QScreen>
#include <QScrollArea> #include <QScrollArea>
#include <QScrollBar> #include <QScrollBar>
#include <QSequentialAnimationGroup>
#include <QShortcut>
#include <QStackedLayout>
#include <QStackedWidget> #include <QStackedWidget>
#include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <libcockatrice/settings/paths_settings.h> #include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h> #include <libcockatrice/settings/personal_settings.h>
static QScrollArea *makeScrollable(QWidget *widget) /**
* @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 *makeScrollableModern(QWidget *widget)
{ {
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
@ -40,46 +63,14 @@ static QScrollArea *makeScrollable(QWidget *widget)
return scrollArea; return scrollArea;
} }
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
{ {
auto rec = QGuiApplication::primaryScreen()->availableGeometry(); 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); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
contentsWidget = new QListWidget; setupUi();
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);
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
retranslateUi(); retranslateUi();
@ -87,65 +78,341 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
adjustSize(); adjustSize();
} }
void DlgSettings::createIcons() void DlgSettings::setupUi()
{ {
generalButton = new QListWidgetItem(contentsWidget); // Search bar
generalButton->setTextAlignment(Qt::AlignHCenter); searchEdit = new QLineEdit;
generalButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); searchEdit->setClearButtonEnabled(true);
generalButton->setIcon(QPixmap("theme:config/general")); searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
searchEdit->installEventFilter(this);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
appearanceButton = new QListWidgetItem(contentsWidget); auto *searchLayout = new QHBoxLayout;
appearanceButton->setTextAlignment(Qt::AlignHCenter); searchLayout->addWidget(searchEdit);
appearanceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
appearanceButton->setIcon(QPixmap("theme:config/appearance"));
userInterfaceButton = new QListWidgetItem(contentsWidget); // Tab bar (built in setupTabBar)
userInterfaceButton->setTextAlignment(Qt::AlignHCenter); setupTabBar();
userInterfaceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
userInterfaceButton->setIcon(QPixmap("theme:config/interface"));
deckEditorButton = new QListWidgetItem(contentsWidget); // Pages stacked widget
deckEditorButton->setTextAlignment(Qt::AlignHCenter); pagesWidget = new QStackedWidget;
deckEditorButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled);
deckEditorButton->setIcon(QPixmap("theme:config/deckeditor"));
storageButton = new QListWidgetItem(contentsWidget); auto *generalPage = new GeneralSettingsPage;
storageButton->setTextAlignment(Qt::AlignHCenter); auto *appearancePage = new AppearanceSettingsPage;
storageButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); auto *userInterfacePage = new UserInterfaceSettingsPage;
storageButton->setIcon(QPixmap("theme:config/storage")); auto *deckEditorPage = new DeckEditorSettingsPage;
auto *storagePage = new StorageSettingsPage;
auto *messagesPage = new MessagesSettingsPage;
auto *soundPage = new SoundSettingsPage;
auto *shortcutsPage = new ShortcutSettingsPage;
messagesButton = new QListWidgetItem(contentsWidget); pages.append(generalPage);
messagesButton->setTextAlignment(Qt::AlignHCenter); pages.append(appearancePage);
messagesButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); pages.append(userInterfacePage);
messagesButton->setIcon(QPixmap("theme:config/messages")); pages.append(deckEditorPage);
pages.append(storagePage);
pages.append(messagesPage);
pages.append(soundPage);
pages.append(shortcutsPage);
soundButton = new QListWidgetItem(contentsWidget); pagesWidget->addWidget(makeScrollableModern(generalPage));
soundButton->setTextAlignment(Qt::AlignHCenter); pagesWidget->addWidget(makeScrollableModern(appearancePage));
soundButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); pagesWidget->addWidget(makeScrollableModern(userInterfacePage));
soundButton->setIcon(QPixmap("theme:config/sound")); pagesWidget->addWidget(makeScrollableModern(deckEditorPage));
pagesWidget->addWidget(makeScrollableModern(storagePage));
pagesWidget->addWidget(makeScrollableModern(messagesPage));
pagesWidget->addWidget(makeScrollableModern(soundPage));
pagesWidget->addWidget(makeScrollableModern(shortcutsPage));
shortcutsButton = new QListWidgetItem(contentsWidget); // Search results view (hidden by default)
shortcutsButton->setTextAlignment(Qt::AlignHCenter); searchResultsView = new QListView;
shortcutsButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); searchResultsView->setUniformItemSizes(false);
shortcutsButton->setIcon(QPixmap("theme:config/shorcuts")); searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
searchResultsView->setVisible(false);
searchResultsView->setStyleSheet(
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
connect(contentsWidget, &QListWidget::currentItemChanged, this, &DlgSettings::changePage); searchModel = new SettingsSearchModel(this);
searchDelegate = new SettingsSearchDelegate(this);
searchResultsView->setModel(searchModel);
searchResultsView->setItemDelegate(searchDelegate);
connect(searchResultsView, &QListView::activated, this, &DlgSettings::onSearchResultActivated);
// 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
resetButton = new QPushButton;
connect(resetButton, &QPushButton::clicked, this, &DlgSettings::onResetDefaultsClicked);
auto *buttonBox = new QHBoxLayout;
buttonBox->addWidget(resetButton);
buttonBox->addStretch();
okButton = new QPushButton;
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) { tabBarWidget = new QWidget;
current = previous; auto *tabLayout = new QHBoxLayout;
tabLayout->setContentsMargins(0, 0, 0, 0);
tabLayout->setSpacing(2);
struct TabInfo
{
QString iconResource;
int pageIndex;
};
const TabInfo tabInfos[] = {{"theme:config/general", 0}, {"theme:config/appearance", 1},
{"theme:config/interface", 2}, {"theme:config/deckeditor", 3},
{"theme:config/storage", 4}, {"theme:config/messages", 5},
{"theme:config/sound", 6}, {"theme:config/shorcuts", 7}};
for (const auto &info : tabInfos) {
auto *tabButton = new QToolButton;
tabButton->setCheckable(true);
tabButton->setIcon(QPixmap(info.iconResource));
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 = info.pageIndex] { onTabClicked(idx); });
tabButtons.append(tabButton);
tabLayout->addWidget(tabButton);
} }
pagesWidget->setCurrentIndex(contentsWidget->row(current)); tabBarWidget->setLayout(tabLayout);
}
void DlgSettings::buildSearchIndex()
{
QList<SettingsSearchEntry> allEntries;
if (pageNames.isEmpty()) {
pageNames << tr("General") << tr("Appearance") << tr("User Interface") << tr("Card Sources") << tr("Storage")
<< tr("Chat") << tr("Sound") << tr("Shortcuts");
}
searchDelegate->setPageNames(pageNames);
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;
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();
}
} 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::onSearchResultActivated(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) {
QScrollArea *scrollArea = qobject_cast<QScrollArea *>(pagesWidget->currentWidget());
if (scrollArea) {
scrollArea->ensureWidgetVisible(entry.widget);
}
entry.widget->setFocus();
flashWidget(entry.widget);
}
}
void DlgSettings::onResetDefaultsClicked()
{
if (QMessageBox::question(this, tr("Reset to Defaults"),
tr("Are you sure you want to reset all settings on this page to their defaults?")) !=
QMessageBox::Yes) {
return;
}
if (currentTabIndex >= 0 && currentTabIndex < pages.size()) {
pages[currentTabIndex]->resetToDefaults();
}
// Rebuild search index after reset
buildSearchIndex();
} }
void DlgSettings::setTab(int index) void DlgSettings::setTab(int index)
{ {
if (index <= contentsWidget->count() - 1 && index >= 0) { if (index >= 0 && index < tabButtons.size()) {
changePage(contentsWidget->item(index), contentsWidget->currentItem()); setActiveTab(index);
contentsWidget->setCurrentRow(index);
} }
} }
@ -155,6 +422,47 @@ void DlgSettings::updateLanguage()
installNewTranslator(); 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 && 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) void DlgSettings::closeEvent(QCloseEvent *event)
{ {
bool showLoadError = true; bool showLoadError = true;
@ -209,7 +517,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() || if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() ||
SettingsCache::instance().paths().getDeckPath().isEmpty()) { SettingsCache::instance().paths().getDeckPath().isEmpty()) {
//! \todo Prompt to create the deck directory.
if (QMessageBox::critical( if (QMessageBox::critical(
this, tr("Error"), this, tr("Error"),
tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"), tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"),
@ -221,7 +528,6 @@ void DlgSettings::closeEvent(QCloseEvent *event)
if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() || if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() ||
SettingsCache::instance().paths().getPicsPath().isEmpty()) { SettingsCache::instance().paths().getPicsPath().isEmpty()) {
//! \todo Prompt to create the pictures directory.
if (QMessageBox::critical(this, tr("Error"), if (QMessageBox::critical(this, tr("Error"),
tr("The path to your card pictures directory is invalid. Would you like to go back " tr("The path to your card pictures directory is invalid. Would you like to go back "
"and set the correct path?"), "and set the correct path?"),
@ -236,15 +542,25 @@ void DlgSettings::closeEvent(QCloseEvent *event)
void DlgSettings::retranslateUi() void DlgSettings::retranslateUi()
{ {
setWindowTitle(tr("Settings")); setWindowTitle(tr("Settings"));
retranslateTabNames();
generalButton->setText(tr("General")); searchEdit->setPlaceholderText(tr("Search settings..."));
appearanceButton->setText(tr("Appearance")); resetButton->setText(tr("Reset to Defaults"));
userInterfaceButton->setText(tr("User Interface")); okButton->setText(tr("OK"));
storageButton->setText(tr("Storage"));
deckEditorButton->setText(tr("Card Sources"));
messagesButton->setText(tr("Chat"));
soundButton->setText(tr("Sound"));
shortcutsButton->setText(tr("Shortcuts"));
contentsWidget->reset(); // Rebuild search index for translated text
buildSearchIndex();
}
void DlgSettings::retranslateTabNames()
{
pageNames.clear();
pageNames << tr("General") << tr("Appearance") << tr("User Interface") << tr("Card Sources") << tr("Storage")
<< tr("Chat") << tr("Sound") << tr("Shortcuts");
const QStringList tabLabels = pageNames;
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
tabButtons[i]->setText(tabLabels[i]);
}
} }

View file

@ -1,22 +1,34 @@
/** /**
* @file dlg_settings.h * @file dlg_settings.h
* @brief Main settings dialog for the Cockatrice client
* @ingroup Dialogs * @ingroup Dialogs
*/ */
//! \todo Document this file.
#ifndef DLG_SETTINGS_H #ifndef DLG_SETTINGS_H
#define DLG_SETTINGS_H #define DLG_SETTINGS_H
#include <QComboBox>
#include <QDialog> #include <QDialog>
#include <QLoggingCategory> #include <QLoggingCategory>
class QPushButton;
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
class QListWidget;
class QStackedWidget; 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 class DlgSettings : public QDialog
{ {
Q_OBJECT Q_OBJECT
@ -25,19 +37,46 @@ public:
void setTab(int index); void setTab(int index);
private slots: private slots:
void changePage(QListWidgetItem *current, QListWidgetItem *previous); void onTabClicked(int index);
void onSearchTextChanged(const QString &text);
void onSearchResultActivated(const QModelIndex &index);
void onResetDefaultsClicked();
void updateLanguage(); void updateLanguage();
private: private:
QListWidget *contentsWidget; // UI elements
QStackedWidget *pagesWidget; QLineEdit *searchEdit; ///< Search bar for filtering settings
QListWidgetItem *generalButton, *appearanceButton, *userInterfaceButton, *deckEditorButton, *storageButton, QWidget *tabBarWidget; ///< Container widget for the tab buttons
*messagesButton, *soundButton, *shortcutsButton; QList<QToolButton *> tabButtons; ///< Navigation tab buttons
void createIcons(); QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
QListView *searchResultsView; ///< Search results list view
QWidget *pagesContainer; ///< Container stacking pages and search results
QPushButton *resetButton; ///< Button to reset current page to defaults
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
QStringList pageNames; ///< Translated page names for breadcrumb display
void setupUi();
void setupTabBar();
void buildSearchIndex();
void switchToTabMode();
void switchToSearchMode();
void navigateToSearchResult(const QModelIndex &index);
void setActiveTab(int index);
void flashWidget(QWidget *widget);
void retranslateUi(); void retranslateUi();
void retranslateTabNames();
protected: protected:
void closeEvent(QCloseEvent *event) override; void closeEvent(QCloseEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
}; };
#endif #endif // DLG_SETTINGS_H

View file

@ -0,0 +1,117 @@
#include "abstract_settings_page.h"
#include "settings_search_model.h"
#include <QCheckBox>
#include <QGroupBox>
#include <QLabel>
#include <QLayout>
QList<SettingsSearchEntry> AbstractSettingsPage::getSearchEntries()
{
return autoDetectSearchEntries(this, -1);
}
void AbstractSettingsPage::resetToDefaults()
{
}
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;
}
for (int i = 0; i < groupLayout->count(); ++i) {
QLayoutItem *item = groupLayout->itemAt(i);
if (!item) {
continue;
}
// Direct widget children (checkboxes, labels with buddies, etc.)
if (QWidget *widget = item->widget()) {
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()) {
continue;
} else if (labelWidget) {
label = labelWidget->text();
}
}
if (!label.isEmpty()) {
// Strip accelerator markers (&) for search
QString cleanLabel = label;
cleanLabel.remove(QLatin1Char('&'));
SettingsSearchEntry entry;
entry.pageIndex = pageIndex;
entry.groupTitle = groupTitle;
entry.widgetLabel = cleanLabel;
entry.fullSearchText = QStringLiteral("%1 %2 %3").arg(groupTitle, cleanLabel, cleanLabel.toLower());
entry.widget = widget;
entries.append(entry);
}
}
// Layout items (nested grids, hboxes, vboxes)
if (item->layout()) {
for (int j = 0; j < item->layout()->count(); ++j) {
QLayoutItem *nestedItem = item->layout()->itemAt(j);
if (!nestedItem || !nestedItem->widget()) {
continue;
}
QWidget *nestedWidget = nestedItem->widget();
QString label;
auto *checkBox = qobject_cast<QCheckBox *>(nestedWidget);
if (checkBox) {
label = checkBox->text();
} else {
auto *labelWidget = qobject_cast<QLabel *>(nestedWidget);
if (labelWidget && !labelWidget->text().isEmpty()) {
label = labelWidget->text();
}
}
if (!label.isEmpty()) {
QString cleanLabel = label;
cleanLabel.remove(QLatin1Char('&'));
SettingsSearchEntry entry;
entry.pageIndex = pageIndex;
entry.groupTitle = groupTitle;
entry.widgetLabel = cleanLabel;
entry.fullSearchText =
QStringLiteral("%1 %2 %3").arg(groupTitle, cleanLabel, cleanLabel.toLower());
entry.widget = nestedWidget;
entries.append(entry);
}
}
}
}
}
return entries;
}

View file

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

View file

@ -0,0 +1,95 @@
/**
* @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::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);
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();
// 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);
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(contentRect.left(), yPos, contentRect.width(), 20), Qt::AlignLeft | Qt::AlignVCenter,
breadcrumbText);
yPos += 20;
// Setting label
QFont labelFont = option.font;
labelFont.setPointSize(labelFont.pointSize() + 1);
QColor labelColor =
isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text);
painter->setFont(labelFont);
painter->setPen(labelColor);
painter->drawText(QRect(contentRect.left(), yPos, contentRect.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,33 @@
/**
* @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 <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);
private:
QStringList pageNames; ///< Translated page names indexed by page position
};
#endif // COCKATRICE_SETTINGS_SEARCH_DELEGATE_H

View file

@ -0,0 +1,156 @@
/**
* @file settings_search_model.cpp
* @brief Implementation of the settings search list model
* @ingroup Dialogs
*/
#include "settings_search_model.h"
#include <QSet>
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) {
int score = relevanceScore(sourceEntries[i], 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 PageIndexRole:
return entry.pageIndex;
case GroupTitleRole:
return entry.groupTitle;
case WidgetLabelRole:
return entry.widgetLabel;
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()]];
}
QList<int> SettingsSearchModel::filteredPageIndices() const
{
QSet<int> pages;
for (int idx : filteredIndices) {
pages.insert(sourceEntries[idx].pageIndex);
}
QList<int> result = pages.values();
std::sort(result.begin(), result.end());
return result;
}

View file

@ -0,0 +1,83 @@
/**
* @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 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 roles for accessing entry fields
*/
enum Roles
{
EntryRole = Qt::UserRole + 1, ///< Full SettingsSearchEntry object
PageIndexRole, ///< Entry's page index (int)
GroupTitleRole, ///< Entry's group title (QString)
WidgetLabelRole, ///< Entry's widget label (QString)
HighlightStartRole, ///< Start of highlight range
HighlightEndRole, ///< End of highlight range
};
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;
/** @brief Returns sorted list of page indices present in filtered results */
QList<int> filteredPageIndices() 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

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