Keep cached image redirects within the inclusive hop limit

Accept a terminal URL reached by exactly the permitted number of redirects,
while preserving cycle and overlong-chain invalidation. Match Qt's redirect
classification by omitting HTTP 305 from both reply paths. Add focused
GoogleTest coverage for the cache resolver and retain current upstream.
This commit is contained in:
Bryce! 2026-11-04 15:59:11 -05:00
commit 4ee7b416a9
451 changed files with 15753 additions and 11787 deletions

View file

@ -3,6 +3,7 @@
#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_edit_password.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_request.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_reset.h"
@ -99,6 +100,10 @@ void ConnectionController::wireClientSignals()
connect(remoteClient, &RemoteClient::sigPasswordVerifierReady, this,
&ConnectionController::onPasswordVerifierReady);
connect(remoteClient, &RemoteClient::sigPasswordChangeSuccess, this,
&ConnectionController::onPasswordChangeSuccess);
connect(remoteClient, &RemoteClient::sigPasswordChangeError, this, &ConnectionController::onPasswordChangeError);
}
void ConnectionController::connectToServer()
@ -351,10 +356,16 @@ void ConnectionController::onLoginError(int r,
}
case Response::RespPasswordChangeRequired: {
QMessageBox::information(
dialogParent, tr("Password Change Required"),
tr("An administrator has reset your password. Please contact your server administrator to obtain "
"your temporary password, then log in and change it via Account -> Change Password."));
// The credentials were right, but the account may not be used until a new password
// is chosen. Let the user complete the change here: sending them off to "ask the
// administrator for your temporary password" sends them in circles, since they just
// logged in with it and the session ends without a way to comply.
DlgEditPassword dlg(dialogParent);
dlg.setWindowTitle(tr("Password Change Required"));
if (dlg.exec()) {
remoteClient->changePasswordToServer(dlg.getOldPassword(), dlg.getNewPassword());
return; // onPasswordChangeSuccess/onPasswordChangeError decides the next step
}
remoteClient->disconnectFromServer();
return;
}
@ -517,6 +528,43 @@ void ConnectionController::onNotifyUserAboutUpdate()
"To update your client, go to Help -> Check for Updates."));
}
void ConnectionController::onPasswordChangeSuccess()
{
// The credential saved for this profile is stale now: drop it so the next login asks for
// the new password instead of replaying the old verifier.
if (!pendingSaveName.isEmpty()) {
SettingsCache::instance().servers().setServerPassword(pendingSaveName, "");
}
QMessageBox::information(dialogParent, tr("Password Changed"),
tr("Your password has been changed. Please log in again with your new password."));
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onPasswordChangeError(int responseCode)
{
QString reason;
switch (static_cast<Response::ResponseCode>(responseCode)) {
case Response::RespWrongPassword:
reason = tr("The current password is incorrect.");
break;
case Response::RespPasswordTooShort:
reason = tr("The new password is too short.");
break;
case Response::RespClientUpdateRequired:
reason = tr("The server does not accept the new password format.");
break;
default:
reason = tr("The password could not be changed (error %1).").arg(responseCode);
break;
}
QMessageBox::critical(dialogParent, tr("Password Change Failed"), reason);
remoteClient->disconnectFromServer();
connectToServer();
}
void ConnectionController::onForgotPasswordSuccess()
{
QMessageBox::information(

View file

@ -94,6 +94,10 @@ private slots:
// Persists the derived scrypt verifier after a successful challenge-response login
void onPasswordVerifierReady(const QString &verifier);
// Completes the forced password change the server demanded at login
void onPasswordChangeSuccess();
void onPasswordChangeError(int responseCode);
private:
void wireClientSignals();
void updateWindowTitle();

View file

@ -0,0 +1,42 @@
/**
* @file deck_analysis_url.h
* @ingroup ApiInterfaces
*/
#ifndef DECK_ANALYSIS_URL_H
#define DECK_ANALYSIS_URL_H
#include <QString>
#include <QUrl>
/**
* Validates a deck address returned by a deck analysis service.
*
* Only the expected host (optionally "www."-prefixed), HTTPS, default ports and
* URLs without user information are accepted. Legacy HTTP addresses for the same
* service are upgraded to HTTPS in place instead of being rejected.
*
* Relative addresses must be resolved against the response URL by the caller.
*
* @return true when @p url names a deck on @p serviceHost; false otherwise.
*/
inline bool normalizeAnalysisUrl(QUrl &url, const QString &serviceHost)
{
const QString host = url.host().toLower();
const bool legacyHttp = url.scheme() == QStringLiteral("http");
const int defaultPort = legacyHttp ? 80 : 443;
if (!url.isValid() || (!legacyHttp && url.scheme() != QStringLiteral("https")) || !url.userInfo().isEmpty() ||
!url.path().startsWith('/') || (url.port() != -1 && url.port() != defaultPort) ||
(host != serviceHost && host != QStringLiteral("www.") + serviceHost)) {
return false;
}
// Both services accept HTTPS. Older public metadata may still identify the
// same deck with HTTP; retain its host/path/query while using HTTPS to open it.
if (legacyHttp) {
url.setScheme(QStringLiteral("https"));
url.setPort(-1);
}
return true;
}
#endif

View file

@ -1,5 +1,6 @@
#include "deck_stats_interface.h"
#include "deck_analysis_url.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
@ -10,12 +11,14 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QSharedPointer>
#include <QString>
#include <QTextDocumentFragment>
#include <QUrl>
#include <QUrlQuery>
#include <QWidget>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -29,30 +32,56 @@ DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent)
connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished);
}
void DeckStatsInterface::showError(const QString &message)
{
// The notice belongs to the originating editor, not this one-shot request.
// Retire the request before opening UI: opening a window may reenter Qt.
QWidget *owner = qobject_cast<QWidget *>(parent());
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
notice->setTextFormat(Qt::PlainText);
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
notice->setAttribute(Qt::WA_DeleteOnClose);
deleteLater();
notice->open();
}
void DeckStatsInterface::queryFinished(QNetworkReply *reply)
{
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::critical(nullptr, tr("Error"), reply->errorString());
reply->deleteLater();
deleteLater();
if (replyHandled) {
return;
}
QString data(reply->readAll());
replyHandled = true;
// All terminal paths release the reply before showing UI or invoking an
// external URL handler, either of which may destroy the originating editor.
reply->deleteLater();
static const QRegularExpression rx("<meta property=\"og:url\" content=\"([^\"]+)\"");
auto match = rx.match(data);
if (!match.hasMatch()) {
QMessageBox::critical(nullptr, tr("Error"), tr("The reply from the server could not be parsed."));
deleteLater();
if (reply->error() != QNetworkReply::NoError) {
showError(reply->errorString());
return;
}
QString deckUrl = match.captured(1);
QDesktopServices::openUrl(deckUrl);
const QString data = QString::fromUtf8(reply->readAll());
static const QRegularExpression rx(
R"(<meta\b(?=[^>]*\bproperty\s*=\s*(['"])og:url\1)[^>]*\bcontent\s*=\s*(['"])(.*?)\2)",
QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption);
const auto match = rx.match(data);
if (!match.hasMatch()) {
showError(tr("The reply from Deckstats did not contain a deck address."));
return;
}
const QString address = QTextDocumentFragment::fromHtml(match.captured(3)).toPlainText();
QUrl deckUrl = reply->url().resolved(QUrl(address, QUrl::StrictMode));
if (address.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("deckstats.net"))) {
showError(tr("The reply from Deckstats contained an invalid deck address."));
return;
}
QPointer<DeckStatsInterface> guard(this);
deleteLater();
if (!QDesktopServices::openUrl(deckUrl) && guard) {
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
.arg(deckUrl.toDisplayString()));
}
}
void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
@ -60,12 +89,8 @@ void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
DeckList deckWithoutTokens;
copyDeckWithoutTokens(deck, deckWithoutTokens);
QUrl params;
QUrlQuery urlQuery;
urlQuery.addQueryItem("deck", deckWithoutTokens.writeToString_Plain());
urlQuery.addQueryItem("decktitle", deck.getName());
params.setQuery(urlQuery);
data.append(params.query(QUrl::EncodeReserved).toUtf8());
data.append("deck=" + QUrl::toPercentEncoding(deckWithoutTokens.writeToString_Plain()));
data.append("&decktitle=" + QUrl::toPercentEncoding(deck.getName()));
}
void DeckStatsInterface::analyzeDeck(const DeckList &deck)
@ -84,7 +109,9 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList
{
auto copyIfNotAToken = [&destination](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) {
// Missing local card data is not evidence that a card is a token. Keep
// its original name and quantity so the service analyzes the whole deck.
if ((!dbCard || !dbCard->getIsToken()) && node->getName() != DECK_ZONE_MAYBEBOARD) {
DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1);
addedCard->setNumber(card->getNumber());
}

View file

@ -11,6 +11,7 @@
#include <qtmetamacros.h>
class QByteArray;
class QString;
class QNetworkAccessManager;
class QNetworkReply;
class DeckList;
@ -20,6 +21,8 @@ class DeckStatsInterface : public QObject
Q_OBJECT
private:
QNetworkAccessManager *manager;
bool replyHandled = false;
void showError(const QString &message);
/**
* Deckstats doesn't recognize token cards, and instead tries to find the

View file

@ -1,5 +1,6 @@
#include "tapped_out_interface.h"
#include "deck_analysis_url.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
@ -13,15 +14,17 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QPointer>
#include <QRegularExpression>
#include <QRegularExpressionMatch>
#include <QRegularExpressionMatchIterator>
#include <QSharedPointer>
#include <QString>
#include <QStringList>
#include <QTextDocumentFragment>
#include <QUrl>
#include <QUrlQuery>
#include <QVariant>
#include <QWidget>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
@ -35,54 +38,67 @@ TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent)
connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished);
}
void TappedOutInterface::showError(const QString &message)
{
// The notice belongs to the originating editor, not this one-shot request.
// Retire the request before opening UI: opening a window may reenter Qt.
QWidget *owner = qobject_cast<QWidget *>(parent());
auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner);
notice->setTextFormat(Qt::PlainText);
notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard);
notice->setAttribute(Qt::WA_DeleteOnClose);
deleteLater();
notice->open();
}
void TappedOutInterface::queryFinished(QNetworkReply *reply)
{
if (replyHandled) {
return;
}
replyHandled = true;
// All terminal paths release the reply before showing UI or invoking an
// external URL handler, either of which may destroy the originating editor.
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::critical(nullptr, tr("Error"), reply->errorString());
reply->deleteLater();
deleteLater();
showError(reply->errorString());
return;
}
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (reply->hasRawHeader("Location")) {
/*
* If the reply contains a "Location" header, a relative URL to the deck on TappedOut
* can be extracted from the header. The http status is a 302 "redirect".
*/
QString deckUrl = reply->rawHeader("Location");
qCInfo(TappedOutInterfaceLog) << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl;
QDesktopServices::openUrl("https://tappedout.net" + deckUrl);
} else {
/*
* Otherwise, the deck has not been parsed correctly. Error messages can be extracted
* from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li")
*/
QString data(reply->readAll());
QStringList errorMessageList = {tr("Unable to analyze the deck.")};
static const QRegularExpression rx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>");
auto match = rx.match(data);
if (match.hasMatch()) {
QString errors = match.captured(1);
static const QRegularExpression rx2("<li>(.*?)</li>");
static const QRegularExpression rxremove("<[^>]*>");
auto matchIterator = rx2.globalMatch(errors);
while (matchIterator.hasNext()) {
auto match2 = matchIterator.next();
errorMessageList.append(match2.captured(1).remove(rxremove).simplified());
}
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) {
const QByteArray location = reply->rawHeader("Location");
QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode));
if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) {
showError(tr("The reply from TappedOut contained an invalid deck address."));
return;
}
QString errorMessage = errorMessageList.join("\n");
qCWarning(TappedOutInterfaceLog) << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size()
<< "message" << errorMessage;
QMessageBox::critical(nullptr, tr("Error"), errorMessage);
QPointer<TappedOutInterface> guard(this);
deleteLater();
if (!QDesktopServices::openUrl(deckUrl) && guard) {
showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1")
.arg(deckUrl.toDisplayString()));
}
return;
}
reply->deleteLater();
deleteLater();
const QString data = QString::fromUtf8(reply->readAll());
QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")};
static const QRegularExpression errorsRx("<div class=\"alert alert-danger.*?<ul>(.*?)</ul>",
QRegularExpression::DotMatchesEverythingOption);
const auto match = errorsRx.match(data);
if (match.hasMatch()) {
static const QRegularExpression itemRx("<li>(.*?)</li>", QRegularExpression::DotMatchesEverythingOption);
auto items = itemRx.globalMatch(match.captured(1));
while (items.hasNext()) {
const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified();
if (!error.isEmpty()) {
errorMessageList.append(error);
}
}
}
qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus;
showError(errorMessageList.join('\n'));
}
void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data)
@ -90,13 +106,9 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray
DeckList mainboard, sideboard;
copyDeckSplitMainAndSide(deck, mainboard, sideboard);
QUrl params;
QUrlQuery urlQuery;
urlQuery.addQueryItem("name", deck.getName());
urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true));
urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true));
params.setQuery(urlQuery);
data.append(params.query(QUrl::EncodeReserved).toUtf8());
data.append("name=" + QUrl::toPercentEncoding(deck.getName()));
data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true)));
data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true)));
}
void TappedOutInterface::analyzeDeck(const DeckList &deck)
@ -117,7 +129,9 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi
{
auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) {
CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName());
if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) {
// Exclude confirmed tokens, but preserve cards missing from the local
// database, including newly released cards in an imported deck.
if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) {
return;
}

View file

@ -14,6 +14,7 @@
inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface");
class QByteArray;
class QString;
class QNetworkAccessManager;
class QNetworkReply;
class DeckList;
@ -29,6 +30,8 @@ class TappedOutInterface : public QObject
Q_OBJECT
private:
QNetworkAccessManager *manager;
bool replyHandled = false;
void showError(const QString &message);
void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard);
private slots:

View file

@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json";
static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/";
static const QString MOXFIELD_SUFFIX = "/";
static const QString DECKSTATS_BASE = "https://deckstats.net/decks/";
static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1";
static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/";
bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
{
static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)");
static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)");
static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)");
static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)");
static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)");
QRegularExpressionMatch match;
@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo)
return true;
} else if ((match = rxDeckstats.match(url)).hasMatch()) {
QString deckPath = match.captured(1);
outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/",
outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE,
.deckID = deckPath,
.fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX,
.fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX,
.provider = DeckProvider::Deckstats};
return true;
} else if ((match = rxManabox.match(url)).hasMatch()) {
QString deckPath = match.captured(1);
outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE,
.deckID = deckPath,
.fullUrl = MANABOX_BASE + deckPath,
.provider = DeckProvider::Manabox};
return true;
}
return false;

View file

@ -15,6 +15,7 @@ enum class DeckProvider
Archidekt,
Moxfield,
Deckstats,
Manabox,
Unknown
};

View file

@ -8,12 +8,19 @@
#define INTERFACE_JSON_DECK_PARSER_H
#include "../../../interface/deck_loader/card_node_function.h"
#include "libcockatrice/deck_list/tree/inner_deck_list_node.h"
#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QLoggingCategory>
#include <QString>
#include <QTextStream>
#include <libcockatrice/card/import/card_name_normalizer.h>
#include <libcockatrice/deck_list/deck_list.h>
inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser");
class IJsonDeckParser
{
public:
@ -32,9 +39,6 @@ public:
QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString();
deckList.setName(deckName);
deckList.setComments(deckDescription);
QString outputText;
QTextStream outStream(&outputText);
@ -50,8 +54,14 @@ public:
outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n';
}
outStream.flush();
outStream.seek(0);
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.setComments(deckDescription);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
deckList.setName(deckName);
deckList.setComments(deckDescription);
return deckList;
}
@ -67,9 +77,6 @@ public:
QString deckName = obj.value("name").toString();
QString deckDescription = obj.value("description").toString();
deckList.setName(deckName);
deckList.setComments(deckDescription);
QString outputText;
QTextStream outStream(&outputText);
@ -97,8 +104,14 @@ public:
outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n';
}
outStream.flush();
outStream.seek(0);
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.setComments(deckDescription);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
deckList.setName(deckName);
deckList.setComments(deckDescription);
QJsonObject commandersObj = obj.value("commanders").toObject();
if (!commandersObj.isEmpty()) {
@ -114,6 +127,113 @@ public:
}
}
// Moxfield stores Oathbreaker signature spells in their own board,
// separate from both mainboard and commanders. They are playable deck
// cards, but must not replace the Oathbreaker as the banner card.
const QJsonObject signatureSpells = obj.value("signatureSpells").toObject();
for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) {
const QJsonObject entry = it.value().toObject();
const QJsonObject card = entry.value("card").toObject();
const QString name = card.value("name").toString();
const int quantity = entry.value("quantity").toInt();
if (name.isEmpty() || quantity <= 0) {
continue;
}
const QString set = card.value("set").toString().toUpper();
const QString collectorNumber = card.value("cn").toString();
const QString providerId = card.value("scryfall_id").toString();
deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId);
}
return deckList;
}
};
class ManaboxJsonParser : public IJsonDeckParser
{
enum BoardType : quint32
{
MainBoard = 0,
SideBoard = 1,
Commander = 2,
MaybeBoard = 3,
};
public:
DeckList parse(const QJsonObject &obj) override
{
DeckList deckList;
QString deckName = obj.value("name").toString();
QString outputText;
QString mainboardText;
QString sideboardText;
QTextStream mainboardStream(&mainboardText);
QTextStream sideboardStream(&sideboardText);
QTextStream outStream(&outputText);
auto commanderList = QList<QJsonObject>();
auto maybeboardList = QList<QJsonObject>();
for (auto entry : obj.value("cards").toArray()) {
auto card = entry.toObject();
auto collectorNumber = card.value("collectorNumber").toString();
auto cardName = card.value("name").toString();
auto boardType = static_cast<BoardType>(card.value("boardCategory").toInt());
auto setName = card.value("setId").toString();
auto quantity = card.value("quantity").toInt();
auto providerId = card.value("scryfallId").toString();
switch (boardType) {
case MainBoard:
mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber
<< '\n';
break;
case SideBoard:
sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") "
<< collectorNumber << '\n';
break;
case MaybeBoard:
maybeboardList.append(card);
break;
case Commander:
commanderList.append(card);
break;
default:
qCWarning(InterfaceJsonDeckParserLog)
<< "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName;
break;
}
}
outStream << mainboardText << sideboardText;
deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer());
deckList.setName(deckName);
deckList.forEachCard(CardNodeFunction::ResolveProviderId());
for (const auto &maybeboardCard : maybeboardList) {
QString cardName = maybeboardCard.value("name").toString();
QString setName = maybeboardCard.value("setId").toString().toUpper();
QString collectorNumber = maybeboardCard.value("collectorNumber").toString();
QString providerId = maybeboardCard.value("scryfallId").toString();
for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) {
deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId);
}
}
for (const auto &commanderCard : commanderList) {
QString commanderName = commanderCard.value("name").toString();
QString setName = commanderCard.value("setId").toString().toUpper();
QString collectorNumber = commanderCard.value("collectorNumber").toString();
QString providerId = commanderCard.value("scryfallId").toString();
deckList.setBannerCard({commanderName, providerId});
deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId);
}
return deckList;
}
};

View file

