This commit is contained in:
BruebachL 2026-08-05 09:52:11 -07:00 committed by GitHub
commit 29eecb8f24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 3149 additions and 337 deletions

View File

@ -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 \

View File

@ -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 \

View File

@ -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 \

View File

@ -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 \

View File

@ -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 \

View File

@ -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 \

View File

@ -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 \

View File

@ -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 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 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 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 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 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 }}"

View File

@ -19,10 +19,13 @@ if(WITH_CLIENT)
Multimedia
Network
PrintSupport
ShaderTools
Svg
WebSockets
Widgets
Xml
Quick
QuickWidgets
)
endif()
if(WITH_ORACLE)

View File

@ -354,14 +354,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)
@ -433,6 +454,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)

View File

@ -2,6 +2,7 @@
<qresource prefix="/">
<file>resources/cardback.svg</file>
<file>resources/cockatrice.svg</file>
<file>resources/cockatrice-logo-white.svg</file>
<file>resources/hand.svg</file>
<file>resources/hr.jpg</file>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -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();
}

View File

@ -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());

View File

@ -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-<scheme>.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);

View File

@ -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 <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/servers_settings.h>
#include <libcockatrice/utility/string_limits.h>
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,115 @@ 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();
} else {
// Rebuild the list so the previously selected host's details are
// repopulated (mirrors DlgConnect::newHostSelected).
preRebuildComboBoxList();
}
}
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;
@ -375,5 +523,29 @@ 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();
}
void DlgRegister::actRemoveSavedServer()
{
SettingsCache::instance().servers().removeServer(hostEdit->text());
previousHosts->removeItem(previousHosts->currentIndex());
}

View File

@ -1,25 +1,24 @@
/**
* @file dlg_register.h
* @ingroup AccountDialogs
*/
//! \todo Document this file.
#ifndef DLG_REGISTER_H
#define DLG_REGISTER_H
#include <QComboBox>
#include <QDialog>
#include <QLineEdit>
#include <QMap>
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<QString, std::pair<QString, UserConnection_Information>> savedHostList;
const QString placeHolderText = QStringLiteral("Downloading...");
};
#endif
#endif // DLG_REGISTER_H

View File

@ -0,0 +1,250 @@
#ifndef BANNER_SHADER_CONFIG_H
#define BANNER_SHADER_CONFIG_H
#include <QColor>
#include <QObject>
/**
* 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

View File

@ -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 <QCloseEvent>
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>
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<BannerHost::Motif> 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();
}

View File

@ -0,0 +1,71 @@
#ifndef FIRST_RUN_WIZARD_H
#define FIRST_RUN_WIZARD_H
#include <QDialog>
#include <QList>
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<FirstRunWizardPage *> pages;
int currentIndex = -1;
};
#endif // FIRST_RUN_WIZARD_H

View File

@ -0,0 +1 @@
#include "first_run_wizard_page.h"

View File

@ -0,0 +1,75 @@
#ifndef FIRST_RUN_WIZARD_PAGE_H
#define FIRST_RUN_WIZARD_PAGE_H
#include <QWidget>
/** @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

View File

@ -0,0 +1,56 @@
#include "account_setup_page.h"
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
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."));
}

View File

@ -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

View File

@ -0,0 +1,297 @@
#include "card_database_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include <QComboBox>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSpinBox>
#include <QUrl>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/updates_settings.h>
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<int>::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<int>::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);
}

View File

@ -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

View File

@ -0,0 +1,30 @@
#include "finish_page.h"
#include <QLabel>
#include <QVBoxLayout>
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!"));
}

View File

@ -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

View File

@ -0,0 +1,183 @@
#include "preferences_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include <QCheckBox>
#include <QGroupBox>
#include <QScrollArea>
#include <QVBoxLayout>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/sound_settings.h>
#include <libcockatrice/settings/updates_settings.h>
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"));
}

View File

@ -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

View File

@ -0,0 +1,230 @@
#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 <QComboBox>
#include <QDir>
#include <QFile>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QMessageBox>
#include <QVBoxLayout>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
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<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged);
connect(schemeCombo, QOverload<int>::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<int>::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);
// 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;
}
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<BackgroundSources::Type>();
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
}
// 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);
paletteDirty = true;
}
bool ThemeSetupPage::validatePage()
{
if (paletteDirty) {
const QString scheme = resolvedScheme();
PaletteConfig cfg =
PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme);
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;
}
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)"));
}

View File

@ -0,0 +1,58 @@
#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):
* - 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
* the preview doesn't fall back to 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();
QString writableThemeDir() const;
QComboBox *themeCombo;
QComboBox *schemeCombo;
QGroupBox *accentGroup;
QuickSetupPanel *quickSetupPanel;
QComboBox *homeTabBackgroundCombo;
bool paletteDirty = false;
};
#endif // THEME_SETUP_PAGE_H

View File

@ -0,0 +1,31 @@
#include "welcome_page.h"
#include <QLabel>
#include <QVBoxLayout>
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."));
}

View File

@ -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

View File

@ -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.51.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
}
}
}

View File

@ -0,0 +1,195 @@
#include "shader_banner_widget.h"
#include "banner_shader_config.h"
#include <QPainter>
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickWidget>
#include <QResizeEvent>
#include <QStackedLayout>
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);
}
}

View File

@ -0,0 +1,83 @@
#ifndef SHADER_BANNER_WIDGET_H
#define SHADER_BANNER_WIDGET_H
#include <QElapsedTimer>
#include <QTimer>
#include <QWidget>
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

View File

@ -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;
}

View File

@ -0,0 +1,84 @@
#include "step_indicator_widget.h"
#include <QPainter>
#include <QPainterPath>
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;
}
}

View File

@ -0,0 +1,34 @@
#ifndef STEP_INDICATOR_WIDGET_H
#define STEP_INDICATOR_WIDGET_H
#include <QWidget>
/** @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

View File

@ -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()

View File

@ -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;