diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index e59326214..5baf10b1e 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -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 diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index d41c948eb..9b20372db 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -1,205 +1,41 @@ /** * @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 -#include #include -#include -#include #include -#include -#include #include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include #include #include -#include -#include -#include -#include -#include #include -#include #include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include #include #include #include #include -#include -class QModelIndex; - -/** - * @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")}; -} - -DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false) +DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) { auto rec = QGuiApplication::primaryScreen()->availableGeometry(); setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); 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 +47,25 @@ 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)); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); + retranslateUi(); - 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 allEntries; - - const QStringList pageNames = translatedPageNames(); - searchDelegate->setPageNames(pageNames); - searchDelegate->setPageIcons(pageIconResources()); - - for (int i = 0; i < pages.size(); ++i) { - QList 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(widget)) { - scrollArea->ensureWidgetVisible(entry.widget); - break; - } - widget = widget->parentWidget(); - } - entry.widget->setFocus(); - flashWidget(entry.widget); - } + adjustSize(); } 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(which)); + return settingsView->page(which); } void DlgSettings::updateLanguage() @@ -454,49 +74,6 @@ void DlgSettings::updateLanguage() installNewTranslator(); } -bool DlgSettings::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == searchEdit && event->type() == QEvent::KeyPress) { - auto *keyEvent = static_cast(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 +153,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]); - } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 1f15e76d5..9942b410c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -1,105 +1,59 @@ /** * @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 -#include #include -#include -#include #include 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 so other hosts (e.g. a settings tab) can reuse them. */ 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 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 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 diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp new file mode 100644 index 000000000..c2d9860c3 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -0,0 +1,497 @@ +/** + * @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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @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")}; +} + +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); + + // 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, &SettingsView::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); + + // 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); + 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 SettingsView::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 SettingsView::buildSearchIndex() +{ + QList allEntries; + + const QStringList pageNames = translatedPageNames(); + searchDelegate->setPageNames(pageNames); + searchDelegate->setPageIcons(pageIconResources()); + + for (int i = 0; i < pages.size(); ++i) { + QList 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(); + } + setActiveTab(index); +} + +void SettingsView::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 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; + tabBarWidget->setVisible(false); + pagesWidget->setVisible(false); + searchResultsView->setVisible(true); + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } +} + +void SettingsView::switchToTabMode() +{ + searchActive = false; + tabBarWidget->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); + + // 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(widget)) { + scrollArea->ensureWidgetVisible(entry.widget); + break; + } + widget = widget->parentWidget(); + } + entry.widget->setFocus(); + flashWidget(entry.widget); + } +} + +void SettingsView::setTab(int index) +{ + if (index >= 0 && index < tabButtons.size()) { + setActiveTab(index); + } +} + +AbstractSettingsPage *SettingsView::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + +bool SettingsView::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == searchEdit && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(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() +{ + retranslateTabNames(); + + // 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::retranslateTabNames() +{ + const QStringList tabLabels = translatedPageNames(); + + for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { + tabButtons[i]->setText(tabLabels[i]); + } +} diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h new file mode 100644 index 000000000..fe1b925d8 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.h @@ -0,0 +1,98 @@ +/** + * @file settings_view.h + * @brief Settings content widget hosted by the settings dialog (and, later, the settings tab) + * @ingroup Dialogs + */ +#ifndef SETTINGS_VIEW_H +#define SETTINGS_VIEW_H + +#include +#include +#include +#include +#include + +class QModelIndex; +class QObject; +class QEvent; + +class QStackedWidget; +class QToolButton; +class QListView; +class QLineEdit; +class AbstractSettingsPage; +class SettingsSearchModel; +class SettingsSearchDelegate; + +/** + * @brief Settings pages with tabbed navigation and search, without any dialog chrome + * + * Owns the search bar, the tab bar, the stacked settings pages and the search + * results view. Dialog/tab hosts only add their own frame (buttons, titles). + */ +class SettingsView : public QWidget +{ + Q_OBJECT +public: + /** + * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * + * Use these values instead of raw indices so reordering pages never silently + * breaks external callers like tab_room.cpp. + */ + enum SettingsPage + { + GeneralPage = 0, + AppearancePage, + UserInterfacePage, + DeckEditorPage, + StoragePage, + MessagesPage, + SoundPage, + ShortcutsPage, + NumPages + }; + + explicit 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 + QWidget *tabBarWidget; ///< Container widget for the tab buttons + QList 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 + + // Data + QList pages; ///< All settings page instances + SettingsSearchModel *searchModel; ///< Model for search results + SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering + int currentTabIndex; ///< Currently active tab index + bool searchActive; ///< Whether search mode is active + + void setupUi(); + void setupTabBar(); + void buildSearchIndex(); + void switchToTabMode(); + void switchToSearchMode(); + void navigateToSearchResult(const QModelIndex &index); + void setActiveTab(int index); + static void flashWidget(QWidget *widget); + static QStringList translatedPageNames(); + + void retranslateUi(); + void retranslateTabNames(); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; +}; + +#endif // SETTINGS_VIEW_H