@ -2,253 +2,305 @@
#include "../../../../main.h"
#include "../../../settings/cache_settings.h"
#include "spoiler_file.h"
#include <QChar>
#include <QCryptographicHash>
#include <QDateTime>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QIODevice>
#include <QList>
#include <QLocale>
#include <QMessageLogger>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSharedPointer>
#include <QSystemTrayIcon>
#include <QTimeZone>
#include <QTimer>
#include <QUrl>
#include <QtConcurrentRun>
#include <QtGlobal>
#include <functional>
#include <exception>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/network_manager.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <tuple>
#include <utility>
#include <version_string.h>
#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"
#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr)
namespace
{
isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus();
if (isSpoilerDownloadEnabled) {
// Start the process of checking if we're in spoiler season
// File exists means we're in spoiler season
startSpoilerDownloadProcess(SPOILERS_STATUS_URL, false);
const QUrl SPOILERS_STATUS_URL(
QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled"));
const QUrl SPOILERS_URL(QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml"));
// Request coordination stays on the GUI thread; reload workers never access it.
QPointer<SpoilerBackgroundUpdater> currentUpdater;
QPointer<QFutureWatcher<bool>> activeReload;
quint64 newestOperation = 0;
bool removeSpoilerFile(const QString &path)
{
QFile file(path);
if (!file.exists()) {
return false;
}
if (!file.remove()) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Could not remove spoiler database" << path << file.errorString();
return false;
}
return true;
}
} // namespace
SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *parent, bool forceReload)
: QObject(parent), enabledAtStart(SettingsCache::instance().downloads().getDownloadSpoilersStatus()),
forceReload(forceReload), targetPath(SettingsCache::instance().paths().getSpoilerCardDatabasePath()),
operationId(++newestOperation), manager(NetworkManagerFactory::create(this)), notificationIcon(trayIcon)
{
connect(&SettingsCache::instance().downloads(), &DownloadSettings::downloadSpoilerStatusChanged, this,
&SpoilerBackgroundUpdater::cancelIfSettingsChanged);
connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this,
&SpoilerBackgroundUpdater::cancelIfSettingsChanged);
// Even a disabled/no-op operation completes after its caller can connect signals.
QTimer::singleShot(0, this, &SpoilerBackgroundUpdater::begin);
}
void SpoilerBackgroundUpdater::begin()
{
if (completed) {
return;
}
if (cancelled || operationId != newestOperation ||
(reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus()
: (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) ||
targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
finish();
return;
}
const auto previous = currentUpdater;
if (activeReload) {
// The existing database API stores its final load status after its parser
// mutex is released. Do not overlap updater-owned calls to that API.
connect(activeReload, &QFutureWatcher<bool>::finished, this, &SpoilerBackgroundUpdater::begin,
static_cast<Qt::ConnectionType>(Qt::QueuedConnection | Qt::SingleShotConnection));
if (previous && previous != this) {
previous->cancel();
}
return;
}
currentUpdater = this;
QPointer<SpoilerBackgroundUpdater> guard(this);
if (previous && previous != this) {
previous->cancel();
}
if (!guard) {
return;
}
if (!isCurrent()) {
finish();
} else if (reloadOnly) {
if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) {
finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card "
"Database to retry."));
return;
}
reloadDatabase(false);
} else {
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Disabled";
downloadFromURL(SPOILERS_STATUS_URL, false);
}
}
void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool saveResults)
bool SpoilerBackgroundUpdater::isCurrent() const
{
auto spoilerURL = QUrl(url);
downloadFromURL(spoilerURL, saveResults);
return !completed && !cancelled && currentUpdater == this && operationId == newestOperation &&
(reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) &&
targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath();
}
void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults)
void SpoilerBackgroundUpdater::cancelIfSettingsChanged()
{
auto *nam = NetworkManagerFactory::create(this);
auto request = QNetworkRequest(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
QNetworkReply *reply = nam->get(request);
if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() ||
targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) {
cancel();
}
}
void SpoilerBackgroundUpdater::cancel()
{
if (completed) {
return;
}
cancelled = true;
const auto reply = activeReply;
activeReply.clear();
QPointer<SpoilerBackgroundUpdater> guard(this);
if (reply) {
reply->abort();
if (reply) {
reply->deleteLater();
}
}
// A running reload owns no updater state. Allow its completion to retire its
// watcher before a heap-owned updater is released by the caller.
if (guard && !reloading) {
finish();
}
}
void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults)
{
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
request.setTransferTimeout(60000);
activeReply = manager->get(request);
if (saveResults) {
// This will write out to the file (used for spoiler.xml)
connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
connect(activeReply, &QNetworkReply::finished, this,
&SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile);
} else {
// This will check the status (used to see if we're in spoiler season or not)
connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled);
}
}
void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
{
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || reply != activeReply) {
return;
}
activeReply.clear();
reply->deleteLater();
if (!isCurrent()) {
finish();
return;
}
if (reply->error() == QNetworkReply::NoError) {
downloadFromURL(SPOILERS_URL, true);
} else if (reply->error() == QNetworkReply::ContentNotFoundError) {
const bool existed = QFileInfo::exists(targetPath);
seasonEnded = existed;
if (existed && !removeSpoilerFile(targetPath)) {
finish(tr("The spoiler season has ended, but the old spoiler database could not be removed."));
return;
}
if (existed || forceReload) {
reloadDatabase(false);
} else {
finish();
}
} else {
finish(tr("Could not check spoiler availability: %1").arg(reply->errorString()));
}
}
void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile()
{
// Check for server reply
auto *reply = dynamic_cast<QNetworkReply *>(sender());
QNetworkReply::NetworkError errorCode = reply->error();
if (errorCode == QNetworkReply::NoError) {
spoilerData = reply->readAll();
// Save the spoiler.xml file to the disk
saveDownloadedFile(spoilerData);
reply->deleteLater();
emit spoilerCheckerDone();
auto *reply = qobject_cast<QNetworkReply *>(sender());
if (!reply || reply != activeReply) {
return;
}
activeReply.clear();
reply->deleteLater();
if (!isCurrent()) {
finish();
return;
}
if (reply->error() != QNetworkReply::NoError) {
finish(tr("Could not download spoilers: %1").arg(reply->errorString()));
return;
}
spoilerData = reply->readAll();
QString error;
const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error);
if (result == SpoilerFile::SaveResult::Failed) {
finish(isCurrent() ? error : QString());
} else if (result == SpoilerFile::SaveResult::Written || forceReload) {
reloadDatabase(result == SpoilerFile::SaveResult::Written);
} else {
qCWarning(SpoilerBackgroundUpdaterLog) << "Error downloading spoilers file" << errorCode;
emit spoilerCheckerDone();
finish();
}
}
bool SpoilerBackgroundUpdater::deleteSpoilerFile()
void SpoilerBackgroundUpdater::reloadDatabase(bool updated)
{
QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
QFileInfo fi(fileName);
QDir fileDir(fi.path());
QFile file(fileName);
// Delete the spoiler.xml file
if (file.exists() && file.remove()) {
qCInfo(SpoilerBackgroundUpdaterLog) << "Deleting spoiler.xml";
return true;
}
qCInfo(SpoilerBackgroundUpdaterLog) << "Error: Spoiler.xml not found or not deleted";
return false;
}
void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled()
{
auto *response = dynamic_cast<QNetworkReply *>(sender());
QNetworkReply::NetworkError errorCode = response->error();
if (errorCode == QNetworkReply::ContentNotFoundError) {
// Spoiler season is offline at this point, so the spoiler.xml file can be safely deleted
// The user should run Oracle to get the latest card information
if (deleteSpoilerFile() && trayIcon) {
trayIcon->showMessage(tr("Spoilers season has ended"), tr("Deleting spoiler.xml. Please run Oracle"));
reloading = true;
auto *database = CardDatabaseManager::getInstance();
// The job outlives an individual request, so destroying that request cannot
// let a newer updater overlap the worker that is still completing.
auto *watcher = new QFutureWatcher<bool>(QCoreApplication::instance());
activeReload = watcher;
connect(watcher, &QFutureWatcher<bool>::finished, watcher, [watcher] {
if (activeReload == watcher) {
activeReload.clear();
}
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Season Offline";
emit spoilerCheckerDone();
} else if (errorCode == QNetworkReply::NoError) {
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Online";
startSpoilerDownloadProcess(SPOILERS_URL, true);
} else if (errorCode == QNetworkReply::HostNotFoundError) {
if (trayIcon) {
trayIcon->showMessage(tr("Spoilers download failed"), tr("No internet connection"));
}
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed due to no internet connection";
emit spoilerCheckerDone();
} else {
if (trayIcon) {
trayIcon->showMessage(tr("Spoilers download failed"), tr("Error") + " " + (short)errorCode);
}
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed with reason" << errorCode;
emit spoilerCheckerDone();
}
}
bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
{
QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath();
QFileInfo fi(fileName);
QDir fileDir(fi.path());
if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) {
return false;
}
// Check if the data matches. If it does, then spoilers are up to date.
if (getHash(fileName) == getHash(data)) {
if (trayIcon) {
trayIcon->showMessage(tr("Spoilers already up to date"), tr("No new spoilers added"));
}
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Up to Date";
return false;
}
QFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File open (w) failed for" << fileName;
file.close();
return false;
}
if (file.write(data) == -1) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File write (w) failed for" << fileName;
file.close();
return false;
}
file.close();
// Data written, so reload the card database
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
// Spoilers can introduce sets the config has never seen; the loader never
// runs checkUnknownSets() itself, so run it once the finished snapshot has
// been swapped in.
auto *watcher = new QFutureWatcher<void>(this);
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
watcher->deleteLater();
CardDatabaseManager::getInstance()->checkUnknownSets();
});
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
// If the user has notifications enabled, let them know
// when the database was last updated
if (trayIcon) {
QList<QByteArray> lines = data.split('\n');
for (const QByteArray &line : lines) {
if (line.contains("Created At:")) {
QString timeStamp = QString(line).replace("Created At:", "").trimmed();
timeStamp.chop(6); // Remove " (UTC)"
auto utcTime = QLocale().toDateTime(timeStamp, "ddd, MMM dd yyyy, hh:mm:ss");
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
utcTime.setTimeZone(QTimeZone::UTC);
#else
utcTime.setTimeSpec(Qt::UTC);
#endif
QString localTime = utcTime.toLocalTime().toString("MMM d, hh:mm");
trayIcon->showMessage(tr("Spoilers have been updated!"), tr("Last change:") + " " + localTime);
emit spoilersUpdatedSuccessfully();
return true;
}
connect(watcher, &QFutureWatcher<bool>::finished, this, [this, watcher, database, updated] {
bool loaded = false;
try {
loaded = watcher->result() && database->getLoadStatus() == Ok;
} catch (const std::exception &error) {
qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler database reload failed:" << error.what();
}
}
return true;
reloading = false;
if (!isCurrent()) {
finish();
} else if (!loaded) {
finish(reloadOnly
? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.")
: tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the "
"reload."));
} else {
finish({}, updated);
}
});
watcher->setFuture(QtConcurrent::run([database] {
database->reloadCardDatabasesAndNotify();
return true;
}));
}
QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName)
void SpoilerBackgroundUpdater::finish(const QString &error, bool updated)
{
QFile file(fileName);
if (file.open(QFile::ReadOnly)) {
// Only read the first 512 bytes (enough to get the "created" tag)
const QByteArray bytes = file.read(512);
QCryptographicHash hash(QCryptographicHash::Algorithm::Md5);
hash.addData(bytes);
qCInfo(SpoilerBackgroundUpdaterLog) << "File Hash =" << hash.result();
file.close();
return hash.result();
} else {
qCWarning(SpoilerBackgroundUpdaterLog) << "getHash ReadOnly failed!";
file.close();
return QByteArray();
if (completed) {
return;
}
completed = true;
spoilerData = QByteArray();
if (currentUpdater == this) {
currentUpdater.clear();
}
QPointer<SpoilerBackgroundUpdater> guard(this);
if (!error.isEmpty() && !cancelled) {
qCWarning(SpoilerBackgroundUpdaterLog) << error;
if (notificationIcon) {
notificationIcon->showMessage(tr("Spoiler update failed"), error);
}
if (!guard) {
return;
}
emit spoilerUpdateFailed(error);
} else if (updated && !cancelled) {
if (notificationIcon) {
notificationIcon->showMessage(tr("Spoilers have been updated!"),
tr("The card database now includes the downloaded spoilers."));
}
} else if (seasonEnded && !cancelled && notificationIcon) {
notificationIcon->showMessage(tr("Spoiler season has ended"),
tr("The spoiler database was removed. Please run Oracle to get released cards."));
}
if (guard) {
emit spoilerCheckerDone();
}
}
QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data)
SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::current()
{
// Only read the first 512 bytes (enough to get the "created" tag)
const QByteArray bytes = data.left(512);
QCryptographicHash hash(QCryptographicHash::Algorithm::Md5);
hash.addData(bytes);
qCInfo(SpoilerBackgroundUpdaterLog) << "Data Hash =" << hash.result();
return hash.result();
return currentUpdater;
}
SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile()
{
// Deletion and the following reload share the operation lifetime. Callers can
// observe either failure before the queued operation starts, and an explicit
// retry still reloads when the file has already been removed.
auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true);
updater->reloadOnly = true;
connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater);
return updater;
}

View file

@ -2,7 +2,6 @@
* @file spoiler_background_updater.h
* @ingroup Client
*/
//! \todo Document this file.
#ifndef COCKATRICE_SPOILER_DOWNLOADER_H
#define COCKATRICE_SPOILER_DOWNLOADER_H
@ -10,10 +9,13 @@
#include <QByteArray>
#include <QLoggingCategory>
#include <QObject>
#include <QPointer>
#include <QString>
#include <qtmetamacros.h>
class QProcess;
class QNetworkAccessManager;
class QNetworkReply;
class QSystemTrayIcon;
class QUrl;
inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater");
@ -22,30 +24,48 @@ class SpoilerBackgroundUpdater : public QObject
{
Q_OBJECT
public:
explicit SpoilerBackgroundUpdater(QObject *apParent = nullptr);
explicit SpoilerBackgroundUpdater(QObject *parent = nullptr, bool forceReload = false);
inline QString getCardUpdaterBinaryName()
{
return "oracle";
}
QByteArray getHash(const QString fileName);
QByteArray getHash(QByteArray data);
static bool deleteSpoilerFile();
// Queues removal and reload; the application owns the returned updater and
// deletes it on completion. Callers can connect its failure/completion signals.
static SpoilerBackgroundUpdater *deleteSpoilerFile();
// The operation currently running, if any. The updater outlives the page that
// started it, so a reopened page uses this to observe the in-flight update.
static SpoilerBackgroundUpdater *current();
private slots:
void begin();
void actDownloadFinishedSpoilersFile();
void actCheckIfSpoilerSeasonEnabled();
void cancelIfSettingsChanged();
private:
bool isSpoilerDownloadEnabled;
QProcess *cardUpdateProcess;
const bool enabledAtStart;
const bool forceReload;
const QString targetPath;
const quint64 operationId;
QNetworkAccessManager *manager;
QPointer<QNetworkReply> activeReply;
QPointer<QSystemTrayIcon> notificationIcon;
QByteArray spoilerData;
void startSpoilerDownloadProcess(QString url, bool saveResults);
void downloadFromURL(QUrl url, bool saveResults);
bool saveDownloadedFile(QByteArray data);
bool completed = false;
bool cancelled = false;
bool reloading = false;
bool seasonEnded = false;
bool reloadOnly = false;
bool isCurrent() const;
void cancel();
void finish(const QString &error = {}, bool updated = false);
void downloadFromURL(const QUrl &url, bool saveResults);
void reloadDatabase(bool updated);
signals:
void spoilersUpdatedSuccessfully();
void spoilerCheckerDone();
void spoilerUpdateFailed(const QString &message);
};
#endif // COCKATRICE_SPOILER_DOWNLOADER_H
#endif // COCKATRICE_SPOILER_DOWNLOADER_H

View file

@ -0,0 +1,109 @@
#include "spoiler_file.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QXmlStreamReader>
namespace
{
QByteArray hashFile(const QString &path)
{
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
QCryptographicHash hash(QCryptographicHash::Sha256);
return hash.addData(&file) ? hash.result() : QByteArray();
}
} // namespace
namespace SpoilerFile
{
QString validate(const QByteArray &data)
{
QXmlStreamReader xml(data);
if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The download is not a Cockatrice card database. The previous spoiler file was preserved.");
}
bool versionOk = false;
const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk);
if (!versionOk || (version != 3 && version != 4)) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The spoiler database uses an unsupported format. The previous spoiler file was preserved.");
}
bool hasCards = false;
int depth = 1;
while (!xml.atEnd()) {
const auto token = xml.readNext();
if (token == QXmlStreamReader::StartElement) {
++depth;
if (depth == 2 && xml.name() == QStringLiteral("cards")) {
hasCards = true;
}
} else if (token == QXmlStreamReader::EndElement) {
--depth;
}
}
if (xml.hasError() || !hasCards) {
return QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"The spoiler database is incomplete or invalid. The previous spoiler file was preserved.");
}
return {};
}
SaveResult
save(const QString &targetPath, const QByteArray &data, const std::function<bool()> &isCurrent, QString &error)
{
error = validate(data);
if (!error.isEmpty()) {
return SaveResult::Failed;
}
if (!isCurrent()) {
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) {
return SaveResult::Unchanged;
}
const QDir directory = QFileInfo(targetPath).dir();
if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) {
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"Could not create the spoiler database directory: %1")
.arg(directory.absolutePath());
return SaveResult::Failed;
}
QSaveFile file(targetPath);
file.setDirectWriteFallback(false);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) {
error = QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"Could not save the spoiler database to %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
if (!isCurrent()) {
file.cancelWriting();
error = QCoreApplication::translate("SpoilerBackgroundUpdater",
"The spoiler update was cancelled because its settings changed.");
return SaveResult::Failed;
}
if (!file.commit()) {
error = QCoreApplication::translate(
"SpoilerBackgroundUpdater",
"Could not replace the spoiler database at %1: %2. The previous file was preserved.")
.arg(targetPath, file.errorString());
return SaveResult::Failed;
}
return SaveResult::Written;
}
} // namespace SpoilerFile

View file

@ -0,0 +1,24 @@
#ifndef COCKATRICE_SPOILER_FILE_H
#define COCKATRICE_SPOILER_FILE_H
#include <QByteArray>
#include <QString>
#include <functional>
namespace SpoilerFile
{
enum class SaveResult
{
Failed,
Unchanged,
Written
};
QString validate(const QByteArray &data);
// Validate before opening the destination, compare complete files, and replace
// atomically. Check operation ownership both before writing and before committing.
SaveResult
save(const QString &targetPath, const QByteArray &data, const std::function<bool()> &isCurrent, QString &error);
} // namespace SpoilerFile
#endif // COCKATRICE_SPOILER_FILE_H

View file

@ -6,6 +6,7 @@
#include "shortcuts_settings.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache)
SettingsCache &SettingsCache::instance()
{
return *settingsCache; // returns a QT managed singleton reference
SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference
// The release channels own QNetworkAccessManagers, which must be destroyed
// while the application object is alive. ~SettingsCache() runs during
// static teardown, after QApplication is gone, so hook the application's
// shutdown instead. Retried here because the first access may predate the
// application object.
cache.installShutdownHook();
return cache;
}
void SettingsCache::installShutdownHook()
{
if (shutdownHookInstalled) {
return;
}
QCoreApplication *application = QCoreApplication::instance();
if (application == nullptr) {
return;
}
connect(application, &QCoreApplication::aboutToQuit, this, [this] {
for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) {
delete releaseChannel;
}
releaseChannels.clear();
});
shutdownHookInstalled = true;
}
QString SettingsCache::getDataPath()

View file

@ -90,8 +90,13 @@ private:
QString themeName;
QList<ReleaseChannel *> releaseChannels;
bool shutdownHookInstalled = false;
bool isPortableBuild;
// Connects the aboutToQuit handler that deletes the release channels while
// the application object is still alive; a no-op before it exists.
void installShutdownHook();
[[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const;
[[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const;
void loadPaths();

View file

@ -1,5 +1,6 @@
#include "shortcut_treeview.h"
#include "../../interface/theme_manager.h"
#include "cache_settings.h"
#include "shortcuts_settings.h"
@ -78,6 +79,18 @@ ShortcutTreeView::ShortcutTreeView(QWidget *parent) : QTreeView(parent)
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&ShortcutTreeView::refreshShortcuts);
// Group headers store their font on the item; re-apply the role family on
// font changes (setFont emits itemChanged, which repaints the rows).
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
QFont groupFont = themeManager->appFont(AppFont::InterfaceDefault);
groupFont.setBold(true);
for (int row = 0; row < shortcutsModel->rowCount(); ++row) {
if (auto *groupItem = shortcutsModel->item(row)) {
groupItem->setFont(groupFont);
}
}
});
}
void ShortcutTreeView::populateShortcutsModel()
@ -93,7 +106,8 @@ void ShortcutTreeView::populateShortcutsModel()
curParent = parentItems.value(group);
} else {
curParent = new QStandardItem(group);
static QFont font = curParent->font();
// Family comes from the InterfaceDefault theme role; bold stays caller-side.
QFont font = themeManager->appFont(AppFont::InterfaceDefault);
font.setBold(true);
curParent->setFont(font);
parentItems.insert(group, curParent);

View file

@ -1,6 +1,6 @@
#include "filter_builder.h"
#include "../interface/pixel_map_generator.h"
#include "../interface/theme_icon.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include <QComboBox>
@ -30,7 +30,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i));
}
QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString());
QPushButton *ok = new QPushButton(loadThemedIcon(QStringLiteral("icons/increment")), QString());
ok->setObjectName("ok");
ok->setMaximumSize(20, 20);

View file

@ -1,5 +1,7 @@
#include "filter_tree_model.h"
#include "../interface/theme_manager.h"
#include <QFlags>
#include <QFont>
#include <QString>
@ -11,6 +13,27 @@
class QObject;
namespace
{
/**
* @brief Visits every filter item below @p node, depth first.
*/
template <typename Callback> void forEachFilter(const FilterTreeNode *node, const Callback &callback)
{
if (node == nullptr) {
return;
}
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
callback(filterItem);
}
for (int i = 0; i < node->childCount(); ++i) {
forEachFilter(node->nodeAt(i), callback);
}
}
} // namespace
FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
{
fTree = new FilterTree;
@ -18,6 +41,12 @@ FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent)
connect(fTree, &FilterTree::postInsertRow, this, &FilterTreeModel::proxyEndInsertRow);
connect(fTree, &FilterTree::preRemoveRow, this, &FilterTreeModel::proxyBeginRemoveRow);
connect(fTree, &FilterTree::postRemoveRow, this, &FilterTreeModel::proxyEndRemoveRow);
// FontRole data reads the DeckList role live; rebroadcast so attached
// views re-query instead of waiting for an incidental repaint.
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
emit layoutAboutToBeChanged();
emit layoutChanged();
});
}
FilterTreeModel::~FilterTreeModel()
@ -110,46 +139,31 @@ void FilterTreeModel::clearFiltersOfType(CardFilter::Attr filterType)
QList<const CardFilter *> FilterTreeModel::getFiltersOfType(CardFilter::Attr filterType) const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
if (filterItem->attr() == filterType) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
});
return filters;
}
QStringList FilterTreeModel::getFilterTermsOfType(CardFilter::Attr filterType) const
{
QStringList terms;
forEachFilter(fTree, [&](const FilterItem *filterItem) {
if (filterItem->attr() == filterType) {
terms.append(filterItem->text());
}
});
return terms;
}
QList<const CardFilter *> FilterTreeModel::allFilters() const
{
QList<const CardFilter *> filters;
if (!fTree) {
return filters;
}
std::function<void(const FilterTreeNode *)> traverse;
traverse = [&](const FilterTreeNode *node) {
if (const auto *filterItem = dynamic_cast<const FilterItem *>(node)) {
QString text = filterItem->text();
filters.append(new CardFilter(text, filterItem->type(), filterItem->attr()));
}
for (int i = 0; i < node->childCount(); ++i) {
traverse(node->nodeAt(i));
}
};
traverse(fTree);
forEachFilter(fTree, [&](const FilterItem *filterItem) {
filters.append(new CardFilter(filterItem->text(), filterItem->type(), filterItem->attr()));
});
return filters;
}
@ -196,7 +210,8 @@ QVariant FilterTreeModel::data(const QModelIndex &index, int role) const
switch (role) {
case Qt::FontRole:
if (!node->isLeaf()) {
QFont f;
// Family comes from the DeckList theme role; bold stays caller-side.
QFont f = themeManager->appFont(AppFont::DeckList);
f.setBold(true);
return f;
}

View file

@ -28,7 +28,14 @@ public slots:
void addFilter(const CardFilter *f);
void removeFilter(const CardFilter *f);
void clearFiltersOfType(CardFilter::Attr filterType);
/**
* @brief Copies every active filter of one attribute into new CardFilter objects.
*
* The caller owns the returned filters. Prefer getFilterTermsOfType() when only
* the terms are needed.
*/
[[nodiscard]] QList<const CardFilter *> getFiltersOfType(CardFilter::Attr filterType) const;
/// @brief Copies every active filter into new CardFilter objects. The caller owns them.
[[nodiscard]] QList<const CardFilter *> allFilters() const;
private slots:
@ -48,6 +55,13 @@ public:
{
return fTree;
}
/**
* @brief The texts of every active filter of one attribute, without allocating filters.
*
* Prefer this over getFiltersOfType() when only the filter terms are needed.
*/
[[nodiscard]] QStringList getFilterTermsOfType(CardFilter::Attr filterType) const;
[[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override;
[[nodiscard]] int columnCount(const QModelIndex & /*parent*/ = QModelIndex()) const override;
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;

View file

@ -7,6 +7,7 @@
#include <QBrush>
#include <QColor>
#include <QCursor>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
#include <QPainterPath>
@ -51,7 +52,20 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item,
update();
});
connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater);
// The source card can be destroyed mid-drag (e.g. moved by a server event). deleteLater() alone is not enough:
// a scene repaint can run before the deferred delete and paint the dead card. Detach from the scene immediately.
connect(_item, &QObject::destroyed, this, [this]() {
hide();
if (QGraphicsScene *sc = scene()) {
sc->removeItem(this);
}
deleteLater();
});
}
AbstractCardItem *AbstractCardDragItem::getItem() const
{
return item.data();
}
QPainterPath AbstractCardDragItem::shape() const
@ -65,6 +79,10 @@ QPainterPath AbstractCardDragItem::shape() const
void AbstractCardDragItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget)
{
if (item.isNull()) {
return;
}
item->paint(painter, option, widget);
// adds a mask to the card so it looks like the card hasnt been placed yet

View file

@ -14,6 +14,7 @@
#include <QList>
#include <QObject>
#include <QPointF>
#include <QPointer>
#include <QRectF>
#include <qtmetamacros.h>
@ -24,7 +25,8 @@ class AbstractCardDragItem : public QObject, public QGraphicsItem
Q_OBJECT
Q_INTERFACES(QGraphicsItem)
protected:
AbstractCardItem *item;
/// Guarded pointer: the source card may be destroyed while this drag item still exists.
QPointer<AbstractCardItem> item;
QPointF hotSpot;
QList<AbstractCardDragItem *> childDrags;
@ -44,10 +46,8 @@ public:
}
[[nodiscard]] QPainterPath shape() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
[[nodiscard]] AbstractCardItem *getItem() const
{
return item;
}
/// @return the source card, or nullptr if it has already been destroyed.
[[nodiscard]] AbstractCardItem *getItem() const;
[[nodiscard]] QPointF getHotSpot() const
{
return hotSpot;

View file

@ -3,6 +3,7 @@
#include "../../client/settings/cache_settings.h"
#include "../../interface/card_localization.h"
#include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/theme_manager.h"
#include "../card_dimensions.h"
#include "../game_scene.h"
#include "../z_values.h"
@ -154,7 +155,7 @@ void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &transla
painter->rotate(angle);
painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2);
QFont f;
QFont f = themeManager->appFont(AppFont::CardText);
f.setPixelSize(fontSize * scale);
painter->setFont(f);

View file

@ -1,5 +1,7 @@
#include "abstract_graphics_item.h"
#include "../../interface/theme_manager.h"
#include <QBrush>
#include <QFont>
#include <QFontMetrics>
@ -20,7 +22,7 @@ void AbstractGraphicsItem::paintNumberEllipse(int number,
painter->save();
QString numStr = QString::number(number);
QFont font("Serif");
QFont font = themeManager->cardTextFont();
font.setPixelSize(fontSize);
font.setWeight(QFont::Bold);

View file

@ -130,8 +130,9 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
sc->removeItem(this);
QList<CardDragItem *> dragItemList;
CardZoneLogic *startZone = static_cast<CardItem *>(item)->getZone();
if (currentZone && !(static_cast<CardItem *>(item)->getAttachedTo() && (startZone == currentZone->getLogic()))) {
CardZoneLogic *startZone = static_cast<CardItem *>(item.data())->getZone();
if (currentZone &&
!(static_cast<CardItem *>(item.data())->getAttachedTo() && (startZone == currentZone->getLogic()))) {
if (!occupied) {
dragItemList.append(this);
}
@ -139,7 +140,7 @@ void CardDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
for (int i = 0; i < childDrags.size(); i++) {
CardDragItem *c = static_cast<CardDragItem *>(childDrags[i]);
if (!occupied &&
!(static_cast<CardItem *>(c->item)->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!(static_cast<CardItem *>(c->item.data())->getAttachedTo() && (startZone == currentZone->getLogic())) &&
!c->occupied) {
dragItemList.append(c);
}

View file

@ -1,6 +1,7 @@
#include "counter_general.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_manager.h"
#include "abstract_graphics_item.h"
#include <QFont>
@ -41,7 +42,7 @@ void GeneralCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /
painter->drawPixmap(QPoint(0, 0), pixmap);
if (value) {
QFont f("Serif");
QFont f = themeManager->cardTextFont();
f.setPixelSize(qMax((int)(radius * scaleFactor), 10));
f.setWeight(QFont::Bold);
painter->setPen(Qt::black);

View file

@ -77,7 +77,7 @@ void DeckViewCardDragItem::updatePosition(const QPointF &cursorScenePos)
void DeckViewCardDragItem::handleDrop(DeckViewCardContainer *target)
{
auto *card = static_cast<DeckViewCard *>(item);
auto *card = static_cast<DeckViewCard *>(item.data());
auto *start = static_cast<DeckViewCardContainer *>(item->parentItem());
start->removeCard(card);
target->addCard(card);
@ -240,8 +240,8 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI
painter->drawLine(QPointF(0, separatorY), QPointF(width, separatorY));
painter->setPen(QColor(Qt::white));
QFont f("Serif");
f.setStyleHint(QFont::Serif);
// Family comes from the DeckList theme role; pixel sizes/weights unchanged.
QFont f = themeManager->deckListFont();
f.setPixelSize(24);
f.setWeight(QFont::Bold);
painter->setFont(f);
@ -292,8 +292,8 @@ QList<QPair<int, int>> DeckViewCardContainer::getRowsAndCols() const
int DeckViewCardContainer::getCardTypeTextWidth() const
{
QFont f("Serif");
f.setStyleHint(QFont::Serif);
// Family comes from the DeckList theme role; pixel size/weight unchanged.
QFont f = themeManager->deckListFont();
f.setPixelSize(16);
f.setWeight(QFont::Bold);
QFontMetrics fm(f);
@ -366,6 +366,15 @@ void DeckViewCardContainer::setWidth(qreal _width)
DeckViewScene::DeckViewScene(QObject *parent) : QGraphicsScene(parent), locked(true), deck(0), optimalAspectRatio(1.0)
{
// Zone headers read the DeckList role at paint time and cache their
// rendering, so touch every item to invalidate the caches and pick up the
// new family on font changes (containers cannot connect this themselves:
// QGraphicsItem is not a QObject).
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
for (auto *item : items()) {
item->update();
}
});
}
DeckViewScene::~DeckViewScene()

View file

@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state)
DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
: QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId)
{
setObjectName("deckViewContainer");
loadLocalButton = new QPushButton;
loadRemoteButton = new QPushButton;
loadFromClipboardButton = new QPushButton;
@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent)
forceStartGameButton = new QPushButton;
sideboardLockButton = new ToggleButton;
loadLocalButton->setObjectName("loadLocalButton");
loadRemoteButton->setObjectName("loadRemoteButton");
loadFromClipboardButton->setObjectName("loadFromClipboardButton");
readyStartButton->setObjectName("readyStartButton");
forceStartGameButton->setObjectName("forceStartGameButton");
unloadDeckButton->setObjectName("unloadDeckButton");
sideboardLockButton->setObjectName("sideboardLockButton");
connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck);
connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck);
connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard);

View file

@ -14,6 +14,7 @@ class TabGame;
TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent)
: QTabWidget(nullptr), playerId(_playerId), parentGame(parent)
{
setObjectName(QString("tabbedDeckView_%1").arg(_playerId));
setTabsClosable(true);
connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab);

View file

@ -1,6 +1,7 @@
#include "game_scene.h"
#include "../client/settings/cache_settings.h"
#include "../interface/theme_manager.h"
#include "animated_item.h"
#include "board/abstract_card_item.h"
#include "board/arrow_item.h"
@ -35,6 +36,7 @@
#include <functional>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/arrow_data.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_event_handler.h>
#include <libcockatrice/game/player/player_info.h>
@ -64,6 +66,14 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
addItem(phasesToolbar);
connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this,
&GameScene::rearrange);
// CardText items (P/T boxes, counters, player names) read the role at
// paint time and cache their rendering, so touch every item to invalidate
// the caches and pick up the new family on font changes.
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
for (auto *item : items()) {
item->update();
}
});
rearrange();
}
@ -432,6 +442,37 @@ QList<PlayerLogic *> GameScene::collectActivePlayers(int &firstPlayerIndex) cons
}
}
}
// Lay out the table in seat order rather than id order. Players missing
// from the tracked order keep id order at the end.
QList<int> seatOrder;
for (auto *pgItem : playerViews.values()) {
if (PlayerLogic *p = pgItem->getLogic()) {
seatOrder = p->getGame()->getGameState()->getSeatOrder();
break;
}
}
if (!seatOrder.isEmpty()) {
std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) {
int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId());
int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId());
if (indexA == -1) {
indexA = seatOrder.size() + a->getPlayerInfo()->getId();
}
if (indexB == -1) {
indexB = seatOrder.size() + b->getPlayerInfo()->getId();
}
return indexA < indexB;
});
// Recompute the local-player index after sorting.
firstPlayerIndex = 0;
for (int i = 0; i < activePlayers.size(); ++i) {
if (activePlayers[i]->getPlayerInfo()->getLocal()) {
firstPlayerIndex = i;
break;
}
}
}
return activePlayers;
}

View file

@ -2,12 +2,14 @@
#include "../client/settings/cache_settings.h"
#include "../client/settings/shortcuts_settings.h"
#include "../interface/theme_manager.h"
#include "game_scene.h"
#include <QAction>
#include <QBrush>
#include <QColor>
#include <QEvent>
#include <QFont>
#include <QGraphicsScene>
#include <QGridLayout>
#include <QLabel>
@ -24,6 +26,21 @@
#include <libcockatrice/utility/qt_utils.h>
#include <qnamespace.h>
namespace
{
QFont deckOverlayFont(int pixelSize, bool bold)
{
// The drag/total counters and the tally columns are in-game overlays, not
// deck list text: keep the monospace family the stylesheets used to set so
// digit widths stay stable as counts tick and `white-space: pre` lines up.
QFont font(QStringLiteral("monospace"));
font.setStyleHint(QFont::Monospace);
font.setPixelSize(pixelSize);
font.setBold(bold);
return font;
}
} // namespace
// QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings.
// This subclass disables that behavior so dragCountLabel can appear above it.
class SelectionRubberBand : public QRubberBand
@ -48,6 +65,7 @@ protected:
GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0)
{
setObjectName("gameView");
setBackgroundBrush(QBrush(QColor(0, 0, 0)));
setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing);
setViewportUpdateMode(BoundingRectViewportUpdate);
@ -75,15 +93,14 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this);
const QString baseProperties = "color: white; "
"font-family: monospace; "
"background-color: rgba(0, 0, 0, 160); "
"border-radius: 3px; "
"padding: 1px 2px; "
"white-space: pre;";
const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;";
const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;";
const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;";
const QString dragCountLabelStyle = baseProperties;
const QString totalCountLabelStyle = baseProperties;
const QString subtypeTallyLabelStyle = baseProperties;
dragCountLabel = new QLabel(this);
dragCountLabel->setStyleSheet(dragCountLabelStyle);
@ -100,6 +117,27 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par
tallyLayout->setContentsMargins(2, 2, 2, 2);
tallyLayout->setSpacing(2);
tallyContainer->hide();
applyDeckOverlayFonts();
}
void GameView::applyDeckOverlayFonts()
{
dragCountLabel->setFont(deckOverlayFont(14, true));
totalCountLabel->setFont(deckOverlayFont(16, true));
tallyContainer->setFont(deckOverlayFont(12, false));
for (int i = 0; i < tallyLayout->count(); ++i) {
if (auto *label = qobject_cast<QLabel *>(tallyLayout->itemAt(i)->widget())) {
// Count column (odd positions) keeps the bold emphasis.
const bool count = i % 2 == 1;
label->setFont(deckOverlayFont(count ? 14 : 12, count));
}
}
// The container keeps the size computed from the previous fonts, so force
// rebuildTallyLabels() to re-measure it instead of reusing cachedTallyRows.
cachedTallyRows.clear();
updateTotalSelectionCount();
}
void GameView::resizeEvent(QResizeEvent *event)
@ -219,9 +257,8 @@ QSize GameView::rebuildTallyLabels(const QList<TallyRow> &entries)
{
clearTallyLabels();
const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;");
const QString countStyle =
QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;");
const QString nameStyle = QStringLiteral("color: white; background: transparent;");
const QString countStyle = QStringLiteral("color: white; background: transparent;");
int totalHeight = 0;
int maxNameWidth = 0;
@ -231,11 +268,13 @@ QSize GameView::rebuildTallyLabels(const QList<TallyRow> &entries)
for (const TallyRow &entry : entries) {
auto *nameLabel = new QLabel(entry.name, tallyContainer);
nameLabel->setStyleSheet(nameStyle);
nameLabel->setFont(deckOverlayFont(12, false));
nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
tallyLayout->addWidget(nameLabel, row, 0);
auto *countLabel = new QLabel(entry.value, tallyContainer);
countLabel->setStyleSheet(countStyle);
countLabel->setFont(deckOverlayFont(14, true));
countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
tallyLayout->addWidget(countLabel, row, 1);

View file

@ -50,6 +50,7 @@ private slots:
void refreshShortcuts();
void updateTotalSelectionCount(const QSize &viewSize = QSize());
void setFocusDisabled(bool disabled);
void applyDeckOverlayFonts();
public slots:
void updateSceneRect(const QRectF &rect);

View file

@ -4,6 +4,8 @@
#include "../../client/settings/card_counter_settings.h"
#include "../../client/sound_engine.h"
#include "../../interface/card_localization.h"
#include "../../interface/theme_icon.h"
#include "../../interface/theme_manager.h"
#include "../../interface/widgets/server/chat_view/chat_view.h"
#include "../board/card_item.h"
#include "../board/translate_counter_name.h"
@ -12,8 +14,12 @@
#include <QColor>
#include <QDebug>
#include <QList>
#include <QPalette>
#include <QPixmap>
#include <QSharedPointer>
#include <QStringList>
#include <QTextDocument>
#include <QUrl>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/board/card_list.h>
#include <libcockatrice/game/game_state.h>
@ -120,6 +126,7 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
void MessageLogWidget::containerProcessingDone()
{
currentContext = MessageContext_None;
contextJudgeName.clear();
messageSuffix = messagePrefix = QString();
}
@ -605,6 +612,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed)
.arg(reversed ? tr("reversed") : tr("normal")));
}
void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized)
{
QStringList sanitizedNames;
for (const QString &name : playerNames) {
sanitizedNames.append(sanitizeHtml(name));
}
const QString order = sanitizedNames.join(" -> ");
if (randomized) {
appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order));
} else {
appendHtmlServerMessage(tr("New turn order set: %1.").arg(order));
}
}
void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls)
{
if (rolls.length() == 1) {
@ -841,8 +862,27 @@ void MessageLogWidget::logUndoDrawFailed(PlayerLogic *player)
void MessageLogWidget::setContextJudgeName(QString name)
{
messagePrefix = QString("<span style=\"color:black\">");
messageSuffix = QString("</span> [<img height=12 src=\"theme:icons/scales\"> %1]").arg(sanitizeHtml(name));
contextJudgeName = std::move(name);
refreshJudgeSuffix();
}
void MessageLogWidget::refreshJudgeSuffix()
{
const QPixmap pixmap = loadThemedIcon(QStringLiteral("icons/scales")).pixmap(QSize(24, 24));
if (!pixmap.isNull()) {
// Rich text cannot resolve `currentColor`; register a palette-rendered
// image so already-appended judge tags repaint on a theme switch.
document()->addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("cockatrice:scales")), pixmap);
document()->markContentsDirty(0, document()->characterCount());
}
if (contextJudgeName.isEmpty()) {
return;
}
messagePrefix = QStringLiteral("<span style=\"color:%1\">").arg(palette().color(QPalette::WindowText).name());
messageSuffix =
QStringLiteral("</span> [<img height=12 src=\"cockatrice:scales\"> %1]").arg(sanitizeHtml(contextJudgeName));
}
void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString optionalFontColor)
@ -891,4 +931,6 @@ void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEve
MessageLogWidget::MessageLogWidget(TabSupervisor *_tabSupervisor, AbstractGame *_game, QWidget *parent)
: ChatView(_tabSupervisor, _game, true, parent), currentContext(MessageContext_None)
{
connect(themeManager, &ThemeManager::paletteChanged, this, &MessageLogWidget::refreshJudgeSuffix);
connect(themeManager, &ThemeManager::themeChanged, this, &MessageLogWidget::refreshJudgeSuffix);
}

View file

@ -39,8 +39,10 @@ private:
MessageContext currentContext;
QString messagePrefix, messageSuffix;
QString contextJudgeName;
static QPair<QString, QString> getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange);
void refreshJudgeSuffix();
public:
void connectToPlayerEventHandler(PlayerEventHandler *player);
@ -94,6 +96,7 @@ public slots:
int amount,
bool isLentToAnotherPlayer);
void logReverseTurn(PlayerLogic *player, bool reversed);
void logTurnOrderChanged(QStringList playerNames, bool randomized);
void logRollDie(PlayerLogic *player, int sides, const QList<uint> &rolls);
void logSay(PlayerLogic *player, QString message);
void logSetActivePhase(int phase);

View file

@ -1,6 +1,7 @@
#include "player_list_widget.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_icon.h"
#include "../../interface/widgets/server/user/user_context_menu.h"
#include "libcockatrice/protocol/pb/serverinfo_user.pb.h"
#include "user_level.h"
@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{
readyIcon = themePixmap(QStringLiteral("icons/ready_start"));
notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start"));
concededIcon = themePixmap(QStringLiteral("icons/conceded"));
playerIcon = loadColorAdjustedPixmap("theme:icons/player");
judgeIcon = loadColorAdjustedPixmap("theme:icons/scales");
spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator");
lockIcon = themePixmap(QStringLiteral("icons/lock"));
readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"));
notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"));
concededIcon = loadThemedIcon(QStringLiteral("icons/conceded"));
playerIcon = loadThemedIcon(QStringLiteral("icons/player"));
judgeIcon = loadThemedIcon(QStringLiteral("icons/scales"));
spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator"));
lockIcon = loadThemedIcon(QStringLiteral("icons/lock"));
if (tabSupervisor) {
itemDelegate = new PlayerListItemDelegate(this);

View file

@ -2,6 +2,7 @@
#include "../../client/settings/cache_settings.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_manager.h"
#include "../board/abstract_counter.h"
#include "../board/abstract_graphics_item.h"
#include "../board/arrow_target.h"
@ -76,7 +77,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
QRectF translatedRect = path.controlPointRect();
QSize translatedSize = translatedRect.size().toSize();
QFont font("Serif");
QFont font = themeManager->cardTextFont();
font.setWeight(QFont::Bold);
font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9));
painter->setFont(font);
@ -226,7 +227,7 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
name = name.mid(0, 10) + "...";
}
QFont font;
QFont font = themeManager->appFont(AppFont::CardText);
font.setPixelSize(qMax(qRound(translatedNameRect.height() / 1.5), 9));
painter->setFont(font);
painter->setPen(Qt::white);

View file

@ -0,0 +1,628 @@
#include "pq_cockatrice_event_player.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "board/card_item.h"
#include "game_scene.h"
#include "game_view.h"
#include "zones/card_zone.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QAction>
#include <QApplication>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QLoggingCategory>
#include <QMenu>
#include <QMouseEvent>
#include <QTreeWidget>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/game_state.h>
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer")
namespace
{
/**
* Strips Qt's mnemonic markers so a test can name a menu entry by the label the
* user sees ("Turn Over" rather than "T&urn Over").
*/
QString normalizeActionText(const QString &text)
{
QString normalized = text;
normalized.remove('&');
return normalized.simplified();
}
/**
* Maps a printable character onto the key a US keyboard would report for it, so
* typing produces the same QKeyEvents a recording would.
*/
Qt::Key keyForCharacter(QChar character)
{
if (character.isLetter()) {
return static_cast<Qt::Key>(Qt::Key_A + (character.toUpper().unicode() - 'A'));
}
if (character.isDigit()) {
return static_cast<Qt::Key>(Qt::Key_0 + (character.unicode() - '0'));
}
switch (character.unicode()) {
case ' ':
return Qt::Key_Space;
case '\n':
return Qt::Key_Return;
case '\t':
return Qt::Key_Tab;
case '-':
return Qt::Key_Minus;
case '_':
return Qt::Key_Underscore;
case '/':
return Qt::Key_Slash;
case ',':
return Qt::Key_Comma;
case '.':
return Qt::Key_Period;
case '\'':
return Qt::Key_Apostrophe;
case ';':
return Qt::Key_Semicolon;
case ':':
return Qt::Key_Colon;
case '"':
return Qt::Key_QuoteDbl;
case '&':
return Qt::Key_Ampersand;
case '(':
return Qt::Key_ParenLeft;
case ')':
return Qt::Key_ParenRight;
case '[':
return Qt::Key_BracketLeft;
case ']':
return Qt::Key_BracketRight;
default:
return Qt::Key_unknown;
}
}
/** Depth-first search for the first model index whose label matches @p text. */
QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {})
{
for (int row = 0; row < model->rowCount(parent); ++row) {
const QModelIndex index = model->index(row, 0, parent);
if (index.data(Qt::DisplayRole).toString() == text) {
return index;
}
const QModelIndex child = findModelIndexByText(model, text, index);
if (child.isValid()) {
return child;
}
}
return {};
}
bool needsShift(QChar character)
{
if (character.isUpper()) {
return true;
}
return QStringLiteral("_:\"&()").contains(character);
}
} // namespace
pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent)
{
}
bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error)
{
// A known command that fails is a handled-but-failed event: QtTesting logs
// "Unhandled event" when playEvent returns false, so report through `error`
// (the contract playExpectProperty already follows) and still return true.
const auto run = [&error](bool ok) {
if (!ok) {
error = true;
}
return true;
};
if (command == "clickCard") {
return run(playClickCard(object, arguments));
} else if (command == "flipCard") {
return run(playFlipCard(object, arguments));
} else if (command == "cardAction") {
return run(playCardAction(object, arguments));
} else if (command == "moveCard") {
return run(playMoveCard(object, arguments));
} else if (command == "drawArrow") {
return run(playDrawArrow(object, arguments));
} else if (command == "createToken") {
return run(playCreateToken(object, arguments));
} else if (command == "selectItem") {
return run(playSelectItem(object, arguments));
} else if (command == "typeText") {
return run(playTypeText(object, arguments));
} else if (command == "expectProperty") {
return playExpectProperty(object, arguments, error);
}
return false;
}
QGraphicsItem *
pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName)
{
for (QGraphicsItem *gi : scene->items()) {
if (gi->type() != typeCard) {
continue;
}
auto *card = static_cast<CardItem *>(gi);
if (!card->getZone() || card->getZone()->getName() != zoneName) {
continue;
}
QString actualName = card->getName();
if (actualName == cardName || actualName.endsWith(" " + cardName)) {
return card;
}
}
return nullptr;
}
QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName)
{
for (QGraphicsItem *gi : scene->items()) {
if (gi->type() != typeZone) {
continue;
}
auto *zone = static_cast<CardZone *>(gi);
if (zone->getLogic() && zone->getLogic()->getName() == zoneName) {
return zone;
}
}
return nullptr;
}
QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText)
{
if (!menu || actionText.isEmpty()) {
return nullptr;
}
for (QAction *action : menu->actions()) {
if (action->isSeparator()) {
continue;
}
if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) {
return action;
}
if (QMenu *subMenu = action->menu()) {
if (QAction *found = findActionInMenu(subMenu, actionText)) {
return found;
}
}
}
return nullptr;
}
QMenu *pqCockatriceEventPlayer::visiblePopupMenu()
{
for (QWidget *w : QApplication::topLevelWidgets()) {
auto *menu = qobject_cast<QMenu *>(w);
if (menu && menu->isVisible()) {
return menu;
}
}
return nullptr;
}
QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item)
{
return view->mapFromScene(item->sceneBoundingRect().center());
}
void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view,
QEvent::Type type,
const QPoint &pos,
Qt::MouseButton button,
Qt::MouseButtons buttons)
{
QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier);
qApp->notify(view->viewport(), &event);
}
bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView";
return false;
}
auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed());
if (!target) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
const QPoint pos = viewportPoint(gv, target);
// A double click is the play gesture from hand: press/release, then the
// double-click event and its release, exactly as a recording produces it.
sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton);
return true;
}
bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene";
return false;
}
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
if (!card) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over"));
}
bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments;
return false;
}
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene";
return false;
}
auto *card = qgraphicsitem_cast<CardItem *>(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()));
if (!card) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed();
return false;
}
return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed());
}
bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command,
QGraphicsView *view,
CardItem *card,
const QString &actionText)
{
const QPoint pos = viewportPoint(view, card);
sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton);
qApp->processEvents();
QMenu *menu = visiblePopupMenu();
if (!menu) {
qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click";
return false;
}
QAction *action = findActionInMenu(menu, actionText);
if (!action) {
qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText;
menu->close();
return false;
}
action->trigger();
menu->close();
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments;
return false;
}
const QString cardName = parts[0].trimmed();
const QString srcZoneName = parts[1].trimmed();
const QString dstZoneName = parts[2].trimmed();
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene";
return false;
}
auto *card = findCardInScene(scene, cardName, srcZoneName);
if (!card) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName;
return false;
}
auto *dstZone = findZoneInScene(scene, dstZoneName);
if (!dstZone) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found";
return false;
}
const QPoint srcPos = viewportPoint(gv, card);
const QPoint dstPos = viewportPoint(gv, dstZone);
// A drag has to travel past Qt's start distance before the scene turns it
// into a card drag item, otherwise the move is interpreted as a click.
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton);
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton);
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 4) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments;
return false;
}
const QString srcCardName = parts[0].trimmed();
const QString srcZoneName = parts[1].trimmed();
const QString dstCardName = parts[2].trimmed();
const QString dstZoneName = parts[3].trimmed();
QGraphicsScene *scene = gv->scene();
if (!scene) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene";
return false;
}
auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName);
if (!srcCard) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName;
return false;
}
auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName);
if (!dstCard) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName;
return false;
}
const QPoint srcPos = viewportPoint(gv, srcCard);
const QPoint dstPos = viewportPoint(gv, dstCard);
const int dragDistance = 2 * QApplication::startDragDistance() + 10;
const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0);
sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton);
qApp->processEvents();
sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton);
sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton);
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments)
{
auto *gv = qobject_cast<QGraphicsView *>(object);
if (!gv) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView";
return false;
}
QStringList parts = arguments.split(',');
if (parts.size() < 3) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments;
return false;
}
QString tokenName = parts[0].trimmed();
QString tokenColor = parts[1].trimmed();
QString tokenPt = parts[2].trimmed();
TabGame *tabGame = nullptr;
QWidget *w = gv;
while (w) {
tabGame = qobject_cast<TabGame *>(w);
if (tabGame) {
break;
}
w = w->parentWidget();
}
if (!tabGame) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found";
return false;
}
AbstractGame *game = tabGame->getGame();
if (!game) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game";
return false;
}
int activePlayerId = game->getGameState()->getActivePlayer();
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId);
if (!player) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player";
return false;
}
TokenInfo info;
info.name = tokenName;
info.color = tokenColor;
info.pt = tokenPt;
info.destroy = true;
info.faceDown = false;
player->getPlayerActions()->actCreateToken(info);
for (int i = 0; i < 10; ++i) {
qApp->processEvents();
}
return true;
}
bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments)
{
const QString text = arguments.trimmed();
if (text.isEmpty()) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select";
return false;
}
if (auto *tree = qobject_cast<QTreeWidget *>(object)) {
QTreeWidgetItemIterator item(tree);
while (*item) {
if ((*item)->text(0) == text) {
tree->setCurrentItem(*item);
qApp->processEvents();
return true;
}
++item;
}
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text;
return false;
}
if (auto *view = qobject_cast<QAbstractItemView *>(object)) {
const QModelIndex index = findModelIndexByText(view->model(), text);
if (!index.isValid()) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text;
return false;
}
view->setCurrentIndex(index);
qApp->processEvents();
return true;
}
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view";
return false;
}
bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments)
{
auto *widget = qobject_cast<QWidget *>(object);
if (!widget) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget";
return false;
}
for (const QChar character : arguments) {
const Qt::Key key = keyForCharacter(character);
const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier;
QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character));
QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character));
qApp->notify(widget, &press);
qApp->notify(widget, &release);
}
qApp->processEvents();
return true;
}
bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error)
{
if (!object) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object";
error = true;
return true;
}
QStringList parts = arguments.split(',');
if (parts.size() < 2) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments;
error = true;
return true;
}
const QString propertyName = parts[0].trimmed();
const QString expected = parts.mid(1).join(',');
const QVariant actual = object->property(propertyName.toUtf8().constData());
if (!actual.isValid()) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER)
<< "expectProperty:" << object->objectName() << "has no property" << propertyName;
error = true;
return true;
}
if (actual.toString() != expected) {
qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is"
<< actual.toString() << "but the test expected" << expected;
error = true;
return true;
}
return true;
}

View file

@ -0,0 +1,91 @@
#ifndef PQ_COCKATRICE_EVENT_PLAYER_H
#define PQ_COCKATRICE_EVENT_PLAYER_H
#include "pqWidgetEventPlayer.h"
#include <QModelIndex>
class CardItem;
class QAction;
class QGraphicsItem;
class QGraphicsScene;
class QGraphicsView;
class QMenu;
/**
* Custom event player for Cockatrice game interactions.
*
* Card commands are resolution independent: the card is looked up in the
* QGraphicsScene by name and zone, then mouse events are sent at the card's
* current viewport position. That keeps recordings usable across screen sizes
* and window geometries.
*
* Commands:
* clickCard "cardName,zoneName"
* left press/release + double click — plays a card from hand, taps a
* permanent, opens the deck view entry, depending on the zone
* flipCard "cardName,zoneName"
* right-click → "Turn Over" (shorthand for cardAction)
* cardAction "cardName,zoneName,actionText"
* right-click the card and trigger the first context menu action whose
* label contains actionText; submenus are searched as well, so
* "Add counter (A)" works
* moveCard "cardName,srcZoneName,dstZoneName"
* left-drag the card onto the destination zone — plays a card, moves it
* to the graveyard/exile, puts it back into hand, ...
* drawArrow "srcCard,srcZone,dstCard,dstZone"
* right-drag an arrow from one card to another
* createToken "name,color,pt"
* create a token through the active local player, as the table context
* menu's "Create token" dialog would
* selectItem "item text"
* selects the first item of a tree/list widget whose label matches, for
* navigating the settings navigation tree or similar label-driven views
* typeText "some text"
* types the text into the widget one key event at a time — use it for
* deck lists and search boxes instead of one event per character
* expectProperty "propertyName,expectedValue"
* assertion: playback fails unless object->property(propertyName)
* equals expectedValue — for example "count,2" on the tab widget
*/
class pqCockatriceEventPlayer : public pqWidgetEventPlayer
{
Q_OBJECT
typedef pqWidgetEventPlayer Superclass;
public:
explicit pqCockatriceEventPlayer(QObject *parent = nullptr);
using Superclass::playEvent;
bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override;
private:
bool playClickCard(QObject *object, const QString &arguments);
bool playFlipCard(QObject *object, const QString &arguments);
bool playCardAction(QObject *object, const QString &arguments);
bool playMoveCard(QObject *object, const QString &arguments);
bool playDrawArrow(QObject *object, const QString &arguments);
bool playCreateToken(QObject *object, const QString &arguments);
bool playSelectItem(QObject *object, const QString &arguments);
bool playTypeText(QObject *object, const QString &arguments);
bool playExpectProperty(QObject *object, const QString &arguments, bool &error);
/** @brief Triggers the context menu entry @p actionText after right-clicking @p card. */
bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText);
static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName);
static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName);
static QAction *findActionInMenu(QMenu *menu, const QString &actionText);
static QMenu *visiblePopupMenu();
/** @brief Viewport position of the item's centre, as the mouse events have to be sent. */
static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item);
static void sendMouseEvent(QGraphicsView *view,
QEvent::Type type,
const QPoint &pos,
Qt::MouseButton button,
Qt::MouseButtons buttons);
};
#endif // PQ_COCKATRICE_EVENT_PLAYER_H

View file

@ -99,9 +99,13 @@ void ZoneViewZone::initializeCards(const QList<const ServerInfo_Card *> &cardLis
}
reorganizeCards();
} else if (!qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->contentsKnown()) {
PendingCommand *pend = qobject_cast<ZoneViewZoneLogic *>(getLogic())->requestZoneDump();
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
PendingCommand *pend = viewLogic->prepareZoneDump();
if (pend != nullptr) {
// A local game answers synchronously, so the handler has to be connected before the
// command is sent or the response would be delivered to nobody.
connect(pend, &PendingCommand::finished, this, &ZoneViewZone::zoneDumpReceived);
viewLogic->sendZoneDump(pend);
}
} else {
const CardList &c = qobject_cast<ZoneViewZoneLogic *>(getLogic())->getOriginalZone()->getCards();

View file

@ -3,6 +3,7 @@
#include "../../client/settings/cache_settings.h"
#include "../../filters/syntax_help.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_icon.h"
#include "../card_dimensions.h"
#include "../z_values.h"
#include "libcockatrice/utility/macros.h"
@ -93,8 +94,8 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
searchEdit.setFocusPolicy(Qt::ClickFocus);
searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit.setClearButtonEnabled(true);
searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });

View file

@ -238,6 +238,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
});
card.emitPixmapUpdated();
emit getInstance().pixmapLoaded(card);
}
void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card)

View file

@ -146,6 +146,14 @@ signals:
/** @brief Emitted after the worker has finished clearing the network and redirect caches. */
void networkCacheCleared();
/**
* @brief Emitted once an image has been loaded and put into the pixmap cache.
*
* Views that paint cards from the loader without owning a widget per card can
* listen to this to repaint the rows whose picture just became available.
*/
void pixmapLoaded(const ExactCard &card);
private slots:
/**
* @brief Triggered when the user changes the picture download settings.

View file

@ -11,6 +11,7 @@
#include <QSharedPointer>
#include <QTimer>
#include <QUrl>
#include <QtGlobal>
class ExactCard;
@ -38,32 +39,94 @@ void CardPictureLoaderStatusBar::cleanOldEntries()
if (!loadLog || !loadLog->popup) {
return;
}
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget :
loadLog->popup->findChildren<CardPictureLoaderRequestStatusDisplayWidget *>()) {
const QDateTime now = QDateTime::currentDateTime();
// Removing an entry mutates the log, so walk a copy of it.
const QList<CardPictureLoaderRequestStatusDisplayWidget *> entries = logEntries;
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) {
statusDisplayWidget->queryElapsedSeconds();
if (statusDisplayWidget->getFinished() &&
QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(QDateTime::currentDateTime()) > 10) {
loadLog->removeSettingsWidget(statusDisplayWidget);
progressBar->setMaximum(progressBar->maximum() - 1);
progressBar->setValue(progressBar->value() - 1);
if (!statusDisplayWidget->getFinished()) {
continue;
}
if (QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(now) > LOG_ENTRY_LIFETIME_SECS) {
removeLogEntry(statusDisplayWidget);
}
}
}
void CardPictureLoaderStatusBar::addQueuedImageLoad(const QUrl &url, const ExactCard &card, const QString &setName)
{
loadLog->addSettingsWidget(new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName));
if (logEntries.size() >= MAX_LOG_ENTRIES) {
trimLog();
}
auto *entry = new CardPictureLoaderRequestStatusDisplayWidget(loadLog, url, card, setName);
loadLog->addSettingsWidget(entry);
logEntries.append(entry);
entriesByUrl.insert(entry->getUrl(), entry);
progressBar->setMaximum(progressBar->maximum() + 1);
}
void CardPictureLoaderStatusBar::addSuccessfulImageLoad(const QUrl &url)
{
progressBar->setValue(progressBar->value() + 1);
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget :
loadLog->popup->findChildren<CardPictureLoaderRequestStatusDisplayWidget *>()) {
if (statusDisplayWidget->getUrl() == url.toString()) {
statusDisplayWidget->queryElapsedSeconds();
statusDisplayWidget->setFinished();
const QList<CardPictureLoaderRequestStatusDisplayWidget *> entries = entriesByUrl.values(url.toString());
if (entries.isEmpty()) {
// The entry may already have been dropped from the log; the download
// itself still succeeded, there is just nothing left to mark.
return;
}
int finishedEntries = 0;
for (CardPictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : entries) {
if (statusDisplayWidget->getFinished()) {
continue;
}
statusDisplayWidget->queryElapsedSeconds();
statusDisplayWidget->setFinished();
++finishedEntries;
}
// The bar counts the entries currently in the log, so only entries that are
// still listed move it.
progressBar->setValue(progressBar->value() + finishedEntries);
}
void CardPictureLoaderStatusBar::trimLog()
{
if (logEntries.isEmpty()) {
return;
}
// Prefer evicting an entry that has already finished; only fall back to an
// in-flight request when the whole log is still pending.
CardPictureLoaderRequestStatusDisplayWidget *toRemove = nullptr;
for (CardPictureLoaderRequestStatusDisplayWidget *entry : logEntries) {
if (entry->getFinished()) {
toRemove = entry;
break;
}
}
}
removeLogEntry(toRemove != nullptr ? toRemove : logEntries.constFirst());
}
void CardPictureLoaderStatusBar::removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry)
{
if (entry == nullptr) {
return;
}
logEntries.removeOne(entry);
entriesByUrl.remove(entry->getUrl(), entry);
// The progress bar's maximum counts the entries currently in the log and
// its value the finished ones, so dropping an entry moves both counters back.
// Move the value first: a maximum of 0 turns the bar into a busy indicator and
// resets its value.
if (entry->getFinished()) {
progressBar->setValue(qMax(0, progressBar->value() - 1));
}
progressBar->setMaximum(qMax(0, progressBar->maximum() - 1));
loadLog->removeSettingsWidget(entry);
}

View file

@ -1,10 +1,13 @@
#ifndef PICTURE_LOADER_STATUS_BAR_H
#define PICTURE_LOADER_STATUS_BAR_H
#include <QList>
#include <QMultiHash>
#include <QString>
#include <QWidget>
#include <qtmetamacros.h>
class CardPictureLoaderRequestStatusDisplayWidget;
class ExactCard;
class QHBoxLayout;
class QProgressBar;
@ -21,11 +24,20 @@ class SettingsButtonWidget;
* - Shows overall progress of image downloads.
* - Maintains a log of individual requests via a popup (SettingsButtonWidget).
* - Cleans up finished request entries automatically after a delay.
*
* The log is a diagnostic view, not a queue: it keeps at most MAX_LOG_ENTRIES
* entries and indexes them by URL, so a burst of requests stays O(1) per event
* instead of scanning every entry the popup holds.
*/
class CardPictureLoaderStatusBar : public QWidget
{
Q_OBJECT
public:
/** Entries kept in the log; older ones are evicted as new requests arrive. */
static constexpr int MAX_LOG_ENTRIES = 50;
/** Seconds a finished entry stays in the log before it is cleaned up. */
static constexpr int LOG_ENTRY_LIFETIME_SECS = 10;
/**
* @brief Constructs a status bar with progress bar and log button.
* @param parent Parent widget
@ -55,10 +67,28 @@ public slots:
void cleanOldEntries();
private:
/**
* @brief Evicts the oldest finished entry (or the oldest entry if none finished)
* until the log fits within MAX_LOG_ENTRIES.
*/
void trimLog();
/**
* @brief Removes one entry from the log, its URL index and the popup, and keeps
* the progress bar counters in step with the entries that remain.
* @param entry Entry to remove
*/
void removeLogEntry(CardPictureLoaderRequestStatusDisplayWidget *entry);
QHBoxLayout *layout; ///< Horizontal layout containing progress bar and log button
QProgressBar *progressBar; ///< Progress bar showing overall download progress
SettingsButtonWidget *loadLog; ///< Popup log showing individual request statuses
QTimer *cleaner; ///< Timer for periodically cleaning old log entries
QList<CardPictureLoaderRequestStatusDisplayWidget *> logEntries; ///< Logged requests, oldest first
// A URL can back several live entries: a request parked for a deferred retry is re-queued while
// its first entry is still pending, so a completion must finish every entry for that URL.
QMultiHash<QString, CardPictureLoaderRequestStatusDisplayWidget *>
entriesByUrl; ///< Log entries indexed by their URL for O(1) completion lookup
};
#endif // PICTURE_LOADER_STATUS_BAR_H

View file

@ -142,8 +142,8 @@ void CardPictureLoaderWorkerWork::handleNetworkReply(QNetworkReply *reply)
{
const QVariant redirectTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute);
const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const bool isRedirect = statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 305 ||
statusCode == 307 || statusCode == 308;
const bool isRedirect =
statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308;
bool redirectFailure = isRedirect && !redirectTarget.isValid();
if (redirectTarget.isValid()) {
const QUrl url = reply->url();
@ -261,8 +261,7 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply)
// List of status codes from https://doc.qt.io/qt-6/qnetworkreply.html#redirected
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 305 || statusCode == 307 ||
statusCode == 308) {
if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308) {
QUrl redirectUrl = reply->url().resolved(reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl());
qCDebug(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName()

View file

@ -14,15 +14,18 @@ inline QUrl resolveImageRedirects(const QUrl &url, QHash<QUrl, QPair<QUrl, QDate
{
QUrl resolved = url;
QSet<QUrl> visited;
for (int depth = 0; depth < maxHops; ++depth) {
for (int depth = 0; depth <= maxHops; ++depth) {
if (visited.contains(resolved)) {
break;
}
visited.insert(resolved);
const auto it = cache.constFind(resolved);
if (it == cache.cend() || it.value().first.isEmpty()) {
return resolved;
}
if (depth == maxHops) {
break;
}
visited.insert(resolved);
resolved = it.value().first;
}
for (const QUrl &entry : std::as_const(visited)) {

View file

@ -1,5 +1,6 @@
#include "deck_loader.h"
#include "../theme_manager.h"
#include "libcockatrice/card/card_info.h"
#include "libcockatrice/card/database/card_database_querier.h"
#include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h"
@ -38,6 +39,7 @@
#include <QTextTableFormat>
#include <QVector>
#include <QtConcurrentRun>
#include <algorithm>
#include <functional>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/card/import/card_name_normalizer.h>
@ -371,8 +373,12 @@ bool DeckLoader::saveToStream_Plain(QTextStream &out,
saveToStream_DeckHeader(out, deckList);
}
// loop zones
for (auto zoneNode : deckList.getZoneNodes()) {
// Plain deck readers expect the main deck before the sideboard. Reorder a
// copy so native saves and the relative order of other zones stay intact.
auto zones = deckList.getZoneNodes();
std::stable_partition(zones.begin(), zones.end(),
[](const auto *zone) { return zone->getName() == DECK_ZONE_MAIN; });
for (auto zoneNode : zones) {
saveToStream_DeckZone(out, zoneNode, addComments, addSetNameAndNumber);
// end of zone
@ -579,6 +585,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
if (!cards.isEmpty()) {
QTextBlockFormat blockFormat;
QTextCharFormat charFormat;
// Family comes from the DeckList theme role; sizes/weights unchanged.
charFormat.setFontFamilies({themeManager->deckListFont().family()});
charFormat.setFontPointSize(11);
charFormat.setFontWeight(QFont::Bold);
cursor->insertBlock(blockFormat, charFormat);
@ -592,6 +600,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
const AbstractDecklistCardNode *card = cards[i];
QTextCharFormat cellCharFormat;
cellCharFormat.setFontFamilies({themeManager->deckListFont().family()});
cellCharFormat.setFontPointSize(9);
QTextTableCell cell = table->cellAt(i, 0);
@ -613,6 +622,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
QTextBlockFormat blockFormat;
QTextCharFormat charFormat;
charFormat.setFontFamilies({themeManager->deckListFont().family()});
charFormat.setFontPointSize(14);
charFormat.setFontWeight(QFont::Bold);
@ -640,14 +650,18 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList)
{
QTextDocument doc;
QFont font("Serif");
font.setStyleHint(QFont::Serif);
// Family comes from the DeckList theme role; the print sizes/weights below stay unchanged.
// Print output is a one-shot snapshot and intentionally does not subscribe
// to fontsChanged (there is no live view to repaint); reprinting picks up
// the current role font.
QFont font = themeManager->deckListFont();
doc.setDefaultFont(font);
QTextCursor cursor(&doc);
QTextBlockFormat headerBlockFormat;
QTextCharFormat headerCharFormat;
headerCharFormat.setFontFamilies({themeManager->deckListFont().family()});
headerCharFormat.setFontPointSize(16);
headerCharFormat.setFontWeight(QFont::Bold);

View file

@ -27,9 +27,9 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(context->hostname, context->port);
if (index >= 0) {
context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
if (const auto server = servers.getServer(index)) {
context->username = server->username;
context->password = server->password;
emitFinished();
} else {
emitFailed(tr("No saved credentials for this server"));
@ -69,9 +69,8 @@ void IntentGetLoginCredentials::onPreconditionNotSatisfied()
// it with the raw hostname when addNewServer updates the entry in place.
QString saveName = context->hostname;
const int existingIndex = servers.findServerIndex(context->hostname, context->port);
if (existingIndex >= 0) {
saveName =
servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString();
if (const auto existing = servers.getServer(existingIndex)) {
saveName = existing->saveName;
if (saveName.isEmpty()) {
saveName = context->hostname;
}

View file

@ -418,12 +418,10 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.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(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
const auto server = servers.getServer(index);
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
server->password);
return;
}
client->disconnectFromServer();
@ -438,11 +436,9 @@ void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
// when credentials are saved, otherwise stay offline.
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.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(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
const auto server = servers.getServer(index);
if (server && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), server->username,
server->password);
}
}

View file

@ -1,5 +1,7 @@
#include "color_button.h"
#include "../theme_manager.h"
#include <QBrush>
#include <QColorDialog>
#include <QFont>
@ -21,6 +23,7 @@ ColorButton::ColorButton(QWidget *parent) : QToolButton(parent)
setCursor(Qt::PointingHandCursor);
setToolTip(tr("Click to pick a color"));
connect(this, &QToolButton::clicked, this, &ColorButton::pickColor);
connect(themeManager, &ThemeManager::fontsChanged, this, &ColorButton::updateSwatch);
}
void ColorButton::setColor(const QColor &c)
@ -73,7 +76,8 @@ void ColorButton::updateSwatch()
// Hex label — black or white for contrast
int luma = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue();
painter.setPen((luma > 128 && color.alpha() > 80) ? QColor(0, 0, 0, 180) : QColor(255, 255, 255, 200));
QFont f = font();
// Family comes from the InterfaceDefault theme role; the 8px size stays caller-side.
QFont f = themeManager->appFont(AppFont::InterfaceDefault);
f.setPixelSize(8);
painter.setFont(f);
painter.drawText(rect(), Qt::AlignCenter, color.name().toUpper());

View file

@ -0,0 +1,136 @@
#include "font_grid_widget.h"
#include "../theme_manager.h"
#include <QApplication>
#include <QFont>
#include <QFontComboBox>
#include <QGridLayout>
#include <QLabel>
#include <QMetaEnum>
#include <QSpinBox>
#include <QtGlobal>
static constexpr int MIN_FONT_SIZE = 6;
static constexpr int MAX_FONT_SIZE = 72;
static constexpr int FALLBACK_FONT_SIZE = 10;
static const QMap<AppFont::Role, const char *> APP_FONT_DESCRIPTIONS = {
{AppFont::InterfaceDefault, QT_TR_NOOP("Default font for menus, dialogs and widgets")},
{AppFont::ChatLog, QT_TR_NOOP("Font for chat messages and logs")},
{AppFont::CardText, QT_TR_NOOP("Font for text rendered on cards")},
{AppFont::DeckList, QT_TR_NOOP("Font for deck lists and the deck editor")},
};
static QString roleDisplayName(AppFont::Role role)
{
switch (role) {
case AppFont::InterfaceDefault:
return FontGridWidget::tr("Interface default");
case AppFont::ChatLog:
return FontGridWidget::tr("Chat log");
case AppFont::CardText:
return FontGridWidget::tr("Card text");
case AppFont::DeckList:
return FontGridWidget::tr("Deck list");
default:
return QString();
}
}
FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent)
{
auto *grid = new QGridLayout(this);
grid->setSpacing(3);
grid->setContentsMargins(12, 8, 12, 8);
grid->setColumnStretch(0, 0);
grid->setColumnStretch(1, 1);
grid->setColumnStretch(2, 0);
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
auto *label = new QLabel(this);
grid->addWidget(label, i, 0);
auto *familyCombo = new QFontComboBox(this);
grid->addWidget(familyCombo, i, 1);
connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); });
auto *sizeSpin = new QSpinBox(this);
sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE);
grid->addWidget(sizeSpin, i, 2);
connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); });
label->setBuddy(familyCombo);
fontRows[role] = {label, familyCombo, sizeSpin};
}
retranslateUi();
}
void FontGridWidget::loadFonts(const FontConfig &cfg)
{
// Suppress fontsChanged while materializing fallback rows so the initial
// load never reads as a user edit (mirrors paletteGrid blocking).
blockSignals(true);
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
const FontRow &row = fontRows[role];
// Roles absent from the file resolve the same way the runtime does: an unset
// role falls back to the config's InterfaceDefault, and only then to the
// startup font. appFont() would return the live preview instead, so Reset and
// a first load must not measure against it.
const QFont fallback = cfg.fonts.value(AppFont::InterfaceDefault, themeManager->defaultAppFont(role));
const QFont font = cfg.fonts.value(role, fallback);
row.familyCombo->setCurrentFont(font);
int size = font.pointSize();
if (size <= 0) {
size = qApp->font().pointSize();
}
if (size <= 0) {
size = FALLBACK_FONT_SIZE;
}
row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE));
}
blockSignals(false);
}
FontConfig FontGridWidget::currentFontConfig() const
{
FontConfig cfg;
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
const FontRow &row = fontRows.value(role);
// Canonical family + point size only: a fresh QFont carries default
// (non-bold, non-italic) weight/style, so nothing combo-side leaks in.
QFont font(row.familyCombo->currentFont().family());
font.setPointSize(row.sizeSpin->value());
cfg.fonts[role] = font;
}
return cfg;
}
void FontGridWidget::retranslateUi()
{
QMetaEnum fontEnum = QMetaEnum::fromType<AppFont::Role>();
for (int i = 0; i < fontEnum.keyCount(); ++i) {
auto role = static_cast<AppFont::Role>(fontEnum.value(i));
const FontRow &row = fontRows.value(role);
row.label->setText(roleDisplayName(role));
row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, "")));
row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, "")));
row.familyCombo->setAccessibleName(roleDisplayName(role));
row.sizeSpin->setToolTip(tr("Point size for %1").arg(roleDisplayName(role)));
row.sizeSpin->setAccessibleName(tr("Point size for %1").arg(roleDisplayName(role)));
row.sizeSpin->setSuffix(tr(" pt"));
}
}

View file

@ -0,0 +1,43 @@
#ifndef COCKATRICE_FONT_GRID_WIDGET_H
#define COCKATRICE_FONT_GRID_WIDGET_H
#include "../theme_config.h"
#include <QMap>
#include <QWidget>
#include <qtmetamacros.h>
class QFontComboBox;
class QGridLayout;
class QLabel;
class QSpinBox;
// Per-role font editors — one (family, point size) row per AppFont::Role.
// Owns the load/read round-trip for FontConfig but has no file I/O, mirroring
// PaletteGridWidget. Only family and point size are edited; weight and italic
// stay caller-side concerns and are stripped when reading back.
class FontGridWidget : public QWidget
{
Q_OBJECT
public:
explicit FontGridWidget(QWidget *parent = nullptr);
void loadFonts(const FontConfig &cfg);
FontConfig currentFontConfig() const;
void retranslateUi();
signals:
void fontsChanged();
private:
struct FontRow
{
QLabel *label = nullptr;
QFontComboBox *familyCombo = nullptr;
QSpinBox *sizeSpin = nullptr;
};
QMap<AppFont::Role, FontRow> fontRows;
};
#endif // COCKATRICE_FONT_GRID_WIDGET_H

View file

@ -2,6 +2,7 @@
#include "../theme_config.h"
#include "../theme_manager.h"
#include "font_grid_widget.h"
#include "palette_generator.h"
#include "palette_grid_widget.h"
#include "quick_setup_panel.h"
@ -41,6 +42,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
// Load both scheme configs upfront so switching is instant
loadSchemes();
loadFonts();
loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light";
@ -125,6 +127,33 @@ void PaletteEditorDialog::setupUi()
paletteGrid->setVisible(false);
root->addWidget(paletteGrid, 1);
// Font section — sibling to the palette grid, sharing its collapsible
// advanced area pattern. Fonts are shared per theme, not split by scheme.
fontToggleButton = new QPushButton(this);
fontToggleButton->setCheckable(true);
fontToggleButton->setChecked(false);
fontToggleButton->setFlat(true);
fontToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }"
"QPushButton:checked { }");
root->addWidget(fontToggleButton);
fontSeparator = makeSeparator();
fontSeparator->setVisible(false);
root->addWidget(fontSeparator);
fontSection = new QWidget;
auto *fontSectionLayout = new QVBoxLayout(fontSection);
fontSectionLayout->setContentsMargins(0, 0, 0, 0);
fontSectionLayout->setSpacing(0);
fontNoteLabel = new QLabel(fontSection);
fontNoteLabel->setContentsMargins(12, 4, 12, 0);
fontNoteLabel->setWordWrap(true);
fontSectionLayout->addWidget(fontNoteLabel);
fontGrid = new FontGridWidget(fontSection);
fontSectionLayout->addWidget(fontGrid);
fontSection->setVisible(false);
root->addWidget(fontSection);
// Footer
root->addWidget(makeSeparator());
footer = new QWidget;
@ -157,6 +186,10 @@ void PaletteEditorDialog::setupUi()
connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply);
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent);
connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); });
connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] {
fontsDirty = true;
autoApplyTimer->start();
});
connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault);
connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset);
connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave);
@ -166,15 +199,32 @@ void PaletteEditorDialog::setupUi()
paletteGridToggleButton->setText(open ? tr("▼ Edit Palette") : tr("▶ Edit Palette"));
paletteGridSeparator->setVisible(open);
paletteGrid->setVisible(open);
if (open) {
setMinimumHeight(680);
resize(width(), 680);
} else {
setMinimumHeight(220);
adjustSize(); // shrinks to fit just the visible content
}
updateAdvancedHeight();
});
connect(fontToggleButton, &QPushButton::toggled, this, [this](bool open) {
fontToggleButton->setText(open ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts"));
fontSeparator->setVisible(open);
fontSection->setVisible(open);
updateAdvancedHeight();
});
}
void PaletteEditorDialog::updateAdvancedHeight()
{
int height = 220;
if (paletteGridToggleButton->isChecked()) {
height += 460;
}
if (fontToggleButton->isChecked()) {
height += 200;
}
setMinimumHeight(height);
if (paletteGrid->isVisible() || fontSection->isVisible()) {
resize(width(), height);
} else {
adjustSize(); // shrinks to fit just the visible content
}
}
void PaletteEditorDialog::retranslateUi()
@ -189,12 +239,16 @@ void PaletteEditorDialog::retranslateUi()
editingLabel->setText(tr("Editing:"));
paletteGridToggleButton->setText(tr("▶ Edit Palette"));
paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks"));
fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts"));
fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme"));
fontNoteLabel->setText(tr("Fonts belong to theme %1 (shared by Light and Dark schemes)").arg(themeName));
fontGrid->retranslateUi();
revertButton->setText(tr("↺ Revert to theme default"));
resetBtn->setText(tr("Reset"));
saveBtn->setText(tr("Save"));
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette and fonts"));
saveBtn->setToolTip(tr("Write palette-%1.toml and fonts.toml, then reload the theme").arg(loadedScheme.toLower()));
if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) {
saveBtn->setEnabled(false);
@ -228,6 +282,13 @@ void PaletteEditorDialog::loadSchemes()
}
}
void PaletteEditorDialog::loadFonts()
{
savedFontConfig = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName);
fontGrid->loadFonts(savedFontConfig);
fontsDirty = false;
}
void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme)
{
QColor seed = workingConfig.value(scheme).colors.value(QPalette::Active).value(QPalette::Highlight);
@ -258,6 +319,9 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi
void PaletteEditorDialog::onApply()
{
themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme);
if (fontsDirty) {
themeManager->previewFonts(fontGrid->currentFontConfig());
}
}
void PaletteEditorDialog::onSave()
@ -308,6 +372,20 @@ void PaletteEditorDialog::onSave()
savedConfig[it.key()] = it.value();
}
// Persist the shared per-theme fonts when they changed.
if (fontsDirty) {
const FontConfig fontCfg = fontGrid->currentFontConfig();
if (fontCfg != savedFontConfig) {
if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir));
return;
}
savedFontConfig = fontCfg;
}
fontsDirty = false;
}
themeManager->reloadCurrentTheme();
accept();
}
@ -316,6 +394,12 @@ void PaletteEditorDialog::onReset()
{
workingConfig[loadedScheme] = savedConfig[loadedScheme];
paletteGrid->loadPalette(savedConfig[loadedScheme]);
// Restore the live theme before re-reading the grid: loadFonts resolves
// unset roles, and the preview has currentFonts pointing at the edited
// config until the theme is reloaded.
themeManager->reloadCurrentTheme();
fontGrid->loadFonts(savedFontConfig);
fontsDirty = false;
seedAccentFromScheme(loadedScheme);
}
@ -350,6 +434,27 @@ void PaletteEditorDialog::onRevertToDefault()
workingConfig[loadedScheme] = def;
paletteGrid->loadPalette(def);
seedAccentFromScheme(loadedScheme);
// Revert the shared per-theme fonts the same way: delete the user-written
// fonts.toml so the theme falls back to its shipped default, then reflect
// the resolved fonts in the editor.
QFile::remove(QDir(saveDir).absoluteFilePath(FontConfig::fileName()));
// Reload the live theme so the font revert takes effect immediately.
themeManager->reloadCurrentTheme();
const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName);
savedFontConfig = fontDef;
fontGrid->loadFonts(fontDef);
fontsDirty = false;
}
void PaletteEditorDialog::reject()
{
// Discard any unsaved live preview (palette and fonts) so closing without
// Save restores the active theme instead of leaking the preview globally.
themeManager->reloadCurrentTheme();
QDialog::reject();
}
void PaletteEditorDialog::changeEvent(QEvent *e)

View file

@ -15,6 +15,7 @@ class QComboBox;
class QDialogButtonBox;
class QPushButton;
class PaletteGridWidget;
class FontGridWidget;
class QuickSetupPanel;
class QFrame;
class QWidget;
@ -25,6 +26,7 @@ class PaletteEditorDialog : public QDialog
public:
explicit PaletteEditorDialog(const QString &themeDirPath, const QString &themeName, QWidget *parent = nullptr);
void loadSchemes();
void reject() override;
private slots:
void onSave();
@ -38,8 +40,10 @@ private:
void setupUi();
void retranslateUi();
void refreshChromePalettes();
void updateAdvancedHeight();
void loadScheme(const QString &scheme); // snapshot current, switch, load
void seedAccentFromScheme(const QString &scheme);
void loadFonts(); // shared per theme, not split by light/dark
// Sub-widgets
QWidget *header;
@ -49,6 +53,11 @@ private:
PaletteGridWidget *paletteGrid = nullptr;
QPushButton *paletteGridToggleButton = nullptr;
QFrame *paletteGridSeparator = nullptr;
FontGridWidget *fontGrid = nullptr;
QPushButton *fontToggleButton = nullptr;
QFrame *fontSeparator = nullptr;
QWidget *fontSection = nullptr;
QLabel *fontNoteLabel = nullptr;
QWidget *footer;
QComboBox *schemeComboBox = nullptr;
QDialogButtonBox *buttonBox = nullptr;
@ -68,10 +77,18 @@ private:
QMap<QString, PaletteConfig> workingConfig;
QMap<QString, PaletteConfig> savedConfig;
// Fonts are shared per theme (fonts.toml, not split by light/dark), so a
// single saved config suffices; the live edits are read from fontGrid.
FontConfig savedFontConfig;
// Set only by user-driven fontsChanged after the initial load completes.
// Gates both previewFonts and the fonts.toml write so a palette-only Save
// never materializes fallback fonts to disk.
bool fontsDirty = false;
QTimer *autoApplyTimer = nullptr;
protected:
void changeEvent(QEvent *e) override;
};
#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H
#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H

View file

@ -76,6 +76,18 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
layout = QtUtils::makeLayout<QVBoxLayout>(this);
layout->addWidget(scroll);
connect(themeManager, &ThemeManager::fontsChanged, this, &PaletteGridWidget::applyInterfaceDefaultFont);
}
void PaletteGridWidget::applyInterfaceDefaultFont()
{
// The editor's own chrome follows the InterfaceDefault theme role; bold stays caller-side.
for (auto *label : headerLabels) {
QFont headerFont = themeManager->appFont(AppFont::InterfaceDefault);
headerFont.setBold(true);
label->setFont(headerFont);
}
}
void PaletteGridWidget::buildGrid(QWidget *host)
@ -101,7 +113,8 @@ void PaletteGridWidget::buildGrid(QWidget *host)
auto *label = new QLabel(groupHeaders[col], host);
label->setAlignment(Qt::AlignCenter);
label->setToolTip(groupTips[col]);
label->setFont(QtUtils::boldFont(label->font()));
// Family comes from the InterfaceDefault theme role; bold stays caller-side.
label->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)));
label->setAutoFillBackground(true);
label->setContentsMargins(4, 4, 4, 4);
grid->addWidget(label, 0, col + 1);
@ -146,7 +159,7 @@ void PaletteGridWidget::buildGrid(QWidget *host)
auto *appHeader = new QLabel(tr("App colors"), host);
appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette"));
appHeader->setFont(QtUtils::boldFont(appHeader->font()));
appHeader->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)));
appHeader->setAutoFillBackground(true);
appHeader->setContentsMargins(4, 4, 4, 4);
grid->addWidget(appHeader, appHeaderRow, 0, 1, 4);

View file

@ -34,6 +34,10 @@ private:
void changeEvent(QEvent *e);
void refreshChromePalettes();
private slots:
void applyInterfaceDefaultFont();
private:
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons;
QMap<AppColor::Role, ColorButton *> appColorButtons;
QScrollArea *scroll;

View file

@ -1,5 +1,6 @@
#include "quick_setup_panel.h"
#include "../theme_manager.h"
#include "color_button.h"
#include <QApplication>
@ -28,10 +29,15 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent)
labelLow = new QLabel(this);
labelHigh = new QLabel(this);
QFont small = labelLow->font();
small.setPointSizeF(small.pointSizeF() * 0.82);
labelLow->setFont(small);
labelHigh->setFont(small);
// Family comes from the InterfaceDefault theme role; the *0.82 size stays caller-side.
auto applySmallFont = [this]() {
QFont small = themeManager->appFont(AppFont::InterfaceDefault);
small.setPointSizeF(small.pointSizeF() * 0.82);
labelLow->setFont(small);
labelHigh->setFont(small);
};
applySmallFont();
connect(themeManager, &ThemeManager::fontsChanged, this, applySmallFont);
QPalette dimmed = labelLow->palette();
dimmed.setColor(QPalette::WindowText, qApp->palette().color(QPalette::Mid));
labelLow->setPalette(dimmed);

View file

@ -1,6 +1,7 @@
#include "pixel_map_generator.h"
#include "libcockatrice/network/server/remote/user_level.h"
#include "theme_icon.h"
#include "theme_manager.h"
#include <QApplication>
@ -15,7 +16,6 @@
#include <QDomNodeList>
#include <QFile>
#include <QIODevice>
#include <QImage>
#include <QImageReader>
#include <QLatin1Char>
#include <QLatin1String>
@ -436,33 +436,19 @@ QIcon UserLevelPixmapGenerator::generateIconWithColorOverride(int height,
QMap<QString, QIcon> UserLevelPixmapGenerator::iconCache;
QPixmap LockPixmapGenerator::generatePixmap(int height)
{
int key = height;
if (pmCache.contains(key)) {
return pmCache.value(key);
}
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")),
QSize(height, height), true);
pmCache.insert(key, pixmap);
return pixmap;
}
QMap<int, QPixmap> LockPixmapGenerator::pmCache;
QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded)
{
QString key = QString::number(expanded) + ":" + QString::number(height);
const QString key = QString::number(expanded) + ":" + QString::number(height);
if (pmCache.contains(key)) {
return pmCache.value(key);
}
QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed";
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name),
QSize(height, height), true);
// Render through the themed icon engine: it keeps the glyph's aspect ratio,
// follows the palette, and renders at the device pixel ratio instead of
// handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x
// display.
const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed");
const QPixmap pixmap = loadThemedIcon(prefix).pixmap(QSize(height, height), qApp->devicePixelRatio());
pmCache.insert(key, pixmap);
return pixmap;
}
@ -520,30 +506,11 @@ QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const Q
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::masterCache;
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name)
{
// Prefer an authored scheme-qualified variant when one exists for this asset.
const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size()));
if (!variant.isEmpty()) {
return QPixmap(QStringLiteral("theme:") + variant);
}
// Legacy fallback: runtime-invert for dark mode when no authored variant.
if (qApp->palette().windowText().color().lightness() > 200) {
QImage img(name);
img.invertPixels();
QPixmap result;
result.convertFromImage(img);
return result;
} else {
return QPixmap(name);
}
}
QPixmap themePixmap(QStringView prefix)
{
const QString resolved = themeManager->assetPath(prefix);
return QPixmap(QStringLiteral("theme:") + resolved);
const QPixmap sentinel = loadSentinelSvgPixmap(resolved);
return sentinel.isNull() ? QPixmap(QStringLiteral("theme:") + resolved) : sentinel;
}
void clearPixmapGeneratorCaches()
@ -553,7 +520,6 @@ void clearPixmapGeneratorCaches()
PingPixmapGenerator::clear();
CountryPixmapGenerator::clear();
UserLevelPixmapGenerator::clear();
LockPixmapGenerator::clear();
DropdownIconPixmapGenerator::clear();
ManaSymbolPixmapGenerator::clear();
}

View file

@ -106,19 +106,6 @@ public:
}
};
class LockPixmapGenerator
{
private:
static QMap<int, QPixmap> pmCache;
public:
static QPixmap generatePixmap(int height);
static void clear()
{
pmCache.clear();
}
};
class DropdownIconPixmapGenerator
{
private:
@ -160,13 +147,11 @@ public:
}
};
QPixmap loadColorAdjustedPixmap(const QString &name);
// Loads a "theme:" asset (with no file extension in prefix), preferring the
// Returns a "theme:" asset (with no file extension in prefix), preferring the
// scheme-qualified variant (prefix-dark / prefix-light, resolved via
// ThemeManager::assetPath) and falling back to the plain asset. Callers load
// the returned path directly. Use for scheme-sensitive pixmaps like
// backgrounds, the card back, and the app logo.
// ThemeManager::assetPath) and falling back to the plain asset. Token-driven
// SVGs resolve their `currentColor` sentinel from the palette. Use for
// scheme-sensitive pixmaps like backgrounds, the card back, and the app logo.
QPixmap themePixmap(QStringView prefix);
// Clears every PixmapGenerator's static cache so scheme variants are

View file

@ -0,0 +1,102 @@
#include "recording.h"
#include "pqEventTypes.h"
#include <QFile>
#include <QTextStream>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent)
{
}
XMLEventObserver::~XMLEventObserver() = default;
void XMLEventObserver::setStream(QTextStream *stream)
{
if (xmlStream) {
xmlStream->writeEndElement();
xmlStream->writeEndDocument();
xmlStream.reset();
}
if (Stream) {
*Stream << xmlString;
}
xmlString.clear();
pqEventObserver::setStream(stream);
if (Stream) {
xmlStream.reset(new QXmlStreamWriter(&xmlString));
xmlStream->setAutoFormatting(true);
xmlStream->writeStartDocument();
xmlStream->writeStartElement(QStringLiteral("events"));
}
}
void XMLEventObserver::onRecordEvent(const QString &widget,
const QString &command,
const QString &arguments,
const int &eventType)
{
if (!xmlStream) {
return;
}
xmlStream->writeStartElement(QStringLiteral("event"));
xmlStream->writeAttribute(QStringLiteral("widget"), widget);
if (eventType == pqEventTypes::ACTION_EVENT) {
xmlStream->writeAttribute(QStringLiteral("command"), command);
} else { // pqEventTypes::CHECK_EVENT
xmlStream->writeAttribute(QStringLiteral("property"), command);
}
xmlStream->writeAttribute(QStringLiteral("arguments"), arguments);
xmlStream->writeEndElement();
}
XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent)
{
}
XMLEventSource::~XMLEventSource() = default;
void XMLEventSource::setContent(const QString &xmlFileName)
{
xmlStream.reset();
QFile xml(xmlFileName);
if (!xml.open(QIODevice::ReadOnly)) {
qCWarning(RecordingLog) << "Could not read test file" << xmlFileName;
return;
}
xmlStream.reset(new QXmlStreamReader(xml.readAll()));
}
int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType)
{
if (!xmlStream) {
return DONE;
}
while (!xmlStream->atEnd()) {
if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) {
continue;
}
const QXmlStreamAttributes attributes = xmlStream->attributes();
eventType = pqEventTypes::ACTION_EVENT;
widget = attributes.value(QStringLiteral("widget")).toString();
command = attributes.value(QStringLiteral("command")).toString();
if (command.isEmpty()) {
// A recorded check event carries a property instead of a command.
command = attributes.value(QStringLiteral("property")).toString();
}
arguments = attributes.value(QStringLiteral("arguments")).toString();
return SUCCESS;
}
if (xmlStream->hasError()) {
qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString();
}
return DONE;
}

View file

@ -0,0 +1,54 @@
#ifndef COCKATRICE_RECORDING_H
#define COCKATRICE_RECORDING_H
#include "pqEventObserver.h"
#include "pqEventSource.h"
#include <QLoggingCategory>
#include <QScopedPointer>
inline Q_LOGGING_CATEGORY(RecordingLog, "recording");
class QXmlStreamReader;
class QXmlStreamWriter;
/**
* @brief Writes a session recording as an XML test file.
*
* The events are buffered in memory and flushed to the stream when recording
* ends, because the stream is handed over once the file name is known.
*/
class XMLEventObserver : public pqEventObserver
{
QScopedPointer<QXmlStreamWriter> xmlStream;
QString xmlString;
public:
explicit XMLEventObserver(QObject *parent);
~XMLEventObserver() override;
protected:
void setStream(QTextStream *stream) override;
void onRecordEvent(const QString &widget,
const QString &command,
const QString &arguments,
const int &eventType) override;
};
/**
* @brief Plays the events of an XML test file back, one at a time.
*/
class XMLEventSource : public pqEventSource
{
QScopedPointer<QXmlStreamReader> xmlStream;
public:
explicit XMLEventSource(QObject *parent);
~XMLEventSource() override;
protected:
void setContent(const QString &xmlFileName) override;
int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override;
};
#endif // COCKATRICE_RECORDING_H

View file

@ -304,4 +304,150 @@ QPalette PaletteConfig::apply(QPalette base) const
}
return base;
}
}
bool FontConfig::hasFonts() const
{
return !fonts.isEmpty();
}
QString FontConfig::toToml() const
{
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
QString entries;
for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) {
const char *roleName = roleEnum.valueToKey(it.key());
if (!roleName) {
continue;
}
QString family = it.value().family().trimmed();
int pointSize = it.value().pointSize();
if (family.isEmpty() || pointSize <= 0) {
continue;
}
entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize);
}
if (entries.isEmpty()) {
return {};
}
return "[Fonts]\n" + entries + "\n";
}
QString FontConfig::fileName()
{
return "fonts.toml";
}
FontConfig FontConfig::fromFile(const QString &filePath)
{
FontConfig cfg;
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) {
return cfg;
}
QMetaEnum roleEnum = QMetaEnum::fromType<AppFont::Role>();
QString currentSection;
QTextStream in(&f);
while (!in.atEnd()) {
QString line = in.readLine().trimmed();
if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) {
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
currentSection = line.mid(1, line.length() - 2).trimmed();
continue;
}
if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) {
continue;
}
int eq = line.indexOf('=');
if (eq < 0) {
continue;
}
QString key = line.left(eq).trimmed();
QString value = line.mid(eq + 1).trimmed();
// Strip inline comments if preceded by whitespace
for (int i = 1; i < value.size(); ++i) {
if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) {
value = value.left(i).trimmed();
break;
}
}
// Strip surrounding quotes, if any
if (value.size() >= 2 &&
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) {
value = value.mid(1, value.size() - 2).trimmed();
}
int comma = value.lastIndexOf(',');
if (comma < 0) {
continue;
}
QString family = value.left(comma).trimmed();
// Tolerate a partially-quoted family, e.g. "Inter", 10
if (family.size() >= 2 &&
((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) {
family = family.mid(1, family.size() - 2).trimmed();
}
bool ok = false;
int pointSize = value.mid(comma + 1).trimmed().toInt(&ok);
if (!ok || family.isEmpty() || pointSize <= 0) {
continue;
}
if (key.startsWith("AppFont::")) {
key = key.mid(QStringLiteral("AppFont::").size());
}
int roleInt = roleEnum.keyToValue(key.toUtf8().constData());
if (roleInt < 0) {
continue;
}
// Canonical family + point size only: a fresh QFont carries default
// (non-bold, non-italic) weight/style, so nothing caller-side leaks in.
// No validation against installed fonts; Qt substitution applies.
QFont font(family);
font.setPointSize(pointSize);
cfg.fonts[static_cast<AppFont::Role>(roleInt)] = font;
}
return cfg;
}
FontConfig FontConfig::fromThemeDir(const QString &themeDirPath)
{
if (themeDirPath.isEmpty()) {
return {};
}
return fromFile(QDir(themeDirPath).absoluteFilePath(fileName()));
}

View file

@ -2,6 +2,7 @@
#define COCKATRICE_THEME_CONFIG_H
#include <QColor>
#include <QFont>
#include <QMap>
#include <QPalette>
#include <QSharedPointer>
@ -23,6 +24,23 @@ enum Role
Q_ENUM_NS(Role)
} // namespace AppColor
// Application-specific font roles, one configurable family + point size per
// purpose. Stored in a shared fonts.toml under a [Fonts] section (one file per
// theme, not split by light/dark). Only family and point size are stored;
// weight/italic are stripped on load and stay caller-side concerns.
namespace AppFont
{
Q_NAMESPACE
enum Role
{
InterfaceDefault,
ChatLog,
CardText,
DeckList,
};
Q_ENUM_NS(Role)
} // namespace AppFont
struct ThemeConfig
{
QString colorScheme;
@ -60,4 +78,33 @@ struct PaletteConfig
QPalette apply(QPalette base) const;
};
#endif // COCKATRICE_THEME_CONFIG_H
/** @brief Per-purpose font configuration, stored as family + point size only.
*
* Serialized to a shared fonts.toml file with a [Fonts] section, e.g.
* @code
* [Fonts]
* InterfaceDefault = "Inter, 10"
* @endcode
*/
struct FontConfig
{
QMap<AppFont::Role, QFont> fonts;
bool operator==(const FontConfig &rhs) const
{
return fonts == rhs.fonts;
}
bool operator!=(const FontConfig &rhs) const
{
return !(*this == rhs);
}
bool hasFonts() const;
QString toToml() const;
static QString fileName();
static FontConfig fromFile(const QString &filePath);
static FontConfig fromThemeDir(const QString &themeDirPath);
};
#endif // COCKATRICE_THEME_CONFIG_H

View file

@ -0,0 +1,300 @@
#include "theme_icon.h"
#include "libcockatrice/utility/svg_recolor.h"
#include "theme_manager.h"
#include <QApplication>
#include <QByteArray>
#include <QColor>
#include <QFile>
#include <QHash>
#include <QIODevice>
#include <QIconEngine>
#include <QImageReader>
#include <QObject>
#include <QPaintDevice>
#include <QPainter>
#include <QPointF>
#include <QRect>
#include <QRectF>
#include <QSize>
#include <QString>
#include <QStringLiteral>
#include <QStyle>
#include <QSvgRenderer>
#include <QtGlobal>
#include <utility>
namespace
{
/**
* @brief Palette color that replaces `currentColor`, following the icon mode.
*/
QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role)
{
const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active;
const QPalette::ColorRole effective =
mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role;
return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1();
}
/**
* @brief Reads an SVG through the `theme:` search path.
*/
QByteArray readSvg(const QString &resolvedPath)
{
QFile file(QStringLiteral("theme:") + resolvedPath);
if (!file.open(QIODevice::ReadOnly)) {
return {};
}
return file.readAll();
}
/**
* @brief Renders SVG bytes, scaled to @p size when one is given.
*
* An empty size means the asset's intrinsic size; @p devicePixelRatio only
* applies in that case so HiDPI callers still get a crisp glyph.
*/
QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio)
{
QSvgRenderer renderer(data);
if (!renderer.isValid()) {
return {};
}
QSize renderSize = size;
if (renderSize.isEmpty()) {
renderSize = renderer.defaultSize();
renderSize =
QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio));
}
if (!renderSize.isValid() || renderSize.isEmpty()) {
return {};
}
QPixmap pixmap(renderSize);
pixmap.fill(Qt::transparent);
QPainter painter(&pixmap);
renderer.render(&painter);
return pixmap;
}
bool hasSentinel(const QByteArray &svg)
{
return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive);
}
QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio)
{
return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) +
QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio);
}
/**
* @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand.
*
* Qt's SVG renderer has no ambient color, so the sentinel is substituted from
* the current palette on every pixmap request. Asset resolution, the file bytes
* and the intrinsic size are cached until the theme changes: a repaint then
* does no file I/O at all. The render cache is keyed by resolved path and pixel
* size plus a variant: the palette color for sentinel SVGs, the icon mode for
* assets that carry their own colors. A palette change therefore produces new
* keys instead of reusing art built under the previous palette, and the
* non-sentinel path (raster overrides, plain SVGs) is cached like any other.
*/
class ThemeSvgIconEngine final : public QObject, public QIconEngine
{
public:
ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role)
{
if (themeManager) {
// A theme switch changes the resolved asset (scheme-qualified
// variants, raster overrides) and a palette change invalidates
// every cached colour, so both drop the caches instead of
// re-reading theme.cfg and the assets on every repaint.
connect(themeManager, &ThemeManager::themeChanged, this, &ThemeSvgIconEngine::invalidate);
connect(themeManager, &ThemeManager::paletteChanged, this, &ThemeSvgIconEngine::invalidate);
}
}
QIconEngine *clone() const override
{
return new ThemeSvgIconEngine(prefix, role);
}
void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override
{
const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0;
const QSize logicalSize = fitToSize(rect.size());
if (logicalSize.isEmpty()) {
return;
}
const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio),
qRound(logicalSize.height() * devicePixelRatio));
const QPixmap pixmap = render(pixelSize, devicePixelRatio, mode);
if (!pixmap.isNull()) {
// QRect::center() would floor and drop the last row/column of a
// 16x16 glyph; the center has to be fractional so glyphs stay
// unshifted and unsclipped in item views.
const QSizeF contentSize = pixmap.deviceIndependentSize();
const QRectF targetRect(
QRectF(rect).center() - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize);
painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect()));
}
}
QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override
{
return render(fitToSize(size), 1.0, mode);
}
QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override
{
const QSize logicalSize = fitToSize(size);
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale));
#else
// Qt < 6.8 passes the device-dependent size to the engine.
const QSize pixelSize = logicalSize;
#endif
return render(pixelSize, scale, mode);
}
QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override
{
return fitToSize(size);
}
private:
void invalidate()
{
hasResolvedAsset = false;
hasIntrinsicSize = false;
cache.clear();
}
void ensureResolved() const
{
if (hasResolvedAsset && (!themeManager || themeManager->getCurrentThemePath() == resolvedThemePath)) {
return;
}
resolvedThemePath = themeManager ? themeManager->getCurrentThemePath() : QString();
resolvedPath = themeManager ? themeManager->assetPath(prefix) : QString();
resolvedAssetIsSvg = resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive);
resolvedSvg = resolvedAssetIsSvg ? readSvg(resolvedPath) : QByteArray();
resolvedHasSentinel = hasSentinel(resolvedSvg);
hasResolvedAsset = true;
hasIntrinsicSize = false;
}
QSize intrinsicSize() const
{
ensureResolved();
if (!hasIntrinsicSize) {
if (resolvedAssetIsSvg) {
QSvgRenderer renderer(resolvedSvg);
intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize();
} else {
// A theme may override a glyph with a raster; take its real
// aspect ratio instead of assuming the 24x24 SVG default, so
// actualSize() and the rendered pixmap agree.
QImageReader reader(QStringLiteral("theme:") + resolvedPath);
intrinsicSizeCache = reader.size();
}
if (intrinsicSizeCache.isEmpty()) {
intrinsicSizeCache = QSize(24, 24);
}
hasIntrinsicSize = true;
}
return intrinsicSizeCache;
}
QSize fitToSize(const QSize &size) const
{
if (size.isEmpty()) {
return size;
}
return intrinsicSize().scaled(size, Qt::KeepAspectRatio);
}
QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const
{
QPixmap pixmap(QStringLiteral("theme:") + resolvedPath);
if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) {
pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
if (mode == QIcon::Disabled && !pixmap.isNull()) {
pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr);
}
return pixmap;
}
QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const
{
ensureResolved();
// Same cache for both paths: sentinel art varies by palette color, art
// with its own colors varies by mode (Disabled is generated). A color
// variant is never a bare digit, so the two key spaces cannot collide.
const QByteArray variant =
resolvedHasSentinel ? sentinelColor(mode, role) : QByteArray::number(static_cast<int>(mode));
const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio);
const auto cached = cache.constFind(key);
if (cached != cache.constEnd()) {
return cached.value();
}
QPixmap pixmap;
if (resolvedHasSentinel) {
pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio);
} else {
// Authored art carries its own colors: use Qt's normal loader so
// raster overrides and non-SVG assets keep working.
pixmap = fromAsset(pixelSize, mode);
}
if (!pixmap.isNull()) {
// Set the ratio before caching: assigning it to a cached pixmap
// afterwards detaches (deep-copies) it on every paint.
pixmap.setDevicePixelRatio(devicePixelRatio);
}
if (cache.size() >= 64) {
cache.clear();
}
cache.insert(key, pixmap);
return pixmap;
}
QString prefix;
QPalette::ColorRole role;
mutable QHash<QString, QPixmap> cache;
mutable QString resolvedPath;
mutable QString resolvedThemePath;
mutable QByteArray resolvedSvg;
mutable QSize intrinsicSizeCache;
mutable bool resolvedAssetIsSvg = false;
mutable bool resolvedHasSentinel = false;
mutable bool hasResolvedAsset = false;
mutable bool hasIntrinsicSize = false;
};
} // namespace
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role)
{
return QIcon(new ThemeSvgIconEngine(prefix.toString(), role));
}
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size)
{
if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) {
return {};
}
const QByteArray svg = readSvg(resolvedPath);
if (!hasSentinel(svg)) {
return {};
}
const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1();
return renderSvg(resolveSvgColorSentinel(svg, color), size, 1.0);
}

View file

@ -0,0 +1,50 @@
/**
* @file theme_icon.h
* @ingroup Interface
*/
#ifndef THEME_ICON_H
#define THEME_ICON_H
#include <QIcon>
#include <QPalette>
#include <QPixmap>
#include <QSize>
#include <QString>
#include <QStringView>
/**
* @brief Builds a palette-aware icon from a `theme:` asset.
*
* SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot
* cannot follow palette changes, so this returns a QIcon whose engine resolves
* the sentinel from the current palette every time it is asked for a pixmap.
* Icons therefore stay legible when the user switches between light and dark
* themes without recreating any actions or buttons.
*
* @param prefix Asset prefix relative to the `theme:` search path, with no file
* extension (for example `icons/search`), matching themePixmap().
* @param role Palette role whose color replaces the sentinel. Selected glyphs
* use HighlightedText unless a role other than WindowText is given.
* @return An icon that follows the application palette.
*/
QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText);
/**
* @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel.
*
* Non-SVG assets and SVGs without the sentinel return a null pixmap so callers
* can fall back to their usual loading path (including legacy inversion).
*
* @param resolvedPath Asset path relative to the `theme:` search path, as
* returned by ThemeManager::assetPath (includes extension).
* @param role Palette role whose color replaces the sentinel.
* @param size Pixel size to render at; an empty size uses the SVG's intrinsic size.
* @return The recolored pixmap, or a null pixmap when the asset is not a
* sentinel-carrying SVG.
*/
QPixmap loadSentinelSvgPixmap(const QString &resolvedPath,
QPalette::ColorRole role = QPalette::WindowText,
const QSize &size = {});
#endif // THEME_ICON_H

View file

@ -12,6 +12,7 @@
#include <QFile>
#include <QFileInfo>
#include <QFlags>
#include <QFontDatabase>
#include <QGuiApplication>
#include <QIODevice>
#include <QLatin1Char>
@ -127,8 +128,10 @@ static QString usableDefaultStyle(const QString &style)
ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
{
defaultStyleName = usableDefaultStyle(qApp->style()->objectName());
// Capture the untouched application palette before any theme is applied.
// Capture the untouched application palette and font before any theme is
// applied.
defaultPalette = qApp->palette();
defaultFont = qApp->font();
ensureThemeDirectoryExists();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
@ -440,6 +443,46 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath
return cfg;
}
FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath)
{
if (themeDirPath.isEmpty()) {
return {};
}
return FontConfig::fromThemeDir(themeDirPath);
}
FontConfig ThemeManager::loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName)
{
FontConfig cfg = FontConfig::fromThemeDir(themeDirPath);
if (!cfg.hasFonts()) {
// The shipped fonts.toml may live in the system theme directory rather
// than the resolved (user) theme directory, so built-in themes still
// get their curated fonts.
cfg = FontConfig::fromThemeDir(QDir(systemThemesBasePath()).absoluteFilePath(themeName));
}
return cfg;
}
bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg)
{
if (themeDirPath.isEmpty()) {
return false;
}
QDir dir(themeDirPath);
if (!dir.exists()) {
dir.mkpath(".");
}
QFile f(dir.absoluteFilePath(FontConfig::fileName()));
if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) {
return false;
}
QTextStream(&f) << cfg.toToml();
return true;
}
bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg)
{
if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) {
@ -488,6 +531,24 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem
applyStyleAndPalette(themeName, themeCfg, cfg, scheme);
}
void ThemeManager::previewFonts(const FontConfig &cfg)
{
currentFonts = cfg;
// QApplication::setFont already sends a font change to every widget, so a
// repolish pass is not needed for fonts (unlike the palette path).
const auto it = cfg.fonts.constFind(AppFont::InterfaceDefault);
if (it != cfg.fonts.constEnd()) {
qApp->setFont(it.value());
} else {
// A config without InterfaceDefault means "no override": restore the
// startup font instead of leaving the last preview applied.
qApp->setFont(defaultFont);
}
emit fontsChanged();
}
void ThemeManager::applyStyleAndPalette(const QString &themeName,
const ThemeConfig &themeCfg,
const PaletteConfig &palCfg,
@ -591,6 +652,56 @@ QColor ThemeManager::appColor(AppColor::Role role) const
return accent;
}
QFont ThemeManager::appFont(AppFont::Role role) const
{
const auto it = currentFonts.fonts.constFind(role);
if (it != currentFonts.fonts.constEnd()) {
return it.value();
}
// Role fallbacks preserve the pre-theme defaults when no fonts.toml is
// present: an unset role resolves to the application font. Surfaces that
// hardcoded a different family ask for it explicitly: counter painters
// through cardTextFont(), the deck view headers and printed deck list
// through deckListFont(), the report dialogs through
// report_utils::reportLogFont(). Only the family basis is fixed there;
// sizes stay caller-side.
return qApp->font();
}
bool ThemeManager::hasAppFont(AppFont::Role role) const
{
return currentFonts.fonts.contains(role);
}
QFont ThemeManager::cardTextFont() const
{
if (hasAppFont(AppFont::CardText)) {
return appFont(AppFont::CardText);
}
QFont serifFont(QStringLiteral("Serif"));
serifFont.setStyleHint(QFont::Serif);
return serifFont;
}
QFont ThemeManager::deckListFont() const
{
if (hasAppFont(AppFont::DeckList)) {
return appFont(AppFont::DeckList);
}
QFont serifFont(QStringLiteral("Serif"));
serifFont.setStyleHint(QFont::Serif);
return serifFont;
}
QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const
{
// No per-role scaling and no preview: an unset role resolves to the font
// captured at startup, which is what the theme falls back to once the
// editor closes.
return defaultFont;
}
void ThemeManager::themeChangedSlot()
{
QString themeName = SettingsCache::instance().getThemeName();
@ -642,6 +753,19 @@ void ThemeManager::themeChangedSlot()
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
// ── Load fonts: shared per theme, no light/dark split ───────────────────
FontConfig fonts = loadDefaultFontConfig(dirPath, themeName);
currentFonts = fonts;
const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault);
if (fontIt != currentFonts.fonts.constEnd()) {
qApp->setFont(fontIt.value());
} else {
// No theme font: restore the pristine application font captured at
// startup, so a reverted preview cannot leak into the live theme.
qApp->setFont(defaultFont);
}
emit fontsChanged();
QStringList resources;
if (!dirPath.isEmpty()) {
resources << dir.absolutePath();

View file

@ -11,6 +11,7 @@
#include <QBrush>
#include <QColor>
#include <QFont>
#include <QLoggingCategory>
#include <QMap>
#include <QObject>
@ -47,10 +48,15 @@ private:
// palette is applied. Used as the base when a theme supplies no palette, so
// switching away from a custom palette restores the original colours.
QPalette defaultPalette;
// Pristine application font captured at startup, before any theme font is
// applied. Restored when a theme (or a preview) supplies no InterfaceDefault
// font, so switching away from a themed font cannot leave it behind.
QFont defaultFont;
QString currentThemePath;
std::array<QBrush, Role::MaxRole + 1> brushes;
QStringMap availableThemes;
QMap<AppColor::Role, QColor> currentAppColors;
FontConfig currentFonts;
/*
Internal cache for multiple backgrounds
*/
@ -105,6 +111,9 @@ public:
// Load/save per-scheme palette colors
static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme);
static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
// Load/save shared per-theme fonts (fonts.toml, not split by scheme)
static FontConfig loadFontConfig(const QString &themeDirPath);
static bool saveFontConfig(const QString &themeDirPath, const FontConfig &cfg);
// Resolve prefix to a scheme-qualified "theme:" path. Existence is probed
// internally across the formats themes may ship (.png/.jpg/.svg), so
// callers load the returned path directly. Prefers "<prefix>-<dark|light>"
@ -123,6 +132,9 @@ public:
// theme directory when it is absent from the resolved (user) directory.
static PaletteConfig
loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme);
// Load the theme's shipped default fonts, falling back to the system theme
// directory when fonts.toml is absent from the resolved (user) directory.
static FontConfig loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName);
/** @brief Writes cfg to disk as the theme's palette-<scheme>.toml and updates the
* theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave
* and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */
@ -132,10 +144,31 @@ public:
void reloadCurrentTheme();
void previewPalette(const PaletteConfig &cfg, const QString &scheme);
void previewFonts(const FontConfig &cfg);
// Resolves an application color role: the theme's stored [AppColors] value
// when present, otherwise a palette-accent-derived fallback.
QColor appColor(AppColor::Role role) const;
/** @brief Resolves an application font role: the theme's stored [Fonts]
* value when present, otherwise the application font. Roles that
* had a historical hardcoded family ask for it through
* cardTextFont() and deckListFont() instead. */
QFont appFont(AppFont::Role role) const;
/** @brief Whether the active theme defines this role in fonts.toml. */
bool hasAppFont(AppFont::Role role) const;
/** @brief The CardText role with its historical Serif fallback. Used by the
* counter painters, which were Serif before the role existed. */
QFont cardTextFont() const;
/** @brief The DeckList role with its historical Serif fallback. Used by the
* two surfaces that hardcoded Serif before the role existed (the
* deck view zone headers and the printed deck list); every other
* DeckList reader falls back to the application font. */
QFont deckListFont() const;
/** @brief Resolves an application font role against the startup font only,
* ignoring any previewed config in currentFonts. Editors that show
* "what this role resolves to" must use this, because appFont()
* reflects a live preview while the editor is open. */
QFont defaultAppFont(AppFont::Role role) const;
QBrush &getBgBrush(Role zone);
QBrush getExtraBgBrush(Role zone, int zoneId = 0);
@ -144,6 +177,7 @@ protected slots:
signals:
void themeChanged();
void paletteChanged();
void fontsChanged();
};
extern ThemeManager *themeManager;

