From 6f9a5584172706887e729842e8b332aa5eb58832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Thu, 30 Jul 2026 12:22:02 +0200 Subject: [PATCH 1/4] [App] Add onboarding wizard Took 10 minutes Took 3 minutes Took 7 minutes Took 9 minutes --- cmake/FindQtRuntime.cmake | 3 + cockatrice/CMakeLists.txt | 46 +- cockatrice/cockatrice.qrc | 1 + .../resources/cockatrice-logo-white.svg | 21 + cockatrice/resources/cockatrice.svg | 412 +++++----------- .../palette_editor/palette_editor_dialog.cpp | 34 +- cockatrice/src/interface/theme_manager.cpp | 13 + cockatrice/src/interface/theme_manager.h | 4 + .../widgets/dialogs/dlg_register.cpp | 196 +++++++- .../interface/widgets/dialogs/dlg_register.h | 35 +- .../widgets/onboarding/banner_shader_config.h | 250 ++++++++++ .../widgets/onboarding/first_run_wizard.cpp | 218 +++++++++ .../widgets/onboarding/first_run_wizard.h | 71 +++ .../onboarding/first_run_wizard_page.cpp | 1 + .../onboarding/first_run_wizard_page.h | 75 +++ .../onboarding/pages/account_setup_page.cpp | 56 +++ .../onboarding/pages/account_setup_page.h | 38 ++ .../pages/card_database_setup_page.cpp | 297 +++++++++++ .../pages/card_database_setup_page.h | 76 +++ .../widgets/onboarding/pages/finish_page.cpp | 30 ++ .../widgets/onboarding/pages/finish_page.h | 22 + .../pages/preferences_setup_page.cpp | 183 +++++++ .../onboarding/pages/preferences_setup_page.h | 51 ++ .../onboarding/pages/theme_setup_page.cpp | 201 ++++++++ .../onboarding/pages/theme_setup_page.h | 55 +++ .../widgets/onboarding/pages/welcome_page.cpp | 31 ++ .../widgets/onboarding/pages/welcome_page.h | 22 + .../widgets/onboarding/qml/BrandBanner.qml | 62 +++ .../onboarding/shader_banner_widget.cpp | 195 ++++++++ .../widgets/onboarding/shader_banner_widget.h | 83 ++++ .../onboarding/shaders/brand_banner.frag | 461 ++++++++++++++++++ .../onboarding/step_indicator_widget.cpp | 84 ++++ .../onboarding/step_indicator_widget.h | 34 ++ cockatrice/src/interface/window_main.cpp | 32 +- cockatrice/src/interface/window_main.h | 11 +- 35 files changed, 3076 insertions(+), 328 deletions(-) create mode 100644 cockatrice/resources/cockatrice-logo-white.svg create mode 100644 cockatrice/src/interface/widgets/onboarding/banner_shader_config.h create mode 100644 cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/first_run_wizard.h create mode 100644 cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/finish_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h create mode 100644 cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml create mode 100644 cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h create mode 100644 cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag create mode 100644 cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp create mode 100644 cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 485affe52..200199e59 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -19,10 +19,13 @@ if(WITH_CLIENT) Multimedia Network PrintSupport + ShaderTools Svg WebSockets Widgets Xml + Quick + QuickWidgets ) endif() if(WITH_ORACLE) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 7d0e22fd8..206e21632 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -352,14 +352,35 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp src/interface/key_signals.cpp src/interface/logger.cpp + src/interface/widgets/onboarding/banner_shader_config.h + src/interface/widgets/onboarding/first_run_wizard.cpp + src/interface/widgets/onboarding/first_run_wizard.h + src/interface/widgets/onboarding/first_run_wizard_page.cpp + src/interface/widgets/onboarding/first_run_wizard_page.h + src/interface/widgets/onboarding/pages/account_setup_page.cpp + src/interface/widgets/onboarding/pages/account_setup_page.h + src/interface/widgets/onboarding/pages/card_database_setup_page.cpp + src/interface/widgets/onboarding/pages/card_database_setup_page.h + src/interface/widgets/onboarding/pages/finish_page.cpp + src/interface/widgets/onboarding/pages/finish_page.h + src/interface/widgets/onboarding/pages/preferences_setup_page.cpp + src/interface/widgets/onboarding/pages/preferences_setup_page.h + src/interface/widgets/onboarding/pages/theme_setup_page.cpp + src/interface/widgets/onboarding/pages/theme_setup_page.h + src/interface/widgets/onboarding/pages/welcome_page.cpp + src/interface/widgets/onboarding/pages/welcome_page.h + src/interface/widgets/onboarding/shader_banner_widget.cpp + src/interface/widgets/onboarding/shader_banner_widget.h + src/interface/widgets/onboarding/step_indicator_widget.cpp + src/interface/widgets/onboarding/step_indicator_widget.h + src/interface/widgets/server/user/user_info_popup.cpp + src/interface/widgets/server/user/user_info_popup.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h - src/interface/widgets/server/user/user_info_popup.cpp - src/interface/widgets/server/user/user_info_popup.h ) add_subdirectory(sounds) @@ -431,6 +452,27 @@ if(Qt6_FOUND) ${cockatrice_MOC_SRCS} MANUAL_FINALIZATION ) + qt6_add_shaders( + cockatrice + "onboarding_shaders" + PREFIX + "/onboarding/shaders" + BASE + "src/interface/widgets/onboarding/shaders" + FILES + src/interface/widgets/onboarding/shaders/brand_banner.frag + ) + + qt6_add_resources( + cockatrice + "onboarding_qml" + PREFIX + "/onboarding/qml" + BASE + "src/interface/widgets/onboarding/qml" + FILES + src/interface/widgets/onboarding/qml/BrandBanner.qml + ) elseif(Qt5_FOUND) # Qt5 Translations need to be linked at executable creation time if(Qt5LinguistTools_FOUND) diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 9c34929b7..e21bdb0be 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -2,6 +2,7 @@ resources/cardback.svg resources/cockatrice.svg + resources/cockatrice-logo-white.svg resources/hand.svg resources/hr.jpg diff --git a/cockatrice/resources/cockatrice-logo-white.svg b/cockatrice/resources/cockatrice-logo-white.svg new file mode 100644 index 000000000..b3b31077f --- /dev/null +++ b/cockatrice/resources/cockatrice-logo-white.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/cockatrice/resources/cockatrice.svg b/cockatrice/resources/cockatrice.svg index d2e22da31..89ba62dcf 100644 --- a/cockatrice/resources/cockatrice.svg +++ b/cockatrice/resources/cockatrice.svg @@ -2,20 +2,20 @@ + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/" + sodipodi:docname="cockatrice.svg" + xmlns="http://www.w3.org/2000/svg"> + inkscape:current-layer="svg2" + inkscape:showpageshadow="0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#505050"> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -199,7 +159,7 @@ image/svg+xml - + @@ -213,170 +173,60 @@ inkscape:export-xdpi="91.459999" inkscape:export-ydpi="91.459999"> - - - - - - - - - - - - - - diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index adae6e152..9cde72c01 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -290,28 +290,42 @@ void PaletteEditorDialog::onSave() // Persist every scheme that changed, not just the one on screen. Each scheme // has its own file, so edits to the non-active scheme would otherwise be // silently discarded when the dialog closes. + // + // Save the loaded scheme last so commitPalette's global colour-scheme + // update (ThemeConfig::colorScheme) points at the active scheme. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { - const QString &scheme = it.key(); - if (it.value().colors == savedConfig.value(scheme).colors) { - continue; // unchanged — leave the on-disk file alone + if (it.key() == loadedScheme) { + continue; } - - if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) { + if (it.value().colors == savedConfig.value(it.key()).colors) { + continue; + } + if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(scheme), saveDir)); + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(it.key()), saveDir)); return; } } + // Commit the active scheme last so the global colour scheme matches. + if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) { + if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); + return; + } + } else { + // No palette change but scheme may have switched -- still update global config. + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); + globalCfg.colorScheme = loadedScheme; + globalCfg.save(saveDir); + } + // Keep the saved snapshot in sync so Reset behaves correctly afterwards. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { savedConfig[it.key()] = it.value(); } - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); - globalCfg.colorScheme = loadedScheme; - globalCfg.save(saveDir); - themeManager->reloadCurrentTheme(); accept(); } diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 518a97bc6..223f0cff0 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -268,6 +268,19 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } +bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) +{ + if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { + return false; + } + + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); + globalCfg.colorScheme = colorScheme; + globalCfg.save(themeDirPath); + + return true; +} + void ThemeManager::setColorScheme(const QString &scheme) { const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 861ab838b..67e3a8760 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -84,6 +84,10 @@ public: // theme directory when it is absent from the resolved (user) directory. static PaletteConfig loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); + /** @brief Writes cfg to disk as the theme's palette-.toml and updates the + * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave + * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ + static bool commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); void setColorScheme(const QString &scheme); void setStyleName(const QString &styleName); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index fce99a1a7..0e338eed7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,18 +1,62 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" +#include "../server/handle_public_servers.h" +#include "../server/user/user_info_connection.h" -#include +#include #include #include +#include #include #include #include +#include +#include +#include #include #include DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) { + // ── Server picker ────────────────────────────────────────────────── + previousHostButton = new QRadioButton(tr("Known Hosts"), this); + previousHosts = new QComboBox(this); + + btnDeleteServer = new QPushButton(this); + btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row")); + btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); + btnDeleteServer->setFixedWidth(30); + + connect(btnDeleteServer, &QPushButton::clicked, this, &DlgRegister::actRemoveSavedServer); + + hps = new HandlePublicServers(this); + btnRefreshServers = new QPushButton(this); + btnRefreshServers->setIcon(QPixmap("theme:icons/sync")); + btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); + btnRefreshServers->setFixedWidth(30); + + connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); }); + connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this, + &DlgRegister::rebuildComboBoxList); + connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers); + + newHostButton = new QRadioButton(tr("New Host"), this); + + auto *serverPickerRow = new QHBoxLayout; + serverPickerRow->addWidget(previousHosts); + serverPickerRow->addWidget(btnDeleteServer); + serverPickerRow->addWidget(btnRefreshServers); + + auto *serverGroupLayout = new QVBoxLayout; + serverGroupLayout->addWidget(previousHostButton); + serverGroupLayout->addLayout(serverPickerRow); + serverGroupLayout->addWidget(newHostButton); + + auto *serverGroupBox = new QGroupBox(tr("Server")); + serverGroupBox->setLayout(serverGroupLayout); + + // ── Registration fields ──────────────────────────────────────────── ServersSettings &servers = SettingsCache::instance().servers(); infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n" "Your email will be used to verify your account.")); @@ -321,26 +365,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) realnameEdit->setMaxLength(MAX_NAME_LENGTH); realnameLabel->setBuddy(realnameEdit); + // ── Layout ───────────────────────────────────────────────────────── auto *grid = new QGridLayout; - grid->addWidget(infoLabel, 0, 0, 1, 2); - grid->addWidget(hostLabel, 1, 0); - grid->addWidget(hostEdit, 1, 1); - grid->addWidget(portLabel, 2, 0); - grid->addWidget(portEdit, 2, 1); - grid->addWidget(playernameLabel, 3, 0); - grid->addWidget(playernameEdit, 3, 1); - grid->addWidget(passwordLabel, 4, 0); - grid->addWidget(passwordEdit, 4, 1); - grid->addWidget(passwordConfirmationLabel, 5, 0); - grid->addWidget(passwordConfirmationEdit, 5, 1); - grid->addWidget(emailLabel, 6, 0); - grid->addWidget(emailEdit, 6, 1); - grid->addWidget(emailConfirmationLabel, 7, 0); - grid->addWidget(emailConfirmationEdit, 7, 1); - grid->addWidget(countryLabel, 9, 0); - grid->addWidget(countryEdit, 9, 1); - grid->addWidget(realnameLabel, 10, 0); - grid->addWidget(realnameEdit, 10, 1); + grid->addWidget(serverGroupBox, 0, 0, 1, 2); + grid->addWidget(infoLabel, 1, 0, 1, 2); + grid->addWidget(hostLabel, 2, 0); + grid->addWidget(hostEdit, 2, 1); + grid->addWidget(portLabel, 3, 0); + grid->addWidget(portEdit, 3, 1); + grid->addWidget(playernameLabel, 4, 0); + grid->addWidget(playernameEdit, 4, 1); + grid->addWidget(passwordLabel, 5, 0); + grid->addWidget(passwordEdit, 5, 1); + grid->addWidget(passwordConfirmationLabel, 6, 0); + grid->addWidget(passwordConfirmationEdit, 6, 1); + grid->addWidget(emailLabel, 7, 0); + grid->addWidget(emailEdit, 7, 1); + grid->addWidget(emailConfirmationLabel, 8, 0); + grid->addWidget(emailConfirmationEdit, 8, 1); + grid->addWidget(countryLabel, 10, 0); + grid->addWidget(countryEdit, 10, 1); + grid->addWidget(realnameLabel, 11, 0); + grid->addWidget(realnameEdit, 11, 1); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk); @@ -352,13 +398,111 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) setLayout(mainLayout); setWindowTitle(tr("Register to server")); - setFixedHeight(sizeHint().height()); - setMinimumWidth(300); + setMinimumWidth(360); + + connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected); + connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected); + connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo); + + previousHostButton->setChecked(true); + + preRebuildComboBoxList(); +} + +DlgRegister::~DlgRegister() = default; + +void DlgRegister::downloadThePublicServers() +{ + btnRefreshServers->setDisabled(true); + previousHosts->clear(); + previousHosts->addItem(placeHolderText); + hps->downloadPublicServers(); +} + +void DlgRegister::preRebuildComboBoxList() +{ + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + if (savedHostList.size() == 1) { + downloadThePublicServers(); + } else { + rebuildComboBoxList(); + } +} + +void DlgRegister::rebuildComboBoxList(int failure) +{ + Q_UNUSED(failure); + + previousHosts->clear(); + + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + auto &servers = SettingsCache::instance().servers(); + QString previousHostName = servers.getPrevioushostName(); + + for (const auto &pair : savedHostList) { + const auto &tmp = pair.second; + QString saveName = tmp.getSaveName(); + if (saveName.size()) { + previousHosts->addItem(saveName); + if (saveName.compare(previousHostName) == 0) { + previousHosts->setCurrentIndex(previousHosts->count() - 1); + } + } + } + + btnRefreshServers->setDisabled(false); +} + +void DlgRegister::previousHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(false); + btnRefreshServers->setDisabled(false); + hostEdit->setDisabled(true); + portEdit->setDisabled(true); + } +} + +void DlgRegister::newHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(true); + btnRefreshServers->setDisabled(true); + hostEdit->setDisabled(false); + hostEdit->clear(); + hostEdit->setPlaceholderText(tr("Server URL")); + portEdit->setDisabled(false); + portEdit->clear(); + portEdit->setPlaceholderText(tr("Communication Port")); + playernameEdit->setDisabled(false); + playernameEdit->clear(); + } +} + +void DlgRegister::updateDisplayInfo(const QString &saveName) +{ + if (saveName.isEmpty() || saveName == placeHolderText) { + return; + } + + UserConnection_Information uci; + QStringList _data = uci.getServerInfo(saveName); + + if (_data.size() < 7) { + return; + } + + hostEdit->setText(_data.at(1)); + portEdit->setText(_data.at(2)); + playernameEdit->setText(_data.at(3)); } void DlgRegister::actOk() { - //! \todo This stuff should be using QValidators. if (passwordEdit->text().length() < 8) { QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short.")); return; @@ -377,3 +521,9 @@ void DlgRegister::actOk() accept(); } + +void DlgRegister::actRemoveSavedServer() +{ + SettingsCache::instance().servers().removeServer(hostEdit->text()); + previousHosts->removeItem(previousHosts->currentIndex()); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.h b/cockatrice/src/interface/widgets/dialogs/dlg_register.h index abed9ff51..2c8884a9f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.h @@ -1,25 +1,24 @@ -/** - * @file dlg_register.h - * @ingroup AccountDialogs - */ -//! \todo Document this file. - #ifndef DLG_REGISTER_H #define DLG_REGISTER_H #include #include #include +#include +class HandlePublicServers; class QLabel; class QPushButton; -class QCheckBox; +class QRadioButton; +class UserConnection_Information; class DlgRegister : public QDialog { Q_OBJECT public: explicit DlgRegister(QWidget *parent = nullptr); + ~DlgRegister() override; + [[nodiscard]] QString getHost() const { return hostEdit->text(); @@ -48,15 +47,35 @@ public: { return realnameEdit->text(); } + +public slots: + void downloadThePublicServers(); + private slots: void actOk(); + void previousHostSelected(bool state); + void newHostSelected(bool state); + void updateDisplayInfo(const QString &saveName); + void preRebuildComboBoxList(); + void rebuildComboBoxList(int failure = -1); + void actRemoveSavedServer(); private: + QRadioButton *newHostButton; + QRadioButton *previousHostButton; + QComboBox *previousHosts; + QPushButton *btnDeleteServer; + QPushButton *btnRefreshServers; + HandlePublicServers *hps; + QLabel *infoLabel, *hostLabel, *portLabel, *playernameLabel, *passwordLabel, *passwordConfirmationLabel, *emailLabel, *emailConfirmationLabel, *countryLabel, *realnameLabel; QLineEdit *hostEdit, *portEdit, *playernameEdit, *passwordEdit, *passwordConfirmationEdit, *emailEdit, *emailConfirmationEdit, *realnameEdit; QComboBox *countryEdit; + + QMap> savedHostList; + const QString placeHolderText = QStringLiteral("Downloading..."); }; -#endif +#endif // DLG_REGISTER_H diff --git a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h new file mode 100644 index 000000000..32f3e89c0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -0,0 +1,250 @@ +#ifndef BANNER_SHADER_CONFIG_H +#define BANNER_SHADER_CONFIG_H + +#include +#include + +/** + * Uniform values fed to brand_banner.frag, exposed to QML as the + * "bannerConfig" context property. + * + * Two independent "banks" (A/B) each carry their own mode/speed/seed so + * BrandBanner.qml can render both simultaneously and crossfade between + * them via opacity -- see frontIsA. The shared palette (colorA/colorB/ + * accent) and clock (time/aspect) apply to both banks identically, since + * only the foreground motif changes between onboarding pages, never the + * brand palette. + * + * Deliberately plain `property` (not `required property`) on the QML side + * -- a required-property shadowing bug bit the home-screen particle + * background before, and there's no reason to reintroduce that risk here. + */ +class BannerShaderConfig : public QObject +{ + Q_OBJECT + Q_PROPERTY(qreal time READ time WRITE setTime NOTIFY timeChanged) + Q_PROPERTY(qreal aspect READ aspect WRITE setAspect NOTIFY aspectChanged) + + Q_PROPERTY(qreal modeA READ modeA WRITE setModeA NOTIFY modeAChanged) + Q_PROPERTY(qreal speedA READ speedA WRITE setSpeedA NOTIFY speedAChanged) + Q_PROPERTY(qreal seedA READ seedA WRITE setSeedA NOTIFY seedAChanged) + + Q_PROPERTY(qreal modeB READ modeB WRITE setModeB NOTIFY modeBChanged) + Q_PROPERTY(qreal speedB READ speedB WRITE setSpeedB NOTIFY speedBChanged) + Q_PROPERTY(qreal seedB READ seedB WRITE setSeedB NOTIFY seedBChanged) + + Q_PROPERTY(bool frontIsA READ frontIsA WRITE setFrontIsA NOTIFY frontIsAChanged) + + Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged) + Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged) + Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged) + + Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged) + Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged) + +public: + explicit BannerShaderConfig(QObject *parent = nullptr) : QObject(parent) + { + } + + qreal time() const + { + return m_time; + } + void setTime(qreal v) + { + if (v != m_time) { + m_time = v; + emit timeChanged(); + } + } + + qreal aspect() const + { + return m_aspect; + } + void setAspect(qreal v) + { + if (v != m_aspect) { + m_aspect = v; + emit aspectChanged(); + } + } + + qreal modeA() const + { + return m_modeA; + } + void setModeA(qreal v) + { + if (v != m_modeA) { + m_modeA = v; + emit modeAChanged(); + } + } + qreal speedA() const + { + return m_speedA; + } + void setSpeedA(qreal v) + { + if (v != m_speedA) { + m_speedA = v; + emit speedAChanged(); + } + } + qreal seedA() const + { + return m_seedA; + } + void setSeedA(qreal v) + { + if (v != m_seedA) { + m_seedA = v; + emit seedAChanged(); + } + } + + qreal modeB() const + { + return m_modeB; + } + void setModeB(qreal v) + { + if (v != m_modeB) { + m_modeB = v; + emit modeBChanged(); + } + } + qreal speedB() const + { + return m_speedB; + } + void setSpeedB(qreal v) + { + if (v != m_speedB) { + m_speedB = v; + emit speedBChanged(); + } + } + qreal seedB() const + { + return m_seedB; + } + void setSeedB(qreal v) + { + if (v != m_seedB) { + m_seedB = v; + emit seedBChanged(); + } + } + + bool frontIsA() const + { + return m_frontIsA; + } + void setFrontIsA(bool v) + { + if (v != m_frontIsA) { + m_frontIsA = v; + emit frontIsAChanged(); + } + } + + QColor colorA() const + { + return m_colorA; + } + void setColorA(const QColor &c) + { + if (c != m_colorA) { + m_colorA = c; + emit colorAChanged(); + } + } + QColor colorB() const + { + return m_colorB; + } + void setColorB(const QColor &c) + { + if (c != m_colorB) { + m_colorB = c; + emit colorBChanged(); + } + } + QColor accent() const + { + return m_accent; + } + void setAccent(const QColor &c) + { + if (c != m_accent) { + m_accent = c; + emit accentChanged(); + } + } + + bool logoVisible() const + { + return m_logoVisible; + } + void setLogoVisible(bool v) + { + if (v != m_logoVisible) { + m_logoVisible = v; + emit logoVisibleChanged(); + } + } + + qreal logoGlow() const + { + return m_logoGlow; + } + void setLogoGlow(qreal v) + { + if (v != m_logoGlow) { + m_logoGlow = v; + emit logoGlowChanged(); + } + } + +signals: + void timeChanged(); + void aspectChanged(); + void modeAChanged(); + void speedAChanged(); + void seedAChanged(); + void modeBChanged(); + void speedBChanged(); + void seedBChanged(); + void frontIsAChanged(); + void colorAChanged(); + void colorBChanged(); + void accentChanged(); + void logoVisibleChanged(); + void logoGlowChanged(); + +private: + qreal m_time = 0.0; + qreal m_aspect = 16.0 / 9.0; + + qreal m_modeA = 0.0; + qreal m_speedA = 1.0; + qreal m_seedA = 0.0; + + qreal m_modeB = 0.0; + qreal m_speedB = 1.0; + qreal m_seedB = 0.0; + + bool m_frontIsA = true; + + QColor m_colorA{0x1A, 0x1A, 0x20}; + QColor m_colorB{0x0E, 0x0E, 0x12}; + QColor m_accent{0x8B, 0xDD, 0x6B}; + + bool m_logoVisible = false; + qreal m_logoGlow = 1.0; +}; + +#endif // BANNER_SHADER_CONFIG_H diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp new file mode 100644 index 000000000..618ac6f26 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -0,0 +1,218 @@ +#include "first_run_wizard.h" + +#include "first_run_wizard_page.h" +#include "pages/account_setup_page.h" +#include "pages/card_database_setup_page.h" +#include "pages/finish_page.h" +#include "pages/preferences_setup_page.h" +#include "pages/theme_setup_page.h" +#include "pages/welcome_page.h" +#include "shader_banner_widget.h" +#include "step_indicator_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) +{ + setWindowFlag(Qt::WindowContextHelpButtonHint, false); + setMinimumSize(640, 490); + resize(720, 550); + + bannerHost = new BannerHost(this); + + titleLabel = new QLabel(this); + QFont titleFont = titleLabel->font(); + titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + subtitleLabel = new QLabel(this); + subtitleLabel->setWordWrap(true); + + stack = new QStackedWidget(this); + stepIndicator = new StepIndicatorWidget(this); + + backButton = new QPushButton(this); + skipButton = new QPushButton(this); + nextButton = new QPushButton(this); + nextButton->setDefault(true); + + connect(backButton, &QPushButton::clicked, this, &FirstRunWizard::goBack); + connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); + connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); + + auto *headerLayout = new QVBoxLayout; + headerLayout->setContentsMargins(0, 0, 0, 0); + headerLayout->addWidget(bannerHost); + headerLayout->addSpacing(12); + headerLayout->addWidget(titleLabel); + headerLayout->addWidget(subtitleLabel); + + auto *navLayout = new QHBoxLayout; + navLayout->addWidget(backButton); + navLayout->addWidget(skipButton); + navLayout->addStretch(); + navLayout->addWidget(stepIndicator); + navLayout->addStretch(); + navLayout->addWidget(nextButton); + + auto *root = new QVBoxLayout(this); + root->addLayout(headerLayout); + root->addSpacing(8); + root->addWidget(stack, 1); + root->addSpacing(8); + root->addLayout(navLayout); + + auto *welcome = new WelcomePage(this); + auto *cardDb = new CardDatabaseSetupPage(this); + auto *theme = new ThemeSetupPage(this); + auto *account = new AccountSetupPage(this); + auto *prefs = new PreferencesSetupPage(this); + auto *finishPg = new FinishPage(this); + + cardDatabasePage = cardDb; + + connect(cardDb, &CardDatabaseSetupPage::updateRequested, this, &FirstRunWizard::cardDatabaseUpdateRequested); + connect(cardDb, &CardDatabaseSetupPage::manualSetupRequested, this, + &FirstRunWizard::manualCardDatabaseSetupRequested); + connect(account, &AccountSetupPage::registerRequested, this, &FirstRunWizard::registerRequested); + connect(account, &AccountSetupPage::connectRequested, this, &FirstRunWizard::connectRequested); + + connect(cardDb, &CardDatabaseSetupPage::advanceRequested, this, [this] { + if (stack->currentWidget() == cardDatabasePage) { + showPage(currentIndex + 1); + } + }); + + addPage(welcome); + addPage(cardDb); + addPage(theme); + addPage(account); + addPage(prefs); + addPage(finishPg); + + stepIndicator->setStepCount(pages.count()); + retranslateUi(); + showPage(0); +} + +void FirstRunWizard::addPage(FirstRunWizardPage *page) +{ + pages.append(page); + stack->addWidget(page); + connect(page, &FirstRunWizardPage::completeChanged, this, &FirstRunWizard::updateChrome); +} + +void FirstRunWizard::showPage(int index) +{ + if (index < 0 || index >= pages.count()) { + return; + } + currentIndex = index; + stack->setCurrentIndex(index); + pages[index]->initializePage(); + stepIndicator->setCurrentStep(index); + static const QList motifs = { + BannerHost::Motif::Welcome, BannerHost::Motif::CardDatabase, BannerHost::Motif::Theming, + BannerHost::Motif::Account, BannerHost::Motif::Preferences, BannerHost::Motif::Finish, + }; + if (index < motifs.size()) { + bannerHost->setMotif(motifs[index]); + } + titleLabel->setText(pages[index]->stepTitle()); + subtitleLabel->setText(pages[index]->stepSubtitle()); + subtitleLabel->setVisible(!pages[index]->stepSubtitle().isEmpty()); + updateChrome(); +} + +void FirstRunWizard::updateChrome() +{ + if (currentIndex < 0) { + return; + } + FirstRunWizardPage *page = pages[currentIndex]; + const bool isLast = (currentIndex == pages.count() - 1); + + backButton->setVisible(currentIndex > 0); + skipButton->setVisible(page->isSkippable()); + nextButton->setEnabled(page->isComplete()); + + QString customText = page->nextButtonText(); + if (!customText.isEmpty()) { + nextButton->setText(customText); + } else { + nextButton->setText(isLast ? tr("Finish") : tr("Next")); + } +} + +void FirstRunWizard::goNext() +{ + FirstRunWizardPage *page = pages[currentIndex]; + if (!page->validatePage() || !page->handleNextClick()) { + return; + } + if (currentIndex == pages.count() - 1) { + finish(); + return; + } + showPage(currentIndex + 1); +} + +void FirstRunWizard::goBack() +{ + showPage(currentIndex - 1); +} + +void FirstRunWizard::skip() +{ + showPage(currentIndex + 1); +} + +void FirstRunWizard::onCardDatabaseUpdateFinished(bool success) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateFinished(success); + } +} + +void FirstRunWizard::finish() +{ + accept(); +} + +void FirstRunWizard::closeEvent(QCloseEvent *event) +{ + // Every step persists its own choice as it's made, so closing early + // isn't destructive -- treat it exactly like reaching the end. + QDialog::closeEvent(event); +} + +void FirstRunWizard::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QDialog::changeEvent(event); +} + +void FirstRunWizard::retranslateUi() +{ + setWindowTitle(tr("Welcome to Cockatrice")); + backButton->setText(tr("Back")); + skipButton->setText(tr("Skip")); + for (FirstRunWizardPage *page : std::as_const(pages)) { + page->retranslateUi(); + } + if (currentIndex >= 0) { + titleLabel->setText(pages[currentIndex]->stepTitle()); + subtitleLabel->setText(pages[currentIndex]->stepSubtitle()); + } + updateChrome(); +} diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h new file mode 100644 index 000000000..2c186ef95 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -0,0 +1,71 @@ +#ifndef FIRST_RUN_WIZARD_H +#define FIRST_RUN_WIZARD_H + +#include +#include + +class BannerHost; +class FirstRunWizardPage; +class StepIndicatorWidget; +class CardDatabaseSetupPage; +class QLabel; +class QPushButton; +class QStackedWidget; + +/** @brief Polished first-run onboarding flow: card database setup, theme + * selection, server account setup, and a handful of key preferences. + * + * Deliberately ignorant of network/registration/download internals -- + * pages that need them emit request signals for MainWindow to fulfill. + * Every choice is written to SettingsCache as it's made (via the pages + * themselves, same as AppearanceSettingsPage does), so "Skip" or closing + * the window never discards anything already confirmed. */ +class FirstRunWizard : public QDialog +{ + Q_OBJECT + +public: + explicit FirstRunWizard(QWidget *parent = nullptr); + +signals: + void registerRequested(); + void connectRequested(); + void cardDatabaseUpdateRequested(); + void manualCardDatabaseSetupRequested(); + +public slots: + /** @brief Forwarded from MainWindow once the background card database update process exits. */ + void onCardDatabaseUpdateFinished(bool success); + +protected: + void closeEvent(QCloseEvent *event) override; + void changeEvent(QEvent *event) override; + +private slots: + void goNext(); + void goBack(); + void skip(); + void updateChrome(); + +private: + void addPage(FirstRunWizardPage *page); + void showPage(int index); + void retranslateUi(); + void finish(); + + QStackedWidget *stack; + StepIndicatorWidget *stepIndicator; + BannerHost *bannerHost; + QLabel *titleLabel; + QLabel *subtitleLabel; + QPushButton *backButton; + QPushButton *skipButton; + QPushButton *nextButton; + + CardDatabaseSetupPage *cardDatabasePage = nullptr; + + QList pages; + int currentIndex = -1; +}; + +#endif // FIRST_RUN_WIZARD_H diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp new file mode 100644 index 000000000..6da8958f2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp @@ -0,0 +1 @@ +#include "first_run_wizard_page.h" diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h new file mode 100644 index 000000000..bdcd123bd --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h @@ -0,0 +1,75 @@ +#ifndef FIRST_RUN_WIZARD_PAGE_H +#define FIRST_RUN_WIZARD_PAGE_H + +#include + +/** @brief Base class for a single step of FirstRunWizard. + * + * QWidget-based rather than QWizardPage-based: FirstRunWizard is a + * QDialog + QStackedWidget shell (not a QWizard) so it can own the + * banner/step-dot chrome that QWizard's native styles don't give us + * consistent control over. Naming mirrors OracleWizardPage for + * familiarity only -- the two hierarchies are unrelated. */ +class FirstRunWizardPage : public QWidget +{ + Q_OBJECT + +public: + explicit FirstRunWizardPage(QWidget *parent = nullptr) : QWidget(parent) + { + } + + /** @brief Called every time the page becomes visible, including navigating back to it. */ + virtual void initializePage() + { + } + + /** @brief Called before advancing past this page. Return false to block navigation; + the page itself is responsible for telling the user why. */ + virtual bool validatePage() + { + return true; + } + + /** @brief Whether Next/Finish should currently be enabled. Pages doing async work + can flip this mid-step; emit completeChanged() when they do. */ + virtual bool isComplete() const + { + return true; + } + + /** @brief Whether the wizard's "Skip" button should be offered on this page. */ + virtual bool isSkippable() const + { + return false; + } + + virtual QString stepTitle() const = 0; + virtual QString stepSubtitle() const + { + return {}; + } + + /** @brief Override to replace the "Next"/"Finish" button text on this page. + Return an empty string to use the default label. */ + virtual QString nextButtonText() const + { + return {}; + } + + /** @brief Called when the user presses the Next button. Return true to allow + advancing to the next page, false to stay on this page (e.g. to + trigger an async action first). */ + virtual bool handleNextClick() + { + return true; + } + + virtual void retranslateUi() = 0; + +signals: + void completeChanged(); + void advanceRequested(); +}; + +#endif // FIRST_RUN_WIZARD_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp new file mode 100644 index 000000000..2107ea8bf --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp @@ -0,0 +1,56 @@ +#include "account_setup_page.h" + +#include +#include +#include + +AccountSetupPage::AccountSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + registerButton = new QPushButton(this); + connectButton = new QPushButton(this); + skipHintLabel = new QLabel(this); + skipHintLabel->setWordWrap(true); + skipHintLabel->setAlignment(Qt::AlignCenter); + + connect(registerButton, &QPushButton::clicked, this, &AccountSetupPage::registerRequested); + connect(connectButton, &QPushButton::clicked, this, &AccountSetupPage::connectRequested); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addSpacing(16); + layout->addWidget(registerButton, 0, Qt::AlignHCenter); + layout->addWidget(connectButton, 0, Qt::AlignHCenter); + layout->addSpacing(16); + layout->addWidget(skipHintLabel); + layout->addStretch(); + + retranslateUi(); +} + +bool AccountSetupPage::isSkippable() const +{ + return true; +} + +QString AccountSetupPage::stepTitle() const +{ + return tr("Join a Server"); +} + +QString AccountSetupPage::stepSubtitle() const +{ + return tr("Optional — you can always do this later from the menu."); +} + +void AccountSetupPage::retranslateUi() +{ + bodyLabel->setText(tr("Playing online needs a server account.")); + registerButton->setText(tr("Register a new account…")); + connectButton->setText(tr("I already have one — Connect…")); + skipHintLabel->setText(tr("Just want to play locally? Skip this and connect whenever you're ready.")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h new file mode 100644 index 000000000..0d9b76699 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h @@ -0,0 +1,38 @@ +#ifndef ACCOUNT_SETUP_PAGE_H +#define ACCOUNT_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; +class QPushButton; + +/** @brief First-run account step. Does NOT embed DlgRegister's fields: they exist + * to be handed to ConnectionController's network registration flow, which + * this wizard has no visibility into. Reimplementing the fields here + * without that wiring would look functional and silently do nothing -- + * worse than reuse. So: a friendly landing spot that opens the *existing* + * DlgRegister / connect flow via signals FirstRunWizard forwards. */ +class AccountSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit AccountSetupPage(QWidget *parent = nullptr); + + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +signals: + void registerRequested(); + void connectRequested(); + +private: + QLabel *bodyLabel; + QPushButton *registerButton; + QPushButton *connectButton; + QLabel *skipHintLabel; +}; + +#endif // ACCOUNT_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp new file mode 100644 index 000000000..688350a73 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -0,0 +1,297 @@ +#include "card_database_setup_page.h" + +#include "../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardDatabaseSetupPage::CardDatabaseSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setAlignment(Qt::AlignCenter); + + progressBar = new QProgressBar(this); + progressBar->setRange(0, 0); + progressBar->setTextVisible(false); + progressBar->setFixedWidth(280); + + retryButton = new QPushButton(this); + manualButton = new QPushButton(this); + + connect(retryButton, &QPushButton::clicked, this, [this] { + setState(State::Running); + emit updateRequested(); + }); + connect(manualButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::manualSetupRequested); + + // ── Advanced: custom download source ─────────────────────────────── + advancedToggleButton = new QPushButton(this); + advancedToggleButton->setCheckable(true); + advancedToggleButton->setChecked(false); + advancedToggleButton->setFlat(true); + advancedToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" + "QPushButton:checked { }"); + + advancedPanel = new QWidget(this); + advancedPanel->setVisible(false); + + urlLineEdit = new QLineEdit(advancedPanel); + urlHintLabel = new QLabel(advancedPanel); + urlHintLabel->setWordWrap(true); + + restoreDefaultUrlButton = new QPushButton(advancedPanel); + applyAndRetryButton = new QPushButton(advancedPanel); + + connect(advancedToggleButton, &QPushButton::toggled, this, &CardDatabaseSetupPage::onToggleAdvanced); + connect(restoreDefaultUrlButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onRestoreDefaultUrl); + connect(applyAndRetryButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onApplyCustomUrl); + + auto *advancedButtonRow = new QHBoxLayout; + advancedButtonRow->addWidget(restoreDefaultUrlButton); + advancedButtonRow->addStretch(); + advancedButtonRow->addWidget(applyAndRetryButton); + + auto *advancedLayout = new QVBoxLayout(advancedPanel); + advancedLayout->setContentsMargins(12, 4, 12, 4); + advancedLayout->addWidget(urlLineEdit); + advancedLayout->addWidget(urlHintLabel); + advancedLayout->addLayout(advancedButtonRow); + + // ── Startup card update check ─────────────────────────────────────── + auto &upd = SettingsCache::instance().updates(); + + const auto updateBehavior = [this] { + auto &u = SettingsCache::instance().updates(); + int idx = startupBehaviorCombo->currentIndex(); + u.setStartupCardUpdateCheckPromptForUpdate(idx == 1); + u.setStartupCardUpdateCheckAlwaysUpdate(idx == 2); + }; + + startupBehaviorLabel = new QLabel(this); + startupBehaviorCombo = new QComboBox(this); + startupBehaviorCombo->addItem(QString()); // placeholder, filled in retranslateUi + startupBehaviorCombo->addItem(QString()); + startupBehaviorCombo->addItem(QString()); + if (upd.getStartupCardUpdateCheckPromptForUpdate()) { + startupBehaviorCombo->setCurrentIndex(1); + } else if (upd.getStartupCardUpdateCheckAlwaysUpdate()) { + startupBehaviorCombo->setCurrentIndex(2); + } else { + startupBehaviorCombo->setCurrentIndex(0); + } + connect(startupBehaviorCombo, QOverload::of(&QComboBox::currentIndexChanged), this, updateBehavior); + + checkIntervalLabel = new QLabel(this); + checkIntervalSpinBox = new QSpinBox(this); + checkIntervalSpinBox->setMinimum(1); + checkIntervalSpinBox->setMaximum(30); + checkIntervalSpinBox->setValue(upd.getCardUpdateCheckInterval()); + connect(checkIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), &upd, + &UpdatesSettings::setCardUpdateCheckInterval); + + auto *checkGrid = new QGridLayout; + checkGrid->addWidget(startupBehaviorLabel, 0, 0); + checkGrid->addWidget(startupBehaviorCombo, 0, 1); + checkGrid->addWidget(checkIntervalLabel, 1, 0); + checkGrid->addWidget(checkIntervalSpinBox, 1, 1); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(statusLabel); + layout->addSpacing(12); + layout->addWidget(progressBar, 0, Qt::AlignHCenter); + layout->addSpacing(12); + layout->addWidget(retryButton, 0, Qt::AlignHCenter); + layout->addWidget(manualButton, 0, Qt::AlignHCenter); + layout->addSpacing(16); + layout->addWidget(advancedToggleButton); + layout->addWidget(advancedPanel); + layout->addSpacing(8); + layout->addLayout(checkGrid); + layout->addStretch(); + + retranslateUi(); +} + +bool CardDatabaseSetupPage::alreadyHaveDatabase() const +{ + return CardDatabaseManager::getInstance()->getCardList().count() > 0; +} + +QString CardDatabaseSetupPage::oracleSettingsFilePath() const +{ + return SettingsCache::instance().getSettingsPath() + "oracle.ini"; +} + +QString CardDatabaseSetupPage::readCustomUrl() const +{ + QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); + return oracleSettings.value("allsetsurl").toString(); +} + +void CardDatabaseSetupPage::writeCustomUrl(const QString &url) +{ + QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); + if (url.isEmpty()) { + oracleSettings.remove("allsetsurl"); + } else { + oracleSettings.setValue("allsetsurl", url); + } +} + +void CardDatabaseSetupPage::initializePage() +{ + urlLineEdit->setText(readCustomUrl()); + + if (state != State::NotStarted) { + return; + } + + if (alreadyHaveDatabase()) { + setState(State::Succeeded); + return; + } + + // Don't auto-download — wait for the user to press "Download". + setState(State::NotStarted); + statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); +} + +void CardDatabaseSetupPage::onUpdateFinished(bool success) +{ + setState(success ? State::Succeeded : State::Failed); + if (success) { + emit advanceRequested(); + } +} + +QString CardDatabaseSetupPage::nextButtonText() const +{ + return state == State::NotStarted ? tr("Download") : QString(); +} + +bool CardDatabaseSetupPage::handleNextClick() +{ + if (state == State::NotStarted) { + setState(State::Running); + emit updateRequested(); + return false; + } + return true; +} + +void CardDatabaseSetupPage::onToggleAdvanced(bool open) +{ + advancedToggleButton->setText(open ? tr("▼ Advanced: custom download source") + : tr("▶ Advanced: custom download source")); + advancedPanel->setVisible(open); +} + +void CardDatabaseSetupPage::onApplyCustomUrl() +{ + const QString text = urlLineEdit->text().trimmed(); + + if (!text.isEmpty()) { + const QUrl url = QUrl::fromUserInput(text); + if (!url.isValid()) { + QMessageBox::warning(this, tr("Invalid URL"), + tr("That doesn't look like a valid URL. Double-check it and try again, " + "or clear the field to use the default source.")); + return; + } + } + + writeCustomUrl(text); + setState(State::Running); + emit updateRequested(); +} + +void CardDatabaseSetupPage::onRestoreDefaultUrl() +{ + urlLineEdit->clear(); + writeCustomUrl(QString()); +} + +void CardDatabaseSetupPage::setState(State newState) +{ + state = newState; + + progressBar->setVisible(state == State::Running); + retryButton->setVisible(state == State::Failed); + manualButton->setVisible(state == State::Failed); + applyAndRetryButton->setEnabled(state != State::Running); + + switch (state) { + case State::NotStarted: + statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); + break; + case State::Running: + statusLabel->setText(tr("Downloading the latest card database…")); + break; + case State::Succeeded: + statusLabel->setText(tr("Card database ready ✓")); + break; + case State::Failed: + statusLabel->setText( + tr("Couldn't download the card database automatically. Check your connection and retry, " + "set it up manually, or skip this for now — you can do it later from the Card Database menu.")); + break; + } + + emit completeChanged(); +} + +bool CardDatabaseSetupPage::isComplete() const +{ + return state != State::Running; +} + +bool CardDatabaseSetupPage::isSkippable() const +{ + return state != State::Succeeded; +} + +QString CardDatabaseSetupPage::stepTitle() const +{ + return tr("Card Database"); +} + +QString CardDatabaseSetupPage::stepSubtitle() const +{ + return tr("Cockatrice needs card data to know what you're playing with."); +} + +void CardDatabaseSetupPage::retranslateUi() +{ + retryButton->setText(tr("Retry")); + manualButton->setText(tr("Set up manually…")); + + onToggleAdvanced(advancedToggleButton->isChecked()); + urlLineEdit->setPlaceholderText(tr("Leave blank to use the default source")); + urlHintLabel->setText(tr("Only change this if you know you need a mirror or a custom card data source.")); + restoreDefaultUrlButton->setText(tr("Restore default")); + applyAndRetryButton->setText(tr("Apply && retry")); + + startupBehaviorLabel->setText(tr("Check for card database updates on startup")); + startupBehaviorCombo->setItemText(0, tr("Don't check")); + startupBehaviorCombo->setItemText(1, tr("Prompt for update")); + startupBehaviorCombo->setItemText(2, tr("Always update in the background")); + + checkIntervalLabel->setText(tr("Check for card database updates every")); + checkIntervalSpinBox->setSuffix(tr(" days")); + + setState(state); +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h new file mode 100644 index 000000000..c030718cd --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -0,0 +1,76 @@ +#ifndef CARD_DATABASE_SETUP_PAGE_H +#define CARD_DATABASE_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QComboBox; +class QLabel; +class QLineEdit; +class QProgressBar; +class QPushButton; +class QSpinBox; +class QWidget; + +class CardDatabaseSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit CardDatabaseSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool isComplete() const override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + QString nextButtonText() const override; + bool handleNextClick() override; + void retranslateUi() override; + + void onUpdateFinished(bool success); + +signals: + void updateRequested(); + void manualSetupRequested(); + +private: + enum class State + { + NotStarted, + Running, + Succeeded, + Failed, + }; + + void setState(State newState); + bool alreadyHaveDatabase() const; + + QString oracleSettingsFilePath() const; + QString readCustomUrl() const; + void writeCustomUrl(const QString &url); + + void onToggleAdvanced(bool open); + void onApplyCustomUrl(); + void onRestoreDefaultUrl(); + + QLabel *statusLabel; + QProgressBar *progressBar; + QPushButton *retryButton; + QPushButton *manualButton; + + QPushButton *advancedToggleButton; + QWidget *advancedPanel; + QLineEdit *urlLineEdit; + QLabel *urlHintLabel; + QPushButton *restoreDefaultUrlButton; + QPushButton *applyAndRetryButton; + + QLabel *startupBehaviorLabel; + QComboBox *startupBehaviorCombo; + QLabel *checkIntervalLabel; + QSpinBox *checkIntervalSpinBox; + + State state = State::NotStarted; +}; + +#endif // CARD_DATABASE_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp new file mode 100644 index 000000000..4205fa532 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp @@ -0,0 +1,30 @@ +#include "finish_page.h" + +#include +#include + +FinishPage::FinishPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addStretch(); + + retranslateUi(); +} + +QString FinishPage::stepTitle() const +{ + return tr("You're All Set"); +} + +void FinishPage::retranslateUi() +{ + bodyLabel->setText( + tr("That's everything for now. Jump into Settings any time to change your mind about any of this.\n\n" + "Have fun!")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h new file mode 100644 index 000000000..40ebc6ed0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h @@ -0,0 +1,22 @@ +#ifndef FINISH_PAGE_H +#define FINISH_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; + +class FinishPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit FinishPage(QWidget *parent = nullptr); + + QString stepTitle() const override; + void retranslateUi() override; + +private: + QLabel *bodyLabel; +}; + +#endif // FINISH_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp new file mode 100644 index 000000000..0f4f500be --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -0,0 +1,183 @@ +#include "preferences_setup_page.h" + +#include "../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QGroupBox *makeGroup(QWidget *parent, QVBoxLayout *outerLayout) +{ + auto *group = new QGroupBox(parent); + new QVBoxLayout(group); + outerLayout->addWidget(group); + return group; +} +} // namespace + +PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + auto *content = new QWidget; + auto *contentLayout = new QVBoxLayout(content); + + appearanceGroup = makeGroup(content, contentLayout); + styleUserListCheckBox = new QCheckBox(appearanceGroup); + cardScalingCheckBox = new QCheckBox(appearanceGroup); + roundCardCornersCheckBox = new QCheckBox(appearanceGroup); + displayCardNamesCheckBox = new QCheckBox(appearanceGroup); + autoRotateCardsCheckBox = new QCheckBox(appearanceGroup); + tapAnimationCheckBox = new QCheckBox(appearanceGroup); + appearanceGroup->layout()->addWidget(styleUserListCheckBox); + appearanceGroup->layout()->addWidget(cardScalingCheckBox); + appearanceGroup->layout()->addWidget(roundCardCornersCheckBox); + appearanceGroup->layout()->addWidget(displayCardNamesCheckBox); + appearanceGroup->layout()->addWidget(autoRotateCardsCheckBox); + appearanceGroup->layout()->addWidget(tapAnimationCheckBox); + + notificationsGroup = makeGroup(content, contentLayout); + notificationsEnabledCheckBox = new QCheckBox(notificationsGroup); + soundEnabledCheckBox = new QCheckBox(notificationsGroup); + notificationsGroup->layout()->addWidget(notificationsEnabledCheckBox); + notificationsGroup->layout()->addWidget(soundEnabledCheckBox); + + gameplayGroup = makeGroup(content, contentLayout); + doubleClickToPlayCheckBox = new QCheckBox(gameplayGroup); + horizontalHandCheckBox = new QCheckBox(gameplayGroup); + playToStackCheckBox = new QCheckBox(gameplayGroup); + gameplayGroup->layout()->addWidget(doubleClickToPlayCheckBox); + gameplayGroup->layout()->addWidget(horizontalHandCheckBox); + gameplayGroup->layout()->addWidget(playToStackCheckBox); + + menuGroup = makeGroup(content, contentLayout); + showShortcutsCheckBox = new QCheckBox(menuGroup); + menuGroup->layout()->addWidget(showShortcutsCheckBox); + + dataGroup = makeGroup(content, contentLayout); + picDownloadCheckBox = new QCheckBox(dataGroup); + checkUpdatesOnStartupCheckBox = new QCheckBox(dataGroup); + showTipsOnStartupCheckBox = new QCheckBox(dataGroup); + dataGroup->layout()->addWidget(picDownloadCheckBox); + dataGroup->layout()->addWidget(checkUpdatesOnStartupCheckBox); + dataGroup->layout()->addWidget(showTipsOnStartupCheckBox); + + contentLayout->addStretch(); + + auto *scrollArea = new QScrollArea(this); + scrollArea->setWidget(content); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(scrollArea); + + SettingsCache &settings = SettingsCache::instance(); + + connect(styleUserListCheckBox, &QCheckBox::toggled, &settings.interface(), &InterfaceSettings::setStyleUserList); + connect(cardScalingCheckBox, &QCheckBox::toggled, &settings.cardsDisplay(), &CardsDisplaySettings::setCardScaling); + connect(roundCardCornersCheckBox, &QCheckBox::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setRoundCardCorners); + connect(displayCardNamesCheckBox, &QCheckBox::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setDisplayCardNames); + connect(autoRotateCardsCheckBox, &QCheckBox::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setAutoRotateSidewaysLayoutCards); + connect(tapAnimationCheckBox, &QCheckBox::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setTapAnimation); + + connect(notificationsEnabledCheckBox, &QCheckBox::toggled, &settings.interface(), + &InterfaceSettings::setNotificationsEnabled); + connect(soundEnabledCheckBox, &QCheckBox::toggled, &settings.sound(), &SoundSettings::setSoundEnabled); + + connect(doubleClickToPlayCheckBox, &QCheckBox::toggled, &settings.interface(), + &InterfaceSettings::setDoubleClickToPlay); + connect(horizontalHandCheckBox, &QCheckBox::toggled, &settings.interface(), &InterfaceSettings::setHorizontalHand); + connect(playToStackCheckBox, &QCheckBox::toggled, &settings.interface(), &InterfaceSettings::setPlayToStack); + + connect(showShortcutsCheckBox, &QCheckBox::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setShowShortcuts); + + connect(picDownloadCheckBox, &QCheckBox::toggled, &settings.personal(), &PersonalSettings::setPicDownload); + connect(checkUpdatesOnStartupCheckBox, &QCheckBox::toggled, &settings.updates(), + &UpdatesSettings::setCheckUpdatesOnStartup); + connect(showTipsOnStartupCheckBox, &QCheckBox::toggled, &settings.personal(), + &PersonalSettings::setShowTipsOnStartup); + + retranslateUi(); +} + +void PreferencesSetupPage::initializePage() +{ + SettingsCache &settings = SettingsCache::instance(); + + styleUserListCheckBox->setChecked(settings.interface().getStyleUserList()); + cardScalingCheckBox->setChecked(settings.cardsDisplay().getScaleCards()); + roundCardCornersCheckBox->setChecked(settings.cardsDisplay().getRoundCardCorners()); + displayCardNamesCheckBox->setChecked(settings.cardsDisplay().getDisplayCardNames()); + autoRotateCardsCheckBox->setChecked(settings.cardsDisplay().getAutoRotateSidewaysLayoutCards()); + tapAnimationCheckBox->setChecked(settings.cardsDisplay().getTapAnimation()); + + notificationsEnabledCheckBox->setChecked(settings.interface().getNotificationsEnabled()); + soundEnabledCheckBox->setChecked(settings.sound().getSoundEnabled()); + + doubleClickToPlayCheckBox->setChecked(settings.interface().getDoubleClickToPlay()); + horizontalHandCheckBox->setChecked(settings.interface().getHorizontalHand()); + playToStackCheckBox->setChecked(settings.interface().getPlayToStack()); + + showShortcutsCheckBox->setChecked(settings.cardsDisplay().getShowShortcuts()); + + picDownloadCheckBox->setChecked(settings.personal().getPicDownload()); + checkUpdatesOnStartupCheckBox->setChecked(settings.updates().getCheckUpdatesOnStartup()); + showTipsOnStartupCheckBox->setChecked(settings.personal().getShowTipsOnStartup()); +} + +bool PreferencesSetupPage::isSkippable() const +{ + return true; +} + +QString PreferencesSetupPage::stepTitle() const +{ + return tr("A Few Preferences"); +} + +QString PreferencesSetupPage::stepSubtitle() const +{ + return tr("Defaults are fine — tweak these now or from Settings anytime."); +} + +void PreferencesSetupPage::retranslateUi() +{ + appearanceGroup->setTitle(tr("Appearance")); + styleUserListCheckBox->setText(tr("Use the styled user list (avatars, role colours)")); + cardScalingCheckBox->setText(tr("Scale cards to fit the window")); + roundCardCornersCheckBox->setText(tr("Round card corners")); + displayCardNamesCheckBox->setText(tr("Display card names on pictured cards")); + autoRotateCardsCheckBox->setText(tr("Auto-rotate sideways layout cards")); + tapAnimationCheckBox->setText(tr("Animate tapping cards")); + + notificationsGroup->setTitle(tr("Notifications && Sound")); + notificationsEnabledCheckBox->setText(tr("Show desktop notifications")); + soundEnabledCheckBox->setText(tr("Play sound effects")); + + gameplayGroup->setTitle(tr("Gameplay")); + doubleClickToPlayCheckBox->setText(tr("Double-click a card to play it")); + horizontalHandCheckBox->setText(tr("Display hand horizontally")); + playToStackCheckBox->setText(tr("Play cards to top of stack")); + + menuGroup->setTitle(tr("Menus")); + showShortcutsCheckBox->setText(tr("Show keyboard shortcuts in menus")); + + dataGroup->setTitle(tr("Updates && Data")); + picDownloadCheckBox->setText(tr("Automatically download card images")); + picDownloadCheckBox->setToolTip(tr("Turn this off if you're on a limited connection — " + "card art just won't load until you turn it back on.")); + checkUpdatesOnStartupCheckBox->setText(tr("Check for client updates on startup")); + showTipsOnStartupCheckBox->setText(tr("Show tip of the day on startup")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h new file mode 100644 index 000000000..b850b312c --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h @@ -0,0 +1,51 @@ +#ifndef PREFERENCES_SETUP_PAGE_H +#define PREFERENCES_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QCheckBox; +class QGroupBox; + +/** @brief A curated subset of settings for the user to adjust. + **/ +class PreferencesSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit PreferencesSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +private: + QGroupBox *appearanceGroup; + QCheckBox *styleUserListCheckBox; + QCheckBox *cardScalingCheckBox; + QCheckBox *roundCardCornersCheckBox; + QCheckBox *displayCardNamesCheckBox; + QCheckBox *autoRotateCardsCheckBox; + QCheckBox *tapAnimationCheckBox; + + QGroupBox *notificationsGroup; + QCheckBox *notificationsEnabledCheckBox; + QCheckBox *soundEnabledCheckBox; + + QGroupBox *gameplayGroup; + QCheckBox *doubleClickToPlayCheckBox; + QCheckBox *horizontalHandCheckBox; + QCheckBox *playToStackCheckBox; + + QGroupBox *menuGroup; + QCheckBox *showShortcutsCheckBox; + + QGroupBox *dataGroup; + QCheckBox *picDownloadCheckBox; + QCheckBox *checkUpdatesOnStartupCheckBox; + QCheckBox *showTipsOnStartupCheckBox; +}; + +#endif // PREFERENCES_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp new file mode 100644 index 000000000..9baa3f1f3 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -0,0 +1,201 @@ +#include "theme_setup_page.h" + +#include "../../client/settings/cache_settings.h" +#include "../../interface/palette_editor/palette_generator.h" +#include "../../interface/palette_editor/quick_setup_panel.h" +#include "../../interface/theme_manager.h" +#include "../../interface/widgets/general/background_sources.h" + +#include +#include +#include +#include +#include +#include + +ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + themeCombo = new QComboBox(this); + schemeCombo = new QComboBox(this); + schemeCombo->addItem(tr("Light"), QStringLiteral("Light")); + schemeCombo->addItem(tr("Dark"), QStringLiteral("Dark")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + schemeCombo->addItem(tr("Match system"), QStringLiteral("System")); +#endif + + quickSetupPanel = new QuickSetupPanel(this); + + connect(themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged); + connect(schemeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged); + connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent); + + homeTabBackgroundCombo = new QComboBox(this); + for (const auto &entry : BackgroundSources::all()) { + homeTabBackgroundCombo->addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type)); + } + connect(homeTabBackgroundCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + &ThemeSetupPage::onHomeTabBackgroundChanged); + + // Keep the scheme combo honest when the *theme* changes underneath it + // (switching theme reloads that theme's own stored colorScheme), and + // opportunistically seed a palette for themes that ship none at all. + // Mirrors AppearanceSettingsPage's identical listener for the combo-sync + // half of this. + connect(themeManager, &ThemeManager::themeChanged, this, [this] { + const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); + const QString current = cfg.colorScheme; + + schemeCombo->blockSignals(true); + const int idx = schemeCombo->findData(current); + schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0); + schemeCombo->blockSignals(false); + + maybeAutoGeneratePalette(); + }); + + auto *form = new QFormLayout; + form->addRow(tr("Theme:"), themeCombo); + form->addRow(tr("Appearance:"), schemeCombo); + form->addRow(tr("Home screen background:"), homeTabBackgroundCombo); + + accentGroup = new QGroupBox(this); + auto *accentLayout = new QVBoxLayout(accentGroup); + accentLayout->addWidget(quickSetupPanel); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(form); + layout->addWidget(accentGroup); + layout->addStretch(); + + retranslateUi(); +} + +void ThemeSetupPage::initializePage() +{ + themeCombo->blockSignals(true); + themeCombo->clear(); + const QString currentTheme = SettingsCache::instance().getThemeName(); + for (const QString &name : themeManager->getAvailableThemes().keys()) { + themeCombo->addItem(name); + } + const int idx = themeCombo->findText(currentTheme); + themeCombo->setCurrentIndex(idx >= 0 ? idx : 0); + themeCombo->blockSignals(false); + + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); + schemeCombo->blockSignals(true); + const int schemeIdx = schemeCombo->findData(cfg.colorScheme); + schemeCombo->setCurrentIndex(schemeIdx >= 0 ? schemeIdx : 0); + schemeCombo->blockSignals(false); + + homeTabBackgroundCombo->blockSignals(true); + QString homeTabSource = SettingsCache::instance().personal().getHomeTabBackgroundSource(); + int homeTabIdx = homeTabBackgroundCombo->findData(BackgroundSources::fromId(homeTabSource)); + homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0); + homeTabBackgroundCombo->blockSignals(false); + + paletteDirty = false; + maybeAutoGeneratePalette(); +} + +QString ThemeSetupPage::currentScheme() const +{ + return schemeCombo->currentData().toString(); +} + +QString ThemeSetupPage::resolvedScheme() const +{ + const QString scheme = currentScheme(); + if (scheme.isEmpty() || scheme == QStringLiteral("System")) { + return themeManager->isDarkMode(themeManager->getCurrentThemePath()) ? "Dark" : "Light"; + } + return scheme; +} + +void ThemeSetupPage::onThemeChanged(int index) +{ + if (index < 0) { + return; + } + paletteDirty = false; + SettingsCache::instance().setThemeName(themeCombo->itemText(index)); + // Scheme-combo sync and auto-generation both happen via the + // ThemeManager::themeChanged listener above, triggered by setThemeName. +} + +void ThemeSetupPage::onSchemeChanged() +{ + themeManager->setColorScheme(currentScheme()); +} + +void ThemeSetupPage::onHomeTabBackgroundChanged(int index) +{ + if (index < 0) { + return; + } + auto type = homeTabBackgroundCombo->currentData().value(); + SettingsCache::instance().personal().setHomeTabBackgroundSource(BackgroundSources::toId(type)); +} + +void ThemeSetupPage::onGenerateFromAccent(const QColor &accent, int intensity) +{ + PaletteConfig cfg = PaletteGenerator::fromAccent(accent, intensity, resolvedScheme()); + themeManager->previewPalette(cfg, resolvedScheme()); + paletteDirty = true; +} + +void ThemeSetupPage::maybeAutoGeneratePalette() +{ + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString scheme = resolvedScheme(); + + if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || + PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) { + return; // theme already has something real to show -- leave it alone + } + + // Nothing saved, nothing shipped. Rather than showing flat native Qt + // colours during the very first thing a new user sees, seed one from + // whatever accent QuickSetupPanel currently holds (its own built-in + // default the first time through), and mark it dirty so it's written to + // disk if the user moves on without touching the accent controls. + PaletteConfig generated = + PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); + themeManager->previewPalette(generated, scheme); + paletteDirty = true; +} + +bool ThemeSetupPage::validatePage() +{ + if (paletteDirty) { + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString scheme = resolvedScheme(); + PaletteConfig cfg = + PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); + ThemeManager::commitPalette(dirPath, scheme, cfg); + themeManager->reloadCurrentTheme(); + } + return true; +} + +bool ThemeSetupPage::isSkippable() const +{ + return true; +} + +QString ThemeSetupPage::stepTitle() const +{ + return tr("Pick a Look"); +} + +QString ThemeSetupPage::stepSubtitle() const +{ + return tr("You can fine-tune every colour later from Settings → Appearance."); +} + +void ThemeSetupPage::retranslateUi() +{ + accentGroup->setTitle(tr("Accent colour (optional)")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h new file mode 100644 index 000000000..f0b336510 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -0,0 +1,55 @@ +#ifndef THEME_SETUP_PAGE_H +#define THEME_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QComboBox; +class QGroupBox; +class QuickSetupPanel; + +/** @brief First-run theme step. Reuses the same building blocks as Appearance + * settings and the Palette Editor (ThemeManager, PaletteConfig, + * PaletteGenerator, and the QuickSetupPanel widget itself) rather than + * reimplementing palette generation or preview here. + * + * Behavior specific to this page (deliberately not pushed down into + * ThemeManager, to avoid changing app-wide behaviour for existing installs): + * - If the selected theme+scheme has no saved palette and no shipped + * default, one is generated from the QuickSetupPanel's current accent so + * onboarding never shows a flat, unstyled look. */ +class ThemeSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit ThemeSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool validatePage() override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +private slots: + void onThemeChanged(int index); + void onSchemeChanged(); + void onGenerateFromAccent(const QColor &accent, int intensity); + void onHomeTabBackgroundChanged(int index); + +private: + QString currentScheme() const; + QString resolvedScheme() const; // "System" -> actual Light/Dark + void maybeAutoGeneratePalette(); + + QComboBox *themeCombo; + QComboBox *schemeCombo; + QGroupBox *accentGroup; + QuickSetupPanel *quickSetupPanel; + + QComboBox *homeTabBackgroundCombo; + + bool paletteDirty = false; +}; + +#endif // THEME_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp new file mode 100644 index 000000000..ee8e9cdfa --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -0,0 +1,31 @@ +#include "welcome_page.h" + +#include +#include + +WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addStretch(); + + retranslateUi(); +} + +QString WelcomePage::stepTitle() const +{ + return tr("Welcome!"); +} + +void WelcomePage::retranslateUi() +{ + bodyLabel->setText(tr("Let's get you set up. This will only take a minute — " + "we'll grab the card database, pick a look you like, " + "and get you ready to connect to a server.\n\n" + "You can change any of this later from Settings.")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h new file mode 100644 index 000000000..5f395fed2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h @@ -0,0 +1,22 @@ +#ifndef WELCOME_PAGE_H +#define WELCOME_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; + +class WelcomePage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit WelcomePage(QWidget *parent = nullptr); + + QString stepTitle() const override; + void retranslateUi() override; + +private: + QLabel *bodyLabel; +}; + +#endif // WELCOME_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml new file mode 100644 index 000000000..f1a385cad --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -0,0 +1,62 @@ +import QtQuick + +Item { + id: root + + ShaderEffect { + id: effectA + anchors.fill: parent + opacity: bannerConfig.frontIsA ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } + property real iTime: bannerConfig.time + property real uAspect: bannerConfig.aspect + property real uMode: bannerConfig.modeA + property real uSpeed: bannerConfig.speedA + property real uSeed: bannerConfig.seedA + property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) + property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) + property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + ShaderEffect { + id: effectB + anchors.fill: parent + opacity: bannerConfig.frontIsA ? 0.0 : 1.0 + Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } + property real iTime: bannerConfig.time + property real uAspect: bannerConfig.aspect + property real uMode: bannerConfig.modeB + property real uSpeed: bannerConfig.speedB + property real uSeed: bannerConfig.seedB + property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) + property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) + property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + // The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range + Image { + id: logo + anchors.centerIn: parent + visible: bannerConfig.logoVisible + source: "qrc:/resources/cockatrice-logo-white.svg" + width: root.height * 0.6 + height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1) + fillMode: Image.PreserveAspectFit + smooth: true + opacity: 0.5 + 0.5 * bannerConfig.logoGlow + sourceSize: Qt.size(256, 256) + + Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } } + + transform: Scale { + origin.x: logo.width / 2 + origin.y: logo.height / 2 + xScale: 0.94 + 0.06 * bannerConfig.logoGlow + yScale: 0.94 + 0.06 * bannerConfig.logoGlow + } + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp new file mode 100644 index 000000000..fd1fb2a98 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -0,0 +1,195 @@ +#include "shader_banner_widget.h" + +#include "banner_shader_config.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +// Near-black base palette -- the background is dark and quiet so the green +// accent stands out. +constexpr QRgb kColorA = 0x1A1A20; +constexpr QRgb kColorB = 0x0E0E12; +constexpr QRgb kAccent = 0x8BDD6B; +} // namespace + +class GradientFallbackWidget : public QWidget +{ +public: + using QWidget::QWidget; + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + QLinearGradient gradient(0, 0, width(), height()); + gradient.setColorAt(0.0, QColor(kColorA)); + gradient.setColorAt(1.0, QColor(kColorB)); + painter.fillRect(rect(), gradient); + } +}; + +BannerHost::BannerHost(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(150); + + stack = new QStackedLayout(this); + stack->setContentsMargins(0, 0, 0, 0); + + fallback = new GradientFallbackWidget(this); + stack->addWidget(fallback); + + quickWidget = new QQuickWidget(this); + quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + + config = new BannerShaderConfig(quickWidget->engine()); + quickWidget->rootContext()->setContextProperty("bannerConfig", config); + quickWidget->setSource(QUrl("qrc:/onboarding/qml/BrandBanner.qml")); + + if (quickWidget->status() == QQuickWidget::Error) { + activateFallback(); + } else { + connect(quickWidget, &QQuickWidget::sceneGraphError, this, &BannerHost::onSceneGraphFailed); + stack->addWidget(quickWidget); + stack->setCurrentWidget(quickWidget); + } + + connect(&clock, &QTimer::timeout, this, &BannerHost::tick); + clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock + + applyMotifPreset(currentMotif); + updateAspect(); +} + +void BannerHost::activateFallback() +{ + if (usingFallback) { + return; + } + usingFallback = true; + clock.stop(); + stack->setCurrentWidget(fallback); + + if (quickWidget) { + quickWidget->deleteLater(); // takes BannerShaderConfig (parented to its engine) with it + quickWidget = nullptr; + config = nullptr; + } +} + +void BannerHost::onSceneGraphFailed() +{ + activateFallback(); +} + +void BannerHost::setMotif(Motif motif) +{ + currentMotif = motif; + applyMotifPreset(motif); +} + +BannerHost::Preset BannerHost::presetFor(Motif motif) +{ + // speed/seed tuned per motif so e.g. the network "pulse" (Account) reads + // at a deliberately calmer cadence than the data "scan" lines + // (Preferences), even though both come from the same shader. + switch (motif) { + case Motif::Welcome: + return {0.0, 0.6, 0.15}; + case Motif::CardDatabase: + return {1.0, 1.3, 0.42}; + case Motif::Theming: + return {2.0, 1.2, 0.73}; + case Motif::Account: + return {3.0, 0.8, 0.28}; + case Motif::Preferences: + return {4.0, 1.0, 0.61}; + case Motif::Finish: + return {5.0, 1.0, 0.91}; + } + return {0.0, 0.6, 0.15}; +} + +void BannerHost::applyMotifPreset(Motif motif) +{ + if (usingFallback || !config) { + return; + } + + const Preset p = presetFor(motif); + + config->setColorA(QColor(kColorA)); + config->setColorB(QColor(kColorB)); + config->setAccent(QColor(kAccent)); + config->setLogoVisible(motif == Motif::Welcome); + + if (isFirstApply) { + // Nothing on screen yet -- write straight into the front bank, no + // crossfade needed for the very first paint. + config->setModeA(p.mode); + config->setSpeedA(p.speed); + config->setSeedA(p.seed); + config->setFrontIsA(true); + isFirstApply = false; + return; + } + + // Write the new preset into whichever bank is currently hidden, then + // flip which one is front. QML's opacity Behavior does the actual + // crossfade -- BannerHost never animates anything itself. + if (config->frontIsA()) { + config->setModeB(p.mode); + config->setSpeedB(p.speed); + config->setSeedB(p.seed); + config->setFrontIsA(false); + } else { + config->setModeA(p.mode); + config->setSpeedA(p.speed); + config->setSeedA(p.seed); + config->setFrontIsA(true); + } +} + +void BannerHost::updateAspect() +{ + if (config && height() > 0) { + config->setAspect(qreal(width()) / qreal(height())); + } +} + +void BannerHost::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateAspect(); +} + +void BannerHost::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (!usingFallback) { + elapsed.restart(); + clock.start(); + } +} + +void BannerHost::hideEvent(QHideEvent *event) +{ + QWidget::hideEvent(event); + clock.stop(); +} + +void BannerHost::tick() +{ + if (config) { + qreal t = elapsed.elapsed() / 1000.0; + config->setTime(t); + // Visible breathing for the logo: oscillates between 0.0 and 1.0 + qreal glow = 0.5 + 0.5 * qSin(t * 0.4); + config->setLogoGlow(glow); + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h new file mode 100644 index 000000000..2e230ad7f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -0,0 +1,83 @@ +#ifndef SHADER_BANNER_WIDGET_H +#define SHADER_BANNER_WIDGET_H + +#include +#include +#include + +class BannerShaderConfig; +class QQuickWidget; +class GradientFallbackWidget; +class QStackedLayout; + +/** @brief Onboarding banner: a subtle, looping brand-shader animation, one of six + * per-page "motifs" driving the same prebaked fragment shader + * (onboarding/shaders/brand_banner.frag) with different uniform values, so + * every page feels distinct but unmistakably part of the same family. + * + * Motif switches crossfade smoothly (see BrandBanner.qml's two stacked + * ShaderEffect layers + Behavior on opacity) rather than cutting instantly + * -- BannerHost just writes the new preset into whichever layer is + * currently hidden and flips BannerShaderConfig::frontIsA; QML handles the + * actual animation declaratively. + * + * Falls back to a static two-stop gradient (no shader, no QQuickWidget) if + * the platform's Qt Quick scenegraph can't initialize -- e.g. software + * rendering only, or a CI/VM environment with no GPU -- so onboarding + * never blocks or blanks out over a graphics driver problem. The fallback + * is permanent for the lifetime of this widget once triggered. */ +class BannerHost : public QWidget +{ + Q_OBJECT + +public: + enum class Motif + { + Welcome, + CardDatabase, + Theming, + Account, + Preferences, + Finish, + }; + + explicit BannerHost(QWidget *parent = nullptr); + + void setMotif(Motif motif); + +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private slots: + void tick(); + void onSceneGraphFailed(); + +private: + struct Preset + { + qreal mode; + qreal speed; + qreal seed; + }; + + static Preset presetFor(Motif motif); + + void applyMotifPreset(Motif motif); + void updateAspect(); + void activateFallback(); + + QStackedLayout *stack; + QQuickWidget *quickWidget = nullptr; + BannerShaderConfig *config = nullptr; + GradientFallbackWidget *fallback = nullptr; + + QTimer clock; + QElapsedTimer elapsed; + Motif currentMotif = Motif::Welcome; + bool usingFallback = false; + bool isFirstApply = true; +}; + +#endif // SHADER_BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag new file mode 100644 index 000000000..508bd4bc4 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -0,0 +1,461 @@ +#version 440 + +// ════════════════════════════════════════════════════════════════════════ +// brand_banner.frag +// +// One shader, six motifs (uMode 0..5). All motifs composite over a shared +// backgroundField() whose colour is flow-noise-modulated blend of uColorA +// and uColorB. SDFs operate in aspect-corrected space (ac.x = uv.x * +// uAspect) to preserve shape proportions on the wide banner. +// +// IMPORTANT: the uniform block below must list custom uniforms in EXACTLY +// the order they're declared as properties on each ShaderEffect instance in +// BrandBanner.qml (after the two Qt-supplied members, qt_Matrix/qt_Opacity). +// ════════════════════════════════════════════════════════════════════════ + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + float iTime; + float uAspect; + float uMode; + float uSpeed; + float uSeed; + vec4 uColorA; + vec4 uColorB; + vec4 uAccent; + float uLogoGlow; +}; + +// ── Primitives ────────────────────────────────────────────────────────── + +float hash21(vec2 p) +{ + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float valueNoise(vec2 p) +{ + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float fbm(vec2 p) +{ + float v = 0.0; + float amp = 0.5; + for (int i = 0; i < 3; i++) { + v += amp * valueNoise(p); + p *= 2.03; + amp *= 0.5; + } + return v; +} + +float flowNoise(vec2 p, float t) +{ + vec2 warp1 = vec2(fbm(p + vec2(0.0, 0.0)), fbm(p + vec2(5.2, 1.3))); + vec2 warp2 = vec2(fbm(p + 4.0 * warp1 + vec2(1.7, 9.2) + t * 0.6), + fbm(p + 4.0 * warp1 + vec2(8.3, 2.8) - t * 0.5)); + return fbm(p + 4.0 * warp2 + t * 0.15); +} + +float bloom(float d, float coreRadius, float haloRadius) +{ + float core = exp(-(d * d) / (coreRadius * coreRadius)); + float halo = exp(-d / haloRadius) * 0.35; + return core + halo; +} + +float roundedBoxSDF(vec2 p, vec2 halfSize, float radius) +{ + vec2 d = abs(p) - halfSize + radius; + return length(max(d, 0.0)) - radius + min(max(d.x, d.y), 0.0); +} + +// Rotated box SDF -- applies 2D rotation to p before evaluating roundedBoxSDF. +float rotatedBoxSDF(vec2 p, vec2 halfSize, float radius, float angle) +{ + float c = cos(angle); + float s = sin(angle); + vec2 rp = vec2(p.x * c - p.y * s, p.x * s + p.y * c); + return roundedBoxSDF(rp, halfSize, radius); +} + +float vignette(vec2 uv) +{ + vec2 c = uv - 0.5; + c.x *= max(uAspect, 0.0001); + return smoothstep(1.0, 0.25, length(c)); +} + +// ── Shared background ─────────────────────────────────────────────────── + +vec3 backgroundField(vec2 uv, float time) +{ + // Diagonal luminance gradient from (0,0) to (1,1) used as blend factor + // between uColorA and uColorB; modulated by flowNoise. + float baseD = smoothstep(0.0, 1.0, uv.y * 0.5 + uv.x * 0.2); + float painted = flowNoise(uv * 1.5, time * 0.04) - 0.5; + baseD = clamp(baseD + painted * 0.12, 0.0, 1.0); + + vec3 col = mix(uColorA.rgb, uColorB.rgb, baseD); + + // Low-frequency fBM noise pushes local colour toward uColorB for depth + float deep = fbm(uv * 1.0 + vec2(37.1, 12.4) + time * 0.015); + col = mix(col, uColorB.rgb, (deep - 0.5) * 0.08); + + // Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent + float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02); + col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.10; + + return col; +} + +// ── Motifs ────────────────────────────────────────────────────────────── + +// Centre bloom, flow-noise shimmer gated to centre, and 48 orbiting ember +// particles that deflect into a tight ring near the centre. +vec3 motifWelcome(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + float cDist = length(ac - center); + + // Centre bloom at logo position; intensity scales with uLogoGlow + float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); + col += centreLight * 0.20 * uLogoGlow; + + // Flow-noise shimmer gated by Gaussian mask at centre + float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5; + float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp)); + col += shimmer * shimmerMask * 0.04 * uLogoGlow; + + // 48 ember particles: hash-seeded position, speed, size, brightness. + // Embers within a distance threshold of centre are deflected into an + // orbital ring via tangent displacement perpendicular to the centre vector. + const int EMBERS = 48; + for (int i = 0; i < EMBERS; i++) { + float fi = float(i); + + float baseX = hash21(vec2(fi * 7.31 + uSeed, fi * 3.17)); + float baseY = hash21(vec2(fi * 11.9 + uSeed * 1.4, fi * 5.53)); + + float riseSpeed = 0.025 + hash21(vec2(fi * 1.7, uSeed * 2.1)) * 0.035; + float driftAmp = 0.04 + hash21(vec2(fi * 9.3, uSeed)) * 0.06; + float driftFreq = 0.3 + hash21(vec2(fi * 4.1, uSeed * 3.3)) * 0.5; + + float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp; + float pY = fract(baseY + t * riseSpeed); + + float size = 0.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012; + float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30; + + // Fade out near top/bottom edges + float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY); + float twinkle = 0.6 + 0.4 * sin(t * (1.2 + fi * 0.37) + fi * 2.9); + + vec2 ePos = vec2(pX, pY); + + // Embers near centre: deflect into orbital ring via tangent displacement + vec2 toCenter = ePos - center; + float distToCenter = length(toCenter); + float ringWeight = smoothstep(0.38 * asp, 0.06 * asp, distToCenter); + + float orbitPhase = t * (0.15 + fi * 0.020) + fi * 2.3; + float orbitAmount = 0.020 + hash21(vec2(fi * 12.3, uSeed * 2.7)) * 0.020; + vec2 tangent = vec2(-toCenter.y, toCenter.x); + vec2 deflected = ePos + tangent * ringWeight * orbitAmount * asp * sin(orbitPhase); + + float pushOut = ringWeight * (0.008 + hash21(vec2(fi * 6.7, uSeed * 1.1)) * 0.012) * asp; + deflected += normalize(toCenter + 0.001) * pushOut; + + float dist = length(ac - deflected); + float intensity = bright * edgeFade * twinkle; + col += uAccent.rgb * bloom(dist, size, size * 4.0) * intensity; + } + + return col; +} + +// 25 card-shaped box SDFs at parallax depths drifting horizontally across +// the banner; each card has a semi-transparent fill, accent outline, and +// card-back diamond pattern. +vec3 motifCardDatabase(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + + const int CARDS = 25; + for (int i = 0; i < CARDS; i++) { + float fi = float(i); + + // Parallax depth via hash; used to scale size, speed, brightness + float depth = hash21(vec2(fi * 1.37 + uSeed, fi * 0.91)); + + // Card dimensions in corrected space (portrait: height > width) + float cardH = mix(0.055, 0.15, depth); + cardH *= 0.85 + 0.30 * hash21(vec2(fi * 3.14, uSeed * 2.71)); + float cardW = cardH * 0.71; // 5:7 ratio + + // Horizontal drift; nearer cards (higher depth) move faster + float speed = mix(0.06, 0.18, depth); + float xPhase = hash21(vec2(fi * 7.13, uSeed * 4.37)); + xPhase = fract(xPhase + t * speed); + float x = mix(-1.5, asp + 1.5, xPhase); + + // Vertical position: hash distribution with sinusoidal oscillation + float yBase = hash21(vec2(fi * 2.91, uSeed * 1.63)); + float y = yBase + sin(t * 0.6 + fi * 1.9) * 0.035; + y = clamp(y, cardH + 0.02, 1.0 - cardH - 0.02); + + // Random rotation angle ±4 degrees + float tilt = (hash21(vec2(fi * 5.71, uSeed * 8.29)) - 0.5) * 0.14; + + vec2 p = ac - vec2(x, y); + float d = rotatedBoxSDF(p, vec2(cardW, cardH), cardW * 0.14, tilt); + + // Semi-transparent dark fill + float fill = smoothstep(0.015, -0.005, d); + col = mix(col, uColorB.rgb * 0.55, fill * 0.50); + + // Accent outline + float edge = smoothstep(0.035, 0.0, abs(d)); + col += uAccent.rgb * edge * mix(0.18, 0.50, 1.0 - depth); + + // Card-back diamond: smaller rotated box inset from card edges + float innerD = rotatedBoxSDF(p, vec2(cardW * 0.45, cardH * 0.55), cardW * 0.08, tilt); + float innerEdge = smoothstep(0.012, 0.0, abs(innerD)); + col += uAccent.rgb * innerEdge * fill * 0.12 * (1.0 - depth); + + // Centre dot + float dotDist = length(p); + col += uAccent.rgb * bloom(dotDist, 0.008, 0.02) * fill * 0.15 * (1.0 - depth); + } + return col; +} + +// 4 horizontal bands with multi-frequency sinusoidal warp and pulsing width. +vec3 motifTheming(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + + const int BANDS = 4; + for (int i = 0; i < BANDS; i++) { + float fi = float(i); + float yCenter = 0.18 + fi * 0.22; + + // Three summed sinusoids for horizontal undulation + float wave = sin(uv.x * 3.2 + t * 0.5 + fi * 2.1) * 0.08; + wave += sin(uv.x * 7.0 - t * 0.3 + fi * 1.3) * 0.035; + wave += sin(uv.x * 1.6 + t * 0.18 + fi * 3.7) * 0.05; + + float bandDist = abs(uv.y - yCenter - wave); + float bandWidth = 0.04 + sin(t * 0.2 + fi * 0.8) * 0.012; + float band = smoothstep(bandWidth, 0.0, bandDist); + + // Upper bands have higher intensity + float intensity = mix(0.15, 0.38, 1.0 - fi / float(BANDS)); + col += uAccent.rgb * band * intensity; + } + + return col; +} + +// 14 nodes at pseudo-random positions with sinusoidal pulse; edges drawn +// between nodes within a threshold distance; central glow + periodic ring. +vec3 motifAccount(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + + const int NODES = 14; + vec2 nodePos[14]; + float nodePulse[14]; + + for (int i = 0; i < NODES; i++) { + float fi = float(i); + + // Hash-seeded position with gentle sinusoidal drift + float nx = hash21(vec2(fi * 3.17 + uSeed, fi * 1.93)) * asp; + float ny = hash21(vec2(fi * 5.41 + uSeed * 1.7, fi * 2.79)); + + float dx = sin(t * 0.12 + fi * 1.7) * 0.08; + float dy = cos(t * 0.09 + fi * 2.3) * 0.04; + vec2 pos = vec2(nx + dx, ny + dy); + nodePos[i] = pos; + + // Per-node pulse phase, normalised to [0, 1] + float pulsePhase = hash21(vec2(fi * 4.31, uSeed * 6.17)); + float pulse = sin(t * 0.8 + pulsePhase * 6.283) * 0.5 + 0.5; + nodePulse[i] = pulse; + + // Node glow via bloom; intensity modulated by pulse + float dist = length(ac - pos); + col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.20, 0.45, pulse); + } + + // Edges: connect nodes within a radius threshold + float connectDist = asp * 0.22; + for (int i = 0; i < NODES; i++) { + for (int j = i + 1; j < NODES; j++) { + float pairDist = length(nodePos[i] - nodePos[j]); + if (pairDist < connectDist) { + float strength = 1.0 - pairDist / connectDist; + vec2 pa = ac - nodePos[i]; + vec2 ba = nodePos[j] - nodePos[i]; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + float lineDist = length(pa - ba * h); + col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.10; + } + } + } + + // Central bloom at banner centre + float cDist = length(ac - center); + col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12; + + // Periodic expanding ring from centre + float ripplePhase = t * 0.4; + float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7); + col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.10; + + return col; +} + +// 18x5 toggle-grid of rounded boxes with hash-driven on/off per cell; +// a scanning highlight sweeps L-to-R, brightening cells near the scan line. +vec3 motifPreferences(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + + float cols = 18.0; + float rows = 5.0; + vec2 gridUV = uv * vec2(cols, rows); + vec2 cell = fract(gridUV) - 0.5; + vec2 cellId = floor(gridUV); + + // On/off state per cell, hash-seeded for pseudo-randomness + float on = step(0.55, hash21(cellId + uSeed * 10.0)); + + float d = roundedBoxSDF(cell, vec2(0.28, 0.32), 0.06); + + // Filled "on" cells + float cellFill = smoothstep(0.04, -0.02, d); + col += uAccent.rgb * cellFill * on * 0.18; + + // Cell borders (drawn on all cells) + float border = smoothstep(0.025, 0.0, abs(d)); + col += uAccent.rgb * border * 0.06; + + // Scanning highlight: thin line + soft glow sweeping L-to-R + float scanX = fract(t * 0.15); + float scanDist = abs(uv.x - scanX); + float scanLine = smoothstep(0.015, 0.0, scanDist); + col += uAccent.rgb * scanLine * 0.40; + + float scanGlow = smoothstep(0.08, 0.0, scanDist); + col += uAccent.rgb * scanGlow * 0.08; + + // "On" cells near the scan line get extra brightness + float scanProximity = smoothstep(0.12, 0.0, scanDist); + col += uAccent.rgb * cellFill * on * scanProximity * 0.15; + + return col; +} + +// Centre radial bloom with sinusoidal pulse, 4 expanding ring halos with +// outer glow falloff, and 35 rising particles. +vec3 motifFinish(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + float cDist = length(ac - center); + + // Centre bloom with sinusoidal pulse modulation + float pulse = 0.65 + 0.35 * sin(t * 0.4); + col += uAccent.rgb * bloom(cDist, 0.12, 0.55) * 0.10 * pulse; + + // 4 expanding rings: radius increases via phase; ring width grows with + // expansion; combined with exponential outer glow falloff + for (int i = 0; i < 4; i++) { + float fi = float(i); + float phase = fract(t * 0.06 + fi * 0.25); + float ringRadius = phase * asp * 0.7; + float ringDist = abs(cDist - ringRadius); + float ringWidth = 0.025 + phase * 0.025; + float ring = smoothstep(ringWidth, 0.0, ringDist); + float outerGlow = exp(-ringDist / (0.03 + phase * 0.02)) * 0.3; + float combined = ring + outerGlow; + float fade = 1.0 - phase * 0.5; + col += uAccent.rgb * combined * fade * 0.15; + } + + // 35 particles rising vertically with sinusoidal horizontal drift; + // each particle uses bloom with edge fade and twinkle animation + const int PARTICLES = 35; + for (int i = 0; i < PARTICLES; i++) { + float fi = float(i); + float baseX = hash21(vec2(fi * 13.7 + uSeed, fi * 7.31)); + float baseY = hash21(vec2(fi * 23.1 + uSeed * 1.9, fi * 11.3)); + + float riseSpeed = 0.04 + hash21(vec2(fi * 3.1, uSeed * 2.7)) * 0.06; + float driftAmp = 0.03 + hash21(vec2(fi * 8.9, uSeed)) * 0.05; + float driftFreq = 0.4 + hash21(vec2(fi * 5.3, uSeed * 4.1)) * 0.6; + + float pX = baseX * asp + sin(t * driftFreq + fi * 2.3) * driftAmp * asp; + float pY = fract(baseY + t * riseSpeed); + + float size = 0.005 + hash21(vec2(fi * 4.7, uSeed * 3.9)) * 0.010; + float bright = 0.12 + hash21(vec2(fi * 7.1, uSeed * 1.3)) * 0.25; + + float edgeFade = smoothstep(0.0, 0.1, pY) * smoothstep(1.0, 0.9, pY); + float twinkle = 0.5 + 0.5 * sin(t * (1.8 + fi * 0.43) + fi * 3.1); + + vec2 pPos = vec2(pX, pY); + float dist = length(ac - pPos); + col += uAccent.rgb * bloom(dist, size, size * 3.5) * bright * edgeFade * twinkle; + } + + return col; +} + +// ── Main ──────────────────────────────────────────────────────────────── + +void main() +{ + vec2 uv = qt_TexCoord0; + float t = iTime * uSpeed; + + vec3 bg = backgroundField(uv, iTime); + + vec3 col; + if (uMode < 0.5) col = motifWelcome(uv, bg, t); + else if (uMode < 1.5) col = motifCardDatabase(uv, bg, t); + else if (uMode < 2.5) col = motifTheming(uv, bg, t); + else if (uMode < 3.5) col = motifAccount(uv, bg, t); + else if (uMode < 4.5) col = motifPreferences(uv, bg, t); + else col = motifFinish(uv, bg, t); + + col *= mix(0.62, 1.0, vignette(uv)); + fragColor = vec4(col, 1.0) * qt_Opacity; +} diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp new file mode 100644 index 000000000..d25e8544b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp @@ -0,0 +1,84 @@ +#include "step_indicator_widget.h" + +#include +#include + +StepIndicatorWidget::StepIndicatorWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(kDotDiameter + 2 * kVerticalMargin); +} + +void StepIndicatorWidget::setStepCount(int count) +{ + stepCount = qMax(0, count); + currentStep = qBound(0, currentStep, qMax(0, stepCount - 1)); + updateGeometry(); + update(); +} + +void StepIndicatorWidget::setCurrentStep(int index) +{ + if (stepCount == 0) { + return; + } + currentStep = qBound(0, index, stepCount - 1); + update(); +} + +QSize StepIndicatorWidget::sizeHint() const +{ + return minimumSizeHint(); +} + +QSize StepIndicatorWidget::minimumSizeHint() const +{ + if (stepCount == 0) { + return QSize(0, height()); + } + int width = kActiveDotWidth + (stepCount - 1) * kDotDiameter + (stepCount - 1) * kDotSpacing; + return QSize(width, height()); +} + +void StepIndicatorWidget::paintEvent(QPaintEvent * /*event*/) +{ + if (stepCount == 0) { + return; + } + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + const QColor activeColor = palette().color(QPalette::Highlight); + + // QPalette::Mid alpha-blended against a dark Window background reads as + // near-invisible (Mid is itself a dark grey in dark palettes -- see + // PaletteGenerator's satShadeLo/Dark roles). WindowText is guaranteed to + // contrast against Window in any theme by definition, so alpha-blending + // *that* instead keeps the dots visibly dim-but-present in both light and + // dark schemes. Same trick PaletteGenerator uses for placeholder text. + QColor inactiveColor = palette().color(QPalette::WindowText); + inactiveColor.setAlpha(100); + + int totalWidth = 0; + for (int i = 0; i < stepCount; ++i) { + totalWidth += (i == currentStep) ? kActiveDotWidth : kDotDiameter; + if (i > 0) { + totalWidth += kDotSpacing; + } + } + + int x = (width() - totalWidth) / 2; + const int y = height() / 2; + + for (int i = 0; i < stepCount; ++i) { + const bool active = (i == currentStep); + const int dotWidth = active ? kActiveDotWidth : kDotDiameter; + + QPainterPath path; + QRectF rect(x, y - kDotDiameter / 2.0, dotWidth, kDotDiameter); + path.addRoundedRect(rect, kDotDiameter / 2.0, kDotDiameter / 2.0); + painter.fillPath(path, active ? activeColor : inactiveColor); + + x += dotWidth + kDotSpacing; + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h new file mode 100644 index 000000000..1b85be04f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h @@ -0,0 +1,34 @@ +#ifndef STEP_INDICATOR_WIDGET_H +#define STEP_INDICATOR_WIDGET_H + +#include + +/** @brief Row of dots showing progress through a fixed-length sequence of steps, + * in the style of a mobile/OS setup flow. Purely presentational. */ +class StepIndicatorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit StepIndicatorWidget(QWidget *parent = nullptr); + + void setStepCount(int count); + void setCurrentStep(int index); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + int stepCount = 0; + int currentStep = 0; + + static constexpr int kDotDiameter = 8; + static constexpr int kActiveDotWidth = 22; + static constexpr int kDotSpacing = 10; + static constexpr int kVerticalMargin = 6; +}; + +#endif // STEP_INDICATOR_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 21d847e63..d7398fe14 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -31,6 +31,7 @@ #include "../interface/widgets/dialogs/dlg_tip_of_the_day.h" #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" +#include "../interface/widgets/onboarding/first_run_wizard.h" #include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" @@ -337,6 +338,7 @@ void MainWindow::retranslateUi() aStatusBar->setText(tr("Show Status Bar")); aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); + aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); aShow->setText(tr("Show/Hide")); @@ -398,6 +400,8 @@ void MainWindow::createActions() connect(aViewLog, &QAction::triggered, this, &MainWindow::actViewLog); aOpenSettingsFolder = new QAction(this); connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); + aFirstRunWizard = new QAction(this); + connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -476,6 +480,8 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); + helpMenu->addSeparator(); + helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) @@ -561,9 +567,10 @@ void MainWindow::startupConfigCheck() // no config found, 99% new clean install qCInfo(WindowMainStartupVersionLog) << "Startup: old client version empty, assuming first start after clean install"; - alertForcedOracleRun(VERSION_STRING, false); SettingsCache::instance().downloads().resetToDefaultURLs(); // populate the download urls SettingsCache::instance().personal().setClientVersion(VERSION_STRING); + actCheckServerUpdates(); + runFirstRunWizard(); if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); @@ -643,6 +650,21 @@ void MainWindow::startupConfigCheck() } } +void MainWindow::runFirstRunWizard() +{ + auto *wizard = new FirstRunWizard(this); + wizard->setAttribute(Qt::WA_DeleteOnClose); + + connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground); + connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates); + connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished); + connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer); + connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer); + + wizard->setModal(true); + wizard->show(); +} + void MainWindow::alertForcedOracleRun(const QString &version, bool isUpdate) { if (isUpdate) { @@ -934,6 +956,9 @@ void MainWindow::createCardUpdateProcess(bool background) void MainWindow::exitCardDatabaseUpdate() { + if (!cardUpdateProcess) { + return; + } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -971,14 +996,17 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) exitCardDatabaseUpdate(); QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error)); + emit cardDatabaseUpdateFinished(false); } -void MainWindow::cardUpdateFinished(int, QProcess::ExitStatus exitStatus) +void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus) { + const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); } exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(success); } void MainWindow::actCheckServerUpdates() diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 5f631ddc3..cef262130 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -59,6 +59,10 @@ class DlgTipOfTheDay; class MainWindow : public QMainWindow { Q_OBJECT +signals: + /** @brief Emitted after the background card-database update subprocess exits. */ + void cardDatabaseUpdateFinished(bool success); + public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -113,6 +117,9 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); + + void runFirstRunWizard(); + inline QString getCardUpdaterBinaryName() { return "oracle"; @@ -128,8 +135,8 @@ private: QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; - QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aStatusBar, *aViewLog, - *aOpenSettingsFolder; + QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, + *aViewLog, *aOpenSettingsFolder; TabSupervisor *tabSupervisor; WndSets *wndSets; From fe0ade6013bdccc5f16cfb3ae2b971c30121edec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Thu, 30 Jul 2026 16:44:02 +0200 Subject: [PATCH 2/4] Adjust CI Took 14 minutes --- .ci/Arch/Dockerfile | 2 ++ .ci/Debian12/Dockerfile | 2 ++ .ci/Debian13/Dockerfile | 2 ++ .ci/Fedora43/Dockerfile | 2 +- .ci/Fedora44/Dockerfile | 2 +- .ci/Ubuntu24.04/Dockerfile | 2 ++ .ci/Ubuntu26.04/Dockerfile | 2 ++ .github/workflows/desktop-build.yml | 10 +++++----- 8 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index 36cf5c4ae..f37315262 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -10,8 +10,10 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ ninja \ protobuf \ qt6-base \ + qt6-declarative \ qt6-imageformats \ qt6-multimedia \ + qt6-shadertools \ qt6-svg \ qt6-tools \ qt6-translations \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 202405b84..0fa227d6f 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -20,7 +20,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index d7ab6ac86..13e8b35c7 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -21,7 +21,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 27570cf99..68e894543 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -8,7 +8,7 @@ RUN dnf install -y \ mariadb-devel \ ninja-build \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e6c8da7f3..ffd7c1b9b 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -8,7 +8,7 @@ RUN dnf install -y \ mariadb-devel \ ninja-build \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 809b2e43a..12320c276 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -20,7 +20,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 7b0cd389f..ce3d9cd6c 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -21,7 +21,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index f1846ecf6..4c1d8837e 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -271,7 +271,7 @@ jobs: override_target: 13 package_suffix: "-macOS13_Intel" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools soc: Intel type: Release use_ccache: 1 @@ -286,7 +286,7 @@ jobs: make_package: 1 package_suffix: "-macOS14" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools soc: Apple type: Release use_ccache: 1 @@ -301,7 +301,7 @@ jobs: make_package: 1 package_suffix: "-macOS15" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools soc: Apple type: Release use_ccache: 1 @@ -314,7 +314,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools soc: Apple type: Debug use_ccache: 1 @@ -329,7 +329,7 @@ jobs: make_package: 1 package_suffix: "-Win10" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} From 3cc986cd9e31f8f84b1f82a8262f9bd43b92fd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Thu, 30 Jul 2026 17:27:04 +0200 Subject: [PATCH 3/4] Adjust CI again Took 14 minutes --- .github/workflows/desktop-build.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4c1d8837e..5c2905718 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -271,7 +271,7 @@ jobs: override_target: 13 package_suffix: "-macOS13_Intel" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Intel type: Release use_ccache: 1 @@ -286,7 +286,7 @@ jobs: make_package: 1 package_suffix: "-macOS14" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Release use_ccache: 1 @@ -301,7 +301,7 @@ jobs: make_package: 1 package_suffix: "-macOS15" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Release use_ccache: 1 @@ -314,7 +314,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Debug use_ccache: 1 @@ -329,7 +329,7 @@ jobs: make_package: 1 package_suffix: "-Win10" qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtdeclarative qtshadertools + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} @@ -384,7 +384,7 @@ jobs: id: restore_qt uses: actions/cache/restore@v6 with: - key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} + key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }}-${{ matrix.qt_modules }} path: ${{ github.workspace }}/Qt # Using jurplel/install-qt-action to install Qt without using brew @@ -406,7 +406,7 @@ jobs: if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: actions/cache/save@v6 with: - key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} + key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }}-${{ matrix.qt_modules }} path: ${{ github.workspace }}/Qt - name: "[Windows] Install Qt ${{ matrix.qt_version }}" From 2e6df2ea945c50d2701dca3f9236efa014def2df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 3 Aug 2026 12:56:21 +0200 Subject: [PATCH 4/4] Comments and fixes Took 9 seconds --- .../widgets/dialogs/dlg_register.cpp | 22 +++++++++ .../onboarding/pages/theme_setup_page.cpp | 45 +++++++++++++++---- .../onboarding/pages/theme_setup_page.h | 7 ++- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 0e338eed7..6ae8c9adb 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -480,6 +480,10 @@ void DlgRegister::newHostSelected(bool state) portEdit->setPlaceholderText(tr("Communication Port")); playernameEdit->setDisabled(false); playernameEdit->clear(); + } else { + // Rebuild the list so the previously selected host's details are + // repopulated (mirrors DlgConnect::newHostSelected). + preRebuildComboBoxList(); } } @@ -519,6 +523,24 @@ void DlgRegister::actOk() return; } + ServersSettings &servers = SettingsCache::instance().servers(); + + if (newHostButton->isChecked()) { + // Persist the new host so it shows up in the Connect dialog later. + // The password is never stored: the account is not verified yet. + const QString host = hostEdit->text().trimmed(); + if (!host.isEmpty()) { + servers.addNewServer(host, host, portEdit->text().trimmed(), playernameEdit->text().trimmed(), QString(), + false); + servers.setPrevioushostName(host); + } + } else { + const QString saveName = previousHosts->currentText(); + if (!saveName.isEmpty() && saveName != placeHolderText) { + servers.setPrevioushostName(saveName); + } + } + accept(); } diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp index 9baa3f1f3..a775d6d19 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -7,10 +7,14 @@ #include "../../interface/widgets/general/background_sources.h" #include +#include +#include #include #include #include +#include #include +#include #include ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) @@ -96,8 +100,10 @@ void ThemeSetupPage::initializePage() homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0); homeTabBackgroundCombo->blockSignals(false); + // Opening the page must not touch the running application's palette: + // previews and auto-generation only happen in response to the user + // actually changing a control, never on mere page visibility. paletteDirty = false; - maybeAutoGeneratePalette(); } QString ThemeSetupPage::currentScheme() const @@ -156,11 +162,12 @@ void ThemeSetupPage::maybeAutoGeneratePalette() return; // theme already has something real to show -- leave it alone } - // Nothing saved, nothing shipped. Rather than showing flat native Qt - // colours during the very first thing a new user sees, seed one from - // whatever accent QuickSetupPanel currently holds (its own built-in - // default the first time through), and mark it dirty so it's written to - // disk if the user moves on without touching the accent controls. + // The theme+scheme combination has nothing saved and nothing shipped, and + // the user just switched to it. Rather than leaving a flat, unstyled look, + // seed one from whatever accent QuickSetupPanel currently holds and mark + // it dirty so it's written to disk if the user moves on. Only ever reached + // through user interaction (theme/scheme change, accent drag) -- never on + // page open. PaletteConfig generated = PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); themeManager->previewPalette(generated, scheme); @@ -170,16 +177,38 @@ void ThemeSetupPage::maybeAutoGeneratePalette() bool ThemeSetupPage::validatePage() { if (paletteDirty) { - const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString scheme = resolvedScheme(); PaletteConfig cfg = PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); - ThemeManager::commitPalette(dirPath, scheme, cfg); + if (!ThemeManager::commitPalette(writableThemeDir(), scheme, cfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write the theme palette to:\n%1").arg(writableThemeDir())); + return false; + } themeManager->reloadCurrentTheme(); } return true; } +QString ThemeSetupPage::writableThemeDir() const +{ + // Built-in themes resolve to the read-only system themes directory; + // palette edits must go to the user themes directory instead, exactly + // as PaletteEditorDialog does. + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + if (!dirPath.isEmpty()) { + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (f.open(QIODevice::WriteOnly)) { + f.close(); + f.remove(); + return dirPath; + } + } + return QDir(SettingsCache::instance().paths().getThemesPath()) + .absoluteFilePath(SettingsCache::instance().getThemeName()); +} + bool ThemeSetupPage::isSkippable() const { return true; diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h index f0b336510..d1f84c1b9 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -14,9 +14,11 @@ class QuickSetupPanel; * * Behavior specific to this page (deliberately not pushed down into * ThemeManager, to avoid changing app-wide behaviour for existing installs): - * - If the selected theme+scheme has no saved palette and no shipped + * - Opening the page never changes the running palette; previews and + * auto-generation only happen when the user actually changes a control. + * - If a theme+scheme the user selects has no saved palette and no shipped * default, one is generated from the QuickSetupPanel's current accent so - * onboarding never shows a flat, unstyled look. */ + * the preview doesn't fall back to a flat, unstyled look. */ class ThemeSetupPage : public FirstRunWizardPage { Q_OBJECT @@ -41,6 +43,7 @@ private: QString currentScheme() const; QString resolvedScheme() const; // "System" -> actual Light/Dark void maybeAutoGeneratePalette(); + QString writableThemeDir() const; QComboBox *themeCombo; QComboBox *schemeCombo;