From 23798ca7823b70a0a31f4e8d98163803faf7b090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sat, 5 Sep 2026 08:31:27 +0200 Subject: [PATCH 1/2] [DeckShare] Open shared decks via links with a gated preview flow - Serialized url-chain dispatcher in IntentUrlParser; queue-drained urlChainFinished(bool) drives the startup auto-connect fallback - Open-shared-deck intent with sequential download state machine, 15s per-item timeout, partial-success offer, livable Cancel via ApplicationModal dlg_login_prompt interactive fallback - Preview dialog: download progress label, share vocab sweep, palette-highlight selection frame, Space/Enter keyboard toggle, NoFocus checkbox, double-click tile opens immediately - Confirm-before-server-migration with one-shot restore to the previous server on failed/cancelled chains (statusChanged settle deferral), hostname-only identity comparisons - Skip credential link when already connected; arrow-key navigation in FlowWidget; card glows use palette highlight - Address code-review M1-M4 and UI/UX QA blockers 1-2 --- cockatrice/CMakeLists.txt | 5 + .../intents/contexts/context_open_deck.h | 14 + cockatrice/src/interface/intents/intent.cpp | 13 +- cockatrice/src/interface/intents/intent.h | 2 + .../intents/intent_join_server_game.cpp | 11 +- .../src/interface/intents/intent_login.cpp | 27 +- .../intents/intent_open_shared_deck.cpp | 190 ++++++++++++ .../intents/intent_open_shared_deck.h | 59 ++++ .../src/interface/intents/url_parser.cpp | 288 ++++++++++++++++-- cockatrice/src/interface/intents/url_parser.h | 36 ++- ..._info_picture_with_text_overlay_widget.cpp | 6 +- .../deck_preview_card_picture_widget.cpp | 26 +- .../cards/deck_preview_card_picture_widget.h | 18 +- .../deck_share/shared_deck_preview_widget.cpp | 139 +++++++++ .../deck_share/shared_deck_preview_widget.h | 77 +++++ .../widgets/dialogs/dlg_login_prompt.cpp | 50 +++ .../widgets/dialogs/dlg_login_prompt.h | 40 +++ .../dialogs/dlg_shared_decks_preview.cpp | 178 +++++++++++ .../dialogs/dlg_shared_decks_preview.h | 68 +++++ .../general/layout_containers/flow_widget.cpp | 45 +++ .../general/layout_containers/flow_widget.h | 2 + cockatrice/src/interface/window_main.cpp | 49 ++- cockatrice/src/interface/window_main.h | 17 +- cockatrice/src/main.cpp | 20 +- cockatrice/src/single_instance_manager.cpp | 25 +- 25 files changed, 1350 insertions(+), 55 deletions(-) create mode 100644 cockatrice/src/interface/intents/contexts/context_open_deck.h create mode 100644 cockatrice/src/interface/intents/intent_open_shared_deck.cpp create mode 100644 cockatrice/src/interface/intents/intent_open_shared_deck.h create mode 100644 cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp create mode 100644 cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp create mode 100644 cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 444bd8ba4..ddbe6c2b1 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -45,12 +45,14 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_local_game_options.cpp + src/interface/widgets/dialogs/dlg_login_prompt.cpp src/interface/widgets/dialogs/dlg_manage_sets.cpp src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_register.cpp src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp src/interface/widgets/dialogs/dlg_share_deck.cpp + src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -59,6 +61,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/deck_share/deck_share_utils.cpp + src/interface/widgets/deck_share/shared_deck_preview_widget.cpp src/interface/widgets/deck_share/share_bar_widget.cpp src/filters/deck_filter_string.cpp src/filters/filter_builder.cpp @@ -444,6 +447,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_login.h src/interface/intents/intent_open_server_room_by_name.cpp src/interface/intents/intent_open_server_room_by_name.h + src/interface/intents/intent_open_shared_deck.cpp + src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h src/interface/widgets/server/user/user_info_popup.cpp diff --git a/cockatrice/src/interface/intents/contexts/context_open_deck.h b/cockatrice/src/interface/intents/contexts/context_open_deck.h new file mode 100644 index 000000000..03dca088e --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_open_deck.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H +#define COCKATRICE_CONTEXT_OPEN_DECK_H + +#include "context_connect_to_server.h" + +#include + +struct ContextOpenDeck +{ + ContextConnectToServer serverContext; + QString shareToken; +}; + +#endif // COCKATRICE_CONTEXT_OPEN_DECK_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp index c02a89f35..aa06fd81b 100644 --- a/cockatrice/src/interface/intents/intent.cpp +++ b/cockatrice/src/interface/intents/intent.cpp @@ -2,10 +2,11 @@ Intent::Intent(QObject *parent) : QObject(parent) { - // An intent is done as soon as it reports success or failure. Deleting it - // also tears down its dependency chain and disconnects any signal wiring. + // An intent is done as soon as it reports success, failure, or cancellation. + // Deleting it also tears down its dependency chain and disconnects any signal wiring. connect(this, &Intent::finished, this, &QObject::deleteLater); connect(this, &Intent::failed, this, &QObject::deleteLater); + connect(this, &Intent::cancelled, this, &QObject::deleteLater); } Intent::~Intent() = default; @@ -46,3 +47,11 @@ void Intent::emitFailed(const QString &reason) emit failed(reason); } } + +void Intent::emitCancelled() +{ + if (!completed) { + completed = true; + emit cancelled(); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h index 125900ecd..5d9fdd3d6 100644 --- a/cockatrice/src/interface/intents/intent.h +++ b/cockatrice/src/interface/intents/intent.h @@ -16,6 +16,7 @@ public: signals: void finished(); void failed(QString reason); + void cancelled(); protected: // --- Subclasses must implement these --- @@ -29,6 +30,7 @@ protected: // Emit the outcome exactly once; ignore late signals after the intent is done. void emitFinished(); void emitFailed(const QString &reason); + void emitCancelled(); private: bool completed = false; diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index 205c4dc70..6bf46ec72 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -19,13 +19,10 @@ bool IntentJoinServerGame::checkPrecondition() const if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { return false; } - // peerPort() reflects the actual TCP peer, which may differ from the - // configured server port (e.g. when connecting through a proxy), so only - // the hostname is compared here. - if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { - return false; - } - if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + // serverName() reflects the server the client was configured to connect to, + // which may differ from the actual TCP peer (e.g. when connecting through a + // proxy), so only the hostname is compared here. + if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { return false; } diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index ff871fd03..344ef4e00 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -1,8 +1,11 @@ #include "intent_login.h" #include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_login_prompt.h" #include "libcockatrice/settings/servers_settings.h" +#include + IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) { } @@ -29,5 +32,27 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied() { - emitFailed(tr("No saved credentials for this server")); + // No credentials saved for the target server: ask the user for them. They + // opt into saving them so later links to the same server connect directly. + const QString serverText = context->hostname + ":" + context->port; + DlgLoginPrompt dialog(serverText); + // ApplicationModal: the dialog has no parent (the intent is not a widget), + // so WindowModal would not actually block any other window. + dialog.setWindowModality(Qt::ApplicationModal); + + if (dialog.exec() != QDialog::Accepted) { + emitCancelled(); + return; + } + + context->username = dialog.username(); + context->password = dialog.password(); + + if (dialog.savePassword() && !context->username.isEmpty()) { + ServersSettings &servers = SettingsCache::instance().servers(); + servers.addNewServer(context->hostname, context->hostname, context->port, context->username, context->password, + true); + } + + emitFinished(); } diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp new file mode 100644 index 000000000..c7d4f5469 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -0,0 +1,190 @@ +#include "intent_open_shared_deck.h" + +#include "../deck_loader/deck_loader.h" +#include "../widgets/dialogs/dlg_shared_decks_preview.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier), + context(_context.release()) +{ + downloadTimer = new QTimer(this); + downloadTimer->setSingleShot(true); + downloadTimer->setInterval(15000); + connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout); +} + +bool IntentOpenSharedDeck::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // serverName() reflects the server the client was configured to connect to, + // which may differ from the actual TCP peer (e.g. when connecting through a + // proxy), so only the hostname is compared here. + return remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) == 0; +} + +void IntentOpenSharedDeck::onPreconditionSatisfied() +{ + // Resolve the share token to its items first; a share can contain more than + // one deck, and each item is downloaded by id. + Command_DeckShareList cmd; + cmd.set_token(context->shareToken.toStdString()); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */) +{ + if (response.response_code() != Response::RespOk) { + emitFailed(tr("The shared deck could not be found or has expired")); + return; + } + + const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext); + if (resp.items_size() == 0) { + emitFailed(tr("The shared deck is empty")); + return; + } + + QList items; + items.reserve(resp.items_size()); + for (const ServerInfo_DeckShareItem &item : resp.items()) { + items.append(item); + itemNames.insert(item.id(), QString::fromStdString(item.name())); + } + + const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port; + + // Ask the user which decks to open before downloading anything. + previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()), + resp.expires_at(), serverText, items); + connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater); + previewDialog->show(); + previewDialog->raise(); + previewDialog->activateWindow(); +} + +void IntentOpenSharedDeck::startDownloads(const QList &itemIds) +{ + pendingItemIds = itemIds; + totalItems = itemIds.size(); + completedItems = 0; + loadedDecks.clear(); + downloadNextItem(); +} + +void IntentOpenSharedDeck::downloadNextItem() +{ + if (pendingItemIds.isEmpty()) { + finishAll(); + return; + } + + currentItemId = pendingItemIds.takeFirst(); + downloadTimer->start(); + + Command_DeckShareDownload cmd; + cmd.set_token(context->shareToken.toStdString()); + cmd.set_item_id(currentItemId); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::downloadShareFinished(const Response &response, + const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + + QString failureReason; + if (response.response_code() != Response::RespOk) { + failureReason = tr("Failed to download the shared deck"); + } else { + const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext); + const QString deckString = QString::fromStdString(resp.deck()); + if (deckString.isEmpty()) { + failureReason = tr("The shared deck is empty"); + } else { + std::optional deckOpt = + DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + failureReason = tr("The shared deck could not be loaded"); + } else { + loadedDecks.append(deckOpt.value()); + ++completedItems; + previewDialog->setDownloadProgress(completedItems, totalItems, + itemNames.value(currentItemId, tr("Unknown deck"))); + downloadNextItem(); + return; + } + } + } + + onItemFailure(failureReason); +} + +void IntentOpenSharedDeck::onItemFailure(const QString &reason) +{ + downloadTimer->stop(); + + if (loadedDecks.isEmpty()) { + previewDialog->deleteLater(); + emitFailed(reason); + return; + } + + const int downloadedCount = loadedDecks.size(); + const QMessageBox::StandardButton answer = QMessageBox::question( + previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (answer == QMessageBox::Yes) { + finishAll(); + } else { + previewDialog->deleteLater(); + emitCancelled(); + } +} + +void IntentOpenSharedDeck::onDownloadTimeout() +{ + onItemFailure(tr("Timed out while downloading the shared deck")); +} + +void IntentOpenSharedDeck::finishAll() +{ + previewDialog->deleteLater(); + for (const LoadedDeck &deck : loadedDecks) { + tabSupervisor->openDeckInNewTab(deck); + } + emitFinished(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h new file mode 100644 index 000000000..ac8d443b8 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -0,0 +1,59 @@ +#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H +#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H + +#include "contexts/context_open_deck.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include +#include +#include + +class TabSupervisor; +struct LoadedDeck; +class CardDatabaseQuerier; +class DlgSharedDecksPreview; +class QTimer; + +class IntentOpenSharedDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private slots: + void listShareFinished(const Response &response, const CommandContainer &commandContainer); + void downloadShareFinished(const Response &response, const CommandContainer &commandContainer); + void onDownloadTimeout(); + +private: + void startDownloads(const QList &itemIds); + void downloadNextItem(); + void onItemFailure(const QString &reason); + void finishAll(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + const CardDatabaseQuerier *querier; + QScopedPointer context; + DlgSharedDecksPreview *previewDialog = nullptr; + QTimer *downloadTimer; + QMap itemNames; + QList pendingItemIds; + QList loadedDecks; + int currentItemId = 0; + int totalItems = 0; + int completedItems = 0; +}; + +#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H \ No newline at end of file diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 509390611..ee5e084dd 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -1,19 +1,28 @@ #include "url_parser.h" +#include "../../client/settings/cache_settings.h" #include "../widgets/tabs/tab_room.h" #include "../widgets/tabs/tab_supervisor.h" #include "../window_main.h" #include "contexts/context_join_game.h" +#include "contexts/context_open_deck.h" +#include "intent.h" #include "intent_join_server_game.h" #include "intent_login.h" +#include "intent_open_shared_deck.h" #include +#include #include #include #include +#include #include +#include #include +inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser"); + IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) { } @@ -29,16 +38,33 @@ void IntentUrlParser::handle(const QString &urlStr) const QString action = url.host(); QUrlQuery query(url); + qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action; + + QList chain; + Intent *firstIntent = nullptr; if (action == "joingame") { - handleJoinGame(query); + firstIntent = createJoinGameIntent(query, chain); } else if (action == "opendeck") { - // handleOpenDeck(query); + firstIntent = createOpenDeckIntent(query, chain); } else { qWarning() << "Unknown intent:" << action; } + + if (firstIntent == nullptr) { + // The link was invalid or the user declined the confirm: nothing runs. + // Report the idle state when no other chain is queued so that a startup + // launch (which skipped its own connection for this URL) falls back to it. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } + return; + } + + pendingChains.append(chain); + startNextChain(); } -void IntentUrlParser::handleJoinGame(const QUrlQuery &query) +Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, QList &chain) { auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); }; @@ -49,21 +75,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (ctx->roomContext.serverContext.hostname.isEmpty()) { showError(tr("Missing or empty hostname in the game link")); - return; + return nullptr; } bool ok = false; ctx->roomContext.serverContext.port.toUShort(&ok); if (!ok) { showError(tr("Invalid or missing port in the game link")); - return; + return nullptr; } ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); if (!ok) { showError(tr("Invalid or missing room id in the game link")); - return; + return nullptr; } ok = false; @@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (!ok) { showError(tr("Invalid or missing game id in the game link")); - return; + return nullptr; } const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); @@ -80,24 +106,32 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) const QMessageBox::StandardButton answer = QMessageBox::question( mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); if (answer != QMessageBox::Yes) { - return; + return nullptr; } + RemoteClient *client = mainWindow->getRemoteClient(); + ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; + // The join game intent owns the context and the credential lookup; once the // chain finishes (or fails) it deletes the whole tree. - ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; - auto joinGameIntent = - new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx)); + auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx)); joinGameIntent->setParent(this); - - auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); - getLoginCredentialsIntent->setParent(joinGameIntent); - - connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); - connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + chain.append(joinGameIntent); connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); - getLoginCredentialsIntent->execute(); + Intent *firstIntent = joinGameIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); + getLoginCredentialsIntent->setParent(joinGameIntent); + chain.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; } QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) @@ -134,3 +168,221 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); } + +Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, QList &chain) +{ + auto showError = [this](const QString &message) { + QMessageBox::warning(mainWindow, tr("Open shared deck"), message); + }; + + auto ctx = std::make_unique(); + + ctx->serverContext.hostname = query.queryItemValue("hostname"); + ctx->serverContext.port = query.queryItemValue("port"); + ctx->shareToken = query.queryItemValue("share"); + + qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port" + << ctx->serverContext.port << "token length" << ctx->shareToken.length(); + + if (ctx->serverContext.hostname.isEmpty()) { + showError(tr("Missing or empty hostname in the share link")); + return nullptr; + } + + bool ok = false; + const quint16 port = ctx->serverContext.port.toUShort(&ok); + if (!ok || port == 0) { + showError(tr("Invalid or missing port in the share link")); + return nullptr; + } + + if (ctx->shareToken.isEmpty()) { + showError(tr("Missing or empty share value in the share link")); + return nullptr; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + + // When the link would move us away from a live session, ask first — the + // open deck download needs the connection the user already has. Remember + // the current session so a failed or cancelled chain can restore it. + const bool migrating = + client->getStatus() == StatusLoggedIn && !isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port); + if (migrating) { + const QString target = QStringLiteral("%1:%2").arg(ctx->serverContext.hostname, ctx->serverContext.port); + const QString current = + QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return nullptr; + } + migrationTargetHost = ctx->serverContext.hostname; + migrationTargetPort = ctx->serverContext.port; + previousServerHost = client->serverName(); + previousServerPort = QString::number(client->serverPort()); + pendingRestore = true; + } + + ContextConnectToServer *serverContext = &ctx->serverContext; + + // The open deck intent owns the context and the credential lookup; once + // the chain finishes (or fails) it deletes the whole tree. + auto openDeckIntent = + new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx)); + openDeckIntent->setParent(this); + chain.append(openDeckIntent); + connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); + + Intent *firstIntent = openDeckIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); + getLoginCredentialsIntent->setParent(openDeckIntent); + chain.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; +} + +bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const +{ + Q_UNUSED(port); + // Deliberately hostname-only (no port): the intents' preconditions apply the + // same rule, so a link to the same host on another port still connects + // rather than silently reusing an existing session on a different server. + RemoteClient *client = mainWindow->getRemoteClient(); + return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0; +} + +void IntentUrlParser::startNextChain() +{ + if (chainRunning || pendingChains.isEmpty()) { + return; + } + chainRunning = true; + currentChainSucceeded = false; + + const QList chain = pendingChains.takeFirst(); + if (chain.isEmpty()) { + chainRunning = false; + return; + } + + // Only the last intent completes the chain; its terminal signal ends the + // whole run. Cancellation of an intermediate intent (e.g. declined login + // prompt) is forwarded onto the last intent in the chain builders above. + Intent *finalIntent = chain.last(); + connect(finalIntent, &Intent::finished, this, [this]() { + currentChainSucceeded = true; + chainEnded(); + }); + connect(finalIntent, &Intent::failed, this, &IntentUrlParser::chainEnded); + connect(finalIntent, &Intent::cancelled, this, &IntentUrlParser::chainEnded); + + chain.first()->execute(); +} + +void IntentUrlParser::chainEnded() +{ + chainRunning = false; + + // Only a failed or cancelled chain restores the session the link migrated + // away from; a successful one leaves the user where they are. + if (pendingRestore && !currentChainSucceeded) { + restorePreviousServer(); + } + pendingRestore = false; + + startNextChain(); + + // Only report the terminal state once the queue has fully drained, so a + // queued follow-up link keeps the startup fallback out of the picture. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } +} + +void IntentUrlParser::restorePreviousServer() +{ + if (previousServerHost.isEmpty()) { + return; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + const ClientStatus status = client->getStatus(); + + // A failed/cancelled chain can fire while the client is still settling the + // in-flight connection attempt (wrong password, connect timeout). Only + // decide once the client has settled into logged-in or disconnected; + // deciding mid-connect would strand the user offline from their previous + // server. + if (status == StatusDisconnected || status == StatusLoggedIn) { + restoreToPreviousServer(); + return; + } + auto waitConnection = std::make_shared(); + *waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, client, waitConnection]() { + const ClientStatus settled = client->getStatus(); + if (settled == StatusDisconnected || settled == StatusLoggedIn) { + QObject::disconnect(*waitConnection); + restoreToPreviousServer(); + } + }); +} + +void IntentUrlParser::restoreToPreviousServer() +{ + RemoteClient *client = mainWindow->getRemoteClient(); + + // Back on the previous server already → nothing to undo. + if (client->serverName().compare(previousServerHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == previousServerPort) { + return; + } + + // When logged in somewhere, only intervene if that somewhere is the server + // the link moved us to; if the user went elsewhere on their own, leave them. + if (client->getStatus() == StatusLoggedIn) { + const bool onMigrationTarget = client->serverName().compare(migrationTargetHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == migrationTargetPort; + if (!onMigrationTarget) { + return; + } + + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(previousServerHost, previousServerPort); + if (index >= 0 && servers.hasLoginData(previousServerHost, previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(previousServerHost, previousServerPort.toUInt(), username, password); + return; + } + client->disconnectFromServer(); + return; + } + + if (client->getStatus() != StatusDisconnected) { + return; + } + + // The link's connection attempt failed: reconnect to the previous server + // when credentials are saved, otherwise stay offline. + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(previousServerHost, previousServerPort); + if (index >= 0 && servers.hasLoginData(previousServerHost, previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(previousServerHost, previousServerPort.toUInt(), username, password); + } +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index 6d705e013..7464caf4f 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -1,10 +1,22 @@ #ifndef COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H +#include #include #include +class Intent; class MainWindow; struct ContextJoinGame; + +/** + * @brief Parses cockatrice:// links and runs them as serialized intent chains. + * + * Links are parsed by action (joingame/opendeck) and translated into an intent + * chain. Chains are queued and run one at a time: a document can hand multiple + * links to the window while an earlier chain still connects, and running two + * connect chains concurrently tears the connection down. urlChainFinished is + * emitted once the queue has fully drained. + */ class IntentUrlParser : public QObject { Q_OBJECT @@ -12,12 +24,34 @@ class IntentUrlParser : public QObject public: IntentUrlParser(QObject *parent, MainWindow *mainWindow); void handle(const QString &urlStr); - void handleJoinGame(const QUrlQuery &query); + +signals: + /** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */ + void urlChainFinished(bool connected); private: + Intent *createJoinGameIntent(const QUrlQuery &query, QList &chain); + Intent *createOpenDeckIntent(const QUrlQuery &query, QList &chain); QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + [[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const; + void startNextChain(); + void chainEnded(); + void restorePreviousServer(); + void restoreToPreviousServer(); MainWindow *mainWindow; + QList> pendingChains; + bool chainRunning = false; + bool currentChainSucceeded = false; + + // Set when an open-deck link migrates the session to another server. If the + // chain then fails or is cancelled while still on that server, the previous + // session is restored (reconnect if credentials are saved, else disconnect). + QString migrationTargetHost; + QString migrationTargetPort; + QString previousServerHost; + QString previousServerPort; + bool pendingRestore = false; }; #endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index c5cb59b3b..000a88b2f 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) path.addRoundedRect(glowRect, radius, radius); // Soft outer glow - QColor glowColor(0, 150, 255, 80); // subtle blu + QColor glowColor = palette().color(QPalette::Highlight); + glowColor.setAlpha(80); painter.setPen(QPen(glowColor, 6)); painter.drawPath(path); // Thin inner border for crispness - QColor borderColor(0, 150, 255, 200); + QColor borderColor = palette().color(QPalette::Highlight); + borderColor.setAlpha(200); painter.setPen(QPen(borderColor, 2)); painter.drawRoundedRect(pixmapRect, radius, radius); diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp index 707173560..147143e7f 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -27,14 +27,16 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, const QColor &textColor, const QColor &outlineColor, const int fontSize, - const Qt::Alignment alignment) + const Qt::Alignment alignment, + const bool _emitClickImmediately) : CardInfoPictureWithTextOverlayWidget(parent, hoverToZoomEnabled, raiseOnEnter, textColor, outlineColor, fontSize, - alignment) + alignment), + emitClickImmediately(_emitClickImmediately) { singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); @@ -50,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - lastMouseEvent = event; - singleClickTimer->start(QApplication::doubleClickInterval()); + if (emitClickImmediately) { + emit imageClicked(event, this); + emit imageSingleClicked(); + } else { + lastMouseEvent = event; + singleClickTimer->start(QApplication::doubleClickInterval()); + } } else { emit imageClicked(event, this); event->accept(); @@ -61,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - singleClickTimer->stop(); // Prevent single-click logic - emit imageDoubleClicked(lastMouseEvent, this); + if (emitClickImmediately) { + // Do not report a second single click for the second press of the + // double-click; the consumer maps the double-click to select+open. + lastMouseEvent = event; + emit imageDoubleClicked(event, this); + } else { + singleClickTimer->stop(); // Prevent single-click logic + emit imageDoubleClicked(lastMouseEvent, this); + } } } diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h index 303b6bf67..7571bc256 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h @@ -20,13 +20,28 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay Q_OBJECT public: + /** + * @brief Constructs a DeckPreviewCardPictureWidget. + * @param parent The parent widget. + * @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over. + * @param raiseOnEnter If the widget raises its border when the mouse enters. + * @param textColor The color of the overlay text. + * @param outlineColor The color of the outline around the text. + * @param fontSize The font size of the overlay text. + * @param alignment The alignment of the text within the overlay. + * @param emitClickImmediately If true, a left click is reported immediately on click + * instead of after the double-click interval. Use this for selection surfaces + * where reacting to a double-click (select-and-open) would needlessly delay the + * single-click feedback. The double-click signal is still emitted. + */ explicit DeckPreviewCardPictureWidget(QWidget *parent, bool hoverToZoomEnabled = false, bool raiseOnEnter = false, const QColor &textColor = Qt::white, const QColor &outlineColor = Qt::black, int fontSize = 12, - Qt::Alignment alignment = Qt::AlignCenter); + Qt::Alignment alignment = Qt::AlignCenter, + bool _emitClickImmediately = false); signals: void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); @@ -36,6 +51,7 @@ signals: private: QTimer *singleClickTimer; QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event + bool emitClickImmediately; protected: void mousePressEvent(QMouseEvent *event) override; diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp new file mode 100644 index 000000000..d52fa456b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -0,0 +1,139 @@ +#include "shared_deck_preview_widget.h" + +#include "../cards/additional_info/color_identity_widget.h" +#include "../cards/deck_preview_card_picture_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat, + const QString &deckToolTip) + : QWidget(parent) +{ + bannerCardDisplayWidget = + new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true); + bannerCardDisplayWidget->setScaleFactor(100); + const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}}); + bannerCardDisplayWidget->setCard(bannerCard); + bannerCardDisplayWidget->setOverlayText(deckName); + setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip); + setFocusPolicy(Qt::StrongFocus); + setBaseAccessibleName(deckName); + + colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity); + colorIdentityWidget->setVisible(!colorIdentity.isEmpty()); + + gameFormatLabel = new QLabel(gameFormat, this); + gameFormatLabel->setAlignment(Qt::AlignCenter); + gameFormatLabel->setVisible(!gameFormat.isEmpty()); + + selectionCheckBox = new QCheckBox(this); + selectionCheckBox->setToolTip(tr("Select this deck")); + // The tile itself is focusable (Space/Enter toggles); keep the checkbox + // from creating a second tab stop per tile. + selectionCheckBox->setFocusPolicy(Qt::NoFocus); + + // Selection frame reused from the deck-preview selection covenant: a + // palette(highlight) border around the banner card, shown while selected. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + + auto *selectionRow = new QHBoxLayout; + selectionRow->addWidget(selectionCheckBox); + selectionRow->addStretch(1); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addLayout(selectionRow); + layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); + layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); + layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter); + setLayout(layout); + + connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + updateSelectionVisual(checked); + emit selectionToggled(checked); + }); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &SharedDeckPreviewWidget::toggleSelection); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &SharedDeckPreviewWidget::activate); +} + +bool SharedDeckPreviewWidget::isSelected() const +{ + return selectionCheckBox->isChecked(); +} + +void SharedDeckPreviewWidget::setSelected(bool selected) +{ + if (isSelected() == selected) { + return; + } + selectionCheckBox->setChecked(selected); +} + +void SharedDeckPreviewWidget::updateSelectionVisual(bool selected) +{ + selectionFrame->setVisible(selected); + selectionFrame->raise(); + if (selected) { + setAccessibleName(baseAccessibleName + tr(" (selected)")); + } else { + setAccessibleName(baseAccessibleName); + } +} + +void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void SharedDeckPreviewWidget::toggleSelection() +{ + setSelected(!isSelected()); +} + +void SharedDeckPreviewWidget::activate() +{ + setSelected(true); + emit activated(); +} + +void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateSelectionFrameGeometry(); +} + +void SharedDeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); +} + +void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + toggleSelection(); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h new file mode 100644 index 000000000..bd4c4ee5f --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h @@ -0,0 +1,77 @@ +/** + * @file shared_deck_preview_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARED_DECK_PREVIEW_WIDGET_H +#define SHARED_DECK_PREVIEW_WIDGET_H + +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class QCheckBox; +class QFrame; +class QKeyEvent; +class QLabel; +class QResizeEvent; +class CardDatabaseQuerier; + +/** + * @brief A selectable preview tile for a deck that has no local file. + * + * Renders a banner card picture (looked up by name in the card database), the + * deck name, color identity and game format. Used to preview decks shared via a + * cockatrice:// link (metadata from Command_DeckShareList) and the deck + * currently open in the deck editor. + * + * Selection follows the deck-preview covenant: the tile reports its click + * immediately (no double-click interval delay), a palette(highlight) frame + * marks the selected tile, and Space/Enter toggles selection from the keyboard. + * A double click selects the tile and emits activated() so the caller can open + * just that deck. + */ +class SharedDeckPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat = QString(), + const QString &deckToolTip = QString()); + + [[nodiscard]] bool isSelected() const; + void setSelected(bool selected); + + void setBaseAccessibleName(const QString &name); + +signals: + void selectionToggled(bool selected); + void activated(); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void toggleSelection(); + void activate(); + +private: + void updateSelectionVisual(bool selected); + void updateSelectionFrameGeometry(); + + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + QLabel *gameFormatLabel; + QCheckBox *selectionCheckBox; + QFrame *selectionFrame; + QString baseAccessibleName; +}; + +#endif // SHARED_DECK_PREVIEW_WIDGET_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp new file mode 100644 index 000000000..fbc07b7e0 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp @@ -0,0 +1,50 @@ +#include "dlg_login_prompt.h" + +#include +#include +#include +#include +#include +#include + +DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Sign in")); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addWidget( + new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this)); + + auto *formLayout = new QFormLayout; + usernameEdit = new QLineEdit(this); + passwordEdit = new QLineEdit(this); + passwordEdit->setEchoMode(QLineEdit::Password); + formLayout->addRow(tr("Username:"), usernameEdit); + formLayout->addRow(tr("Password:"), passwordEdit); + mainLayout->addLayout(formLayout); + + savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this); + mainLayout->addWidget(savePasswordCheckBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttonBox); + + usernameEdit->setFocus(); +} + +QString DlgLoginPrompt::username() const +{ + return usernameEdit->text().trimmed(); +} + +QString DlgLoginPrompt::password() const +{ + return passwordEdit->text(); +} + +bool DlgLoginPrompt::savePassword() const +{ + return savePasswordCheckBox->isChecked(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h new file mode 100644 index 000000000..47d8586c4 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h @@ -0,0 +1,40 @@ +/** + * @file dlg_login_prompt.h + * @ingroup ConnectionDialogs + */ +//! \todo Document this file. + +#ifndef DLG_LOGIN_PROMPT_H +#define DLG_LOGIN_PROMPT_H + +#include + +class QCheckBox; +class QLineEdit; + +/** + * @brief Small sign-in dialog used when a cockatrice:// link needs credentials + * that are not saved for the target server. + * + * The entered name and password are handed to the intent chain; when the user + * opts to save them, they are stored in the server settings so that later links + * to the same server connect seamlessly. + */ +class DlgLoginPrompt : public QDialog +{ + Q_OBJECT + +public: + explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr); + + [[nodiscard]] QString username() const; + [[nodiscard]] QString password() const; + [[nodiscard]] bool savePassword() const; + +private: + QLineEdit *usernameEdit; + QLineEdit *passwordEdit; + QCheckBox *savePasswordCheckBox; +}; + +#endif // DLG_LOGIN_PROMPT_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp new file mode 100644 index 000000000..6576a3844 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -0,0 +1,178 @@ +#include "dlg_shared_decks_preview.h" + +#include "../deck_share/shared_deck_preview_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items) + : QDialog(parent) +{ + setWindowTitle(tr("Open shared decks")); + resize(700, 500); + + auto *mainLayout = new QVBoxLayout(this); + + auto *titleLabel = new QLabel(tr("Share: %1").arg(shareName.isEmpty() ? tr("Untitled") : shareName), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + mainLayout->addWidget(titleLabel); + + if (!serverText.isEmpty()) { + mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText), this)); + } + + if (expiresAt > 0) { + const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate); + mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this)); + } + + downloadStatusLabel = new QLabel(this); + downloadStatusLabel->setVisible(false); + mainLayout->addWidget(downloadStatusLabel); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + mainLayout->addWidget(flowWidget, 1); + + for (const ServerInfo_DeckShareItem &item : items) { + QStringList tags; + for (const auto &tag : item.tags()) { + tags.append(QString::fromStdString(tag)); + } + + auto *tile = new SharedDeckPreviewWidget( + this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()), + QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", ")); + flowWidget->addWidget(tile); + tiles.append(tile); + itemIds.append(item.id()); + } + + if (tiles.size() == 1) { + tiles.first()->setSelected(true); + } + + auto *buttonBox = new QDialogButtonBox(this); + openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole); + openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole); + buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { + onCancel(); + close(); + }); + + // Esc calls QDialog::reject() directly (which hides the dialog without a + // close event), so route it through the same guarded cancel as the button. + connect(this, &QDialog::rejected, this, [this]() { + onCancel(); + close(); + }); + + connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected); + connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) { + if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) { + openAll(); + } + }); + + for (SharedDeckPreviewWidget *tile : tiles) { + connect(tile, &SharedDeckPreviewWidget::selectionToggled, this, + &DlgSharedDecksPreview::updateOpenSelectedEnabled); + } + for (int i = 0; i < tiles.size(); ++i) { + const int itemId = itemIds.at(i); + // Double-clicking a tile selects it and opens just that deck. + connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() { + resultEmitted = true; + setDownloading(true); + emit openRequested(QList{itemId}); + }); + } + updateOpenSelectedEnabled(); +} + +QList DlgSharedDecksPreview::selectedItemIds() const +{ + QList selectedIds; + for (int i = 0; i < tiles.size(); ++i) { + if (tiles.at(i)->isSelected()) { + selectedIds.append(itemIds.at(i)); + } + } + return selectedIds; +} + +void DlgSharedDecksPreview::openSelected() +{ + const QList selectedIds = selectedItemIds(); + if (selectedIds.isEmpty()) { + return; + } + resultEmitted = true; + setDownloading(true); + emit openRequested(selectedIds); +} + +void DlgSharedDecksPreview::openAll() +{ + resultEmitted = true; + setDownloading(true); + emit openRequested(itemIds); +} + +void DlgSharedDecksPreview::setDownloading(bool downloading) +{ + if (downloadInProgress == downloading) { + return; + } + downloadInProgress = downloading; + downloadStatusLabel->setVisible(downloading); + for (SharedDeckPreviewWidget *tile : tiles) { + tile->setEnabled(!downloading); + } + openSelectedButton->setEnabled(!downloading); + openAllButton->setEnabled(!downloading); +} + +void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName) +{ + if (!downloadInProgress) { + return; + } + downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName)); +} + +void DlgSharedDecksPreview::updateOpenSelectedEnabled() +{ + openSelectedButton->setEnabled(!selectedItemIds().isEmpty()); +} + +void DlgSharedDecksPreview::onCancel() +{ + if (!resultEmitted || downloadInProgress) { + resultEmitted = true; + emit cancelled(); + } +} + +void DlgSharedDecksPreview::closeEvent(QCloseEvent *event) +{ + onCancel(); + QDialog::closeEvent(event); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h new file mode 100644 index 000000000..31820e3a0 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h @@ -0,0 +1,68 @@ +#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H +#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H + +#include +#include + +class FlowWidget; +class QCloseEvent; +class QLabel; +class QPushButton; +class ServerInfo_DeckShareItem; +class SharedDeckPreviewWidget; +class CardDatabaseQuerier; + +/** + * @brief Non-modal preview of the decks contained in a shared-deck link. + * + * Lets the user pick which of the shared decks to open before anything is + * downloaded. Emits openRequested with the ids of the chosen decks, or + * cancelled when the user closes the dialog without choosing. Once the user + * picks, the dialog switches into a "downloading" state: the tiles and open + * buttons are disabled, a progress label shows the current download and Cancel + * stays functional so the download can be aborted. + */ +class DlgSharedDecksPreview : public QDialog +{ + Q_OBJECT + +public: + explicit DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items); + + void setDownloadProgress(int done, int total, const QString ¤tDeckName); + +public slots: + void setDownloading(bool downloading); + +signals: + void openRequested(const QList &itemIds); + void cancelled(); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void openSelected(); + void openAll(); + void updateOpenSelectedEnabled(); + void onCancel(); + +private: + QList selectedItemIds() const; + + FlowWidget *flowWidget; + QList tiles; + QList itemIds; + QPushButton *openSelectedButton; + QPushButton *openAllButton; + QLabel *downloadStatusLabel; + bool resultEmitted = false; + bool downloadInProgress = false; +}; + +#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 025f457bd..6e04fed5a 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -7,6 +7,7 @@ #include "flow_widget.h" #include +#include #include #include #include @@ -177,6 +178,50 @@ QLayoutItem *FlowWidget::itemAt(int index) const return flowLayout->itemAt(index); } +void FlowWidget::keyPressEvent(QKeyEvent *event) +{ + // Keyboard navigation between the flow items: arrow keys move focus just + // like clicking the sibling tiles would. Only items that can take keyboard + // focus (e.g. the deck-preview tiles in shared-deck links) are visited. + const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down; + const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up; + if (!moveForward && !moveBackward) { + QWidget::keyPressEvent(event); + return; + } + + QList focusableItems; + for (int i = 0; i < flowLayout->count(); ++i) { + QWidget *item = flowLayout->itemAt(i)->widget(); + if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) { + focusableItems.append(item); + } + } + + if (focusableItems.isEmpty()) { + QWidget::keyPressEvent(event); + return; + } + + int currentIndex = -1; + for (int i = 0; i < focusableItems.size(); ++i) { + if (focusableItems.at(i)->hasFocus()) { + currentIndex = i; + break; + } + } + + const int delta = moveForward ? 1 : -1; + int nextIndex; + if (currentIndex < 0) { + nextIndex = moveForward ? 0 : focusableItems.size() - 1; + } else { + nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size(); + } + focusableItems.value(nextIndex)->setFocus(); + event->accept(); +} + int FlowWidget::count() const { return flowLayout->count(); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h index a232336d8..3f3a2be2b 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h @@ -11,6 +11,7 @@ #include "../../../layouts/flow_layout.h" #include +#include #include #include #include @@ -44,6 +45,7 @@ public slots: protected: void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; private: Qt::Orientation flowDirection; diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 4567991c8..e3341711f 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -507,6 +507,7 @@ MainWindow::MainWindow(QWidget *parent) connectionController = new ConnectionController(this, this); urlParser = new IntentUrlParser(this, this); + connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished); createActions(); createMenus(); @@ -722,6 +723,7 @@ void MainWindow::applyStartupDestination() connect(credentials, &Intent::finished, connector, &Intent::execute); connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); + connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); }); connect(connector, &Intent::finished, this, [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); @@ -862,18 +864,7 @@ void MainWindow::changeEvent(QEvent *event) } else if (event->type() == QEvent::ActivationChange) { if (isActiveWindow() && !bHasActivated) { bHasActivated = true; - if (!connectTo.isEmpty()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; - connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), - connectTo.password()); - } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup() && - !startupDestinationConnectsToServer()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; - DlgConnect dlg(this); - connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); - } + attemptStartupAutoConnect(); } } @@ -899,6 +890,40 @@ void MainWindow::handleCockatriceLink(const QString &url) urlParser->handle(url); } +void MainWindow::attemptStartupAutoConnect() +{ + if (startupAutoConnectAttempted || skipStartupAutoConnect) { + return; + } + startupAutoConnectAttempted = true; + + if (!connectTo.isEmpty()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; + connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), + connectTo.password()); + } else if (SettingsCache::instance().servers().getAutoConnect() && + !SettingsCache::instance().debug().getLocalGameOnStartup() && !startupDestinationConnectsToServer()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; + DlgConnect dlg(this); + connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), + dlg.getPlayerName(), dlg.getPassword()); + } +} + +void MainWindow::onUrlChainFinished(bool connected) +{ + // A cockatrice:// link owns the startup connection while it runs. When its + // chain ended without connecting (declined, invalid, offline), fall back to + // the startup connection so the activation launch still behaves like a + // normal launch. + if (connected || !skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { + return; + } + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup connect"; + skipStartupAutoConnect = false; + attemptStartupAutoConnect(); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 920145552..747bb7d80 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -75,6 +75,7 @@ public slots: void actCheckClientUpdates(); void actConnect(); void actExit(); + void handleCockatriceLink(const QString &url); private slots: void updateTabMenu(const QList &newMenuList); void statusChanged(ClientStatus _status); @@ -92,7 +93,7 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); - void handleCockatriceLink(const QString &url); + void onUrlChainFinished(bool connected); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); @@ -120,6 +121,8 @@ private slots: void startupDestinationFailed(const QString &reason); [[nodiscard]] bool startupDestinationConnectsToServer() const; + void attemptStartupAutoConnect(); + private: static const QString appName; static const QStringList fileNameFilters; @@ -158,6 +161,8 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; + bool skipStartupAutoConnect = false; + bool startupAutoConnectAttempted = false; QProcess *cardUpdateProcess; DlgViewLog *logviewDialog; GameReplay *replay; @@ -170,6 +175,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + // When set, the window's own startup connection (--connect or auto-connect + // on first activation) is skipped. Used for activation launches: the intent + // chain triggered by a cockatrice:// URL owns the connection, and letting + // auto-connect race against it caused two connectToServer calls to tear + // each other down. onUrlChainFinished() clears this and retries the startup + // connection when the link's chain ended without connecting. + void setSkipStartupAutoConnect(bool skip) + { + skipStartupAutoConnect = skip; + } ~MainWindow() override; RemoteClient *getRemoteClient() const diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index d8aa1cd08..0e719d8ce 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,7 +26,6 @@ #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" #include "interface/intents/intent_open_local_deck.h" -#include "interface/intents/url_parser.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" @@ -45,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -273,10 +273,12 @@ int main(int argc, char *argv[]) SingleInstanceManager instance; if (hasActivationFiles) { + qInfo() << "Activation launch, files:" << startupFiles; // Activation launch: hand off to the primary instance if one is // running, otherwise become the primary ourselves. Do this before // constructing the main window so a hand-off exits cheaply. if (!instance.tryRun(startupFiles)) { + qInfo() << "Handed off to a running instance, exiting"; // Sent successfully → exit return 0; } @@ -325,10 +327,22 @@ int main(int argc, char *argv[]) MainWindow ui; + // A URL launch must own the connection: the intent chain triggered by the + // URL connects to the server named in the URL, so the window's own startup + // auto-connect must not race against it (two connectToServer calls tear + // each other down via doDisconnectFromServer). + const bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) { + return file.startsWith(QStringLiteral("cockatrice://")); + }); + ui.setSkipStartupAutoConnect(hasUrlActivation); + auto handleActivation = [&ui](const QString &file) { if (file.startsWith("cockatrice://")) { - auto urlParser = new IntentUrlParser(&ui, &ui); - urlParser->handle(file); + qInfo() << "Handling URL activation:" << file; + // Route through the window's persistent url parser: it serializes + // link chains so activations handed over while another chain is + // still connecting do not connect concurrently. + ui.handleCockatriceLink(file); } else if (QFileInfo(file).exists()) { auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp index aca23160c..b207a9250 100644 --- a/cockatrice/src/single_instance_manager.cpp +++ b/cockatrice/src/single_instance_manager.cpp @@ -2,6 +2,14 @@ #include +namespace +{ +// Sent by the primary instance after it has read a forwarded payload. Without +// an acknowledgment, a second instance cannot tell a live primary apart from a +// stale socket left behind by a process that is still shutting down. +const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK"); +} // namespace + SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) { } @@ -72,7 +80,14 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) socket.flush(); socket.waitForBytesWritten(1000); - return true; + // Only report a successful hand-off once the primary has acknowledged that + // it actually read the payload. A socket that connects but never answers + // belongs to a process that is dying, so the caller must not treat this as + // a hand-off (otherwise it would exit without anyone handling the files). + if (!socket.waitForReadyRead(1000)) { + return false; + } + return socket.readAll() == ACK_MESSAGE; } void SingleInstanceManager::handleNewConnection() @@ -111,6 +126,14 @@ void SingleInstanceManager::handleNewConnection() QStringList files; payloadStream >> files; + // Acknowledge receipt as soon as the payload is parsed, before the + // primary starts handling it. The handlers run synchronously and can + // take longer than the sender's readiness timeout (e.g. a modal + // confirmation box), which would otherwise make a live primary look + // dead and cause duplicate handling. + socket->write(ACK_MESSAGE); + socket->flush(); + emit filesReceived(files); // Reset buffer (single message use-case) From 8ee45108b3e29a0df7f76b361c70b0a6e5a2eca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sat, 5 Sep 2026 09:23:07 +0200 Subject: [PATCH 2/2] [DeckShare] Browse and open public decks with loading, error and accessibility states Add a public-decks tab that lists decks published by other users using the server's deck visibility feature, previewing each deck's banner card, color identity, tags and upload time without downloading the deck list until the user opens it. - Add a public-decks tab with a shared-settings widget and a remote model that fetches the target user's decks and refreshes both automatically and on user request, with a loading indicator and a server-error message instead of a blank tab when the fetch fails or the connection drops - Render each deck as a focusable preview tile whose banner, color identity, tags and upload time follow the existing Preview settings, with the deck name announced as the tile's accessible name and Space/Enter opening the deck, mirroring the shared-deck preview tile - Show a message box when opening a public deck fails or arrives corrupted - Publish and unpublish decks from the server storage toolbar and context menu, toggling the deck's own visibility bit (what the server persists) rather than the inherited effective state, and batch the visibility refresh until the last in-flight change is acknowledged - Add the Show Upload Time setting so the tile's upload stamp can be hidden like the other preview details - Update the retranslateUi wiring for the new public-decks tab and rename the share action tooltip from "Deck share" to "Share link" --- cockatrice/CMakeLists.txt | 4 + .../remote/remote_decklist_tree_widget.cpp | 40 ++- .../remote/remote_decklist_tree_widget.h | 28 ++- .../widgets/server/user/user_context_menu.cpp | 13 + .../widgets/server/user/user_context_menu.h | 2 + .../widgets/tabs/abstract_tab_deck_editor.cpp | 1 + .../public_decks_quick_settings_widget.cpp | 151 ++++++++++++ .../tabs/public_decks_quick_settings_widget.h | 57 +++++ .../widgets/tabs/tab_deck_storage.cpp | 60 ++++- .../interface/widgets/tabs/tab_deck_storage.h | 6 +- .../widgets/tabs/tab_public_decks.cpp | 230 ++++++++++++++++++ .../interface/widgets/tabs/tab_public_decks.h | 79 ++++++ .../interface/widgets/tabs/tab_supervisor.cpp | 29 +++ .../interface/widgets/tabs/tab_supervisor.h | 4 + .../tab_deck_storage_visual.cpp | 2 +- .../public_deck_preview_widget.cpp | 164 +++++++++++++ .../deck_preview/public_deck_preview_widget.h | 75 ++++++ .../remote_public_decks_model.cpp | 202 +++++++++++++++ .../remote_public_decks_model.h | 125 ++++++++++ .../settings/visual_deck_storage_settings.cpp | 11 + .../settings/visual_deck_storage_settings.h | 3 + tests/settings/settings_defaults_test.cpp | 13 + 22 files changed, 1291 insertions(+), 8 deletions(-) create mode 100644 cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.h create mode 100644 cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_public_decks.h create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp create mode 100644 cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index ddbe6c2b1..b832e7b54 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -322,6 +322,8 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -381,6 +383,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/public_decks_quick_settings_widget.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp @@ -392,6 +395,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_message.cpp src/interface/widgets/tabs/tab_moderation.cpp + src/interface/widgets/tabs/tab_public_decks.cpp src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp index a6add3fca..85ee11488 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp @@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const { - return 3; + return 4; } QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const @@ -121,7 +121,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons if (!index.isValid()) { return QVariant(); } - if (index.column() >= 3) { + if (index.column() >= 4) { return QVariant(); } @@ -134,12 +134,20 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons switch (index.column()) { case 0: return node->getName(); + case 3: + return isEffectivelyPublic(node) ? tr("Public") : tr("Private"); default: return QVariant(); } } case Qt::DecorationRole: return index.column() == 0 ? dirIcon : QVariant(); + case Qt::ToolTipRole: + if (index.column() == 3) { + return isEffectivelyPublic(node) ? tr("This folder is visible to other users") + : tr("This folder is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -153,6 +161,8 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return file->getId(); case 2: return file->getUploadTime(); + case 3: + return isEffectivelyPublic(file) ? tr("Public") : tr("Private"); default: return QVariant(); } @@ -161,6 +171,12 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return index.column() == 0 ? fileIcon : QVariant(); case Qt::TextAlignmentRole: return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft; + case Qt::ToolTipRole: + if (index.column() == 3) { + return isEffectivelyPublic(file) ? tr("This deck is visible to other users") + : tr("This deck is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -183,6 +199,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien return tr("ID"); case 2: return tr("Upload time"); + case 3: + return tr("Visibility"); default: return QVariant(); } @@ -239,13 +257,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt time.setSecsSinceEpoch(fileInfo.creation_time()); beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); - parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent)); + parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent, fileInfo.is_public())); endInsertRows(); } void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent) { DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent); + newItem->setIsPublic(folder.folder().is_public()); const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder(); const int folderItemsSize = folderInfo.items_size(); for (int i = 0; i < folderItemsSize; ++i) { @@ -285,6 +304,21 @@ void RemoteDeckList_TreeModel::refreshTree() client->sendCommand(pend); } +bool RemoteDeckList_TreeModel::isEffectivelyPublic(const Node *node) const +{ + if (node == nullptr || node == root) { + return false; + } + const Node *current = node; + while (current != nullptr) { + if (current->isPublic()) { + return true; + } + current = current->getParent(); + } + return false; +} + void RemoteDeckList_TreeModel::clearTree() { beginResetModel(); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h index 3dd91d7a4..8a66f10a4 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h @@ -27,9 +27,11 @@ public: protected: DirectoryNode *parent; QString name; + bool publicFlag; public: - explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) : parent(_parent), name(_name) + explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) + : parent(_parent), name(_name), publicFlag(false) { } virtual ~Node() = default; @@ -41,6 +43,14 @@ public: { return name; } + [[nodiscard]] bool isPublic() const + { + return publicFlag; + } + void setIsPublic(bool _public) + { + publicFlag = _public; + } }; class DirectoryNode : public Node, public QList { @@ -59,9 +69,14 @@ public: QDateTime uploadTime; public: - FileNode(const QString &_name, int _id, const QDateTime &_uploadTime, DirectoryNode *_parent = nullptr) + FileNode(const QString &_name, + int _id, + const QDateTime &_uploadTime, + DirectoryNode *_parent = nullptr, + bool _isPublic = false) : Node(_name, _parent), id(_id), uploadTime(_uploadTime) { + setIsPublic(_isPublic); } [[nodiscard]] int getId() const { @@ -109,6 +124,11 @@ public: { return root; } + /** + * @brief Whether a node is visible to other users (own flag or inherited + * from any ancestor folder). + */ + [[nodiscard]] bool isEffectivelyPublic(const Node *node) const; void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent); void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent); DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent); @@ -125,6 +145,10 @@ private: public: explicit RemoteDeckList_TreeWidget(AbstractClient *_client, QWidget *parent = nullptr); + [[nodiscard]] RemoteDeckList_TreeModel *getModel() const + { + return treeModel; + } [[nodiscard]] RemoteDeckList_TreeModel::Node *getNode(const QModelIndex &ind) const; [[nodiscard]] RemoteDeckList_TreeModel::Node *getCurrentItem() const; [[nodiscard]] QList getCurrentSelection() const; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 8d5d423f6..307c581f9 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -37,6 +37,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDetails = new QAction(QString(), this); aChat = new QAction(QString(), this); aShowGames = new QAction(QString(), this); + aViewPublicDecks = new QAction(QString(), this); aAddToBuddyList = new QAction(QString(), this); aRemoveFromBuddyList = new QAction(QString(), this); aAddToIgnoreList = new QAction(QString(), this); @@ -62,6 +63,7 @@ void UserContextMenu::retranslateUi() aDetails->setText(tr("User &details")); aChat->setText(tr("Private &chat")); aShowGames->setText(tr("Show this user's &games")); + aViewPublicDecks->setText(tr("View this user's &public decks")); aAddToBuddyList->setText(tr("Add to &buddy list")); aRemoveFromBuddyList->setText(tr("Remove from &buddy list")); aAddToIgnoreList->setText(tr("Add to &ignore list")); @@ -372,6 +374,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos, } menu->addAction(aDetails); menu->addAction(aShowGames); + if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { + menu->addAction(aViewPublicDecks); + } menu->addAction(aChat); const QList inviteOptions = inviteOptionsForUser(userName); if (!inviteOptions.isEmpty()) { @@ -442,6 +447,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aChat->setEnabled(anotherUser && online); aShowGames->setEnabled(online); aReport->setEnabled(anotherUser); + aViewPublicDecks->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -464,6 +470,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execChat(userName); } else if (actionClicked == aShowGames) { execShowGames(userName); + } else if (actionClicked == aViewPublicDecks) { + execViewPublicDecks(userName); } else if (actionClicked == aAddToBuddyList) { execAddToBuddy(userName); } else if (actionClicked == aRemoveFromBuddyList) { @@ -585,6 +593,11 @@ void UserContextMenu::execShowGames(const QString &userName) client->sendCommand(pend); } +void UserContextMenu::execViewPublicDecks(const QString &userName) +{ + tabSupervisor->openTabPublicDecks(userName); +} + void UserContextMenu::execAddToBuddy(const QString &userName) { Command_AddToList cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index f1ce931f8..9c3415e78 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -37,6 +37,7 @@ private: QAction *aUserName; QAction *aDetails; QAction *aShowGames; + QAction *aViewPublicDecks; QAction *aChat; QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; @@ -110,6 +111,7 @@ public: void execInvite(const QString &userName); void execDetails(const QString &userName); void execShowGames(const QString &userName); + void execViewPublicDecks(const QString &userName); void execAddToBuddy(const QString &userName); void execRemoveFromBuddy(const QString &userName); void execAddToIgnore(const QString &userName); diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index 3bd60ad32..c972a200e 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -324,6 +324,7 @@ bool AbstractTabDeckEditor::actSaveDeck() Command_DeckUpload cmd; cmd.set_deck_id(static_cast(loadedDeck.lastLoadInfo.remoteDeckId)); cmd.set_deck_list(deckString.toStdString()); + cmd.set_tags(loadedDeck.deckList.getTags().join(QStringLiteral(",")).toStdString()); PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished); diff --git a/cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.cpp new file mode 100644 index 000000000..86ad51289 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.cpp @@ -0,0 +1,151 @@ +#include "public_decks_quick_settings_widget.h" + +#include "../../../client/settings/cache_settings.h" +#include "../cards/card_size_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +PublicDecksQuickSettingsWidget::PublicDecksQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) +{ + // show color identity on preview tiles checkbox + showColorIdentityCheckBox = new QCheckBox(this); + showColorIdentityCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); + connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &PublicDecksQuickSettingsWidget::showColorIdentityChanged); + connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowColorIdentity); + + // show tags on preview tiles checkbox + showTagsOnDeckPreviewsCheckBox = new QCheckBox(this); + showTagsOnDeckPreviewsCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); + connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &PublicDecksQuickSettingsWidget::showTagsOnDeckPreviewsChanged); + connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); + + // show the last modified / upload time on preview tiles checkbox + showUploadTimeCheckBox = new QCheckBox(this); + showUploadTimeCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &PublicDecksQuickSettingsWidget::showUploadTimeChanged); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime); + + // show tag filter box checkbox + showTagFilterCheckBox = new QCheckBox(this); + showTagFilterCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); + connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &PublicDecksQuickSettingsWidget::showTagFilterChanged); + connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowTagFilter); + + // draw unused color identities checkbox + drawUnusedColorIdentitiesCheckBox = new QCheckBox(this); + drawUnusedColorIdentitiesCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()); + connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &PublicDecksQuickSettingsWidget::drawUnusedColorIdentitiesChanged); + connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities); + + // unused color identities opacity selector + auto unusedColorIdentityOpacityWidget = new QWidget(this); + + unusedColorIdentitiesOpacityLabel = new QLabel(unusedColorIdentityOpacityWidget); + unusedColorIdentitiesOpacitySpinBox = new QSpinBox(unusedColorIdentityOpacityWidget); + + unusedColorIdentitiesOpacitySpinBox->setMinimum(0); + unusedColorIdentitiesOpacitySpinBox->setMaximum(100); + unusedColorIdentitiesOpacitySpinBox->setValue( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageUnusedColorIdentitiesOpacity()); + connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), this, + &PublicDecksQuickSettingsWidget::unusedColorIdentitiesOpacityChanged); + connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageUnusedColorIdentitiesOpacity); + + unusedColorIdentitiesOpacityLabel->setBuddy(unusedColorIdentitiesOpacitySpinBox); + + auto unusedColorIdentityOpacityLayout = new QHBoxLayout(unusedColorIdentityOpacityWidget); + unusedColorIdentityOpacityLayout->setContentsMargins(11, 0, 11, 0); + unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacityLabel); + unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox); + + // card size slider (kept at the bottom, like the Visual Deck Storage) + cardSizeWidget = + new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize()); + connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, this, + &PublicDecksQuickSettingsWidget::cardSizeChanged); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDeckStorageCardSize); + + this->addSettingsWidget(showColorIdentityCheckBox); + this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox); + this->addSettingsWidget(showUploadTimeCheckBox); + this->addSettingsWidget(showTagFilterCheckBox); + this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox); + this->addSettingsWidget(unusedColorIdentityOpacityWidget); + this->addSettingsWidget(cardSizeWidget); + + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &PublicDecksQuickSettingsWidget::retranslateUi); + retranslateUi(); +} + +void PublicDecksQuickSettingsWidget::retranslateUi() +{ + showColorIdentityCheckBox->setText(tr("Show Color Identity")); + showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews")); + showUploadTimeCheckBox->setText(tr("Show Upload Time")); + showTagFilterCheckBox->setText(tr("Show Tag Filter")); + drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities")); + unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity")); + unusedColorIdentitiesOpacitySpinBox->setSuffix("%"); +} + +bool PublicDecksQuickSettingsWidget::getDrawUnusedColorIdentities() const +{ + return drawUnusedColorIdentitiesCheckBox->isChecked(); +} + +bool PublicDecksQuickSettingsWidget::getShowColorIdentity() const +{ + return showColorIdentityCheckBox->isChecked(); +} + +bool PublicDecksQuickSettingsWidget::getShowTagFilter() const +{ + return showTagFilterCheckBox->isChecked(); +} + +bool PublicDecksQuickSettingsWidget::getShowTagsOnDeckPreviews() const +{ + return showTagsOnDeckPreviewsCheckBox->isChecked(); +} + +bool PublicDecksQuickSettingsWidget::getShowUploadTime() const +{ + return showUploadTimeCheckBox->isChecked(); +} + +int PublicDecksQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const +{ + return unusedColorIdentitiesOpacitySpinBox->value(); +} + +CardSizeWidget *PublicDecksQuickSettingsWidget::getCardSizeWidget() const +{ + return cardSizeWidget; +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.h b/cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.h new file mode 100644 index 000000000..63d173f1b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/public_decks_quick_settings_widget.h @@ -0,0 +1,57 @@ +/** + * @file public_decks_quick_settings_widget.h + * @ingroup Tabs + * @brief The quick settings menu for the public decks tab. + * Manages the widgets in the quick settings menu dropdown of the public decks + * tab, and syncs their values with the same SettingsCache keys the Visual Deck + * Storage uses, so shared preview widgets (color identity, tags) behave the + * same way in both places. + */ + +#ifndef PUBLIC_DECKS_QUICK_SETTINGS_WIDGET_H +#define PUBLIC_DECKS_QUICK_SETTINGS_WIDGET_H + +#include "../quick_settings/settings_button_widget.h" + +class CardSizeWidget; +class QCheckBox; +class QLabel; +class QSpinBox; + +class PublicDecksQuickSettingsWidget : public SettingsButtonWidget +{ + Q_OBJECT + + QCheckBox *showColorIdentityCheckBox; + QCheckBox *drawUnusedColorIdentitiesCheckBox; + QCheckBox *showTagFilterCheckBox; + QCheckBox *showTagsOnDeckPreviewsCheckBox; + QCheckBox *showUploadTimeCheckBox; + QLabel *unusedColorIdentitiesOpacityLabel; + QSpinBox *unusedColorIdentitiesOpacitySpinBox; + CardSizeWidget *cardSizeWidget; + +public: + explicit PublicDecksQuickSettingsWidget(QWidget *parent = nullptr); + + void retranslateUi(); + + [[nodiscard]] bool getDrawUnusedColorIdentities() const; + [[nodiscard]] bool getShowColorIdentity() const; + [[nodiscard]] bool getShowTagFilter() const; + [[nodiscard]] bool getShowTagsOnDeckPreviews() const; + [[nodiscard]] bool getShowUploadTime() const; + [[nodiscard]] int getUnusedColorIdentitiesOpacity() const; + [[nodiscard]] CardSizeWidget *getCardSizeWidget() const; + +signals: + void drawUnusedColorIdentitiesChanged(bool enabled); + void showColorIdentityChanged(bool enabled); + void showTagFilterChanged(bool enabled); + void showTagsOnDeckPreviewsChanged(bool enabled); + void showUploadTimeChanged(bool enabled); + void unusedColorIdentitiesOpacityChanged(int opacity); + void cardSizeChanged(int scale); +}; + +#endif // PUBLIC_DECKS_QUICK_SETTINGS_WIDGET_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 6c3f30cda..d8c1d73f5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" @@ -24,11 +25,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -159,6 +162,10 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aShareDecks->setIcon(QPixmap("theme:icons/share")); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); + aPublishDeck = new QAction(this); + aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); + // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aRenameLocal); @@ -171,6 +178,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); rightToolBar->addAction(aShareDecks); + rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); @@ -200,6 +208,7 @@ void TabDeckStorage::retranslateUi() aDeleteLocalDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete")); aShareDecks->setText(tr("Share decks")); + aPublishDeck->setText(tr("Publish/unpublish deck")); aOpenDecksFolder->setText(tr("Open decks folder")); shareBar->retranslateUi(); } @@ -244,6 +253,7 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); aShareDecks->setEnabled(enabled); + aPublishDeck->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); @@ -372,6 +382,12 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa cmd.set_path(targetPath.toStdString()); cmd.set_deck_list(deckString.toStdString()); + const CardRef bannerCard = deck.getBannerCard(); + cmd.set_banner_card_name(bannerCard.name.toStdString()); + cmd.set_banner_card_provider(bannerCard.providerId.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString()); + cmd.set_tags(deck.getTags().join(QStringLiteral(",")).toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished); client->sendCommand(pend); @@ -791,7 +807,49 @@ void TabDeckStorage::shareFromTreeFinished(const Response &response, const Comma void TabDeckStorage::showShareNotice(const QString &message, bool warning) { - QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message, + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, QMessageBox::Ok, this); box.exec(); } + +void TabDeckStorage::actPublishDeck() +{ + const auto selection = serverDirView->getCurrentSelection(); + for (const auto *node : selection) { + Command_DeckSetVisibility cmd; + if (const auto *fileNode = dynamic_cast(node)) { + cmd.set_deck_id(fileNode->getId()); + } else if (const auto *dirNode = dynamic_cast(node)) { + const QString path = dirNode->getPath(); + if (path.isEmpty()) { + continue; // the root folder cannot be published + } + cmd.set_folder_path(path.toStdString()); + } else { + continue; + } + // Toggle the node's own visibility bit (what the server persists); the + // effective visibility shown by the column may additionally be inherited + // from a parent folder. + cmd.set_is_public(!node->isPublic()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeckStorage::setVisibilityFinished); + client->sendCommand(pend); + ++pendingVisibilityChanges; + } +} + +void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/) +{ + if (r.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), + tr("Failed to change deck visibility on server (response code %1).") + .arg(QString::number(static_cast(r.response_code())))); + } + // Refresh once the last in-flight change has been acknowledged so the + // Public/Private column reflects every selected node. + if (--pendingVisibilityChanges == 0) { + serverDirView->refreshTree(); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index 201506789..b14e93ce8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -40,7 +40,8 @@ private: QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenDecksFolder; - QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aNewFolder, *aDeleteRemoteDeck; + QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; + int pendingVisibilityChanges = 0; QString getTargetPath() const; void setRemoteEnabled(bool enabled); @@ -87,6 +88,9 @@ private slots: void onServerSelectionChanged(); void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer); + void actPublishDeck(); + void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer); + void actDeleteRemoteDeck(); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp new file mode 100644 index 000000000..07c20e86c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -0,0 +1,230 @@ +#include "tab_public_decks.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" +#include "../general/layout_containers/flow_widget.h" +#include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" +#include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" +#include "../visual_deck_storage/remote_public_decks_model.h" +#include "../visual_deck_storage/visual_deck_storage_search_widget.h" +#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h" +#include "public_decks_quick_settings_widget.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &_userName) + : Tab(_tabSupervisor), client(_client), userName(_userName) +{ + model = new RemotePublicDecksModel(client, this); + cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); + + titleLabel = new QLabel(tr("Public decks of %1").arg(userName), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + auto *headerLayout = new QHBoxLayout; + headerLayout->addWidget(titleLabel); + headerLayout->addStretch(1); + + // Filter/toolbar row, matching the Visual Deck Storage: color identity filter + // first, the search bar stretching in the middle, and the quick settings + // cogwheel at the end. The card size slider lives inside the cogwheel popup. + emptyLabel = new QLabel(tr("This user has not published any decks."), this); + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setVisible(false); + + statusLabel = new QLabel(this); + statusLabel->setAlignment(Qt::AlignCenter); + statusLabel->setVisible(false); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget->setSpacing(8, 8); + + colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); + searchWidget = new VisualDeckStorageSearchWidget(this); + searchWidget->setPlaceholderText(tr("Search by deck name")); + refreshButton = new QToolButton(this); + refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setFixedSize(32, 32); + quickSettingsWidget = new PublicDecksQuickSettingsWidget(this); + + auto *filterLayout = new QHBoxLayout; + filterLayout->addWidget(colorIdentityFilter); + filterLayout->addWidget(searchWidget, 1); + filterLayout->addWidget(refreshButton); + filterLayout->addWidget(quickSettingsWidget); + + tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return model->allTags(); }); + updateTagsVisibility(quickSettingsWidget->getShowTagFilter()); + + auto *layout = new QVBoxLayout; + layout->addLayout(headerLayout); + layout->addLayout(filterLayout); + layout->addWidget(tagFilterWidget); + layout->addWidget(statusLabel); + layout->addWidget(emptyLabel); + layout->addWidget(flowWidget, 1); + + auto *mainWidget = new QWidget(this); + mainWidget->setLayout(layout); + setCentralWidget(mainWidget); + + connect(refreshButton, &QToolButton::clicked, this, [this] { model->refresh(userName); }); + connect(model, &QAbstractItemModel::modelReset, this, &TabPublicDecks::rebuildGrid); + connect(model, &RemotePublicDecksModel::loadingChanged, this, &TabPublicDecks::updateLoadingState); + connect(model, &RemotePublicDecksModel::loadFailed, this, [this](const QString &message) { + statusLabel->setText(message); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + }); + connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, + [this](const QString &text) { model->setSearchText(text); }); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, + &TabPublicDecks::updateColorFilter); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, + &TabPublicDecks::updateColorFilter); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, &TabPublicDecks::updateTagFilter); + connect(quickSettingsWidget, &PublicDecksQuickSettingsWidget::cardSizeChanged, this, + &TabPublicDecks::updateCardSize); + connect(quickSettingsWidget, &PublicDecksQuickSettingsWidget::showTagFilterChanged, this, + &TabPublicDecks::updateTagsVisibility); + + model->refresh(userName); +} + +QString TabPublicDecks::getTabText() const +{ + return tr("Public decks of %1").arg(userName); +} + +void TabPublicDecks::retranslateUi() +{ + titleLabel->setText(tr("Public decks of %1").arg(userName)); + searchWidget->setPlaceholderText(tr("Search by deck name")); + emptyLabel->setText(tr("This user has not published any decks.")); + refreshButton->setToolTip(tr("Refresh")); + quickSettingsWidget->setToolTip(tr("Public Decks Settings")); + emit tabTextChanged(this, getTabText()); +} + +bool TabPublicDecks::closeRequest() +{ + emit closing(this); + return Tab::closeRequest(); +} + +void TabPublicDecks::rebuildGrid() +{ + flowWidget->clearLayout(); + + const int count = model->rowCount(); + if (count == 0) { + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + } + emptyLabel->setVisible(count == 0); + for (int i = 0; i < count; ++i) { + auto *tile = new PublicDeckPreviewWidget(flowWidget, model->entryAt(i)); + tile->setScaleFactor(cardSize); + connect(tile, &PublicDeckPreviewWidget::openDeckRequested, this, &TabPublicDecks::openDeck); + flowWidget->addWidget(tile); + } + + // The deck set changed, so the tag filter chips are re-gathered from it. + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateColorFilter() +{ + model->setColorFilter(colorIdentityFilter->getFilterMode(), colorIdentityFilter->getActiveColors()); +} + +void TabPublicDecks::updateTagFilter() +{ + const QStringList selectedTags = tagFilterWidget->selectedTags(); + const QStringList excludedTags = tagFilterWidget->excludedTags(); + model->setTagFilter(QSet(selectedTags.cbegin(), selectedTags.cend()), + QSet(excludedTags.cbegin(), excludedTags.cend())); + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateTagsVisibility(bool visible) +{ + tagFilterWidget->setVisible(visible); +} + +void TabPublicDecks::updateLoadingState(bool loading) +{ + if (loading) { + statusLabel->setText(tr("Loading public decks…")); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + statusLabel->setVisible(false); + flowWidget->setVisible(true); + } +} + +void TabPublicDecks::updateCardSize(int scale) +{ + cardSize = scale; + applyCardSize(scale); +} + +void TabPublicDecks::applyCardSize(int scale) +{ + const auto tiles = flowWidget->findChildren(); + for (PublicDeckPreviewWidget *tile : tiles) { + tile->setScaleFactor(scale); + } + flowWidget->setMinimumSizeToMaxSizeHint(); +} + +void TabPublicDecks::openDeck(int deckId) +{ + Command_DeckDownloadPublic cmd; + cmd.set_deck_id(deckId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabPublicDecks::openDeckFinished); + client->sendCommand(pend); +} + +void TabPublicDecks::openDeckFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::warning(this, tr("Open public deck"), + tr("Failed to open the public deck (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckDownload &resp = response.GetExtension(Response_DeckDownload::ext); + std::optional deckOpt = + DeckLoader::loadFromRemote(QString::fromStdString(resp.deck()), LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + QMessageBox::warning(this, tr("Open public deck"), tr("The public deck could not be parsed.")); + return; + } + + tabSupervisor->openDeckInNewTab(deckOpt.value()); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.h b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h new file mode 100644 index 000000000..ad0daef0d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h @@ -0,0 +1,79 @@ +/** + * @file tab_public_decks.h + * @ingroup Tabs + */ + +#ifndef TAB_PUBLIC_DECKS_H +#define TAB_PUBLIC_DECKS_H + +#include "tab.h" + +class AbstractClient; +class CommandContainer; +class DeckPreviewColorIdentityFilterWidget; +class FlowWidget; +class PublicDeckPreviewWidget; +class PublicDecksQuickSettingsWidget; +class QLabel; +class QToolButton; +class RemotePublicDecksModel; +class Response; +class VisualDeckStorageSearchWidget; +class VisualDeckStorageTagFilterWidget; + +/** + * @brief A visual grid of the public decks published by another user. + * + * The grid is rendered from the preview metadata the server stores for the + * decks, so browsing costs no downloads; the deck list is fetched via + * Command_DeckDownloadPublic only when the user opens a deck. Multiple users + * can be browsed simultaneously; each gets its own tab. + */ +class TabPublicDecks final : public Tab +{ + Q_OBJECT + +public: + TabPublicDecks(TabSupervisor *tabSupervisor, AbstractClient *client, const QString &userName); + + [[nodiscard]] QString getTabText() const override; + void retranslateUi() override; + bool closeRequest() override; + + [[nodiscard]] QString getUserName() const + { + return userName; + } + +signals: + void closing(TabPublicDecks *tab); + +private slots: + void openDeck(int deckId); + void openDeckFinished(const Response &response, const CommandContainer &commandContainer); + void updateColorFilter(); + void updateTagFilter(); + void updateCardSize(int scale); + void updateTagsVisibility(bool visible); + void updateLoadingState(bool loading); + +private: + void rebuildGrid(); + void applyCardSize(int scale); + + AbstractClient *client; + QString userName; + RemotePublicDecksModel *model; + FlowWidget *flowWidget; + VisualDeckStorageSearchWidget *searchWidget; + DeckPreviewColorIdentityFilterWidget *colorIdentityFilter; + VisualDeckStorageTagFilterWidget *tagFilterWidget; + QToolButton *refreshButton; + PublicDecksQuickSettingsWidget *quickSettingsWidget; + QLabel *titleLabel; + QLabel *statusLabel; + QLabel *emptyLabel; + int cardSize = 100; +}; + +#endif // TAB_PUBLIC_DECKS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index ed0ddaf06..86c004ab3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -20,6 +20,7 @@ #include "tab_logs.h" #include "tab_message.h" #include "tab_moderation.h" +#include "tab_public_decks.h" #include "tab_replays.h" #include "tab_report.h" #include "tab_room.h" @@ -267,6 +268,10 @@ void TabSupervisor::retranslateUi() while (gameIterator.hasNext()) { tabs.append(gameIterator.next().value()); } + QMapIterator publicDecksIterator(publicDecksTabs); + while (publicDecksIterator.hasNext()) { + tabs.append(publicDecksIterator.next().value()); + } QListIterator replayIterator(replayTabs); while (replayIterator.hasNext()) { tabs.append(replayIterator.next()); @@ -986,6 +991,30 @@ void TabSupervisor::roomLeft(TabRoom *tab) removeTab(indexOf(tab)); } +void TabSupervisor::openTabPublicDecks(const QString &userName) +{ + if (auto *existing = publicDecksTabs.value(userName, nullptr)) { + setCurrentWidget(existing); + return; + } + + auto *tab = new TabPublicDecks(this, client, userName); + connect(tab, &TabPublicDecks::closing, this, &TabSupervisor::publicDecksClosed); + myAddTab(tab); + publicDecksTabs.insert(userName, tab); + setCurrentWidget(tab); +} + +void TabSupervisor::publicDecksClosed(TabPublicDecks *tab) +{ + if (tab == currentWidget()) { + emit setMenu(); + } + + publicDecksTabs.remove(tab->getUserName()); + removeTab(indexOf(tab)); +} + void TabSupervisor::switchToFirstAvailableNetworkTab() { if (!roomTabs.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index b389bad3e..266f2efd5 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -46,6 +46,7 @@ class TabModeration; class TabAccount; class TabDeckEditor; class TabLog; +class TabPublicDecks; class RoomEvent; class GameEventContainer; class Event_GameJoined; @@ -112,6 +113,7 @@ private: QMap gameTabs; QList replayTabs; QMap messageTabs; + QMap publicDecksTabs; QList deckEditorTabs; bool isLocalGame; @@ -196,6 +198,7 @@ public slots: void actTabReplays(bool checked); void openTabServer(); void addRoomTab(const ServerInfo_Room &info, bool setCurrent); + void openTabPublicDecks(const QString &userName); private slots: void refreshShortcuts(); @@ -226,6 +229,7 @@ private slots: void localGameJoined(const Event_GameJoined &event); void gameLeft(TabGame *tab); void roomLeft(TabRoom *tab); + void publicDecksClosed(TabPublicDecks *tab); TabMessage *addMessageTab(const QString &userName, bool focus); void replayLeft(TabGame *tab); void processUserLeft(const QString &userName); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index fca9c9f0c..033ac9154 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -190,7 +190,7 @@ void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status) void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning) { - QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Deck share"), message, + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, QMessageBox::Ok, this); box.exec(); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp new file mode 100644 index 000000000..c076c7525 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -0,0 +1,164 @@ +#include "public_deck_preview_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "../../cards/additional_info/color_identity_widget.h" +#include "../../cards/deck_preview_card_picture_widget.h" +#include "../../general/layout_containers/flow_widget.h" +#include "deck_preview_tag_display_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) + : QWidget(parent) +{ + bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this); + bannerCardDisplayWidget->setFontSize(24); + + // The whole tile is a single focusable, keyboard-operable control: Tab lands + // on it and Space/Enter opens the deck, mirroring the shared-deck preview tile. + setFocusPolicy(Qt::StrongFocus); + + uploadTimeLabel = new QLabel(this); + uploadTimeLabel->setAlignment(Qt::AlignHCenter); + + colorIdentityWidget = new ColorIdentityWidget(this); + + tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + tagsFlowWidget->setSpacing(3, 3); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(uploadTimeLabel); + layout->addWidget(colorIdentityWidget); + layout->addWidget(tagsFlowWidget); + setLayout(layout); + + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, + &PublicDeckPreviewWidget::updateColorIdentityVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, + &PublicDeckPreviewWidget::updateTagsVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowUploadTimeChanged, this, + &PublicDeckPreviewWidget::updateUploadTimeVisibility); + + setEntry(entry); + + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &PublicDeckPreviewWidget::imageClickedEvent); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &PublicDeckPreviewWidget::imageDoubleClickedEvent); + + // resizeEvent clamps every child to the banner picture's width, so collect them + // once here to keep the resize handler from searching the widget tree on every pass. + fixedWidthChildren = {bannerCardDisplayWidget, uploadTimeLabel, colorIdentityWidget, tagsFlowWidget}; +} + +void PublicDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (bannerCardDisplayWidget == nullptr) { + return; + } + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); + } +} + +void PublicDeckPreviewWidget::setEntry(const RemotePublicDecksModel::DeckEntry &entry) +{ + deckId = entry.id; + + hasColorIdentity = !entry.colorIdentity.isEmpty(); + colorIdentityWidget->setColorIdentity(entry.colorIdentity); + updateColorIdentityVisibility(); + + const ExactCard bannerCard = + entry.bannerCardName.isEmpty() + ? ExactCard() + : CardDatabaseManager::query()->getCard(CardRef{entry.bannerCardName, entry.bannerCardProvider}); + bannerCardDisplayWidget->setCard(bannerCard); + + // The deck name is the overlay text on the banner, like the local preview. + bannerCardDisplayWidget->setOverlayText(entry.name); + setToolTip(entry.name); + setBaseAccessibleName(entry.name); + + tagsFlowWidget->clearLayout(); + for (const QString &tag : entry.tags) { + auto *chip = new DeckPreviewTagDisplayWidget(tagsFlowWidget, tag); + chip->setAttribute(Qt::WA_TransparentForMouseEvents); + tagsFlowWidget->addWidget(chip); + } + hasTags = !entry.tags.isEmpty(); + updateTagsVisibility(); + + uploadTimeLabel->setText(tr("Uploaded %1").arg(entry.uploadTime.toString(Qt::TextDate))); + hasUploadTime = !entry.uploadTime.isNull(); + updateUploadTimeVisibility(); +} + +void PublicDeckPreviewWidget::updateColorIdentityVisibility() +{ + colorIdentityWidget->setVisible( + hasColorIdentity && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); +} + +void PublicDeckPreviewWidget::updateTagsVisibility() +{ + tagsFlowWidget->setVisible( + hasTags && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); +} + +void PublicDeckPreviewWidget::updateUploadTimeVisibility() +{ + uploadTimeLabel->setVisible(hasUploadTime && + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); +} + +void PublicDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) { + event->accept(); + emit openDeckRequested(deckId); + return; + } + QWidget::keyPressEvent(event); +} + +void PublicDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void PublicDeckPreviewWidget::setScaleFactor(int scale) +{ + bannerCardDisplayWidget->setScaleFactor(scale); +} + +void PublicDeckPreviewWidget::imageClickedEvent(QMouseEvent * /*event*/, DeckPreviewCardPictureWidget * /*instance*/) +{ + // Reserved: clicking could show a card popup for the banner card. +} + +void PublicDeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent * /*event*/, + DeckPreviewCardPictureWidget * /*instance*/) +{ + emit openDeckRequested(deckId); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h new file mode 100644 index 000000000..a0e5dd43a --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h @@ -0,0 +1,75 @@ +/** + * @file public_deck_preview_widget.h + * @ingroup VisualDeckPreviewWidgets + */ + +#ifndef PUBLIC_DECK_PREVIEW_WIDGET_H +#define PUBLIC_DECK_PREVIEW_WIDGET_H + +#include "../remote_public_decks_model.h" + +#include +#include +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class FlowWidget; +class QKeyEvent; +class QLabel; +class QMouseEvent; +class QResizeEvent; + +/** + * @brief A preview tile for a public deck published by another user. + * + * Renders the banner card picture (looked up by name/provider in the card + * database) with the deck name overlaid, the color identity, the deck's tags + * (read-only) and its upload time, all from the metadata the server stores for + * the deck, so no deck list is downloaded until the user actually opens the + * deck. Double-clicking the banner requests opening it. + */ +class PublicDeckPreviewWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry); + + void setEntry(const RemotePublicDecksModel::DeckEntry &entry); + + /** @brief Sets the accessible name announced to assistive technologies. */ + void setBaseAccessibleName(const QString &name); + + /** @brief Scales the banner card picture, mirroring the Visual Deck Storage. */ + void setScaleFactor(int scale); + +signals: + void openDeckRequested(int deckId); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void updateColorIdentityVisibility(); + void updateTagsVisibility(); + void updateUploadTimeVisibility(); + +private: + int deckId = 0; + QString baseAccessibleName; + bool hasColorIdentity = false; + bool hasTags = false; + bool hasUploadTime = false; + int lastKnownBannerWidth = 0; + QList fixedWidthChildren; + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + FlowWidget *tagsFlowWidget; + QLabel *uploadTimeLabel; +}; + +#endif // PUBLIC_DECK_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp new file mode 100644 index 000000000..ff0fc02ed --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp @@ -0,0 +1,202 @@ +#include "remote_public_decks_model.h" + +#include +#include +#include +#include +#include +#include +#include + +RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ +} + +int RemotePublicDecksModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : visibleIndices.size(); +} + +QVariant RemotePublicDecksModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices.size()) { + return QVariant(); + } + if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { + return decks.at(visibleIndices.at(index.row())).name; + } + return QVariant(); +} + +RemotePublicDecksModel::DeckEntry RemotePublicDecksModel::entryAt(int row) const +{ + if (row < 0 || row >= visibleIndices.size()) { + return DeckEntry{}; + } + return decks.at(visibleIndices.at(row)); +} + +void RemotePublicDecksModel::setSearchText(const QString &text) +{ + searchText = text.trimmed(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setColorFilter(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors) +{ + colorFilterMode = mode; + activeColors = colors; + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setTagFilter(const QSet &selected, const QSet &excluded) +{ + includedTags = selected; + excludedTags = excluded; + rebuildVisibleIndices(); +} + +QSet RemotePublicDecksModel::allTags() const +{ + QSet all; + for (const DeckEntry &entry : decks) { + all.unite(QSet(entry.tags.cbegin(), entry.tags.cend())); + } + return all; +} + +void RemotePublicDecksModel::rebuildVisibleIndices() +{ + QList newIndices; + newIndices.reserve(decks.size()); + for (int row = 0; row < decks.size(); ++row) { + const DeckEntry &entry = decks.at(row); + + if (!searchText.isEmpty() && !entry.name.contains(searchText, Qt::CaseInsensitive)) { + continue; + } + + if (!activeColors.isEmpty()) { + const QString &identity = entry.colorIdentity; + bool colorMatch = true; + switch (colorFilterMode) { + case VisualDeckStorageSortFilterProxyModel::ExactMatch: { + QSet activeSet; + for (const QChar &color : activeColors) { + activeSet.insert(color.toUpper()); + } + QSet identitySet; + for (const QChar &color : identity) { + identitySet.insert(color.toUpper()); + } + colorMatch = activeSet == identitySet; + break; + } + case VisualDeckStorageSortFilterProxyModel::Includes: + colorMatch = std::all_of(activeColors.begin(), activeColors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + break; + case VisualDeckStorageSortFilterProxyModel::Excludes: + colorMatch = std::none_of(activeColors.begin(), activeColors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + break; + } + if (!colorMatch) { + continue; + } + } + + if (!includedTags.isEmpty()) { + const QSet entryTags(entry.tags.cbegin(), entry.tags.cend()); + bool hasAll = std::all_of(includedTags.begin(), includedTags.end(), + [&entryTags](const QString &tag) { return entryTags.contains(tag); }); + if (!hasAll) { + continue; + } + } + + if (!excludedTags.isEmpty() && std::any_of(excludedTags.begin(), excludedTags.end(), + [&entry](const QString &tag) { return entry.tags.contains(tag); })) { + continue; + } + + newIndices.append(row); + } + + beginResetModel(); + visibleIndices = newIndices; + endResetModel(); +} + +void RemotePublicDecksModel::refresh(const QString &userName) +{ + if (loading) { + return; + } + setLoading(true); + Command_DeckListOtherUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &RemotePublicDecksModel::decksReceived); + client->sendCommand(pend); +} + +void RemotePublicDecksModel::clear() +{ + decks.clear(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setLoading(bool value) +{ + if (loading == value) { + return; + } + loading = value; + emit loadingChanged(loading); +} + +void RemotePublicDecksModel::decksReceived(const Response &response, const CommandContainer & /*commandContainer*/) +{ + setLoading(false); + if (response.response_code() != Response::RespOk) { + emit loadFailed(tr("Failed to load the user's public decks (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckList &resp = response.GetExtension(Response_DeckList::ext); + decks.clear(); + addFolder(resp.root()); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::addFolder(const ServerInfo_DeckStorage_Folder &folder) +{ + const int itemCount = folder.items_size(); + for (int i = 0; i < itemCount; ++i) { + addTreeItem(folder.items(i)); + } +} + +void RemotePublicDecksModel::addTreeItem(const ServerInfo_DeckStorage_TreeItem &item) +{ + if (item.has_folder()) { + addFolder(item.folder()); + return; + } + + const ServerInfo_DeckStorage_File &file = item.file(); + DeckEntry entry; + entry.id = item.id(); + entry.name = QString::fromStdString(item.name()); + entry.uploadTime = QDateTime::fromSecsSinceEpoch(file.creation_time()); + entry.bannerCardName = QString::fromStdString(file.banner_card_name()); + entry.bannerCardProvider = QString::fromStdString(file.banner_card_provider()); + entry.colorIdentity = QString::fromStdString(file.color_identity()); + const QString tagsString = QString::fromStdString(file.tags()); + entry.tags = tagsString.split(QLatin1Char(','), Qt::SkipEmptyParts); + decks.append(entry); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h new file mode 100644 index 000000000..a9b4d62d0 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h @@ -0,0 +1,125 @@ +/** + * @file remote_public_decks_model.h + * @ingroup DeckStorageWidgets + */ + +#ifndef REMOTE_PUBLIC_DECKS_MODEL_H +#define REMOTE_PUBLIC_DECKS_MODEL_H + +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include +#include +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class Response; +class ServerInfo_DeckStorage_Folder; +class ServerInfo_DeckStorage_TreeItem; + +/** + * @brief Flat, read-only list of the public decks published by another user. + * + * Fetches the target user's public decks via Command_DeckListOtherUser and + * flattens the response tree into entries carrying the preview metadata stored + * on the server (banner card name/provider and color identity). No deck list is + * downloaded until the user actually opens a deck. + * + * Name and color-identity filtering is applied against this metadata, mirroring + * the Visual Deck Storage's filter semantics, so the grid can be narrowed like + * the local deck storage. + */ +class RemotePublicDecksModel : public QAbstractListModel +{ + Q_OBJECT + +public: + struct DeckEntry + { + int id = 0; + QString name; + QDateTime uploadTime; + QString bannerCardName; + QString bannerCardProvider; + QString colorIdentity; + QStringList tags; + }; + + /** + * @brief The color identity filter mode, shared with the Visual Deck Storage. + */ + using FilterMode = VisualDeckStorageSortFilterProxyModel::FilterMode; + + explicit RemotePublicDecksModel(AbstractClient *client, QObject *parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** @brief Fetches the public decks of another user, replacing the current contents. */ + void refresh(const QString &userName); + void clear(); + + /** @brief Sets a case-insensitive substring filter on the deck name. */ + void setSearchText(const QString &text); + + /** @brief Sets the active color identity filter and mode. */ + void setColorFilter(FilterMode mode, const QSet &colors); + + /** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */ + void setTagFilter(const QSet &selected, const QSet &excluded); + + /** @brief All tags present across all loaded decks, for building filter chips. */ + [[nodiscard]] QSet allTags() const; + + /** @brief The number of decks after filtering. */ + [[nodiscard]] int filteredCount() const + { + return visibleIndices.size(); + } + + /** @brief The number of decks before filtering. */ + [[nodiscard]] int totalCount() const + { + return decks.size(); + } + + /** @brief True while a refresh request is in flight and the grid has no data yet. */ + [[nodiscard]] bool isLoading() const + { + return loading; + } + + [[nodiscard]] DeckEntry entryAt(int row) const; + +signals: + /** @brief Emitted when a refresh starts, completes, or fails (see loading()). */ + void loadingChanged(bool loading); + + /** @brief Emitted when the last refresh failed; contains a user-facing message. */ + void loadFailed(const QString &message); + +private slots: + void decksReceived(const Response &response, const CommandContainer &commandContainer); + +private: + void addFolder(const ServerInfo_DeckStorage_Folder &folder); + void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item); + void rebuildVisibleIndices(); + void setLoading(bool value); + + AbstractClient *client; + QList decks; + QList visibleIndices; ///< Row indices into `decks` that pass the current filters. + bool loading = false; + + QString searchText; + VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes; + QSet activeColors; + QSet includedTags; + QSet excludedTags; +}; + +#endif // REMOTE_PUBLIC_DECKS_MODEL_H diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp index 1b21af58e..3320598d8 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp @@ -130,6 +130,11 @@ bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() con return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); } +bool VisualDeckStorageSettings::getVisualDeckStorageShowUploadTime() const +{ + return getValue("showUploadTime", "interface", "visualDeckStorage", true).toBool(); +} + bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const { return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); @@ -220,6 +225,12 @@ void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); } +void VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime(bool value) +{ + setValue(value, "showUploadTime", "interface", "visualDeckStorage"); + emit visualDeckStorageShowUploadTimeChanged(value); +} + void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) { setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h index fd2a76663..9bc9d4172 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h @@ -20,6 +20,7 @@ public: [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; + [[nodiscard]] bool getVisualDeckStorageShowUploadTime() const; [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; [[nodiscard]] int getVisualDeckStorageTooltipType() const override; @@ -38,6 +39,7 @@ public: void setVisualDeckStorageShowColorIdentity(bool value); void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); + void setVisualDeckStorageShowUploadTime(bool value); void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); void setVisualDeckStorageTooltipType(int value); @@ -54,6 +56,7 @@ signals: void visualDeckStorageShowColorIdentityChanged(bool _visible); void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); + void visualDeckStorageShowUploadTimeChanged(bool _visible); void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); void visualDeckStorageInGameChanged(bool enabled); diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index 139656f27..fb44372aa 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -571,6 +571,19 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet) ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom); } +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), true); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + s.setVisualDeckStorageShowUploadTime(false); + ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false); +} + } // namespace int main(int argc, char **argv)