mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 02:53:25 -08:00
Compare commits
8 commits
a12b97ec50
...
ea6af0048c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6af0048c | ||
|
|
46cdbac830 | ||
|
|
4343544588 | ||
|
|
4750e67b7f | ||
|
|
4974cac59a | ||
|
|
860625be4d | ||
|
|
665f7267e4 | ||
|
|
679762c624 |
21 changed files with 1229 additions and 529 deletions
|
|
@ -60,6 +60,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/dialogs/dlg_view_log.cpp
|
||||
src/interface/widgets/dialogs/override_printing_warning.cpp
|
||||
src/interface/widgets/dialogs/path_safety_warning.cpp
|
||||
src/interface/widgets/dialogs/settings_view.cpp
|
||||
src/interface/widgets/dialogs/tip_of_the_day.cpp
|
||||
src/interface/widgets/deck_share/deck_share_utils.cpp
|
||||
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
|
||||
|
|
|
|||
|
|
@ -1,205 +1,81 @@
|
|||
/**
|
||||
* @file dlg_settings.cpp
|
||||
* @brief Implementation of the main settings dialog
|
||||
* @brief Implementation of the main settings dialog (thin frame around SettingsView)
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#include "dlg_settings.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../pixel_map_generator.h"
|
||||
#include "../main.h"
|
||||
#include "../settings_page/abstract_settings_page.h"
|
||||
#include "../settings_page/appearance_settings_page.h"
|
||||
#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"
|
||||
#include "../settings_page/user_interface_settings_page.h"
|
||||
#include "libcockatrice/card/database/card_database.h"
|
||||
#include "libcockatrice/card/database/card_database_loader.h"
|
||||
#include "libcockatrice/card/database/card_database_manager.h"
|
||||
|
||||
#include <QAbstractAnimation>
|
||||
#include <QAbstractItemView>
|
||||
#include <QApplication>
|
||||
#include <QBrush>
|
||||
#include <QByteArray>
|
||||
#include <QCloseEvent>
|
||||
#include <QColor>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QEasingCurve>
|
||||
#include <QEvent>
|
||||
#include <QFlags>
|
||||
#include <QFrame>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QGuiApplication>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QMessageBox>
|
||||
#include <QMessageLogger>
|
||||
#include <QModelIndex>
|
||||
#include <QObject>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QPushButton>
|
||||
#include <QRect>
|
||||
#include <QScreen>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QSequentialAnimationGroup>
|
||||
#include <QShortcut>
|
||||
#include <QSize>
|
||||
#include <QSizePolicy>
|
||||
#include <QStackedLayout>
|
||||
#include <QStackedWidget>
|
||||
#include <QStringLiteral>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
class QModelIndex;
|
||||
static constexpr int MIN_DIALOG_WIDTH = 750;
|
||||
static constexpr int MIN_DIALOG_HEIGHT = 700;
|
||||
static constexpr int MAX_DIALOG_WIDTH = 1280;
|
||||
static constexpr double DIALOG_SIZE_RATIO = 0.9;
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @brief Calculates the initial dialog size from the host window or screen
|
||||
* @param hostSize Size of the hosting main window, or empty when parentless
|
||||
* @param availableSize Available screen area the dialog must fit into
|
||||
* @param minimumSize Minimum dialog size already applied via setMinimumSize
|
||||
* @return Target size at ~90% of the host with small margins, clamped to fit
|
||||
*/
|
||||
static QScrollArea *makeScrollable(QWidget *widget)
|
||||
static QSize calculateDialogSize(const QSize &hostSize, const QSize &availableSize, const QSize &minimumSize)
|
||||
{
|
||||
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
|
||||
|
||||
auto *scrollArea = new QScrollArea;
|
||||
scrollArea->setWidgetResizable(true);
|
||||
scrollArea->setContentsMargins(0, 0, 0, 0);
|
||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scrollArea->horizontalScrollBar()->setEnabled(false);
|
||||
scrollArea->setWidget(widget);
|
||||
return scrollArea;
|
||||
QSize target = !hostSize.isEmpty() ? QSize(qRound(hostSize.width() * DIALOG_SIZE_RATIO),
|
||||
qRound(hostSize.height() * DIALOG_SIZE_RATIO))
|
||||
: QSize(qRound(availableSize.width() * DIALOG_SIZE_RATIO),
|
||||
qRound(availableSize.height() * DIALOG_SIZE_RATIO));
|
||||
// Evenly-spaced columns leave unnatural dead space on very wide windows, so clamp the width.
|
||||
target.setWidth(qMin(target.width(), MAX_DIALOG_WIDTH));
|
||||
return target.expandedTo(minimumSize).boundedTo(availableSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
|
||||
*/
|
||||
static QStringList pageIconResources()
|
||||
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
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")};
|
||||
}
|
||||
// Fill the main window with small margins so multi-group pages (e.g. Deck Editor) are not buried in a scroll.
|
||||
// 90% of the host window leaves ~5% margins on each side; falls back to 90% of the screen when parentless.
|
||||
QWidget *hostWindow = parent ? parent->window() : nullptr;
|
||||
QScreen *screen = nullptr;
|
||||
QSize hostSize;
|
||||
if (hostWindow) {
|
||||
hostSize = hostWindow->size();
|
||||
screen = QGuiApplication::screenAt(hostWindow->geometry().center());
|
||||
}
|
||||
if (!screen) {
|
||||
screen = QGuiApplication::primaryScreen();
|
||||
}
|
||||
const QRect available = screen ? screen->availableGeometry() : QRect(0, 0, 1280, 800);
|
||||
const QSize minimum(qMin(MIN_DIALOG_WIDTH, available.width()), qMin(MIN_DIALOG_HEIGHT, available.height()));
|
||||
setMinimumSize(minimum);
|
||||
|
||||
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
|
||||
{
|
||||
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
|
||||
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
|
||||
resize(calculateDialogSize(hostSize, available.size(), minimum));
|
||||
setSizeGripEnabled(true);
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
||||
|
||||
setupUi();
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
||||
retranslateUi();
|
||||
|
||||
searchEdit->setFocus();
|
||||
|
||||
adjustSize();
|
||||
}
|
||||
|
||||
void DlgSettings::setupUi()
|
||||
{
|
||||
// Search bar
|
||||
searchEdit = new QLineEdit;
|
||||
searchEdit->setClearButtonEnabled(true);
|
||||
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
||||
searchEdit->installEventFilter(this);
|
||||
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
|
||||
|
||||
auto *searchLayout = new QHBoxLayout;
|
||||
searchLayout->addWidget(searchEdit);
|
||||
|
||||
// Tab bar (built in setupTabBar)
|
||||
setupTabBar();
|
||||
|
||||
// Pages stacked widget
|
||||
pagesWidget = new QStackedWidget;
|
||||
|
||||
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;
|
||||
|
||||
pages.append(generalPage);
|
||||
pages.append(appearancePage);
|
||||
pages.append(userInterfacePage);
|
||||
pages.append(deckEditorPage);
|
||||
pages.append(storagePage);
|
||||
pages.append(messagesPage);
|
||||
pages.append(soundPage);
|
||||
pages.append(shortcutsPage);
|
||||
|
||||
pagesWidget->addWidget(makeScrollable(generalPage));
|
||||
pagesWidget->addWidget(makeScrollable(appearancePage));
|
||||
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
||||
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
||||
pagesWidget->addWidget(makeScrollable(storagePage));
|
||||
pagesWidget->addWidget(makeScrollable(messagesPage));
|
||||
pagesWidget->addWidget(soundPage);
|
||||
pagesWidget->addWidget(shortcutsPage);
|
||||
|
||||
Q_ASSERT(pages.size() == NumPages);
|
||||
|
||||
// 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);
|
||||
settingsView = new SettingsView(this);
|
||||
|
||||
// Bottom buttons
|
||||
auto *buttonBox = new QHBoxLayout;
|
||||
|
|
@ -211,241 +87,23 @@ void DlgSettings::setupUi()
|
|||
|
||||
// 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->addWidget(settingsView);
|
||||
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::setupTabBar()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
||||
retranslateUi();
|
||||
}
|
||||
|
||||
void DlgSettings::setTab(int index)
|
||||
{
|
||||
if (index >= 0 && index < tabButtons.size()) {
|
||||
setActiveTab(index);
|
||||
}
|
||||
settingsView->setTab(index);
|
||||
}
|
||||
|
||||
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
||||
{
|
||||
return pages.value(static_cast<int>(which));
|
||||
return settingsView->page(which);
|
||||
}
|
||||
|
||||
void DlgSettings::updateLanguage()
|
||||
|
|
@ -454,49 +112,6 @@ 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;
|
||||
|
|
@ -576,26 +191,5 @@ void DlgSettings::closeEvent(QCloseEvent *event)
|
|||
void DlgSettings::retranslateUi()
|
||||
{
|
||||
setWindowTitle(tr("Settings"));
|
||||
retranslateTabNames();
|
||||
|
||||
searchEdit->setPlaceholderText(tr("Search settings..."));
|
||||
okButton->setText(tr("OK"));
|
||||
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +1,60 @@
|
|||
/**
|
||||
* @file dlg_settings.h
|
||||
* @brief Main settings dialog for the Cockatrice client
|
||||
* @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView)
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#ifndef DLG_SETTINGS_H
|
||||
#define DLG_SETTINGS_H
|
||||
|
||||
#include "settings_view.h"
|
||||
|
||||
#include <QDialog>
|
||||
#include <QList>
|
||||
#include <QLoggingCategory>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QPushButton;
|
||||
class QModelIndex;
|
||||
class QWidget;
|
||||
class AbstractSettingsPage;
|
||||
|
||||
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
|
||||
|
||||
class QStackedWidget;
|
||||
class QToolButton;
|
||||
class QListView;
|
||||
class QLineEdit;
|
||||
class AbstractSettingsPage;
|
||||
class SettingsSearchModel;
|
||||
class SettingsSearchDelegate;
|
||||
|
||||
/**
|
||||
* @brief Main application settings dialog with tabbed navigation and search
|
||||
* @brief Main application settings dialog hosting the shared SettingsView
|
||||
*
|
||||
* 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.
|
||||
* Thin QDialog frame around SettingsView: only the window title, the OK button
|
||||
* and the close validation live here. All pages, navigation and search are
|
||||
* owned by SettingsView. The dialog fills ~90% of the main window so dense
|
||||
* pages stay visible without losing the modal adjust-and-return flow.
|
||||
*/
|
||||
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
|
||||
};
|
||||
using SettingsPage = SettingsView::SettingsPage;
|
||||
static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage;
|
||||
static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage;
|
||||
static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage;
|
||||
static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage;
|
||||
static constexpr SettingsPage StoragePage = SettingsView::StoragePage;
|
||||
static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage;
|
||||
static constexpr SettingsPage SoundPage = SettingsView::SoundPage;
|
||||
static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage;
|
||||
static constexpr SettingsPage NumPages = SettingsView::NumPages;
|
||||
|
||||
explicit DlgSettings(QWidget *parent = nullptr);
|
||||
void setTab(int index);
|
||||
AbstractSettingsPage *page(SettingsPage which) const;
|
||||
|
||||
private slots:
|
||||
void onTabClicked(int index);
|
||||
void onSearchTextChanged(const QString &text);
|
||||
void onSearchResultClicked(const QModelIndex &index);
|
||||
void updateLanguage();
|
||||
|
||||
private:
|
||||
// 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();
|
||||
SettingsView *settingsView; ///< Shared settings content
|
||||
QPushButton *okButton; ///< Button to close the dialog
|
||||
|
||||
void retranslateUi();
|
||||
void retranslateTabNames();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
};
|
||||
|
||||
#endif // DLG_SETTINGS_H
|
||||
|
|
|
|||
760
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
760
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
|
|
@ -0,0 +1,760 @@
|
|||
/**
|
||||
* @file settings_view.cpp
|
||||
* @brief Implementation of the settings content widget
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#include "settings_view.h"
|
||||
|
||||
#include "../../../client/settings/cache_settings.h"
|
||||
#include "../../pixel_map_generator.h"
|
||||
#include "../settings_page/abstract_settings_page.h"
|
||||
#include "../settings_page/appearance_settings_page.h"
|
||||
#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"
|
||||
#include "../settings_page/user_interface_settings_page.h"
|
||||
|
||||
#include <QAbstractAnimation>
|
||||
#include <QAbstractItemView>
|
||||
#include <QBrush>
|
||||
#include <QColor>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QEasingCurve>
|
||||
#include <QEvent>
|
||||
#include <QFlags>
|
||||
#include <QGraphicsOpacityEffect>
|
||||
#include <QHBoxLayout>
|
||||
#include <QIcon>
|
||||
#include <QKeyEvent>
|
||||
#include <QKeySequence>
|
||||
#include <QLineEdit>
|
||||
#include <QListView>
|
||||
#include <QMessageLogger>
|
||||
#include <QModelIndex>
|
||||
#include <QMouseEvent>
|
||||
#include <QObject>
|
||||
#include <QPalette>
|
||||
#include <QPixmap>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QRect>
|
||||
#include <QScreen>
|
||||
#include <QScrollArea>
|
||||
#include <QScrollBar>
|
||||
#include <QSequentialAnimationGroup>
|
||||
#include <QShortcut>
|
||||
#include <QSignalBlocker>
|
||||
#include <QSize>
|
||||
#include <QSizePolicy>
|
||||
#include <QStackedLayout>
|
||||
#include <QStackedWidget>
|
||||
#include <QStringLiteral>
|
||||
#include <QTreeWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QVariant>
|
||||
#include <QWidget>
|
||||
#include <QtGlobal>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <qnamespace.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);
|
||||
|
||||
auto *scrollArea = new QScrollArea;
|
||||
scrollArea->setWidgetResizable(true);
|
||||
scrollArea->setContentsMargins(0, 0, 0, 0);
|
||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
scrollArea->horizontalScrollBar()->setEnabled(false);
|
||||
scrollArea->setWidget(widget);
|
||||
return scrollArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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")};
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
/// Item data role holding a sub-page row's section widget, stored as a WidgetId
|
||||
constexpr int SectionWidgetRole = Qt::UserRole + 1;
|
||||
using WidgetId = quintptr;
|
||||
|
||||
/**
|
||||
* @brief Packs a section widget for storage in a tree item's data role
|
||||
* @param widget The section's group box (owned by its settings page)
|
||||
* @return The widget as an opaque id
|
||||
*/
|
||||
WidgetId toWidgetId(QWidget *widget)
|
||||
{
|
||||
return reinterpret_cast<WidgetId>(widget);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Unpacks a section widget from a tree item's data role
|
||||
* @param id The stored id, or 0 for page rows without a section
|
||||
* @return The section widget, or nullptr when unset
|
||||
*/
|
||||
QWidget *fromWidgetId(WidgetId id)
|
||||
{
|
||||
return reinterpret_cast<QWidget *>(id);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
|
||||
{
|
||||
setupUi();
|
||||
|
||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
|
||||
retranslateUi();
|
||||
|
||||
searchEdit->setFocus();
|
||||
}
|
||||
|
||||
void SettingsView::setupUi()
|
||||
{
|
||||
// Search bar
|
||||
searchEdit = new QLineEdit;
|
||||
searchEdit->setClearButtonEnabled(true);
|
||||
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
||||
searchEdit->installEventFilter(this);
|
||||
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
|
||||
|
||||
auto *searchLayout = new QHBoxLayout;
|
||||
searchLayout->addWidget(searchEdit);
|
||||
|
||||
// Pages stacked widget
|
||||
pagesWidget = new QStackedWidget;
|
||||
|
||||
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;
|
||||
|
||||
pages.append(generalPage);
|
||||
pages.append(appearancePage);
|
||||
pages.append(userInterfacePage);
|
||||
pages.append(deckEditorPage);
|
||||
pages.append(storagePage);
|
||||
pages.append(messagesPage);
|
||||
pages.append(soundPage);
|
||||
pages.append(shortcutsPage);
|
||||
|
||||
pagesWidget->addWidget(makeScrollable(generalPage));
|
||||
pagesWidget->addWidget(makeScrollable(appearancePage));
|
||||
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
||||
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
||||
pagesWidget->addWidget(makeScrollable(storagePage));
|
||||
pagesWidget->addWidget(makeScrollable(messagesPage));
|
||||
pagesWidget->addWidget(soundPage);
|
||||
pagesWidget->addWidget(shortcutsPage);
|
||||
|
||||
Q_ASSERT(pages.size() == NumPages);
|
||||
|
||||
// Navigation tree (needs the pages for their sections)
|
||||
setupNavTree();
|
||||
|
||||
// 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, &SettingsView::onSearchResultClicked);
|
||||
|
||||
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
|
||||
const QStringList icons = pageIconResources();
|
||||
for (int i = 0; i < pageItems.size() && i < icons.size(); ++i) {
|
||||
if (pageItems[i]) {
|
||||
pageItems[i]->setIcon(0, 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);
|
||||
|
||||
// Main layout: search on top, navigation + pages side by side below
|
||||
auto *contentLayout = new QHBoxLayout;
|
||||
contentLayout->addWidget(navTree);
|
||||
contentLayout->addWidget(pagesContainer, 1);
|
||||
|
||||
auto *mainLayout = new QVBoxLayout;
|
||||
mainLayout->addLayout(searchLayout);
|
||||
mainLayout->addLayout(contentLayout);
|
||||
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) % pages.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 + pages.size()) % pages.size();
|
||||
setActiveTab(prev);
|
||||
});
|
||||
|
||||
// Initialize to first tab
|
||||
setActiveTab(0);
|
||||
}
|
||||
|
||||
void SettingsView::setupNavTree()
|
||||
{
|
||||
navTree = new QTreeWidget;
|
||||
navTree->setHeaderHidden(true);
|
||||
navTree->setRootIsDecorated(true);
|
||||
navTree->setIconSize(QSize(24, 24));
|
||||
navTree->setMinimumWidth(230);
|
||||
navTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
navTree->setStyleSheet(
|
||||
"QTreeWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
||||
|
||||
// Re-click detection has to sample the selection before the view handles the
|
||||
// press: QAbstractItemView updates the current item (emitting
|
||||
// currentItemChanged) and only then emits itemPressed, so itemPressed always
|
||||
// sees the pressed item as "current" and cannot tell a re-click from a first
|
||||
// click. The viewport event filter runs before QAbstractItemView::mousePressEvent.
|
||||
navTree->viewport()->installEventFilter(this);
|
||||
|
||||
// Groups of SettingsPage values, in navigation order
|
||||
static const QList<QList<SettingsPage>> groupedPages = {
|
||||
{GeneralPage, AppearancePage, UserInterfacePage},
|
||||
{DeckEditorPage, StoragePage},
|
||||
{MessagesPage, SoundPage},
|
||||
{ShortcutsPage},
|
||||
};
|
||||
|
||||
const QStringList groupNames = translatedGroupNames();
|
||||
const QStringList pageNames = translatedPageNames();
|
||||
const QStringList iconResources = pageIconResources();
|
||||
|
||||
pageItems.resize(NumPages);
|
||||
for (int i = 0; i < groupedPages.size(); ++i) {
|
||||
auto *group = new QTreeWidgetItem(navTree, {groupNames[i]});
|
||||
group->setFlags(Qt::ItemIsEnabled); // header row: expandable, not selectable
|
||||
group->setExpanded(true);
|
||||
groupItems.append(group);
|
||||
for (SettingsPage page : groupedPages[i]) {
|
||||
auto *item = new QTreeWidgetItem(group, {pageNames[page]});
|
||||
item->setIcon(0, QPixmap(iconResources[page]));
|
||||
item->setData(0, Qt::UserRole, static_cast<int>(page));
|
||||
pageItems[page] = item;
|
||||
// Sub-page rows deep-link into the page's sections (scroll + highlight)
|
||||
for (const SettingsPageSection §ion : pages[page]->getSections()) {
|
||||
auto *sectionItem = new QTreeWidgetItem(item, {section.title});
|
||||
sectionItem->setData(0, Qt::UserRole, static_cast<int>(page));
|
||||
sectionItem->setData(0, SectionWidgetRole, QVariant::fromValue(toWidgetId(section.sectionWidget)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Q_ASSERT(groupItems.size() == groupedPages.size());
|
||||
Q_ASSERT(!pageItems.contains(nullptr));
|
||||
|
||||
connect(navTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current) {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
bool ok = false;
|
||||
int index = current->data(0, Qt::UserRole).toInt(&ok);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
if (QWidget *section = fromWidgetId(current->data(0, SectionWidgetRole).value<WidgetId>())) {
|
||||
navigateToSection(index, section);
|
||||
} else {
|
||||
onTabClicked(index);
|
||||
}
|
||||
});
|
||||
|
||||
connect(navTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item) {
|
||||
const bool wasReClick = pressWasOnCurrentItem;
|
||||
pressWasOnCurrentItem = false;
|
||||
// A fresh click already navigated via currentItemChanged; only a press that
|
||||
// started on the already-current item (captured by the viewport event filter
|
||||
// before the view updated the selection) is a re-click. Re-clicking the
|
||||
// isolated section restores the full page.
|
||||
if (!wasReClick || item == nullptr || item != navTree->currentItem() || isolatedSection == nullptr ||
|
||||
fromWidgetId(item->data(0, SectionWidgetRole).value<WidgetId>()) != isolatedSection) {
|
||||
return;
|
||||
}
|
||||
bool ok = false;
|
||||
const int index = item->data(0, Qt::UserRole).toInt(&ok);
|
||||
if (ok) {
|
||||
// setActiveTab, not onTabClicked: the page is already current and
|
||||
// only setActiveTab clears the section filter
|
||||
setActiveTab(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void SettingsView::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 SettingsView::onTabClicked(int index)
|
||||
{
|
||||
if (searchActive) {
|
||||
switchToTabMode();
|
||||
} else if (index == currentTabIndex && isolatedSection == nullptr) {
|
||||
// Re-selecting the current page row is a no-op, but it must still clear
|
||||
// an isolated section so the page's widgets come back.
|
||||
return;
|
||||
}
|
||||
setActiveTab(index);
|
||||
}
|
||||
|
||||
void SettingsView::setActiveTab(int index)
|
||||
{
|
||||
if (index < 0 || index >= pageItems.size() || !pageItems[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
switchPageContent(index);
|
||||
|
||||
// Selecting the item also expands a collapsed group; selecting the already
|
||||
// current item emits nothing, so the currentItemChanged handler cannot loop.
|
||||
navTree->setCurrentItem(pageItems[index]);
|
||||
}
|
||||
|
||||
void SettingsView::switchPageContent(int index)
|
||||
{
|
||||
if (index < 0 || index >= pageItems.size() || !pageItems[index]) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTabIndex = index;
|
||||
pagesWidget->setCurrentIndex(index);
|
||||
|
||||
// A page row always shows the full page; sections filter down from here
|
||||
applySectionFilter(index, nullptr);
|
||||
|
||||
if (QTreeWidgetItem *parent = pageItems[index]->parent()) {
|
||||
parent->setExpanded(true);
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::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 SettingsView::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 SettingsView::switchToSearchMode()
|
||||
{
|
||||
searchActive = true;
|
||||
navTree->setVisible(false);
|
||||
pagesWidget->setVisible(false);
|
||||
searchResultsView->setVisible(true);
|
||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::switchToTabMode()
|
||||
{
|
||||
searchActive = false;
|
||||
navTree->setVisible(true);
|
||||
pagesWidget->setVisible(true);
|
||||
searchResultsView->setVisible(false);
|
||||
searchEdit->blockSignals(true);
|
||||
searchEdit->clear();
|
||||
searchEdit->blockSignals(false);
|
||||
setActiveTab(currentTabIndex);
|
||||
}
|
||||
|
||||
void SettingsView::onSearchResultClicked(const QModelIndex &index)
|
||||
{
|
||||
navigateToSearchResult(index);
|
||||
}
|
||||
|
||||
void SettingsView::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);
|
||||
|
||||
// Isolate the control's section, then scroll to the widget, focus it, and flash it
|
||||
if (entry.widget) {
|
||||
QWidget *section = nullptr;
|
||||
for (const SettingsPageSection &candidate : pages[entry.pageIndex]->getSections()) {
|
||||
if (candidate.sectionWidget == entry.widget ||
|
||||
(candidate.sectionWidget && candidate.sectionWidget->isAncestorOf(entry.widget))) {
|
||||
section = candidate.sectionWidget;
|
||||
break;
|
||||
}
|
||||
}
|
||||
applySectionFilter(entry.pageIndex, section);
|
||||
// Keep the sidebar in sync with the deep link: highlight the setting's section row
|
||||
selectSectionRow(entry.pageIndex, section);
|
||||
scrollToAndFlash(entry.widget);
|
||||
entry.widget->setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::navigateToSection(int pageIndex, QWidget *sectionWidget)
|
||||
{
|
||||
if (pageIndex < 0 || pageIndex >= pages.size() || !sectionWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Never steal the view's own selection mid-gesture: when this runs from
|
||||
// currentItemChanged the section row is already current (mouse press or arrow key),
|
||||
// and re-selecting the page row here is what made the selection visibly snap back
|
||||
// to the parent on mouse release. selectSectionRow only acts on a known section
|
||||
// that is not current yet, so it syncs programmatic callers without disturbing a gesture.
|
||||
if (currentTabIndex != pageIndex) {
|
||||
if (searchActive) {
|
||||
switchToTabMode();
|
||||
}
|
||||
const QSignalBlocker blocker(navTree);
|
||||
switchPageContent(pageIndex);
|
||||
}
|
||||
applySectionFilter(pageIndex, sectionWidget);
|
||||
|
||||
// Unknown widgets show the full page; known sections keep their row selected
|
||||
selectSectionRow(pageIndex, sectionWidget);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Selects the nav row for a section, expanding its page row
|
||||
*
|
||||
* A null or unknown section (a page whose sections are its own group boxes) leaves
|
||||
* the current selection untouched, so callers can pass a lookup result directly.
|
||||
*
|
||||
* @param pageIndex SettingsPage owning the section
|
||||
* @param sectionWidget Section's group box, or nullptr for no section
|
||||
*/
|
||||
void SettingsView::selectSectionRow(int pageIndex, QWidget *sectionWidget)
|
||||
{
|
||||
if (!sectionWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
QTreeWidgetItem *sectionItem = findSectionItem(pageIndex, sectionWidget);
|
||||
if (!sectionItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
pageItems[pageIndex]->setExpanded(true);
|
||||
if (navTree->currentItem() != sectionItem) {
|
||||
const QSignalBlocker blocker(navTree);
|
||||
navTree->setCurrentItem(sectionItem);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Finds the nav row for a section widget
|
||||
* @param pageIndex SettingsPage owning the section
|
||||
* @param sectionWidget Section's group box
|
||||
* @return The section's tree item, or nullptr when it has no row
|
||||
*/
|
||||
QTreeWidgetItem *SettingsView::findSectionItem(int pageIndex, QWidget *sectionWidget) const
|
||||
{
|
||||
if (pageIndex < 0 || pageIndex >= pageItems.size() || !pageItems[pageIndex]) {
|
||||
return nullptr;
|
||||
}
|
||||
for (int c = 0; c < pageItems[pageIndex]->childCount(); ++c) {
|
||||
QTreeWidgetItem *child = pageItems[pageIndex]->child(c);
|
||||
if (fromWidgetId(child->data(0, SectionWidgetRole).value<WidgetId>()) == sectionWidget) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Shows only the given section of a page, hiding its siblings
|
||||
*
|
||||
* A null sectionWidget restores the full page. Unknown widgets fall back to
|
||||
* the full page so a stale reference can never leave a blank page.
|
||||
*
|
||||
* @param pageIndex SettingsPage to filter
|
||||
* @param sectionWidget Section's group box, or nullptr to show all sections
|
||||
*/
|
||||
void SettingsView::applySectionFilter(int pageIndex, QWidget *sectionWidget)
|
||||
{
|
||||
if (pageIndex < 0 || pageIndex >= pages.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<SettingsPageSection> sections = pages[pageIndex]->getSections();
|
||||
|
||||
bool known = sectionWidget == nullptr;
|
||||
for (const SettingsPageSection §ion : sections) {
|
||||
if (section.sectionWidget == sectionWidget) {
|
||||
known = true;
|
||||
}
|
||||
}
|
||||
if (!known) {
|
||||
sectionWidget = nullptr;
|
||||
}
|
||||
isolatedSection = sectionWidget;
|
||||
|
||||
for (const SettingsPageSection §ion : sections) {
|
||||
if (section.sectionWidget) {
|
||||
section.sectionWidget->setVisible(sectionWidget == nullptr || section.sectionWidget == sectionWidget);
|
||||
}
|
||||
}
|
||||
|
||||
if (auto *scrollArea = qobject_cast<QScrollArea *>(pagesWidget->widget(pageIndex))) {
|
||||
if (QScrollBar *scrollBar = scrollArea->verticalScrollBar()) {
|
||||
scrollBar->setValue(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsView::scrollToAndFlash(QWidget *target)
|
||||
{
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
QWidget *widget = target;
|
||||
while (widget) {
|
||||
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
|
||||
scrollArea->ensureWidgetVisible(target);
|
||||
break;
|
||||
}
|
||||
widget = widget->parentWidget();
|
||||
}
|
||||
flashWidget(target);
|
||||
}
|
||||
|
||||
void SettingsView::setTab(int index)
|
||||
{
|
||||
if (index >= 0 && index < pageItems.size()) {
|
||||
setActiveTab(index);
|
||||
}
|
||||
}
|
||||
|
||||
AbstractSettingsPage *SettingsView::page(SettingsPage which) const
|
||||
{
|
||||
return pages.value(static_cast<int>(which));
|
||||
}
|
||||
|
||||
bool SettingsView::eventFilter(QObject *watched, QEvent *event)
|
||||
{
|
||||
if (watched == navTree->viewport()) {
|
||||
if (event->type() == QEvent::MouseButtonPress) {
|
||||
auto *mouseEvent = static_cast<QMouseEvent *>(event);
|
||||
// Left button only: context presses must never toggle the filter
|
||||
QTreeWidgetItem *pressedItem = navTree->itemAt(mouseEvent->position().toPoint());
|
||||
pressWasOnCurrentItem = mouseEvent->button() == Qt::LeftButton && pressedItem != nullptr &&
|
||||
pressedItem == navTree->currentItem();
|
||||
}
|
||||
return false; // observation only: the view still handles the press
|
||||
}
|
||||
|
||||
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 QWidget::eventFilter(watched, event);
|
||||
}
|
||||
|
||||
void SettingsView::retranslateUi()
|
||||
{
|
||||
retranslateNavNames();
|
||||
|
||||
// NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings
|
||||
// before the view was extracted, and keeping the context preserves existing translations.
|
||||
searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings..."));
|
||||
|
||||
// Rebuild search index for translated text
|
||||
buildSearchIndex();
|
||||
}
|
||||
|
||||
QStringList SettingsView::translatedPageNames()
|
||||
{
|
||||
// NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi).
|
||||
return {QCoreApplication::translate("DlgSettings", "General"),
|
||||
QCoreApplication::translate("DlgSettings", "Appearance"),
|
||||
QCoreApplication::translate("DlgSettings", "User Interface"),
|
||||
QCoreApplication::translate("DlgSettings", "Card Sources"),
|
||||
QCoreApplication::translate("DlgSettings", "Storage"),
|
||||
QCoreApplication::translate("DlgSettings", "Chat"),
|
||||
QCoreApplication::translate("DlgSettings", "Sound"),
|
||||
QCoreApplication::translate("DlgSettings", "Shortcuts")};
|
||||
}
|
||||
|
||||
void SettingsView::retranslateNavNames()
|
||||
{
|
||||
const QStringList groupNames = translatedGroupNames();
|
||||
for (int i = 0; i < groupItems.size() && i < groupNames.size(); ++i) {
|
||||
groupItems[i]->setText(0, groupNames[i]);
|
||||
}
|
||||
|
||||
const QStringList pageNames = translatedPageNames();
|
||||
for (int i = 0; i < pageItems.size() && i < pageNames.size(); ++i) {
|
||||
if (pageItems[i]) {
|
||||
pageItems[i]->setText(0, pageNames[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-page rows mirror the pages' group box titles (already retranslated by the pages)
|
||||
for (int i = 0; i < pageItems.size() && i < pages.size(); ++i) {
|
||||
if (!pageItems[i]) {
|
||||
continue;
|
||||
}
|
||||
const QList<SettingsPageSection> sections = pages[i]->getSections();
|
||||
Q_ASSERT(pageItems[i]->childCount() == sections.size());
|
||||
for (int c = 0; c < pageItems[i]->childCount() && c < sections.size(); ++c) {
|
||||
pageItems[i]->child(c)->setText(0, sections[c].title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList SettingsView::translatedGroupNames()
|
||||
{
|
||||
return {tr("Application"), tr("Cards & Data"), tr("Chat & Sound"), tr("Advanced")};
|
||||
}
|
||||
109
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
109
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* @file settings_view.h
|
||||
* @brief Settings content widget hosted by the settings dialog
|
||||
* @ingroup Dialogs
|
||||
*/
|
||||
#ifndef SETTINGS_VIEW_H
|
||||
#define SETTINGS_VIEW_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QWidget>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
class QModelIndex;
|
||||
class QObject;
|
||||
class QEvent;
|
||||
|
||||
class QStackedWidget;
|
||||
class QTreeWidget;
|
||||
class QTreeWidgetItem;
|
||||
class QListView;
|
||||
class QLineEdit;
|
||||
class AbstractSettingsPage;
|
||||
class SettingsSearchModel;
|
||||
class SettingsSearchDelegate;
|
||||
|
||||
/**
|
||||
* @brief Settings pages with grouped left navigation and search, without any dialog chrome
|
||||
*
|
||||
* Owns the search bar, the navigation tree, the stacked settings pages and the search
|
||||
* results view. The settings dialog only adds its own frame (buttons, titles).
|
||||
*/
|
||||
class SettingsView : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
/**
|
||||
* @brief Page order in the navigation, 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 SettingsView(QWidget *parent = nullptr);
|
||||
void setTab(int index);
|
||||
AbstractSettingsPage *page(SettingsPage which) const;
|
||||
|
||||
private slots:
|
||||
void onTabClicked(int index);
|
||||
void onSearchTextChanged(const QString &text);
|
||||
void onSearchResultClicked(const QModelIndex &index);
|
||||
|
||||
private:
|
||||
// UI elements
|
||||
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
||||
QTreeWidget *navTree; ///< Grouped navigation tree for the pages
|
||||
QList<QTreeWidgetItem *> groupItems; ///< Group header rows, indexed by group
|
||||
QList<QTreeWidgetItem *> pageItems; ///< Page rows, indexed by SettingsPage
|
||||
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
|
||||
QListView *searchResultsView; ///< Search results list view
|
||||
QWidget *pagesContainer; ///< Container stacking pages and search results
|
||||
|
||||
// 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
|
||||
QWidget *isolatedSection = nullptr; ///< Isolated section group box, or nullptr for full pages
|
||||
bool pressWasOnCurrentItem = false; ///< True when the mouse press started on the already-current item
|
||||
|
||||
void setupUi();
|
||||
void setupNavTree();
|
||||
void buildSearchIndex();
|
||||
void switchToTabMode();
|
||||
void switchToSearchMode();
|
||||
void navigateToSearchResult(const QModelIndex &index);
|
||||
void navigateToSection(int pageIndex, QWidget *sectionWidget);
|
||||
void setActiveTab(int index);
|
||||
void switchPageContent(int index);
|
||||
void applySectionFilter(int pageIndex, QWidget *sectionWidget);
|
||||
void selectSectionRow(int pageIndex, QWidget *sectionWidget);
|
||||
QTreeWidgetItem *findSectionItem(int pageIndex, QWidget *sectionWidget) const;
|
||||
static void flashWidget(QWidget *widget);
|
||||
static void scrollToAndFlash(QWidget *target);
|
||||
static QStringList translatedPageNames();
|
||||
static QStringList translatedGroupNames();
|
||||
|
||||
void retranslateUi();
|
||||
void retranslateNavNames();
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||
};
|
||||
|
||||
#endif // SETTINGS_VIEW_H
|
||||
|
|
@ -137,6 +137,30 @@ QList<SettingsSearchEntry> AbstractSettingsPage::getSearchEntries()
|
|||
return autoDetectSearchEntries(this, -1);
|
||||
}
|
||||
|
||||
QList<SettingsPageSection> AbstractSettingsPage::getSections()
|
||||
{
|
||||
QList<SettingsPageSection> sections;
|
||||
|
||||
// Walk the top-level layout in display order (creation order differs on some pages)
|
||||
if (QLayout *mainLayout = layout()) {
|
||||
for (int i = 0; i < mainLayout->count(); ++i) {
|
||||
QLayoutItem *item = mainLayout->itemAt(i);
|
||||
QWidget *widget = item ? item->widget() : nullptr;
|
||||
auto *groupBox = qobject_cast<QGroupBox *>(widget);
|
||||
if (!groupBox || groupBox->title().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sections.append({groupBox->title(), groupBox});
|
||||
}
|
||||
}
|
||||
|
||||
// A single group is the page itself; sub-page entries would only duplicate it
|
||||
if (sections.size() < 2) {
|
||||
sections.clear();
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
QList<SettingsSearchEntry> AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex)
|
||||
{
|
||||
QList<SettingsSearchEntry> entries;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QWidget>
|
||||
#include <qtmetamacros.h>
|
||||
|
||||
|
|
@ -11,12 +12,28 @@
|
|||
|
||||
struct SettingsSearchEntry;
|
||||
|
||||
/**
|
||||
* @brief A navigable section of a settings page (one group box)
|
||||
*/
|
||||
struct SettingsPageSection
|
||||
{
|
||||
QString title; ///< Translated group title
|
||||
QWidget *sectionWidget = nullptr; ///< Group box to scroll to and highlight
|
||||
};
|
||||
|
||||
class AbstractSettingsPage : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
virtual void retranslateUi() = 0;
|
||||
virtual QList<SettingsSearchEntry> getSearchEntries();
|
||||
/**
|
||||
* @brief Sections of this page for sub-page navigation, in display order
|
||||
*
|
||||
* Scans the page's direct-child group boxes; pages with a single group
|
||||
* report no sections so the nav stays flat for them.
|
||||
*/
|
||||
QList<SettingsPageSection> getSections();
|
||||
|
||||
protected:
|
||||
static QList<SettingsSearchEntry> autoDetectSearchEntries(QWidget *page, int pageIndex);
|
||||
|
|
|
|||
|
|
@ -82,10 +82,12 @@
|
|||
#include <QPixmapCache>
|
||||
#include <QPushButton>
|
||||
#include <QSharedPointer>
|
||||
#include <QSize>
|
||||
#include <QStatusBar>
|
||||
#include <QStringLiteral>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QWidget>
|
||||
#include <QtConcurrentRun>
|
||||
#include <QtGlobal>
|
||||
|
|
@ -355,6 +357,9 @@ void MainWindow::retranslateUi()
|
|||
aForgotPassword->setText(tr("&Restore password..."));
|
||||
aSettings->setText(tr("&Settings..."));
|
||||
aSettings->setIcon(themePixmap(QStringLiteral("icons/settings")));
|
||||
settingsButton->setToolTip(tr("Settings"));
|
||||
settingsButton->setAccessibleName(tr("Settings"));
|
||||
settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
|
||||
aExit->setText(tr("&Exit"));
|
||||
|
||||
#if defined(__APPLE__) /* For OSX */
|
||||
|
|
@ -559,6 +564,19 @@ MainWindow::MainWindow(QWidget *parent)
|
|||
|
||||
setCentralWidget(tabSupervisor);
|
||||
|
||||
// Unobtrusive settings entry in the tab bar corner: flat icon-only button that blends into the tab
|
||||
// bar until hovered, mirroring the menu action for discoverability
|
||||
settingsButton = new QToolButton(tabSupervisor);
|
||||
settingsButton->setAutoRaise(true);
|
||||
settingsButton->setToolButtonStyle(Qt::ToolButtonIconOnly);
|
||||
settingsButton->setIconSize(QSize(16, 16));
|
||||
settingsButton->setCursor(Qt::PointingHandCursor);
|
||||
settingsButton->setFocusPolicy(Qt::NoFocus);
|
||||
settingsButton->setStyleSheet(
|
||||
QStringLiteral("QToolButton { border: none; background: transparent; padding: 4px; }"));
|
||||
connect(settingsButton, &QToolButton::clicked, this, &MainWindow::actSettings);
|
||||
tabSupervisor->setCornerWidget(settingsButton, Qt::TopRightCorner);
|
||||
|
||||
retranslateUi();
|
||||
|
||||
if (!restoreGeometry(SettingsCache::instance().layouts().getMainWindowGeometry())) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
#include <qtmetamacros.h>
|
||||
|
||||
class QAction;
|
||||
class QToolButton;
|
||||
class QWidget;
|
||||
struct LocalGameOptions;
|
||||
|
||||
|
|
@ -178,6 +179,7 @@ private:
|
|||
QList<QMenu *> tabMenus;
|
||||
QMenu *cockatriceMenu, *dbMenu, *tabsMenu, *helpMenu, *trayIconMenu;
|
||||
QAction *aAbout, *aSettings, *aShow, *aExit;
|
||||
QToolButton *settingsButton = nullptr;
|
||||
QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen;
|
||||
QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet,
|
||||
*aReloadCardDatabase;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${
|
|||
set(servatrice_SOURCES
|
||||
src/email_parser.cpp
|
||||
src/event_loop_watchdog.cpp
|
||||
src/forwarded_address.cpp
|
||||
src/main.cpp
|
||||
src/metrics_registry.cpp
|
||||
src/ratelimiter.cpp
|
||||
|
|
@ -18,6 +19,7 @@ set(servatrice_SOURCES
|
|||
src/settingscache.cpp
|
||||
src/isl_interface.cpp
|
||||
src/signalhandler.cpp
|
||||
src/trusted_sources.cpp
|
||||
${VERSION_STRING_CPP}
|
||||
src/smtpclient.cpp
|
||||
src/smtp/qxthmac.cpp
|
||||
|
|
|
|||
|
|
@ -42,9 +42,11 @@ websocket_host=any
|
|||
websocket_port=4748
|
||||
|
||||
; If websockets are served through a reverse proxy (e.g. nginx), the real client address can be
|
||||
; read from an HTTP header such as X-Forwarded-For. The header is ONLY trusted when the websocket
|
||||
; connection itself originates from an address listed in security/trusted_sources (default 127.0.0.1,::1),
|
||||
; so that clients cannot spoof their address to bypass bans or rate limits.
|
||||
; read from an HTTP header such as X-Real-IP or X-Forwarded-For. A comma-separated X-Forwarded-For
|
||||
; list resolves to its rightmost entry, which is the one the proxy appended. The header is ONLY
|
||||
; trusted when the websocket connection itself originates from an address listed in
|
||||
; security/trusted_sources (default 127.0.0.1,::1), so that clients cannot spoof their address to
|
||||
; bypass bans or rate limits.
|
||||
web_socket_ip_header=
|
||||
|
||||
; When database is enabled, servatrice writes the server status in the "update" database table; this
|
||||
|
|
@ -465,7 +467,8 @@ legacy_backup_ttl_days=180
|
|||
|
||||
; You may want to allow an unlimited number of users from a trusted source. This setting can contain a
|
||||
; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the
|
||||
; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74"
|
||||
; IP addresses listed (ignoring the max_users_per_address). Entries must be exact IP addresses; hostnames, prefixes
|
||||
; and subnets are ignored. Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74"
|
||||
trusted_sources="127.0.0.1,::1"
|
||||
|
||||
; Servatrice can avoid users from flooding rooms with large number of messages in an interval of time.
|
||||
|
|
|
|||
21
servatrice/src/forwarded_address.cpp
Normal file
21
servatrice/src/forwarded_address.cpp
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#include "forwarded_address.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
QHostAddress parseForwardedAddress(const QByteArray &headerValue)
|
||||
{
|
||||
const QList<QByteArray> entries = headerValue.split(',');
|
||||
for (auto it = entries.crbegin(); it != entries.crend(); ++it) {
|
||||
const QByteArray entry = it->trimmed();
|
||||
if (entry.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const QHostAddress parsed(QString::fromLatin1(entry));
|
||||
if (!parsed.isNull()) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
18
servatrice/src/forwarded_address.h
Normal file
18
servatrice/src/forwarded_address.h
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#ifndef FORWARDED_ADDRESS_H
|
||||
#define FORWARDED_ADDRESS_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QHostAddress>
|
||||
|
||||
/**
|
||||
* @brief Parses the real client address out of a forwarded header value (X-Real-IP, X-Forwarded-For).
|
||||
*
|
||||
* A comma-separated list resolves to its rightmost usable entry. That entry was appended by the
|
||||
* proxy closest to the server and is the only one a direct client cannot choose, since the earlier
|
||||
* entries are passed through verbatim from whatever the client sent.
|
||||
*
|
||||
* @return the parsed address, or a null QHostAddress when the value holds none.
|
||||
*/
|
||||
QHostAddress parseForwardedAddress(const QByteArray &headerValue);
|
||||
|
||||
#endif
|
||||
|
|
@ -33,6 +33,7 @@
|
|||
#include "serversocketinterface.h"
|
||||
#include "settingscache.h"
|
||||
#include "smtpclient.h"
|
||||
#include "trusted_sources.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
|
|
@ -324,6 +325,14 @@ bool Servatrice::initServer()
|
|||
qDebug() << "Maximum websocket user limit:" << getMaxWebSocketUserLimit();
|
||||
}
|
||||
|
||||
// trusted_sources entries are matched as exact addresses; older configs may hold hostnames,
|
||||
// prefixes or subnets that substring matching used to accept and that now never match.
|
||||
for (const QString &entry : getTrustedSources().split(',', Qt::SkipEmptyParts)) {
|
||||
if (QHostAddress(entry.trimmed()).isNull()) {
|
||||
qWarning() << "security/trusted_sources entry is not an IP address and will be ignored:" << entry.trimmed();
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "Accept registered users only:" << getRegOnlyServerEnabled();
|
||||
qDebug() << "Registration enabled:" << getRegistrationEnabled();
|
||||
if (getRegistrationEnabled()) {
|
||||
|
|
@ -1324,6 +1333,11 @@ int Servatrice::getMaxUsersPerAddress() const
|
|||
return settingsCache->value("security/max_users_per_address", 4).toInt();
|
||||
}
|
||||
|
||||
QString Servatrice::getTrustedSources() const
|
||||
{
|
||||
return settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString();
|
||||
}
|
||||
|
||||
int Servatrice::getMessageCountingInterval() const
|
||||
{
|
||||
return settingsCache->value("security/message_counting_interval", 10).toInt();
|
||||
|
|
@ -1537,9 +1551,11 @@ bool Servatrice::isRateLimitExempt(const QString &ipAddress) const
|
|||
{
|
||||
// Mirrors the max_users_per_address bypass in initSession: trusted sources
|
||||
// (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket
|
||||
// client shares one address) are not subject to per-address limits.
|
||||
const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString();
|
||||
return trustedSources.contains(ipAddress, Qt::CaseInsensitive);
|
||||
// client shares one address) are not subject to per-address limits. Loopback
|
||||
// is always exempt, so a same-host proxy whose forwarded address could not be
|
||||
// resolved cannot lock every user out together when trusted_sources is
|
||||
// customized.
|
||||
return QHostAddress(ipAddress).isLoopback() || isTrustedSource(getTrustedSources(), ipAddress);
|
||||
}
|
||||
|
||||
void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName)
|
||||
|
|
|
|||
|
|
@ -321,6 +321,8 @@ public:
|
|||
int getDeckShareMaxDecksPerShare() const;
|
||||
int getDeckShareMaxSharesPerDay() const;
|
||||
int getMaxUsersPerAddress() const;
|
||||
/// Raw security/trusted_sources list; match addresses against it with isTrustedSource().
|
||||
QString getTrustedSources() const;
|
||||
int getMessageCountingInterval() const override;
|
||||
int getMaxMessageCountPerInterval() const override;
|
||||
int getMaxMessageSizePerInterval() const override;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
|
||||
#include "deck_tag_serialization.h"
|
||||
#include "email_parser.h"
|
||||
#include "forwarded_address.h"
|
||||
#include "libcockatrice/protocol/pb/admin_commands.pb.h"
|
||||
#include "libcockatrice/protocol/pb/developer_commands.pb.h"
|
||||
#include "libcockatrice/protocol/pb/moderator_commands.pb.h"
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
#include "server_logger.h"
|
||||
#include "server_protocolhandler.h"
|
||||
#include "settingscache.h"
|
||||
#include "trusted_sources.h"
|
||||
#include "version_string.h"
|
||||
|
||||
#include <QChar>
|
||||
|
|
@ -220,8 +222,7 @@ bool AbstractServerSocketInterface::initSession()
|
|||
delete identSe;
|
||||
|
||||
// allow unlimited number of connections from the trusted sources
|
||||
QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString();
|
||||
if (trustedSources.contains(getAddress(), Qt::CaseInsensitive)) {
|
||||
if (isTrustedSource(servatrice->getTrustedSources(), getPeerAddress())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1908,9 +1909,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com
|
|||
if (amountRemove != 0) {
|
||||
removeSaidMessages(userName, amountRemove);
|
||||
}
|
||||
QString trustedSources = settingsCache->value("server/trusted_sources", "127.0.0.1,::1").toString();
|
||||
int minutes = cmd.minutes();
|
||||
if (trustedSources.contains(address, Qt::CaseInsensitive)) {
|
||||
if (isTrustedSource(servatrice->getTrustedSources(), address)) {
|
||||
address = "";
|
||||
}
|
||||
|
||||
|
|
@ -4560,10 +4560,8 @@ void WebsocketServerSocketInterface::initConnection(void *_socket)
|
|||
if (websocketIPHeader.length() > 0 && socket->request().hasRawHeader(websocketIPHeader)) {
|
||||
// Only trust the forwarded header if the connection itself comes from a trusted proxy,
|
||||
// otherwise a client could spoof its address to bypass bans and rate limits.
|
||||
QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString();
|
||||
if (trustedSources.contains(socket->peerAddress().toString(), Qt::CaseInsensitive)) {
|
||||
QString header(socket->request().rawHeader(websocketIPHeader));
|
||||
QHostAddress parsed(header);
|
||||
if (isTrustedSource(servatrice->getTrustedSources(), socket->peerAddress())) {
|
||||
const QHostAddress parsed = parseForwardedAddress(socket->request().rawHeader(websocketIPHeader));
|
||||
if (!parsed.isNull()) {
|
||||
address = parsed;
|
||||
}
|
||||
|
|
|
|||
23
servatrice/src/trusted_sources.cpp
Normal file
23
servatrice/src/trusted_sources.cpp
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#include "trusted_sources.h"
|
||||
|
||||
#include <QStringList>
|
||||
|
||||
bool isTrustedSource(const QString &trustedSources, const QHostAddress &address)
|
||||
{
|
||||
if (address.isNull()) {
|
||||
return false;
|
||||
}
|
||||
const QStringList entries = trustedSources.split(',', Qt::SkipEmptyParts);
|
||||
for (const QString &entry : entries) {
|
||||
const QHostAddress trusted(entry.trimmed());
|
||||
if (!trusted.isNull() && trusted.isEqual(address, QHostAddress::ConvertV4MappedToIPv4)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isTrustedSource(const QString &trustedSources, const QString &address)
|
||||
{
|
||||
return isTrustedSource(trustedSources, QHostAddress(address.trimmed()));
|
||||
}
|
||||
20
servatrice/src/trusted_sources.h
Normal file
20
servatrice/src/trusted_sources.h
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#ifndef TRUSTED_SOURCES_H
|
||||
#define TRUSTED_SOURCES_H
|
||||
|
||||
#include <QHostAddress>
|
||||
#include <QString>
|
||||
|
||||
/**
|
||||
* @brief True if @p address is listed in the comma-separated @p trustedSources (security/trusted_sources).
|
||||
*
|
||||
* Entries are parsed as addresses and compared exactly, never as substrings, so
|
||||
* 27.0.0.1 does not match 127.0.0.1. IPv4-mapped IPv6 addresses compare equal to
|
||||
* their IPv4 form: a dual-stack listener reports a local proxy as ::ffff:127.0.0.1,
|
||||
* which matches a 127.0.0.1 entry. Malformed entries and null addresses never match.
|
||||
*/
|
||||
bool isTrustedSource(const QString &trustedSources, const QHostAddress &address);
|
||||
|
||||
/** @brief Overload for an address in string form; an unparsable string never matches. */
|
||||
bool isTrustedSource(const QString &trustedSources, const QString &address);
|
||||
|
||||
#endif
|
||||
|
|
@ -208,6 +208,28 @@ cockatrice_add_test(
|
|||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
forwarded_address_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/servatrice/src/forwarded_address.cpp
|
||||
forwarded_address_test.cpp
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/servatrice/src
|
||||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
trusted_sources_test
|
||||
SOURCES
|
||||
${CMAKE_SOURCE_DIR}/servatrice/src/trusted_sources.cpp
|
||||
trusted_sources_test.cpp
|
||||
INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/servatrice/src
|
||||
LABELS
|
||||
server
|
||||
)
|
||||
cockatrice_add_test(
|
||||
NAME
|
||||
server_card_counter_test
|
||||
|
|
|
|||
37
tests/forwarded_address_test.cpp
Normal file
37
tests/forwarded_address_test.cpp
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#include "forwarded_address.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
TEST(ForwardedAddressTest, ParsesSingleAddress)
|
||||
{
|
||||
EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("203.0.113.7"));
|
||||
EXPECT_EQ(QHostAddress("::1"), parseForwardedAddress("::1"));
|
||||
EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress(" 203.0.113.7 "));
|
||||
}
|
||||
|
||||
TEST(ForwardedAddressTest, UsesRightmostEntryOfAList)
|
||||
{
|
||||
// nginx's $proxy_add_x_forwarded_for appends its own view of the client to
|
||||
// whatever the client sent, so only the trailing entry is trustworthy.
|
||||
EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("198.51.100.9, 203.0.113.7"));
|
||||
EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("spoofed.invalid, 203.0.113.7"));
|
||||
EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("203.0.113.7, "));
|
||||
}
|
||||
|
||||
TEST(ForwardedAddressTest, KeepsIpv4MappedEntries)
|
||||
{
|
||||
EXPECT_EQ(QHostAddress("::ffff:203.0.113.7"), parseForwardedAddress("::ffff:203.0.113.7"));
|
||||
}
|
||||
|
||||
TEST(ForwardedAddressTest, RejectsValuesWithoutAnAddress)
|
||||
{
|
||||
EXPECT_TRUE(parseForwardedAddress("").isNull());
|
||||
EXPECT_TRUE(parseForwardedAddress(",").isNull());
|
||||
EXPECT_TRUE(parseForwardedAddress("unknown").isNull());
|
||||
EXPECT_TRUE(parseForwardedAddress("garbage, more garbage").isNull());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
58
tests/trusted_sources_test.cpp
Normal file
58
tests/trusted_sources_test.cpp
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
#include "trusted_sources.h"
|
||||
|
||||
#include "gtest/gtest.h"
|
||||
#include <QHostAddress>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
const QString kDefault = QStringLiteral("127.0.0.1,::1");
|
||||
|
||||
TEST(TrustedSourcesTest, MatchesListedAddresses)
|
||||
{
|
||||
ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("127.0.0.1")));
|
||||
ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("::1")));
|
||||
ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress("10.0.0.1")));
|
||||
}
|
||||
|
||||
TEST(TrustedSourcesTest, MatchesIpv4MappedPeerAgainstPlainIpv4Entry)
|
||||
{
|
||||
// A dual-stack listener reports a local IPv4 proxy as ::ffff:127.0.0.1;
|
||||
// it must still match the default 127.0.0.1 entry.
|
||||
ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("::ffff:127.0.0.1")));
|
||||
}
|
||||
|
||||
TEST(TrustedSourcesTest, MatchesPlainIpv4PeerAgainstMappedEntry)
|
||||
{
|
||||
ASSERT_TRUE(isTrustedSource("::ffff:192.0.2.7", QHostAddress("192.0.2.7")));
|
||||
}
|
||||
|
||||
TEST(TrustedSourcesTest, DoesNotMatchSubstrings)
|
||||
{
|
||||
// Substring matching would let 27.0.0.1 or 0.0.1 pass as 127.0.0.1.
|
||||
ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress("27.0.0.1")));
|
||||
ASSERT_FALSE(isTrustedSource("192.0.2.10", QHostAddress("192.0.2.1")));
|
||||
ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress(":")));
|
||||
}
|
||||
|
||||
TEST(TrustedSourcesTest, ToleratesWhitespaceAndCase)
|
||||
{
|
||||
ASSERT_TRUE(isTrustedSource(" 192.0.2.7 , 2001:DB8::1 ", QHostAddress("2001:db8::1")));
|
||||
ASSERT_TRUE(isTrustedSource(" 192.0.2.7 , 2001:DB8::1 ", QHostAddress("192.0.2.7")));
|
||||
}
|
||||
|
||||
TEST(TrustedSourcesTest, IgnoresMalformedEntriesAndNullAddresses)
|
||||
{
|
||||
ASSERT_FALSE(isTrustedSource("not-an-ip,,", QHostAddress("127.0.0.1")));
|
||||
ASSERT_FALSE(isTrustedSource("", QHostAddress("127.0.0.1")));
|
||||
ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress()));
|
||||
}
|
||||
|
||||
TEST(TrustedSourcesTest, StringOverloadParsesAddress)
|
||||
{
|
||||
ASSERT_TRUE(isTrustedSource(kDefault, QStringLiteral("::ffff:127.0.0.1")));
|
||||
ASSERT_FALSE(isTrustedSource(kDefault, QStringLiteral("27.0.0.1")));
|
||||
ASSERT_FALSE(isTrustedSource(kDefault, QString()));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
Loading…
Add table
Add a link
Reference in a new issue