View file

@ -1,5 +1,6 @@
#include "art_crop_attribution.h"
#include "../../theme_manager.h"
#include "libcockatrice/card/printing/printing_info.h"
#include <QColor>
@ -35,7 +36,10 @@ QRectF paintArtAttribution(QPainter &painter,
painter.save();
// Family comes from the CardText theme role; the incoming painter size
// stays the basis for the scale factor exactly as before.
QFont font = painter.font();
font.setFamily(themeManager->appFont(AppFont::CardText).family());
font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale));
painter.setFont(font);

View file

@ -2,6 +2,8 @@
#include "libcockatrice/card/card_info.h"
#include <QRect>
#include <QSize>
#include <QTransform>
#include <libcockatrice/card/printing/exact_card.h>
#include <qnamespace.h>
@ -18,4 +20,23 @@ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card)
transform.rotate(90);
return art.transformed(transform, Qt::SmoothTransformation);
}
} // namespace CardArtUtils
QPixmap cropCardArt(const QPixmap &art, const ExactCard &card)
{
if (art.isNull()) {
return {};
}
const QPixmap source = rotateSidewaysLayoutArt(art, card);
const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation;
const QSize size = source.size();
const int marginX = size.width() * 0.07;
// Landscape name boxes extend below 5% of the upright image. Remove the
// whole box here, rather than relying on the banner's narrow viewport to
// hide it: the same saved offset is also used by the taller profile popup.
const int topMargin = size.height() * (landscape ? 0.15 : 0.11);
const int bottomMargin = size.height() * 0.45;
const QRect crop(marginX, topMargin, size.width() - 2 * marginX, size.height() - topMargin - bottomMargin);
return source.copy(crop.intersected(source.rect()));
}
} // namespace CardArtUtils

View file

@ -20,6 +20,13 @@ namespace CardArtUtils
* @return @p art rotated 90° clockwise for sideways-layout cards, else @p art.
*/
QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card);
/**
* Return the upright artwork used by banner previews, user rows and profile
* popups. The crop excludes conventional name and rules boxes before callers apply
* their saved pan and zoom, so a taller popup cannot expose the card frame.
*/
QPixmap cropCardArt(const QPixmap &art, const ExactCard &card);
} // namespace CardArtUtils
#endif // CARD_ART_UTILS_H
#endif // CARD_ART_UTILS_H

View file

@ -43,11 +43,6 @@
#include <qlogging.h>
#include <qnamespace.h>
static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396;
// static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457;
static constexpr qreal ASPECT_RATIO = MTG_CARD_ASPECT_RATIO;
static constexpr int BASE_WIDTH = 200;
static constexpr int BASE_HEIGHT = 200;
static constexpr int ENLARGED_PIXMAP_OFFSET = 10;
@ -162,12 +157,11 @@ void CardInfoPictureWidget::resizeEvent(QResizeEvent *event)
*/
void CardInfoPictureWidget::setScaleFactor(const int scale)
{
const int newWidth = BASE_WIDTH * scale / 100;
const int newHeight = static_cast<int>(newWidth * ASPECT_RATIO);
const QSize newSize = sizeForScaleFactor(scale);
scaleFactor = scale;
setFixedSize(newWidth, newHeight);
setFixedSize(newSize);
updatePixmap();
emit cardScaleFactorChanged(scale);
@ -266,8 +260,13 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event)
*/
QSize CardInfoPictureWidget::sizeHint() const
{
return {static_cast<int>(BASE_WIDTH * scaleFactor / 100.0),
static_cast<int>(BASE_WIDTH * scaleFactor / 100.0 * ASPECT_RATIO)};
return sizeForScaleFactor(scaleFactor);
}
QSize CardInfoPictureWidget::sizeForScaleFactor(int scaleFactor)
{
const int width = BASE_WIDTH * scaleFactor / 100;
return {width, static_cast<int>(width * ASPECT_RATIO)};
}
/**

View file

@ -28,6 +28,11 @@ class CardInfoPictureWidget : public QWidget
Q_OBJECT
public:
/// Width of a card picture at 100 % scale, in pixels.
static constexpr int BASE_WIDTH = 200;
/// Height / width of a card picture.
static constexpr qreal ASPECT_RATIO = 1.396;
explicit CardInfoPictureWidget(QWidget *parent = nullptr,
bool hoverToZoomEnabled = false,
bool raiseOnEnter = false);
@ -37,6 +42,12 @@ public:
}
[[nodiscard]] QSize sizeHint() const override;
/**
* @brief The size a card picture has at @p scaleFactor percent.
* @param scaleFactor Percentage of BASE_WIDTH, as set by the card size slider.
*/
[[nodiscard]] static QSize sizeForScaleFactor(int scaleFactor);
public slots:
void setCard(const ExactCard &card);
void setScaleFactor(int scale); // New slot for scaling

View file

@ -1,5 +1,7 @@
#include "card_info_picture_with_text_overlay_widget.h"
#include "../../theme_manager.h"
#include <QBrush>
#include <QChar>
#include <QFont>
@ -43,6 +45,7 @@ CardInfoPictureWithTextOverlayWidget::CardInfoPictureWithTextOverlayWidget(QWidg
fontSize(fontSize), textAlignment(alignment), highlighted(false)
{
this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
connect(themeManager, &ThemeManager::fontsChanged, this, &CardInfoPictureWithTextOverlayWidget::applyCardTextFont);
}
/**
@ -97,6 +100,13 @@ void CardInfoPictureWithTextOverlayWidget::setTextAlignment(const Qt::Alignment
update();
}
void CardInfoPictureWithTextOverlayWidget::applyCardTextFont()
{
// The overlay paint reads the CardText role font at paint time, so a
// theme font change only needs a repaint here.
update();
}
void CardInfoPictureWithTextOverlayWidget::setHighlighted(bool _highlighted)
{
if (highlighted == _highlighted) {
@ -168,8 +178,10 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
return;
}
// Calculate the optimal font size
QFont font = painter.font();
// Calculate the optimal font size. The family comes from the CardText
// theme role; the fontSize member stays the starting size and the
// shrink-to-fit logic below is unchanged.
QFont font = themeManager->appFont(AppFont::CardText);
int optimalFontSize = fontSize; // Start with the user-defined font size
const QFontMetrics baseMetrics(font);
int textWidth = pixmapRect.width();
@ -284,7 +296,7 @@ QSize CardInfoPictureWithTextOverlayWidget::minimumSizeHint() const
const QSize pixmapSize = pixmap.isNull() ? QSize(0, 0) : pixmap.size();
// Get the font metrics for the overlay text
QFont font;
QFont font = themeManager->appFont(AppFont::CardText);
font.setPointSize(fontSize);
const QFontMetrics fontMetrics(font);

View file

@ -47,6 +47,9 @@ protected:
void mousePressEvent(QMouseEvent *event) override;
[[nodiscard]] QSize minimumSizeHint() const override;
private slots:
void applyCardTextFont();
private:
void drawOutlinedText(QPainter &painter,
const QRect &textRect,

View file

@ -0,0 +1,83 @@
#include "card_picture_delegate.h"
#include "../../../client/settings/cache_settings.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "card_art_utils.h"
#include "card_database_visual_display_model.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/settings/cards_display_settings.h"
#include <QPainter>
#include <QPainterPath>
#include <QPixmap>
#include <QRect>
#include <QStyleOptionViewItem>
#include <libcockatrice/card/printing/printing_info.h>
CardPictureDelegate::CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent)
: QStyledItemDelegate(parent), model(_model)
{
}
void CardPictureDelegate::setCardSize(const QSize &size)
{
if (cardSize == size) {
return;
}
cardSize = size;
}
void CardPictureDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
if (model == nullptr) {
return;
}
const ExactCard card = model->cardAt(index.row());
if (!card) {
return;
}
// The grid cell is the card plus SPACING; give the picture the rest.
const int inset = SPACING / 2;
const QRect cardArea = option.rect.adjusted(inset, inset, -inset, -inset);
QPixmap pixmap;
CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, cardArea.size());
CardPictureLoader::getPixmap(pixmap, card, cardArea.size());
if (pixmap.isNull()) {
return;
}
if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) {
pixmap = CardArtUtils::rotateSidewaysLayoutArt(pixmap, card);
}
// Fit the picture into the cell and center it, like CardInfoPictureWidget does.
const QSize pictureSize = pixmap.deviceIndependentSize().toSize().scaled(cardArea.size(), Qt::KeepAspectRatio);
const QRect pictureRect(cardArea.center().x() - pictureSize.width() / 2,
cardArea.center().y() - pictureSize.height() / 2, pictureSize.width(),
pictureSize.height());
const qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners()
? 0.05 * static_cast<qreal>(pictureRect.width())
: 0.0;
painter->save();
painter->setRenderHint(QPainter::Antialiasing);
painter->setRenderHint(QPainter::SmoothPixmapTransform);
QPainterPath shape;
shape.addRoundedRect(pictureRect, radius, radius);
painter->setClipPath(shape);
// Source rect is in device pixels, target in logical ones: drawPixmap scales
// between them without the device pixel ratio being applied twice.
painter->drawPixmap(pictureRect, pixmap, QRectF(pixmap.rect()));
painter->restore();
}
QSize CardPictureDelegate::sizeHint(const QStyleOptionViewItem & /*option*/, const QModelIndex & /*index*/) const
{
return cardSize + QSize(SPACING, SPACING);
}

View file

@ -0,0 +1,45 @@
/**
* @file card_picture_delegate.h
* @ingroup CardWidgets
* @brief Paints a card picture for a visual card grid item.
*/
#ifndef CARD_PICTURE_DELEGATE_H
#define CARD_PICTURE_DELEGATE_H
#include <QSize>
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
class CardDatabaseVisualDisplayModel;
/**
* @brief Draws the card of a CardDatabaseVisualDisplayModel row as a picture.
*
* The delegate only paints rows the view asks for, so a grid of the whole card
* database stays cheap: no widget per card and no pixmap loaded for items that
* are off screen. Card images come from CardPictureLoader, which caches them and
* loads missing ones asynchronously.
*/
class CardPictureDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
/// Gap left around a card picture inside its grid cell, in pixels.
static constexpr int SPACING = 4;
explicit CardPictureDelegate(CardDatabaseVisualDisplayModel *_model, QObject *parent = nullptr);
/// The picture size inside a grid cell. The cell is this size plus SPACING.
void setCardSize(const QSize &size);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
CardDatabaseVisualDisplayModel *model;
QSize cardSize;
};
#endif // CARD_PICTURE_DELEGATE_H

View file

@ -2,6 +2,7 @@
#include "../general/layout_containers/flow_widget.h"
#include <QAbstractScrollArea>
#include <QEvent>
#include <QFlags>
#include <QHBoxLayout>
@ -33,8 +34,8 @@ CardSizeWidget::CardSizeWidget(QWidget *parent, FlowWidget *_flowWidget, int def
cardSizeLabel->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
cardSizeSlider = new QSlider(Qt::Horizontal, this);
cardSizeSlider->setRange(50, 250); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeSlider->setRange(MIN_CARD_SIZE, MAX_CARD_SIZE); ///< Slider range for card size adjustment.
cardSizeSlider->setValue(defaultValue); ///< Initial slider value.
cardSizeLayout->addWidget(cardSizeLabel);
cardSizeLayout->addWidget(cardSizeSlider);
@ -73,13 +74,26 @@ QSlider *CardSizeWidget::getSlider() const
void CardSizeWidget::enableCtrlScrollResize(QWidget *host)
{
host->installEventFilter(this);
const auto scrollAreas = host->findChildren<QScrollArea *>();
for (QScrollArea *scrollArea : scrollAreas) {
if (QWidget *content = scrollArea->widget()) {
content->installEventFilter(this);
const auto watchScrollArea = [this](QAbstractScrollArea *scrollArea) {
if (scrollArea == nullptr) {
return;
}
if (QWidget *viewport = scrollArea->viewport()) {
viewport->installEventFilter(this);
}
if (auto *contentScrollArea = qobject_cast<QScrollArea *>(scrollArea)) {
if (QWidget *content = contentScrollArea->widget()) {
content->installEventFilter(this);
}
}
};
host->installEventFilter(this);
watchScrollArea(qobject_cast<QAbstractScrollArea *>(host));
const auto scrollAreas = host->findChildren<QAbstractScrollArea *>();
for (QAbstractScrollArea *scrollArea : scrollAreas) {
watchScrollArea(scrollArea);
}
}

View file

@ -24,6 +24,10 @@ class CardSizeWidget : public QWidget
Q_OBJECT
public:
/// Range of the card size slider, as a percentage of the base card width.
static constexpr int MIN_CARD_SIZE = 50;
static constexpr int MAX_CARD_SIZE = 250;
explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100);
[[nodiscard]] QSlider *getSlider() const;
@ -31,8 +35,9 @@ public:
* @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants.
*
* Installs this widget as an event filter on the given host widget. If the host contains a
* scroll area the filter is also installed on the scroll area's content widget, so the resize
* intercepts the wheel event before the scroll area would scroll the view.
* scroll area the filter is also installed on the scroll area's content widget, and on the
* viewport of every scroll area base (QListView, QTableView, ...), so the resize intercepts
* the wheel event before the scroll area would scroll the view.
*/
void enableCtrlScrollResize(QWidget *host);

View file

@ -69,6 +69,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent,
updateZoneCardCount();
connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition);
connect(deckListModel, &QAbstractItemModel::layoutChanged, this, &DeckCardZoneDisplayWidget::displayCards);
if (selectionModel) {
connect(selectionModel, &QItemSelectionModel::selectionChanged, this,
&DeckCardZoneDisplayWidget::onSelectionChanged);
@ -174,6 +175,9 @@ void DeckCardZoneDisplayWidget::displayCards()
for (const QPersistentModelIndex &persistent : rows) {
constructAppropriateWidget(persistent);
// Reuse existing widgets when a model sort moves their persistent rows.
// This preserves collapsed groups and card selection during live edits.
cardGroupLayout->insertWidget(persistent.row(), indexToWidgetMap.value(persistent));
}
}

View file

@ -1,6 +1,6 @@
#include "abstract_analytics_panel_widget.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../general/display/banner_widget.h"
#include "deck_list_statistics_analyzer.h"
@ -28,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck
// config button
configureButton = new QPushButton(this);
configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
configureButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings")));
configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog);
bannerAndSettingsLayout->addWidget(configureButton, 0);

View file

@ -1,5 +1,6 @@
#include "resizable_panel.h"
#include "../interface/theme_manager.h"
#include "abstract_analytics_panel_widget.h"
#include "libcockatrice/utility/qt_utils.h"
@ -12,6 +13,7 @@
#include <QDropEvent>
#include <QEvent>
#include <QFlags>
#include <QFont>
#include <QFrame>
#include <QHBoxLayout>
#include <QMimeData>
@ -93,9 +95,17 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid
dragButton->setStyleSheet(QString("QPushButton { "
"background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 %1, stop:1 %2); "
"border: none; color: %3; font-size: 10px; }"
"border: none; color: %3; }"
"QPushButton:hover { background: %4; }")
.arg(mid.name(), dark.name(), placeholderText.name(), midLight.name()));
// Family comes from the InterfaceDefault theme role; the 10px size stays caller-side.
auto applyDragFont = [this]() {
QFont dragFont = themeManager->appFont(AppFont::InterfaceDefault);
dragFont.setPixelSize(10);
dragButton->setFont(dragFont);
};
applyDragFont();
connect(themeManager, &ThemeManager::fontsChanged, this, applyDragFont);
resizeHandle->setStyleSheet(QString("QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, "
"stop:0 %1, stop:1 %2); }")

View file

@ -3,7 +3,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../filters/syntax_help.h"
#include "../../key_signals.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../utility/custom_line_edit.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
@ -40,8 +40,8 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
searchEdit->setObjectName("searchEdit");
searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit->setClearButtonEnabled(true);
searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition);
setFocusProxy(searchEdit);
setFocusPolicy(Qt::ClickFocus);
@ -83,13 +83,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent
&DeckEditorDatabaseDisplayWidget::onRelatedCardClicked);
aAddCard = new QAction(QString(), this);
aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green")));
aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right")));
connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck);
auto *tbAddCard = new QToolButton(this);
tbAddCard->setDefaultAction(aAddCard);
aAddCardToSideboard = new QAction(QString(), this);
aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue")));
aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard")));
connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard);
auto *tbAddCardToSideboard = new QToolButton(this);
tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard);

View file

@ -3,7 +3,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../../key_signals.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../playmat/playmat_settings_dialog.h"
#include "../quick_settings/settings_button_widget.h"
#include "../tabs/abstract_tab_deck_editor.h"
@ -49,6 +49,7 @@
#include <QPushButton>
#include <QSet>
#include <QSharedPointer>
#include <QSignalBlocker>
#include <QSize>
#include <QSizePolicy>
#include <QSplitter>
@ -187,10 +188,17 @@ void DeckEditorDeckDockWidget::createDeckDock()
formatLabel = new QLabel(this);
formatComboBox = new QComboBox(this);
formatComboBox->setObjectName("deckFormatComboBox");
formatComboBox->addItem(tr("Loading Database..."));
formatComboBox->setEnabled(false); // Disable until loaded
commanderBracketWidget = new CommanderBracketWidget(this);
connect(formatComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
formatComboBox->setPlaceholderText(QString());
formatComboBox->setToolTip(QString());
deckStateManager->setFormat(index >= 0 ? formatComboBox->itemData(index).toString() : QString());
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
});
commentsLabel = new QLabel();
commentsLabel->setObjectName("commentsLabel");
@ -240,25 +248,25 @@ void DeckEditorDeckDockWidget::createDeckDock()
&DeckEditorDeckDockWidget::applyActiveGroupCriteria);
aIncrement = new QAction(QString(), this);
aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment")));
aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment")));
connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection);
auto *tbIncrement = new QToolButton(this);
tbIncrement->setDefaultAction(aIncrement);
aDecrement = new QAction(QString(), this);
aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement")));
aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection);
auto *tbDecrement = new QToolButton(this);
tbDecrement->setDefaultAction(aDecrement);
aRemoveCard = new QAction(QString(), this);
aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard);
auto *tbRemoveCard = new QToolButton(this);
tbRemoveCard->setDefaultAction(aRemoveCard);
aSwapCard = new QAction(QString(), this);
aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap")));
aSwapCard->setIcon(loadThemedIcon(QStringLiteral("icons/swap")));
connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection);
auto *tbSwapCard = new QToolButton(this);
tbSwapCard->setDefaultAction(aSwapCard);
@ -355,6 +363,9 @@ void DeckEditorDeckDockWidget::initializeFormats()
{
QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys();
// Rebuilding the list is a view update, not a user edit. In particular,
// clear() emits currentIndexChanged(-1), which must not clear the deck.
const QSignalBlocker blocker(formatComboBox);
formatComboBox->clear(); // Remove "Loading Database..."
formatComboBox->setEnabled(true);
@ -364,28 +375,25 @@ void DeckEditorDeckDockWidget::initializeFormats()
formatComboBox->addItem(formatName, formatName); // store the raw key in itemData
}
QString format = deckStateManager->getMetadata().gameFormat;
if (!format.isEmpty()) {
formatComboBox->setCurrentIndex(formatComboBox->findData(format));
} else {
// Ensure no selection is visible initially
formatComboBox->setCurrentIndex(-1);
}
connect(formatComboBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
QString formatKey;
if (index >= 0) {
formatKey = formatComboBox->itemData(index).toString();
deckStateManager->setFormat(formatKey);
} else {
deckStateManager->setFormat(""); // clear format if deselected
}
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
});
syncFormatSelection();
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
}
void DeckEditorDeckDockWidget::syncFormatSelection()
{
if (!formatComboBox->isEnabled()) {
return;
}
const QSignalBlocker blocker(formatComboBox);
const QString format = deckStateManager->getMetadata().gameFormat;
const int index = format.isEmpty() ? -1 : formatComboBox->findData(format);
const QString unavailable = index < 0 && !format.isEmpty() ? tr("Unavailable: %1").arg(format) : QString();
formatComboBox->setPlaceholderText(unavailable);
formatComboBox->setToolTip(unavailable);
formatComboBox->setCurrentIndex(index);
}
ExactCard DeckEditorDeckDockWidget::getCurrentCard()
{
QModelIndex current = deckView->selectionModel()->currentIndex();
@ -596,9 +604,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel()
updatePlaymatLabel();
updateHash();
formatComboBox->blockSignals(true);
formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat));
formatComboBox->blockSignals(false);
syncFormatSelection();
commanderBracketWidget->setDeck(deckStateManager->getDeckListShared());
@ -762,18 +768,6 @@ void DeckEditorDeckDockWidget::actSwapSelection()
update();
}
void DeckEditorDeckDockWidget::actDecrementCard(const ExactCard &card, QString zoneName)
{
if (!card) {
return;
}
if (card.getInfo().getIsToken()) {
zoneName = DECK_ZONE_TOKENS;
}
deckStateManager->decrementCard(card, zoneName);
}
void DeckEditorDeckDockWidget::actDecrementSelection()
{
auto selectedRows = getSelectedCardNodeSourceIndices();
@ -1054,6 +1048,7 @@ void DeckEditorDeckDockWidget::retranslateUi()
commentsLabel->setText(tr("&Comments:"));
activeGroupCriteriaLabel->setText(tr("Group by:"));
formatLabel->setText(tr("Format:"));
syncFormatSelection();
commanderBracketWidget->retranslateUi();
hashLabel1->setText(tr("Hash:"));

View file

@ -70,7 +70,6 @@ public slots:
void syncDisplayWidgetsToModel();
void actAddCard(const ExactCard &card, const QString &zoneName);
void actIncrementSelection();
void actDecrementCard(const ExactCard &card, QString zoneName);
void actDecrementSelection();
void actSwapCard(const ExactCard &card, const QString &zoneName);
void actSwapSelection();
@ -111,6 +110,7 @@ private:
DeckListModel *getModel() const;
[[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const;
void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement);
void syncFormatSelection();
void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString &currentBoardName);
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);

View file

@ -5,7 +5,7 @@
#include "../../../filters/filter_builder.h"
#include "../../../filters/filter_tree_model.h"
#include "../../key_signals.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../tabs/abstract_tab_deck_editor.h"
#include "libcockatrice/filters/filter_card.h"
@ -64,11 +64,11 @@ void DeckEditorFilterDockWidget::createFiltersDock()
connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter);
aClearFilterOne = new QAction(QString(), this);
aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement")));
aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne);
aClearFilterAll = new QAction(QString(), this);
aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch")));
aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch")));
connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll);
auto *filterDelOne = new QToolButton();

View file

@ -1,6 +1,6 @@
#include "deck_list_history_manager_widget.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../quick_settings/settings_button_widget.h"
#include "deck_state_manager.h"
#include "libcockatrice/deck_list/deck_list_history_manager.h"
@ -32,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
layout = new QHBoxLayout(this);
aUndo = new QAction(QString(), this);
aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo")));
aUndo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_undo")));
aUndo->setShortcut(QKeySequence::Undo);
aUndo->setShortcutContext(Qt::ApplicationShortcut);
connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo);
@ -41,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
undoButton->setDefaultAction(aUndo);
aRedo = new QAction(QString(), this);
aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo")));
aRedo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_redo")));
aRedo->setShortcut(QKeySequence::Redo);
aRedo->setShortcutContext(Qt::ApplicationShortcut);
connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo);
@ -53,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de
layout->addWidget(redoButton);
historyButton = new SettingsButtonWidget(this);
historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history")));
historyButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/arrow_history")));
historyLabel = new QLabel(this);

View file

@ -1,5 +1,7 @@
#include "deck_list_style_proxy.h"
#include "../../theme_manager.h"
#include <QBrush>
#include <QColor>
#include <QFont>
@ -8,6 +10,16 @@
#include <libcockatrice/models/deck_list/deck_list_model.h>
#include <qnamespace.h>
DeckListStyleProxy::DeckListStyleProxy(QObject *parent) : QIdentityProxyModel(parent)
{
// FontRole data reads the DeckList role live; rebroadcast so attached
// views re-query instead of waiting for an incidental repaint.
connect(themeManager, &ThemeManager::fontsChanged, this, [this] {
emit layoutAboutToBeChanged();
emit layoutChanged();
});
}
QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const
{
QModelIndex src = mapToSource(index);
@ -20,7 +32,8 @@ QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const
bool isCard = src.data(DeckRoles::IsCardRole).toBool();
if (role == Qt::FontRole && !isCard) {
QFont f;
// Family comes from the DeckList theme role; bold stays caller-side.
QFont f = themeManager->appFont(AppFont::DeckList);
f.setBold(true);
return f;
}

View file

@ -8,7 +8,7 @@ class DeckListStyleProxy : public QIdentityProxyModel
{
Q_OBJECT
public:
using QIdentityProxyModel::QIdentityProxyModel;
explicit DeckListStyleProxy(QObject *parent = nullptr);
[[nodiscard]] QVariant data(const QModelIndex &index, int role) const override;
};

View file

@ -211,15 +211,15 @@ QModelIndex DeckStateManager::addCard(const ExactCard &card, const QString &zone
return {};
}
QString zone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName;
QString targetZone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName;
CardSetPtr set = card.getPrinting().getSet();
QString setName = set ? set->getCorrectedShortName() : "";
QString reason =
tr("Added (%1): %2 (%3) %4").arg(zone, card.getName(), setName, card.getPrinting().getProperty("num"));
tr("Added (%1): %2 (%3) %4").arg(targetZone, card.getName(), setName, card.getPrinting().getProperty("num"));
QModelIndex idx = modifyDeck(reason, [&card, &zone](auto model) { return model->addCard(card, zone); });
QModelIndex idx = modifyDeck(reason, [&card, &targetZone](auto model) { return model->addCard(card, targetZone); });
if (idx.isValid()) {
emit focusIndexChanged(idx, true);
@ -234,10 +234,22 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString
return {};
}
QString targetZone = zoneName;
QString providerId = card.getPrinting().getUuid();
QString collectorNumber = card.getPrinting().getProperty("num");
QModelIndex idx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber);
QModelIndex idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber);
// Tokens are usually added to the token zone, but it is possible for imported/hand-edited
// decks to contain them in the main or sideboards. This is why we only try to remove the
// token from the token zone if it does not exist in the given zone, allowing us to still
// remove it from there.
if (!idx.isValid() && card.getInfo().getIsToken() && targetZone != DECK_ZONE_TOKENS) {
targetZone = DECK_ZONE_TOKENS;
idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber);
}
if (!idx.isValid()) {
return {};
}
@ -249,7 +261,7 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString
}
// old index is no longer safe since rows could have been removed
QModelIndex newIdx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber);
QModelIndex newIdx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber);
if (newIdx.isValid()) {
emit focusIndexChanged(newIdx, true);
}

View file

@ -188,17 +188,18 @@ public:
///@}
/**
* @brief Adds the given card to the given zone.
* @brief Adds the given card to the given zone. Tokens always go to DECK_ZONE_TOKENS, ignoring the given zone.
* Saves the operation to history if successful.
*
* @param card The card to add
* @param zoneName The zone to add the card to
* @param zoneName The zone to add the card to if the card is not a token
* @return The index of the added card
*/
QModelIndex addCard(const ExactCard &card, const QString &zoneName);
/**
* @brief Removes 1 copy of the given card from the given zone.
* If the card is a token and is not in the given zone, then it is removed from DECK_ZONE_TOKENS instead.
* Saves the operation to history if successful.
*
* @param card The card to remove

View file

@ -1,7 +1,7 @@
#include "dlg_connect.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../server/handle_public_servers.h"
#include "../server/user/user_info_connection.h"
#include "libcockatrice/utility/macros.h"
@ -33,11 +33,12 @@ class QWidget;
DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
{
setObjectName("connectDialog");
previousHostButton = new QRadioButton(tr("Known Hosts"), this);
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
btnDeleteServer->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row")));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
@ -45,7 +46,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync")));
btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync")));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);
@ -85,6 +86,11 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
passwordLabel->setBuddy(passwordEdit);
passwordEdit->setEchoMode(QLineEdit::Password);
hostEdit->setObjectName("connectHostEdit");
portEdit->setObjectName("connectPortEdit");
playernameEdit->setObjectName("connectPlayernameEdit");
passwordEdit->setObjectName("connectPasswordEdit");
savePasswordCheckBox = new QCheckBox(tr("&Save password"));
autoConnectCheckBox = new QCheckBox(tr("A&uto connect"));
@ -115,7 +121,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent)
updateDisplayInfo(previousHosts->currentText());
btnForgotPassword = new QPushButton(this);
btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password")));
btnForgotPassword->setIcon(loadThemedIcon(QStringLiteral("icons/forgot_password")));
btnForgotPassword->setToolTip(tr("Reset Password"));
btnForgotPassword->setFixedWidth(30);
connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword);
@ -290,11 +296,13 @@ void DlgConnect::updateDisplayInfo(const QString &saveName)
playernameEdit->setFocus();
savePasswordCheckBox->setChecked(savePasswordStatus);
storedVerifier.clear();
savedVerifier.clear();
if (savePasswordStatus) {
const QString stored = _data.at(4);
if (stored.startsWith("$")) {
storedVerifier = stored;
savedVerifier = stored;
passwordEdit->clear();
passwordEdit->setPlaceholderText(tr("Password saved"));
} else {
@ -331,6 +339,7 @@ void DlgConnect::newHostSelected(bool state)
passwordEdit->clear();
passwordEdit->setPlaceholderText(QString());
storedVerifier.clear();
savedVerifier.clear();
saveEdit->clear();
saveEdit->setPlaceholderText(tr("Unique Server Name"));
saveEdit->setDisabled(false);
@ -346,9 +355,19 @@ void DlgConnect::passwordSaved(QT_STATE_CHANGED_T state)
Q_UNUSED(state);
if (savePasswordCheckBox->isChecked()) {
autoConnectCheckBox->setEnabled(true);
// Re-ticking keeps a password the user has started typing; otherwise restore the saved
// login this dialog was opened with, so unticking and changing your mind is harmless.
if (passwordEdit->text().isEmpty()) {
storedVerifier = savedVerifier;
passwordEdit->setPlaceholderText(savedVerifier.isEmpty() ? QString() : tr("Password saved"));
}
} else {
autoConnectCheckBox->setChecked(false);
autoConnectCheckBox->setEnabled(false);
// Unticking means the password typed below is sent for this connection: stop sending the
// saved verifier instead, and stop claiming a password is saved.
storedVerifier.clear();
passwordEdit->setPlaceholderText(QString());
}
}
@ -356,10 +375,11 @@ void DlgConnect::actOk()
{
ServersSettings &servers = SettingsCache::instance().servers();
// Never write a newly typed plaintext password to disk when a verifier is already stored:
// the typed value is used for this connection and a fresh verifier is persisted after a
// successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior.
const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier;
// Never write a newly typed plaintext password to disk when a verifier is available: the
// typed value is used for this connection and a fresh verifier is persisted after a
// successful login. Without any verifier we keep the previous (plaintext legacy) behavior.
const QString verifierToSave = storedVerifier.isEmpty() ? savedVerifier : storedVerifier;
const QString passwordToSave = verifierToSave.isEmpty() ? passwordEdit->text() : verifierToSave;
if (newHostButton->isChecked()) {
if (saveEdit->text().isEmpty()) {

View file

@ -98,6 +98,10 @@ private:
QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer;
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
HandlePublicServers *hps;
//! \brief Saved "$scrypt$..." verifier as loaded for the selected profile, kept so that
//! re-ticking "Save password" after unticking restores it.
QString savedVerifier;
//! \brief Verifier in effect for this connection: empty while "Save password" is unticked.
QString storedVerifier;
const QString placeHolderText = tr("Downloading...");
};

View file

@ -116,14 +116,17 @@ void DlgCreateGame::sharedCtor()
shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby"));
shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start"));
createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge"));
auto *gameSetupOptionsLayout = new QGridLayout;
gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0);
gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1);
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0);
gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2);
gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2);
if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) {
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0);
gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2);
} else {
createGameAsJudgeCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setHidden(true);
@ -184,6 +187,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap<int, QString> &_gameType
createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator());
startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal());
shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad());
shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers());
if (!rememberGameSettings->isChecked()) {
actReset();
@ -217,6 +221,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
createGameAsSpectatorCheckBox->setEnabled(false);
startingLifeTotalEdit->setEnabled(false);
shareDecklistsOnLoadCheckBox->setEnabled(false);
shufflePlayersCheckBox->setEnabled(false);
descriptionEdit->setText(QString::fromStdString(gameInfo.description()));
maxPlayersEdit->setValue(gameInfo.max_players());
@ -227,6 +232,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {
@ -264,6 +270,7 @@ void DlgCreateGame::actReset()
startingLifeTotalEdit->setValue(20);
shareDecklistsOnLoadCheckBox->setChecked(false);
shufflePlayersCheckBox->setChecked(false);
createGameAsJudgeCheckBox->setChecked(false);
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -294,6 +301,7 @@ void DlgCreateGame::actOK()
cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked());
cmd.set_starting_life_total(startingLifeTotalEdit->value());
cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked());
cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked());
auto _gameTypes = QString();
QMapIterator<int, QRadioButton *> gameTypeCheckBoxIterator(gameTypeCheckBoxes);
@ -318,6 +326,7 @@ void DlgCreateGame::actOK()
SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked());
SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value());
SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked());
SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked());
SettingsCache::instance().game().setGameTypes(_gameTypes);
}
PendingCommand *pend = room->prepareRoomCommand(cmd);

View file

@ -51,6 +51,7 @@ private:
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
QCheckBox *shareDecklistsOnLoadCheckBox;
QCheckBox *shufflePlayersCheckBox;
QDialogButtonBox *buttonBox;
QPushButton *clearButton;
QCheckBox *rememberGameSettings;

View file

@ -1,6 +1,6 @@
#include "dlg_edit_tokens.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include "card_database_display_model.h"
#include "libcockatrice/card/card_info.h"
@ -40,6 +40,7 @@ class QWidget;
DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr)
{
setObjectName("editTokensDialog");
nameLabel = new QLabel(tr("&Name:"));
nameEdit = new QLineEdit;
nameEdit->setMaxLength(MAX_NAME_LENGTH);
@ -107,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul
&DlgEditTokens::tokenSelectionChanged);
QAction *aAddToken = new QAction(tr("Add token"), this);
aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment")));
aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment")));
connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken);
QAction *aRemoveToken = new QAction(tr("Remove token"), this);
aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement")));
aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement")));
connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken);
auto *databaseToolBar = new QToolBar;

View file

@ -28,12 +28,16 @@ class QWidget;
*/
AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent)
{
setObjectName("dlgDeckTextEdit");
contentsEdit = new QPlainTextEdit;
contentsEdit->setObjectName("contentsEdit");
refreshButton = new QPushButton(tr("&Refresh"));
connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttonBox->setObjectName("buttonBox");
buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole);
connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject);

View file

@ -22,6 +22,7 @@
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QString>
#include <QStringList>
#include <QTextStream>
#include <QUrl>
#include <QVBoxLayout>
@ -33,6 +34,7 @@
class QWidget;
static const QStringList SUPPORTED_PROVIDERS = {"Archidekt", "Deckstats", "ManaBox", "Moxfield", "TappedOut"};
DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent)
{
nam = NetworkManagerFactory::create(this);
@ -41,6 +43,7 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent
setLayout(layout);
instructionLabel = new QLabel(this);
instructionLabel->setWordWrap(true);
layout->addWidget(instructionLabel);
urlEdit = new QLineEdit(this);
@ -64,8 +67,8 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent
void DlgLoadDeckFromWebsite::retranslateUi()
{
instructionLabel->setText(tr("Paste a link to a decklist site here to import it.\n(Archidekt, Deckstats, Moxfield, "
"and TappedOut are supported.)"));
instructionLabel->setText(tr("Paste a link to a decklist site here to import it.") + "\n" +
tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", ")));
}
bool DlgLoadDeckFromWebsite::testValidUrl()
@ -84,8 +87,8 @@ void DlgLoadDeckFromWebsite::accept()
if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) {
qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider";
QMessageBox::warning(this, tr("Load Deck from Website"),
tr("No parser available for this deck provider.\n (Archidekt, Deckstats, Moxfield, "
"and TappedOut are supported.)"));
tr("No parser available for this deck provider.") + "\n" +
tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", ")));
QDialog::reject();
return;
}
@ -152,6 +155,7 @@ void DlgLoadDeckFromWebsite::accept()
"https://archidekt.com/decks/9999999\n"
"https://deckstats.net/decks/99999/9999999-your-deck-name/en\n"
"https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n"
"https://manabox.app/decks/XYZxxa-YZ99Yyyx_yzXzzz\n"
"https://tappedout.net/mtg-decks/your-deck-name/"));
QDialog::reject();
}
@ -164,7 +168,9 @@ QSharedPointer<IJsonDeckParser> DlgLoadDeckFromWebsite::createParserForProvider(
return QSharedPointer<IJsonDeckParser>(new ArchidektJsonParser());
case DeckProvider::Moxfield:
return QSharedPointer<IJsonDeckParser>(new MoxfieldJsonParser());
case DeckProvider::Manabox:
return QSharedPointer<IJsonDeckParser>(new ManaboxJsonParser());
default:
return QSharedPointer<IJsonDeckParser>(nullptr);
}
}
}

View file

@ -16,8 +16,11 @@ class QWidget;
DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
{
setObjectName("localGameDialog");
numberPlayersLabel = new QLabel(tr("Players:"), this);
numberPlayersEdit = new QSpinBox(this);
numberPlayersEdit->setObjectName("numberPlayersEdit");
numberPlayersEdit->setMinimum(1);
numberPlayersEdit->setMaximum(8);
numberPlayersEdit->setValue(1);
@ -28,10 +31,12 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
generalGrid->addWidget(numberPlayersLabel, 0, 0);
generalGrid->addWidget(numberPlayersEdit, 0, 1);
generalGroupBox = new QGroupBox(tr("General"), this);
generalGroupBox->setObjectName("generalGroupBox");
generalGroupBox->setLayout(generalGrid);
startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this);
startingLifeTotalEdit = new QSpinBox(this);
startingLifeTotalEdit->setObjectName("startingLifeTotalEdit");
startingLifeTotalEdit->setMinimum(-999999999);
startingLifeTotalEdit->setMaximum(999999999);
startingLifeTotalEdit->setValue(20);
@ -42,11 +47,14 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent)
gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0);
gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1);
gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this);
gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox");
gameSetupOptionsGroupBox->setLayout(gameSetupGrid);
rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this);
rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox");
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
buttonBox->setObjectName("buttonBox");
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK);
connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject);

View file

@ -1,7 +1,7 @@
#include "dlg_manage_sets.h"
#include "../../../client/settings/cache_settings.h"
#include "../../pixel_map_generator.h"
#include "../../theme_icon.h"
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../interface/widgets/utility/custom_line_edit.h"
#include "libcockatrice/card/database/card_database.h"
@ -48,6 +48,7 @@
WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
{
setObjectName("manageSetsWindow");
setOrderIsSorted = false;
// left toolbar
@ -57,28 +58,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
aTop = new QAction(QString(), this);
aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green")));
aTop->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_top")));
aTop->setToolTip(tr("Move selected set to the top"));
aTop->setEnabled(false);
connect(aTop, &QAction::triggered, this, &WndSets::actTop);
setsEditToolBar->addAction(aTop);
aUp = new QAction(QString(), this);
aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green")));
aUp->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_up")));
aUp->setToolTip(tr("Move selected set up"));
aUp->setEnabled(false);
connect(aUp, &QAction::triggered, this, &WndSets::actUp);
setsEditToolBar->addAction(aUp);
aDown = new QAction(QString(), this);
aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green")));
aDown->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_down")));
aDown->setToolTip(tr("Move selected set down"));
aDown->setEnabled(false);
connect(aDown, &QAction::triggered, this, &WndSets::actDown);
setsEditToolBar->addAction(aDown);
aBottom = new QAction(QString(), this);
aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green")));
aBottom->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_bottom")));
aBottom->setToolTip(tr("Move selected set to the bottom"));
aBottom->setEnabled(false);
connect(aBottom, &QAction::triggered, this, &WndSets::actBottom);
@ -88,7 +89,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
searchField = new LineEditUnfocusable;
searchField->setObjectName("searchEdit");
searchField->setPlaceholderText(tr("Search by set name, code, type, or release date"));
searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition);
searchField->addAction(loadThemedIcon(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition);
searchField->setClearButtonEnabled(true);
setFocusProxy(searchField);
@ -221,6 +222,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent)
WndSets::~WndSets()
{
disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState);
}
void WndSets::closeEvent(QCloseEvent * /*ev*/)

View file

@ -1,11 +1,13 @@
#include "dlg_my_reports.h"
#include "../interface/theme_manager.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_report.pb.h"
#include <QAbstractItemView>
#include <QFont>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
@ -24,7 +26,6 @@
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_my_list.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/qt_utils.h>
#include <qnamespace.h>
#include <string>
@ -69,7 +70,8 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
chatLogEdit->setFont(QtUtils::monospaceFont());
applyChatLogFont();
connect(themeManager, &ThemeManager::fontsChanged, this, &DlgMyReports::applyChatLogFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
@ -122,6 +124,11 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
refreshList();
}
void DlgMyReports::applyChatLogFont()
{
chatLogEdit->setFont(report_utils::reportLogFont());
}
void DlgMyReports::refreshList()
{
selectedReportIdBeforeRefresh = selectedReportId;

Some files were not shown because too many files have changed in this diff Show more