mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-12 21:10:01 -08:00
Merge current upstream into random-reveal review continuation
Preserve SET_PLAYER_ORDER alongside REVEAL_RANDOM_CARDS in the shared command enum; retain current upstream source and the reviewed PlayerActions routing.
This commit is contained in:
commit
e32bd364bd
443 changed files with 15635 additions and 11767 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
42
cockatrice/src/client/network/interfaces/deck_analysis_url.h
Normal file
42
cockatrice/src/client/network/interfaces/deck_analysis_url.h
Normal 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
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ enum class DeckProvider
|
|||
Archidekt,
|
||||
Moxfield,
|
||||
Deckstats,
|
||||
Manabox,
|
||||
Unknown
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
628
cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp
Normal file
628
cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp
Normal 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;
|
||||
}
|
||||
91
cockatrice/src/game_graphics/pq_cockatrice_event_player.h
Normal file
91
cockatrice/src/game_graphics/pq_cockatrice_event_player.h
Normal 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
|
||||
|
|
@ -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); });
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
136
cockatrice/src/interface/palette_editor/font_grid_widget.cpp
Normal file
136
cockatrice/src/interface/palette_editor/font_grid_widget.cpp
Normal 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"));
|
||||
}
|
||||
}
|
||||
43
cockatrice/src/interface/palette_editor/font_grid_widget.h
Normal file
43
cockatrice/src/interface/palette_editor/font_grid_widget.h
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
102
cockatrice/src/interface/recording.cpp
Normal file
102
cockatrice/src/interface/recording.cpp
Normal 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;
|
||||
}
|
||||
54
cockatrice/src/interface/recording.h
Normal file
54
cockatrice/src/interface/recording.h
Normal 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
|
||||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
300
cockatrice/src/interface/theme_icon.cpp
Normal file
300
cockatrice/src/interface/theme_icon.cpp
Normal 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);
|
||||
}
|
||||
50
cockatrice/src/interface/theme_icon.h
Normal file
50
cockatrice/src/interface/theme_icon.h
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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); }")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:"));
|
||||
|
|
|
|||
|
|
@ -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 ¤tBoardName);
|
||||
void addChangeBoardMenu(QMenu *menu, const QString &zoneName);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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...");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ private:
|
|||
QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox,
|
||||
*spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox;
|
||||
QCheckBox *shareDecklistsOnLoadCheckBox;
|
||||
QCheckBox *shufflePlayersCheckBox;
|
||||
QDialogButtonBox *buttonBox;
|
||||
QPushButton *clearButton;
|
||||
QCheckBox *rememberGameSettings;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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*/)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ private slots:
|
|||
void reportDetailsResponse(const Response &response);
|
||||
void addComment();
|
||||
void addCommentResponse(const Response &response);
|
||||
void applyChatLogFont();
|
||||
|
||||
private:
|
||||
void loadReportDetails(const ServerInfo_Report &report);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#include "dlg_register.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"
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
|
|||
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);
|
||||
|
||||
|
|
@ -42,7 +42,7 @@ DlgRegister::DlgRegister(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);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
#include "dlg_report_user.h"
|
||||
|
||||
#include "../interface/theme_manager.h"
|
||||
#include "../utility/report_utils.h"
|
||||
#include "abstract_client.h"
|
||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFont>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
|
|
@ -91,7 +94,8 @@ DlgReportUser::DlgReportUser(AbstractClient *_client,
|
|||
auto *chatLayout = new QVBoxLayout(chatGroup);
|
||||
chatLogEdit = new QTextEdit;
|
||||
chatLogEdit->setReadOnly(true);
|
||||
chatLogEdit->setFont(QtUtils::monospaceFont());
|
||||
applyChatLogFont();
|
||||
connect(themeManager, &ThemeManager::fontsChanged, this, &DlgReportUser::applyChatLogFont);
|
||||
if (!_autoChatLog.isEmpty()) {
|
||||
chatLogEdit->setPlainText(_autoChatLog);
|
||||
} else {
|
||||
|
|
@ -120,6 +124,11 @@ DlgReportUser::DlgReportUser(AbstractClient *_client,
|
|||
layout->addWidget(buttonBox);
|
||||
}
|
||||
|
||||
void DlgReportUser::applyChatLogFont()
|
||||
{
|
||||
chatLogEdit->setFont(report_utils::reportLogFont());
|
||||
}
|
||||
|
||||
void DlgReportUser::actSubmit()
|
||||
{
|
||||
const QString description = descriptionEdit->toPlainText().trimmed();
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue