From c9ebdb451f886408aed55086d522d5e876e5e673 Mon Sep 17 00:00:00 2001 From: tooomm Date: Fri, 26 Jun 2026 16:39:33 +0200 Subject: [PATCH 1/7] CI: Avoid failing tx step if secret can not be accessed (#7012) --- .github/workflows/translations-pull.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index 57df31bf0..a3db5f86d 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -23,6 +23,8 @@ jobs: uses: actions/checkout@v7 - name: "Pull translated strings from Transifex" + # Do not run this step for PR's from forks, they don't have access to the secret + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false uses: transifex/cli-action@v2 with: # Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config From 2914874720ebb697c57bcd5dd23485d6d7ec0555 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:52:24 -0400 Subject: [PATCH 2/7] [Room][UserList] Introduce style delegate (#6981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Room] Additionally show a tab for friends and ignored users instead of just all online users. Took 21 minutes Took 12 minutes * [Room][UserList] Introduce style delegate for user list - Allow users to set a card name and parameters as their background banner - Allow mods to white/blacklist cards - Allow toggling back to the old display style Took 7 minutes Took 28 seconds Took 2 minutes Took 2 minutes * Right checkstate. Took 14 minutes Took 2 minutes * Utility for test. Took 9 minutes Took 8 seconds Took 2 seconds * Lint. Took 10 minutes * Algorithm for sql schema migration Took 13 minutes * Use {prefix}, bound card name, return errors. Took 27 seconds * Convert queue to while loop. Took 19 seconds * Hover popup. Took 36 minutes Took 1 minute * More granular signals, popup for user info. Took 25 minutes Took 8 seconds Took 16 minutes --------- Co-authored-by: Lukas BrΓΌbach --- cockatrice/CMakeLists.txt | 7 + .../src/client/settings/cache_settings.cpp | 8 + .../src/client/settings/cache_settings.h | 7 + .../server/user/user_avatar_provider.cpp | 48 ++ .../server/user/user_avatar_provider.h | 30 + .../server/user/user_card_art_provider.cpp | 146 ++++ .../server/user/user_card_art_provider.h | 39 ++ .../server/user/user_card_settings_dialog.cpp | 264 +++++++ .../server/user/user_card_settings_dialog.h | 70 ++ .../widgets/server/user/user_context_menu.cpp | 110 +++ .../widgets/server/user/user_context_menu.h | 21 + .../widgets/server/user/user_info_box.cpp | 51 +- .../widgets/server/user/user_info_box.h | 7 +- .../widgets/server/user/user_info_popup.cpp | 656 ++++++++++++++++++ .../widgets/server/user/user_info_popup.h | 181 +++++ .../widgets/server/user/user_list_manager.cpp | 89 ++- .../widgets/server/user/user_list_manager.h | 30 +- .../widgets/server/user/user_list_painter.cpp | 342 +++++++++ .../widgets/server/user/user_list_painter.h | 86 +++ .../widgets/server/user/user_list_widget.cpp | 489 ++++++++++++- .../widgets/server/user/user_list_widget.h | 44 +- .../appearance_settings_page.cpp | 13 + .../settings_page/appearance_settings_page.h | 2 + .../widgets/tabs/tab_card_art_rules.cpp | 246 +++++++ .../widgets/tabs/tab_card_art_rules.h | 89 +++ .../src/interface/widgets/tabs/tab_room.cpp | 22 +- .../src/interface/widgets/tabs/tab_room.h | 2 + .../interface/widgets/tabs/tab_supervisor.cpp | 31 + .../interface/widgets/tabs/tab_supervisor.h | 9 +- .../network/server/remote/server.cpp | 19 + .../network/server/remote/server.h | 1 + .../libcockatrice/protocol/pb/CMakeLists.txt | 1 + .../protocol/pb/moderator_commands.proto | 27 + .../libcockatrice/protocol/pb/response.proto | 1 + .../pb/response_card_art_rule_entry.proto | 15 + .../protocol/pb/serverinfo_user.proto | 11 +- .../protocol/pb/session_commands.proto | 12 + libcockatrice_utility/CMakeLists.txt | 1 + .../utility/days_years_between.h | 5 + .../migrations/servatrice_0034_to_0035.sql | 18 + servatrice/servatrice.sql | 18 +- .../src/servatrice_database_interface.cpp | 28 +- .../src/servatrice_database_interface.h | 2 +- servatrice/src/serversocketinterface.cpp | 167 +++++ servatrice/src/serversocketinterface.h | 5 + 45 files changed, 3387 insertions(+), 83 deletions(-) create mode 100644 cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_avatar_provider.h create mode 100644 cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_card_art_provider.h create mode 100644 cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h create mode 100644 cockatrice/src/interface/widgets/server/user/user_info_popup.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_info_popup.h create mode 100644 cockatrice/src/interface/widgets/server/user/user_list_painter.cpp create mode 100644 cockatrice/src/interface/widgets/server/user/user_list_painter.h create mode 100644 cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp create mode 100644 cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h create mode 100644 libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto create mode 100644 servatrice/migrations/servatrice_0034_to_0035.sql diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bd99d08bf..18679664b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -236,10 +236,14 @@ set(cockatrice_SOURCES src/interface/widgets/server/handle_public_servers.cpp src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp + src/interface/widgets/server/user/user_avatar_provider.cpp + src/interface/widgets/server/user/user_card_art_provider.cpp + src/interface/widgets/server/user/user_card_settings_dialog.cpp src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_connection.cpp src/interface/widgets/server/user/user_list_manager.cpp + src/interface/widgets/server/user/user_list_painter.cpp src/interface/widgets/server/user/user_list_widget.cpp src/interface/widgets/settings_page/appearance_settings_page.cpp src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -327,6 +331,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp src/interface/widgets/tabs/tab_game.cpp @@ -349,6 +354,8 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/interface/widgets/server/user/user_info_popup.cpp + src/interface/widgets/server/user/user_info_popup.h ) add_subdirectory(sounds) diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index cc34e1707..28e5eb187 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -371,6 +371,7 @@ SettingsCache::SettingsCache() openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool(); rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt(); + styleUserList = settings->value("appearance/styleUserList", true).toBool(); chatMention = settings->value("chat/mention", true).toBool(); chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool(); chatMentionForeground = settings->value("chat/mentionforeground", true).toBool(); @@ -1045,6 +1046,13 @@ void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs) settings->setValue("replay/rewindBufferingMs", rewindBufferingMs); } +void SettingsCache::setStyleUserList(QT_STATE_CHANGED_T _styleUserList) +{ + styleUserList = static_cast(_styleUserList); + settings->setValue("appearance/styleUserList", styleUserList); + emit styleUserListChanged(); +} + void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention) { chatMention = static_cast(_chatMention); diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index a166917c1..5a5e0c546 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -190,6 +190,7 @@ signals: void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod); void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme); void masterVolumeChanged(int value); + void styleUserListChanged(); void chatMentionCompleterChanged(); void downloadSpoilerTimeIndexChanged(); void downloadSpoilerStatusChanged(); @@ -284,6 +285,7 @@ private: bool autoRotateSidewaysLayoutCards; bool openDeckInNewTab; int rewindBufferingMs; + bool styleUserList; bool chatMention; bool chatMentionCompleter; QString chatMentionColor; @@ -738,6 +740,10 @@ public: { return rewindBufferingMs; } + [[nodiscard]] bool getStyleUserList() const + { + return styleUserList; + } [[nodiscard]] bool getChatMention() const { return chatMention; @@ -1113,6 +1119,7 @@ public slots: void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards); void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab); void setRewindBufferingMs(int _rewindBufferingMs); + void setStyleUserList(QT_STATE_CHANGED_T _styleUserList); void setChatMention(QT_STATE_CHANGED_T _chatMention); void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter); void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground); diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp new file mode 100644 index 000000000..c115caa47 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp @@ -0,0 +1,48 @@ +#include "user_avatar_provider.h" + +#include +#include +#include + +UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client) +{ +} + +const QMap &UserAvatarProvider::cache() const +{ + return avatarCache; +} + +void UserAvatarProvider::requestAvatar(const QString &userName) +{ + if (avatarCache.contains(userName) || pending.contains(userName)) { + return; + } + + pending.insert(userName); + + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &r) { + pending.remove(userName); + + const auto &response = r.GetExtension(Response_GetUserInfo::ext); + const auto &user = response.user_info(); + const std::string &bmp = user.avatar_bmp(); + + QPixmap avatar; + if (!bmp.empty() && + avatar.loadFromData(reinterpret_cast(bmp.data()), static_cast(bmp.size()))) { + avatarCache.insert(userName, avatar); + } else { + avatarCache.insert(userName, QPixmap()); + } + + emit avatarUpdated(userName); + }); + + client->sendCommand(pend); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h new file mode 100644 index 000000000..44491e544 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h @@ -0,0 +1,30 @@ +#ifndef COCKATRICE_USER_AVATAR_PROVIDER_H +#define COCKATRICE_USER_AVATAR_PROVIDER_H + +#include +#include +#include +#include + +class AbstractClient; + +class UserAvatarProvider : public QObject +{ + Q_OBJECT + +public: + explicit UserAvatarProvider(AbstractClient *client, QObject *parent = nullptr); + + void requestAvatar(const QString &userName); + const QMap &cache() const; + +signals: + void avatarUpdated(const QString &userName); + +private: + AbstractClient *client; + QMap avatarCache; + QSet pending; +}; + +#endif // COCKATRICE_USER_AVATAR_PROVIDER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp new file mode 100644 index 000000000..70a56375e --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -0,0 +1,146 @@ +#include "user_card_art_provider.h" + +#include "../../../card_picture_loader/card_picture_loader.h" + +#include +#include + +static QString makeKey(const QString &user, const QString &card) +{ + return user + u'|' + card; +} + +UserCardArtProvider::UserCardArtProvider(QObject *parent) : QObject(parent) +{ + dbReady = (CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok); + + if (!dbReady) { + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, + &UserCardArtProvider::onDatabaseReady); + } +} + +void UserCardArtProvider::onDatabaseReady() +{ + dbReady = true; + processQueue(); +} + +const QMap &UserCardArtProvider::cache() const +{ + return cardArtCache; +} + +void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName) +{ + if (cardName.isEmpty()) { + return; + } + + const QString key = makeKey(userName, cardName); + + if (cardArtCache.contains(key) || pending.contains(key)) { + return; + } + + pending.insert(key); + queue.enqueue(key); + + processQueue(); +} + +QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes) +{ + const QSize sz = fullRes.size(); + const int marginX = sz.width() * 0.07; + const int topMargin = sz.height() * 0.11; + const int bottomMargin = sz.height() * 0.45; + + const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); + + return fullRes.copy(foilRect.intersected(fullRes.rect())); +} + +void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) +{ + if (!cardArtCache.contains(key)) { + cacheInsertionOrder.append(key); + while (cacheInsertionOrder.size() > MaxCacheEntries) { + const QString evicted = cacheInsertionOrder.takeFirst(); + cardArtCache.remove(evicted); + } + } + cardArtCache.insert(key, pixmap); +} + +void UserCardArtProvider::processQueue() +{ + if (!dbReady) { + return; + } + + while (!queue.isEmpty()) { + const QString key = queue.dequeue(); + + const QStringList parts = key.split(u'|'); + if (parts.size() != 2) { + pending.remove(key); + continue; + } + + const QString userName = parts.at(0); + const QString cardName = parts.at(1); + + ExactCard card = CardDatabaseManager::query()->getCard({cardName}); + + if (!card) { + pending.remove(key); + continue; + } + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + // Synchronous hit (already loaded/on disk) + if (!fullRes.isNull()) { + insertIntoCache(key, cropCardArt(fullRes)); + pending.remove(key); + + emit cardArtUpdated(userName); + continue; + } + + // Async load required. + QPointer self(this); + + auto conn = std::make_shared(); + + *conn = connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, + [self, key, userName, card, conn]() mutable { + if (!self) { + return; + } + + QObject::disconnect(*conn); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (!fullRes.isNull()) { + self->insertIntoCache(key, self->cropCardArt(fullRes)); + } else { + self->insertIntoCache(key, QPixmap()); + } + + self->pending.remove(key); + + emit self->cardArtUpdated(userName); + + // Resume processing remaining queued items. + self->processQueue(); + }); + + // Stop here. We'll continue when the async load finishes. + return; + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h new file mode 100644 index 000000000..a3ab874b7 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -0,0 +1,39 @@ +#ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H +#define COCKATRICE_USER_CARD_ART_PROVIDER_H + +#include +#include +#include +#include +#include + +class UserCardArtProvider : public QObject +{ + Q_OBJECT + +public: + explicit UserCardArtProvider(QObject *parent = nullptr); + + void requestCardArt(const QString &userName, const QString &cardName); + const QMap &cache() const; + static QPixmap cropCardArt(const QPixmap &fullRes); + +signals: + void cardArtUpdated(const QString &userName); + +public slots: + void onDatabaseReady(); + +private: + bool dbReady = false; + static constexpr int MaxCacheEntries = 300; + QList cacheInsertionOrder; // FIFO eviction + QMap cardArtCache; + QSet pending; + QQueue queue; + + void processQueue(); + void insertIntoCache(const QString &key, const QPixmap &pixmap); +}; + +#endif // COCKATRICE_USER_CARD_ART_PROVIDER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp new file mode 100644 index 000000000..335ee097e --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -0,0 +1,264 @@ +#include "user_card_settings_dialog.h" + +#include "../../../card_picture_loader/card_picture_loader.h" +#include "card/card_completer_proxy_model.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "user_card_art_provider.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent) +{ + setMinimumSize(400, 72); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) +{ + sourcePixmap = pixmap; + update(); +} + +void CardArtPreviewWidget::setParams(const CardArtParams &p) +{ + params = p; + update(); +} + +void CardArtPreviewWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + + const QColor accentColor(100, 116, 139); + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, accentColor.darker(320)); + bg.setColorAt(1, QColor(18, 22, 30)); + painter.setPen(Qt::NoPen); + painter.setBrush(bg); + painter.drawRoundedRect(cardRect, 6, 6); + painter.setBrush(accentColor); + painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + + if (sourcePixmap.isNull()) { + painter.setPen(QColor(150, 150, 150)); + painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + return; + } + + UserListPainter::drawCardArt(&painter, rect, rect.right() - 4, + QString(), // userName not needed for override path + nullptr, // no cache + params, + &sourcePixmap // πŸ‘ˆ direct pixmap + ); + + // Avatar placeholder so the left-margin interaction is visible + const int avatarX = rect.left() + 14; + const int avatarY = rect.top() + (rect.height() - 36) / 2; + const QRect avatarRect(avatarX, avatarY, 36, 36); + + QPainterPath clip; + clip.addEllipse(avatarRect); + painter.save(); + painter.setClipPath(clip); + painter.setBrush(accentColor.darker(200)); + painter.setPen(Qt::NoPen); + painter.drawEllipse(avatarRect); + painter.restore(); + + painter.setPen(QPen(QColor(70, 80, 95), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); +} + +UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent) + : QDialog(parent), currentParams(initial) +{ + setWindowTitle(tr("Card Art Settings")); + setMinimumWidth(500); + setupUi(); + + // Seed UI from initial params + if (!initial.cardName.isEmpty()) { + searchBar->setText(initial.cardName); + onCardNameChanged(initial.cardName); + } + marginLSpin->setValue(initial.marginPctL); + marginRSpin->setValue(initial.marginPctR); + verticalOffsetSpin->setValue(initial.verticalOffset); + zoomSpin->setValue(initial.zoom); +} + +CardArtParams UserCardArtSettingsDialog::params() const +{ + return currentParams; +} + +QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, double value, double step) +{ + auto *spin = new QDoubleSpinBox; + spin->setRange(min, max); + spin->setSingleStep(step); + spin->setDecimals(3); + spin->setValue(value); + return spin; +} + +void UserCardArtSettingsDialog::initializeSearchBar() +{ + searchBar = new QLineEdit; + searchBar->setPlaceholderText(tr("Type a card name...")); + + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); + cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + searchModel = new CardSearchModel(cardDatabaseDisplayModel, this); + + proxyModel = new CardCompleterProxyModel(this); + proxyModel->setSourceModel(searchModel); + proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + proxyModel->setFilterRole(Qt::DisplayRole); + + completer = new QCompleter(proxyModel, this); + completer->setCompletionRole(Qt::DisplayRole); + completer->setCompletionMode(QCompleter::PopupCompletion); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setFilterMode(Qt::MatchContains); + completer->setMaxVisibleItems(15); + searchBar->setCompleter(completer); + + connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults); + connect(searchBar, &QLineEdit::textEdited, this, [this](const QString &text) { + const QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; + proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); + if (!text.isEmpty()) { + completer->complete(); + } + }); + + connect(completer, static_cast(&QCompleter::activated), this, + [this](const QString &completion) { + if (searchBar->text() != completion) { + searchBar->setText(completion); + searchBar->setCursorPosition(searchBar->text().length()); + } + onCardNameChanged(completion); + }); + + // Also trigger a load when the user hits Return on a typed name + connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); }); +} + +void UserCardArtSettingsDialog::setupUi() +{ + initializeSearchBar(); + + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); + marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); + verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); + zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); + + auto *form = new QFormLayout; + form->addRow(tr("Card name:"), searchBar); + form->addRow(tr("Left margin (%):"), marginLSpin); + form->addRow(tr("Right margin (%):"), marginRSpin); + form->addRow(tr("Vertical offset:"), verticalOffsetSpin); + form->addRow(tr("Zoom:"), zoomSpin); + + auto *controlsGroup = new QGroupBox(tr("Parameters")); + controlsGroup->setLayout(form); + + preview = new CardArtPreviewWidget; + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + auto *previewGroup = new QGroupBox(tr("Preview")); + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + auto *removeBtn = new QPushButton(tr("Remove Banner Card")); + buttons->addButton(removeBtn, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeBtn, &QPushButton::clicked, this, [this]() { + currentParams = CardArtParams{}; // empty cardName signals removal + accept(); + }); + + auto *root = new QVBoxLayout; + root->addWidget(controlsGroup); + root->addWidget(previewGroup); + root->addWidget(buttons); + setLayout(root); + + connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); +} + +void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) +{ + if (name.isEmpty()) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + const ExactCard card = CardDatabaseManager::query()->getCard({name}); + if (!card) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + currentParams.cardName = name; + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this, card](const PrintingInfo &) { + disconnect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, nullptr); + QPixmap loaded; + CardPictureLoader::getPixmap(loaded, card, QSize(745, 1040)); + currentPixmap = UserCardArtProvider::cropCardArt(loaded); + preview->setPixmap(currentPixmap); + }); + return; + } + + currentPixmap = UserCardArtProvider::cropCardArt(fullRes); + preview->setPixmap(currentPixmap); +} + +void UserCardArtSettingsDialog::onParamChanged() +{ + currentParams.marginPctL = marginLSpin->value(); + currentParams.marginPctR = marginRSpin->value(); + currentParams.verticalOffset = verticalOffsetSpin->value(); + currentParams.zoom = zoomSpin->value(); + preview->setParams(currentParams); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h new file mode 100644 index 000000000..cac26c919 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h @@ -0,0 +1,70 @@ +#ifndef COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H +#define COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H + +#include "user_list_painter.h" + +#include +#include + +class QCompleter; +class QLineEdit; +class QDoubleSpinBox; +class CardDatabaseModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class CardCompleterProxyModel; + +class CardArtPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CardArtPreviewWidget(QWidget *parent = nullptr); + + void setPixmap(const QPixmap &pixmap); + void setParams(const CardArtParams ¶ms); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + QPixmap sourcePixmap; + CardArtParams params; +}; + +class UserCardArtSettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit UserCardArtSettingsDialog(const CardArtParams &initial = {}, QWidget *parent = nullptr); + + CardArtParams params() const; + +private slots: + void onCardNameChanged(const QString &name); + void onParamChanged(); + +private: + void setupUi(); + void initializeSearchBar(); + QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); + + QLineEdit *searchBar; + QCompleter *completer; + CardDatabaseModel *cardDatabaseModel; + CardDatabaseDisplayModel *cardDatabaseDisplayModel; + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + + QDoubleSpinBox *marginLSpin; + QDoubleSpinBox *marginRSpin; + QDoubleSpinBox *verticalOffsetSpin; + QDoubleSpinBox *zoomSpin; + CardArtPreviewWidget *preview; + + QPixmap currentPixmap; + CardArtParams currentParams; +}; + +#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index faa96fa1f..11fd02d80 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -542,3 +542,113 @@ void UserContextMenu::showContextMenu(const QPoint &pos, delete menu; } + +void UserContextMenu::execChat(const QString &userName) +{ + emit openMessageDialog(userName, true); +} + +void UserContextMenu::execDetails(const QString &userName) +{ + auto *w = new UserInfoBox(client, false, static_cast(parent()), + Qt::Dialog | Qt::WindowTitleHint | Qt::CustomizeWindowHint | Qt::WindowCloseButtonHint); + w->setAttribute(Qt::WA_DeleteOnClose); + w->updateInfo(userName); +} + +void UserContextMenu::execShowGames(const QString &userName) +{ + Command_GetGamesOfUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::gamesOfUserReceived); + client->sendCommand(pend); +} + +void UserContextMenu::execAddToBuddy(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromBuddy(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("buddy"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execAddToIgnore(const QString &userName) +{ + Command_AddToList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execRemoveFromIgnore(const QString &userName) +{ + Command_RemoveFromList cmd; + cmd.set_list("ignore"); + cmd.set_user_name(userName.toStdString()); + client->sendCommand(client->prepareSessionCommand(cmd)); +} + +void UserContextMenu::execBan(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarn(const QString &userName) +{ + Command_GetUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUser_processUserInfoResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execBanHistory(const QString &userName) +{ + Command_GetBanHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::banUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execWarnHistory(const QString &userName) +{ + Command_GetWarnHistory cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::warnUserHistory_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdminNotes(const QString &userName) +{ + Command_GetAdminNotes cmd; + cmd.set_user_name(userName.toStdString()); + auto *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::getAdminNotes_processResponse); + client->sendCommand(pend); +} + +void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_mod(shouldBeMod); + cmd.set_should_be_judge(shouldBeJudge); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index b0ff89816..28173bfbc 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -74,6 +74,27 @@ public: int playerId, const QString &deckHash, ChatView *chatView = nullptr); + + const UserListProxy *getUserListProxy() const + { + return userListProxy; + } + + // Individual action entry points β€” used by UserInfoPopup to trigger + // actions without re-running the full context menu flow. + void execChat(const QString &userName); + void execDetails(const QString &userName); + void execShowGames(const QString &userName); + void execAddToBuddy(const QString &userName); + void execRemoveFromBuddy(const QString &userName); + void execAddToIgnore(const QString &userName); + void execRemoveFromIgnore(const QString &userName); + void execBan(const QString &userName); + void execWarn(const QString &userName); + void execBanHistory(const QString &userName); + void execWarnHistory(const QString &userName); + void execAdminNotes(const QString &userName); + void execAdjustMod(const QString &userName, bool shouldBeMod, bool shouldBeJudge); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index e41ae6e75..e6cf38787 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -5,6 +5,7 @@ #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" #include "../../interface/widgets/utility/get_text_with_max.h" +#include "user_card_settings_dialog.h" #include #include @@ -61,11 +62,13 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren buttonsLayout->addWidget(&editButton); buttonsLayout->addWidget(&passwordButton); buttonsLayout->addWidget(&avatarButton); + buttonsLayout->addWidget(&bannerCardButton); mainLayout->addLayout(buttonsLayout, 7, 0, 1, 3); connect(&editButton, &QPushButton::clicked, this, &UserInfoBox::actEdit); connect(&passwordButton, &QPushButton::clicked, this, &UserInfoBox::actPassword); connect(&avatarButton, &QPushButton::clicked, this, &UserInfoBox::actAvatar); + connect(&bannerCardButton, &QPushButton::clicked, this, &UserInfoBox::actBannerCard); } setWindowTitle(tr("User Information")); @@ -83,11 +86,15 @@ void UserInfoBox::retranslateUi() editButton.setText(tr("Edit")); passwordButton.setText(tr("Change password")); avatarButton.setText(tr("Change avatar")); + bannerCardButton.setText(tr("Edit Banner Card")); } void UserInfoBox::updateInfo(const ServerInfo_User &user) { - userLevel = UserLevelFlags(user.user_level()); + currentUserInfo = user; + hasUserInfo = true; + + const UserLevelFlags userLevel(user.user_level()); pawnColors = user.pawn_colors(); privLevel = QString::fromStdString(user.privlevel()); @@ -306,6 +313,48 @@ void UserInfoBox::actAvatar() client->sendCommand(pend); } +void UserInfoBox::actBannerCard() +{ + CardArtParams initial; + if (hasUserInfo && currentUserInfo.has_card_art_params()) { + const auto &cap = currentUserInfo.card_art_params(); + initial.cardName = QString::fromStdString(cap.card_name()); + initial.marginPctL = cap.margin_pct_l(); + initial.marginPctR = cap.margin_pct_r(); + initial.verticalOffset = cap.vertical_offset(); + initial.zoom = cap.zoom(); + } + + UserCardArtSettingsDialog dlg(initial, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + const CardArtParams p = dlg.params(); + + Command_SetCardArtParams cmd; + cmd.set_card_name(p.cardName.toStdString()); + if (!p.cardName.isEmpty()) { + cmd.set_margin_pct_l(p.marginPctL); + cmd.set_margin_pct_r(p.marginPctR); + cmd.set_vertical_offset(p.verticalOffset); + cmd.set_zoom(p.zoom); + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, [p, this](const Response &r) { + if (r.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), + tr("The selected card is blacklisted on this server or another error occurred.")); + } else { + updateInfo(nameLabel.text()); // re-fetch so currentUserInfo reflects the change + QMessageBox::information(this, tr("Information"), + p.cardName.isEmpty() ? tr("Banner card removed.") : tr("Banner card updated.")); + } + }); + client->sendCommand(pend); +} + void UserInfoBox::processEditResponse(const Response &r) { switch (r.response_code()) { diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 055ac0096..955cb9d3d 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -12,6 +12,7 @@ #include #include #include +#include #include class AbstractClient; @@ -25,9 +26,11 @@ private: bool editable; QLabel avatarPic, userLevelIcon, nameLabel, realNameLabel1, realNameLabel2, countryLabel1, countryLabel2, countryLabel3, userLevelLabel1, userLevelLabel2, accountAgeLabel1, accountAgeLabel2; - QPushButton editButton, passwordButton, avatarButton; + QPushButton editButton, passwordButton, avatarButton, bannerCardButton; QPixmap avatarPixmap; bool hasAvatar; + ServerInfo_User currentUserInfo; + bool hasUserInfo = false; UserLevelFlags userLevel; ServerInfo_User::PawnColorsOverride pawnColors; QString privLevel; @@ -37,6 +40,7 @@ private: public: UserInfoBox(AbstractClient *_client, bool editable, QWidget *parent = nullptr, Qt::WindowFlags flags = {}); void retranslateUi(); + private slots: void processResponse(const Response &r); void processEditResponse(const Response &r); @@ -47,6 +51,7 @@ private slots: void actEditInternal(const Response &r); void actPassword(); void actAvatar(); + void actBannerCard(); public slots: void updateInfo(const ServerInfo_User &user); void updateInfo(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp new file mode 100644 index 000000000..fd62d5ddf --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -0,0 +1,656 @@ +#include "user_info_popup.h" + +#include "../../interface/pixel_map_generator.h" +#include "../../interface/widgets/tabs/tab_supervisor.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ── Compact game row delegate ───────────────────────────────────────────────── + +class PopupGameDelegate : public QStyledItemDelegate +{ +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QSize sizeHint(const QStyleOptionViewItem &, const QModelIndex &) const override + { + return QSize(0, 38); + } + + void paint(QPainter *p, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + const QVariant var = index.data(PopupRoles::GameData); + if (!var.isValid()) { + QStyledItemDelegate::paint(p, option, index); + return; + } + + p->save(); + p->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing); + + const QRect rect = option.rect; + const ServerInfo_Game game = var.value(); + const bool selected = option.state & QStyle::State_Selected; + + p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26)); + + // State colour dot + const QColor dot = game.started() ? QColor(239, 68, 68) + : (game.player_count() >= game.max_players()) ? QColor(249, 115, 22) + : game.with_password() ? QColor(59, 130, 246) + : QColor(34, 197, 94); + p->setPen(Qt::NoPen); + p->setBrush(dot); + p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); + + // Game title (bold, elided) + QFont tf = option.font; + tf.setBold(true); + p->setFont(tf); + p->setPen(QColor(205, 215, 230)); + const int textX = rect.left() + 26; + const int countW = 52; + const int titleW = rect.width() - textX - countW - 6; + p->drawText(QRect(textX, rect.top(), titleW, rect.height()), Qt::AlignVCenter | Qt::AlignLeft, + QFontMetrics(tf).elidedText(QString::fromStdString(game.description()), Qt::ElideRight, titleW)); + + // Player count + const bool full = game.player_count() >= game.max_players(); + p->setFont(option.font); + p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150)); + p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), + Qt::AlignVCenter | Qt::AlignRight, + QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players())); + + // Row separator + p->setPen(QColor(24, 32, 44)); + p->drawLine(rect.bottomLeft(), rect.bottomRight()); + + p->restore(); + } +}; + +// ── UserInfoHeaderWidget ────────────────────────────────────────────────────── + +UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(HeaderHeight); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); +} + +void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user, + bool online, + const QPixmap &avatar, + const QPixmap &cardArt, + const CardArtParams ¶ms) +{ + m_user = user; + m_online = online; + m_avatar = avatar; + m_cardArt = cardArt; + m_params = params; + update(); +} + +void UserInfoHeaderWidget::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const UserLevelFlags level(m_user.user_level()); + const QString userName = QString::fromStdString(m_user.name()); + const QString privLevel = QString::fromStdString(m_user.privlevel()); + + // Dark base + p.fillRect(rect, QColor(14, 18, 26)); + + // ── Card art background ─────────────────────────────────────────────────── + if (!m_cardArt.isNull()) { + const int w = rect.width(); + const int h = rect.height(); + const int mL = qRound(w * m_params.marginPctL); + const int mR = qRound(w * m_params.marginPctR); + const int dW = w - mL - mR; + + const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height()); + const double scale = base * m_params.zoom; + const int sW = qRound(m_cardArt.width() * scale); + const int sH = qRound(m_cardArt.height() * scale); + + const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const int srcX = (sW - dW) / 2; + const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h)); + + QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + QLinearGradient g(0, 0, img.width(), 0); + g.setColorAt(0.00, Qt::transparent); + g.setColorAt(0.18, Qt::white); + g.setColorAt(0.82, Qt::white); + g.setColorAt(1.00, Qt::transparent); + mask.fillRect(img.rect(), g); + } + p.setOpacity(0.48); + p.drawImage(mL, 0, img); + p.setOpacity(1.0); + } + + // Bottom gradient overlay so avatar and text are always legible + { + QLinearGradient ov(0, 0, 0, rect.height()); + ov.setColorAt(0.0, QColor(14, 18, 26, 0)); + ov.setColorAt(0.55, QColor(14, 18, 26, 110)); + ov.setColorAt(1.0, QColor(14, 18, 26, 230)); + p.fillRect(rect, ov); + } + + // ── Avatar ──────────────────────────────────────────────────────────────── + const QColor accent = [&]() -> QColor { + if (level.testFlag(ServerInfo_User::IsAdmin)) { + return QColor(245, 158, 11); + } + if (level.testFlag(ServerInfo_User::IsModerator)) { + return QColor(59, 130, 246); + } + if (level.testFlag(ServerInfo_User::IsJudge)) { + return QColor(168, 85, 247); + } + return QColor(100, 116, 139); + }(); + + const int ax = LeftPad; + const int ay = rect.height() - AvatarSize - 10; + const QRect ar(ax, ay, AvatarSize, AvatarSize); + + QPainterPath clip; + clip.addEllipse(ar); + p.save(); + p.setClipPath(clip); + + if (!m_avatar.isNull()) { + p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + } else { + p.setPen(Qt::NoPen); + p.setBrush(accent.darker(200)); + p.drawEllipse(ar); + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel); + p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn); + } + p.restore(); + + // Status ring + p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5)); + p.setBrush(Qt::NoBrush); + p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25)); + + // ── Username + badge ────────────────────────────────────────────────────── + const int tx = ax + AvatarSize + AvatarToTextGap; + const int tw = rect.width() - tx - 8; + + QFont nf = font(); + nf.setBold(true); + nf.setPointSizeF(nf.pointSizeF() * 1.12); + p.setFont(nf); + p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115)); + p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft, + QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw)); + + // Level / priv badge + struct + { + QString text; + QColor color; + } badge; + if (level.testFlag(ServerInfo_User::IsAdmin)) { + badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsModerator)) { + badge = {"MOD", QColor(59, 130, 246)}; + } else if (level.testFlag(ServerInfo_User::IsJudge)) { + badge = {"JUDGE", QColor(168, 85, 247)}; + } else if (privLevel == "VIP") { + badge = {"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badge = {"DONATOR", QColor(249, 115, 22)}; + } + + if (!badge.text.isEmpty()) { + QFont bf = font(); + bf.setPointSizeF(bf.pointSizeF() * 0.70); + bf.setBold(true); + p.setFont(bf); + const QFontMetrics bfm(bf); + const int bw = bfm.horizontalAdvance(badge.text) + 10; + const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15); + p.setPen(Qt::NoPen); + p.setBrush(badge.color.darker(160)); + p.drawRoundedRect(br, 3, 3); + p.setPen(badge.color.lighter(150)); + p.drawText(br, Qt::AlignCenter, badge.text); + } +} + +// ── UserInfoPopup ───────────────────────────────────────────────────────────── + +UserInfoPopup::UserInfoPopup(TabSupervisor *ts, + AbstractClient *client, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + QWidget *parent) + : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache), + m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap) +{ + setAttribute(Qt::WA_ShowWithoutActivating); + setFixedWidth(PopupWidth); + setFrameShape(QFrame::NoFrame); + buildUi(); +} + +void UserInfoPopup::buildUi() +{ + setStyleSheet(QStringLiteral("UserInfoPopup {" + " background:#0e1218;" + " border:1px solid #1e2838;" + " border-radius:8px;" + "}")); + + auto *root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // Header + m_header = new UserInfoHeaderWidget(this); + root->addWidget(m_header); + + // Action area β€” rebuilt per user + m_actionArea = new QWidget(this); + m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;")); + root->addWidget(m_actionArea); + + // Thin separator + auto *sep = new QFrame(this); + sep->setFrameShape(QFrame::HLine); + sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;")); + root->addWidget(sep); + + // Games header row + auto *gh = new QHBoxLayout; + gh->setContentsMargins(10, 4, 8, 2); + auto *gl = new QLabel(tr("Games"), this); + gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;")); + gh->addWidget(gl); + gh->addStretch(); + m_refreshBtn = new QPushButton(QStringLiteral("↻"), this); + m_refreshBtn->setFixedSize(20, 20); + m_refreshBtn->setFlat(true); + m_refreshBtn->setStyleSheet( + QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}" + "QPushButton:hover{color:white;}")); + connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); + gh->addWidget(m_refreshBtn); + root->addLayout(gh); + + // Status label + m_gamesStatus = new QLabel(this); + m_gamesStatus->setAlignment(Qt::AlignCenter); + m_gamesStatus->setStyleSheet( + QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;")); + root->addWidget(m_gamesStatus); + + // Games list + m_gamesModel = new QStandardItemModel(this); + m_gamesView = new QListView(this); + m_gamesView->setModel(m_gamesModel); + m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView)); + m_gamesView->setFrameShape(QFrame::NoFrame); + m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_gamesView->setMaximumHeight(220); + m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}" + "QListView::item:selected{background:#232e42;}")); + m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); + + root->addWidget(m_gamesView); + + // Close button β€” positioned absolutely in the top-right corner + m_closeBtn = new QPushButton(QStringLiteral("βœ•"), this); + m_closeBtn->setFixedSize(22, 22); + m_closeBtn->setFlat(true); + m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;" + "border:none;border-radius:11px;font-size:10px;}" + "QPushButton:hover{color:white;background:rgba(200,50,50,200);}")); + connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); +} + +// ── Action button factory ───────────────────────────────────────────────────── + +static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p) +{ + auto *b = new QPushButton(label, p); + b->setToolTip(tip); + b->setFixedHeight(26); + b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + b->setStyleSheet(QStringLiteral("QPushButton{" + " background:#192030;color:#b8c8de;border:1px solid #263040;" + " border-radius:4px;font-size:11px;padding:0 4px;" + "}" + "QPushButton:hover{background:#223050;color:white;}" + "QPushButton:pressed{background:#162030;}" + "QPushButton:disabled{color:#384858;border-color:#192030;}")); + return b; +} + +void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + // Clear previous contents + delete m_actionArea->layout(); + const auto old = m_actionArea->findChildren(QString{}, Qt::FindDirectChildrenOnly); + for (auto *w : old) { + w->deleteLater(); + } + + const QString name = QString::fromStdString(userInfo.name()); + const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level()); + const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name())); + const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator); + const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin); + const auto their = UserLevelFlags(userInfo.user_level()); + const bool isReg = their.testFlag(ServerInfo_User::IsRegistered); + + auto *grid = new QGridLayout(m_actionArea); + grid->setContentsMargins(8, 6, 8, 6); + grid->setSpacing(4); + + int row = 0, col = 0; + const int cols = 3; + auto add = [&](QPushButton *btn) { + grid->addWidget(btn, row, col); + if (++col == cols) { + col = 0; + ++row; + } + }; + + // ── Always visible ──────────────────────────────────────────────────────── + auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea); + chat->setEnabled(!isSelf && online); + connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); }); + add(chat); + + auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea); + connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); }); + add(prof); + + auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea); + games->setEnabled(!isSelf && online); + connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); + add(games); + + // ── Buddy / ignore (registered users only) ──────────────────────────────── + if (!isSelf && isReg) { + if (isBuddy) { + auto *b = makeBtn(tr("βˆ’ Buddy"), tr("Remove from buddy list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); }); + add(b); + } + if (isIgnored) { + auto *b = makeBtn(tr("βˆ’ Ignore"), tr("Remove from ignore list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); }); + add(b); + } else { + auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); }); + add(b); + } + } + + // ── Moderator actions ───────────────────────────────────────────────────── + if (!isSelf && (isMod || isAdmin)) { + if (col != 0) { + ++row; + col = 0; + } // start mod section on a fresh row + + auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea); + auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea); + auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea); + auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea); + connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); }); + connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); }); + connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); }); + connect(wLog, &QPushButton::clicked, this, [this, name] { emit warnHistoryRequested(name); }); + add(ban); + add(warn); + add(bLog); + add(wLog); + } + + // ── Admin actions ───────────────────────────────────────────────────────── + if (!isSelf && isAdmin) { + auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea); + connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); }); + add(notes); + + if (their.testFlag(ServerInfo_User::IsModerator)) { + auto *b = makeBtn(tr("βˆ’ Mod"), tr("Demote from moderator"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); }); + add(b); + } + if (their.testFlag(ServerInfo_User::IsJudge)) { + auto *b = makeBtn(tr("βˆ’ Judge"), tr("Demote from judge"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); }); + add(b); + } else if (isReg) { + auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea); + connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); }); + add(b); + } + } + + m_actionArea->adjustSize(); +} + +void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) +{ + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + adjustSize(); +} + +void UserInfoPopup::onGamesContextMenu(const QPoint &pos) +{ + const QModelIndex idx = m_gamesView->indexAt(pos); + if (!idx.isValid()) { + return; + } + + const QVariant var = idx.data(PopupRoles::GameData); + if (!var.isValid()) { + return; + } + const ServerInfo_Game game = var.value(); + + QMenu menu(this); + menu.setStyleSheet( + QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}" + "QMenu::item:selected{background:#223050;}")); + + const bool canJoin = !game.started() && game.player_count() < game.max_players(); + QAction *join = menu.addAction(tr("Join game")); + join->setEnabled(canJoin); + + QAction *spec = nullptr; + if (game.spectators_allowed()) { + spec = menu.addAction(tr("Spectate")); + } + + const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos)); + if (!chosen) { + return; + } + + if (chosen == join) { + emit joinGameRequested(game.game_id(), game.room_id(), false); + } else if (spec && chosen == spec) { + emit joinGameRequested(game.game_id(), game.room_id(), true); + } +} + +// ── showForUser ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::showForUser(const QString &userName, + const ServerInfo_User &userInfo, + bool online, + bool isBuddy, + bool isIgnored) +{ + m_currentUser = userName; + m_currentUserInfo = userInfo; + m_currentOnline = online; + + // Header + const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{}; + const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName)) + ? m_cardArtParamsMap->value(userName) + : CardArtParams{}; + const QString artKey = userName + u'|' + params.cardName; + const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{}; + m_header->setUserData(userInfo, online, avatar, cardArt, params); + + // Actions + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + + // Games list reset + m_gamesModel->clear(); + m_gamesView->hide(); + m_gamesStatus->setText(tr("Loading games…")); + m_gamesStatus->show(); + + // Close button β€” top-right corner, above everything + m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6); + m_closeBtn->raise(); + + adjustSize(); + fetchGames(); +} + +// ── Games fetch ─────────────────────────────────────────────────────────────── + +void UserInfoPopup::fetchGames() +{ + if (!m_client || m_currentUser.isEmpty()) { + return; + } + + Command_GetGamesOfUser cmd; + cmd.set_user_name(m_currentUser.toStdString()); + + const QString snapshot = m_currentUser; + PendingCommand *pend = m_client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); + m_client->sendCommand(pend); +} + +void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +{ + if (forUser != m_currentUser) { + return; // stale response β€” different user showing now + } + + m_gamesModel->clear(); + + if (r.response_code() != Response::RespOk) { + m_gamesStatus->setText(tr("Could not load games.")); + m_gamesStatus->show(); + m_gamesView->hide(); + return; + } + + const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); + if (resp.game_list_size() == 0) { + m_gamesStatus->setText(tr("No active games.")); + m_gamesStatus->show(); + m_gamesView->hide(); + return; + } + + for (int i = 0; i < resp.game_list_size(); ++i) { + auto *item = new QStandardItem; + item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); + item->setEditable(false); + m_gamesModel->appendRow(item); + } + + m_gamesStatus->hide(); + m_gamesView->show(); + + // Fit exactly to the number of visible rows, scroll when more than 5 + constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint + constexpr int maxRows = 5; + const int count = m_gamesModel->rowCount(); + const int visible = qMin(count, maxRows); + m_gamesView->setFixedHeight(visible * rowH + 2); + m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + + adjustSize(); +} + +void UserInfoPopup::refreshGames() +{ + m_gamesModel->clear(); + m_gamesView->hide(); + m_gamesStatus->setText(tr("Loading games…")); + m_gamesStatus->show(); + fetchGames(); +} + +// ── Mouse events ────────────────────────────────────────────────────────────── + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +void UserInfoPopup::enterEvent(QEnterEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +#else +void UserInfoPopup::enterEvent(QEvent *e) +{ + QFrame::enterEvent(e); + emit mouseEnteredPopup(); +} +#endif +void UserInfoPopup::leaveEvent(QEvent *e) +{ + QFrame::leaveEvent(e); + emit mouseLeftPopup(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h new file mode 100644 index 000000000..0e03147c4 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -0,0 +1,181 @@ +#ifndef COCKATRICE_USER_INFO_POPUP_H +#define COCKATRICE_USER_INFO_POPUP_H + +#include "../../interface/widgets/server/game_type_map.h" +#include "user_list_painter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class QLabel; +class QPushButton; +class TabSupervisor; + +// ── Roles ───────────────────────────────────────────────────────────────────── + +namespace PopupRoles +{ +constexpr int GameData = Qt::UserRole + 10; +} + +// ── Header widget ───────────────────────────────────────────────────────────── + +/** + * @class UserInfoHeaderWidget + * @brief Paints the enlarged banner card art + circular avatar section at the + * top of the UserInfoPopup. + * + * Layout mirrors UserListPainter but at a larger scale: the card art fills the + * full width as a semi-transparent background, a bottom gradient ensures the + * avatar and username text remain legible, and the status ring colour matches + * the UserListPainter convention. + */ +class UserInfoHeaderWidget : public QWidget +{ + Q_OBJECT + + static constexpr int HeaderHeight = 130; + static constexpr int AvatarSize = 68; + static constexpr int AvatarPawnSize = 46; + static constexpr int LeftPad = 14; + static constexpr int AvatarToTextGap = 10; + +public: + explicit UserInfoHeaderWidget(QWidget *parent = nullptr); + + void setUserData(const ServerInfo_User &user, + bool online, + const QPixmap &avatar, + const QPixmap &cardArt, + const CardArtParams ¶ms); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + ServerInfo_User m_user; + bool m_online = false; + QPixmap m_avatar; + QPixmap m_cardArt; + CardArtParams m_params; +}; + +// ── Main popup ──────────────────────────────────────────────────────────────── + +/** + * @class UserInfoPopup + * @brief Floating panel showing an enlarged user card, quick action buttons, + * and a live scrollable games list. + * + * Lifecycle (mirrors DeckEditorDeckDockWidget): + * - showForUser() β€” populate, position externally, call show() + * - mouseEnteredPopup / mouseLeftPopup β€” caller manages hide timer + * - closeRequested() β€” emitted by the internal close button + * + * The popup is a Qt::Tool frameless child so windowOpacity animations and + * move() in screen coordinates work identically to CardInfoPictureEnlargedWidget. + * + * Action signals map 1-to-1 to UserContextMenu::exec*() methods so all action + * logic stays in one place. + */ +class UserInfoPopup : public QFrame +{ + Q_OBJECT + + static constexpr int PopupWidth = 316; + +public: + explicit UserInfoPopup(TabSupervisor *tabSupervisor, + AbstractClient *client, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap, + QWidget *parent); + + /** + * Populate the popup for @p userName and kick off a game list fetch. + * Call show() / move() externally after this. + */ + void + showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + void fetchGames(); + + [[nodiscard]] QString currentUser() const + { + return m_currentUser; + } + + /** Called when buddy/ignore status changes externally while popup is open. */ + void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + +signals: + void mouseEnteredPopup(); + void mouseLeftPopup(); + void closeRequested(); + + /** Emitted when the user requests joining or spectating a game in the list. */ + void joinGameRequested(int gameId, int roomId, bool asSpectator); + + // ── Action signals β€” connect to UserContextMenu::exec*() ────────────────── + void chatRequested(const QString &userName); + void detailsRequested(const QString &userName); + void showGamesRequested(const QString &userName); + void addBuddyRequested(const QString &userName); + void removeBuddyRequested(const QString &userName); + void addIgnoreRequested(const QString &userName); + void removeIgnoreRequested(const QString &userName); + void banRequested(const QString &userName); + void warnRequested(const QString &userName); + void banHistoryRequested(const QString &userName); + void warnHistoryRequested(const QString &userName); + void adminNotesRequested(const QString &userName); + void promoteToModRequested(const QString &userName); + void demoteFromModRequested(const QString &userName); + void promoteToJudgeRequested(const QString &userName); + void demoteFromJudgeRequested(const QString &userName); + +protected: +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + void enterEvent(QEnterEvent *e) override; +#else + void enterEvent(QEvent *e) override; +#endif + void leaveEvent(QEvent *e) override; + +private slots: + void refreshGames(); + void onGamesReceived(const Response &r, const QString &forUser); + void onGamesContextMenu(const QPoint &pos); + +private: + void buildUi(); + void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + + TabSupervisor *m_ts; + AbstractClient *m_client; + const QMap *m_avatarCache; + const QMap *m_cardArtCache; + const QMap *m_cardArtParamsMap; + + QString m_currentUser; + ServerInfo_User m_currentUserInfo; + bool m_currentOnline = false; + + UserInfoHeaderWidget *m_header; + QWidget *m_actionArea; ///< rebuilt per user + QListView *m_gamesView; + QStandardItemModel *m_gamesModel; + QLabel *m_gamesStatus; + QPushButton *m_closeBtn; + QPushButton *m_refreshBtn; +}; + +#endif // COCKATRICE_USER_INFO_POPUP_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp index 4bc2c84d6..216420006 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp @@ -42,6 +42,9 @@ void UserListManager::handleDisconnect() delete ownUserInfo; ownUserInfo = nullptr; + + // Full rebuild β€” all lists are gone + emit listReset(); } void UserListManager::setOwnUserInfo(const ServerInfo_User &userInfo) @@ -63,74 +66,77 @@ void UserListManager::processListUsersResponse(const Response &response) const int userListSize = resp.user_list_size(); for (int i = 0; i < userListSize; ++i) { const ServerInfo_User &info = resp.user_list(i); - const QString &userName = QString::fromStdString(info.name()); - onlineUsers.insert(userName, info); + onlineUsers.insert(QString::fromStdString(info.name()), info); } + + // Bulk load complete β€” widgets rebuild once from the now-populated map + emit listReset(); } void UserListManager::processUserJoinedEvent(const Event_UserJoined &event) { const auto &info = event.user_info(); - const QString &userName = QString::fromStdString(info.name()); - onlineUsers.insert(userName, info); + const QString name = QString::fromStdString(info.name()); + onlineUsers.insert(name, info); + + emit userJoinedOnline(info); } void UserListManager::processUserLeftEvent(const Event_UserLeft &event) { - const auto &userName = QString::fromStdString(event.name()); - onlineUsers.remove(userName); + const QString name = QString::fromStdString(event.name()); + onlineUsers.remove(name); + + emit userLeftOnline(name); } void UserListManager::buddyListReceived(const QList &_buddyList) { for (const auto &user : _buddyList) { - const auto &userName = QString::fromStdString(user.name()); - buddyUsers.insert(userName, user); + buddyUsers.insert(QString::fromStdString(user.name()), user); } + + // Bulk load β€” one reset covers all newly added entries + emit listReset(); } void UserListManager::ignoreListReceived(const QList &_ignoreList) { for (const auto &user : _ignoreList) { - const auto &userName = QString::fromStdString(user.name()); - ignoredUsers.insert(userName, user); + ignoredUsers.insert(QString::fromStdString(user.name()), user); } + + // Bulk load β€” one reset covers all newly added entries + emit listReset(); } void UserListManager::processAddToListEvent(const Event_AddToList &event) { const auto &user = event.user_info(); - const auto &userName = QString::fromStdString(user.name()); + const QString userName = QString::fromStdString(user.name()); + const QString listType = QString::fromStdString(event.list_name()); - const auto &userListType = QString::fromStdString(event.list_name()); - - QMap *userMap; - if (userListType == "buddy") { - userMap = &buddyUsers; - } else if (userListType == "ignore") { - userMap = &ignoredUsers; - } else { - return; + if (listType == "buddy") { + buddyUsers.insert(userName, user); + emit addedToBuddyList(user); + } else if (listType == "ignore") { + ignoredUsers.insert(userName, user); + emit addedToIgnoreList(user); } - - userMap->insert(userName, user); } void UserListManager::processRemoveFromListEvent(const Event_RemoveFromList &event) { - const auto &userListType = QString::fromStdString(event.list_name()); - const auto &userName = QString::fromStdString(event.user_name()); + const QString listType = QString::fromStdString(event.list_name()); + const QString userName = QString::fromStdString(event.user_name()); - QMap *userMap; - if (userListType == "buddy") { - userMap = &buddyUsers; - } else if (userListType == "ignore") { - userMap = &ignoredUsers; - } else { - return; + if (listType == "buddy") { + buddyUsers.remove(userName); + emit removedFromBuddyList(userName); + } else if (listType == "ignore") { + ignoredUsers.remove(userName); + emit removedFromIgnoreList(userName); } - - userMap->remove(userName); } bool UserListManager::isOwnUserRegistered() const @@ -155,16 +161,9 @@ bool UserListManager::isUserIgnored(const QString &userName) const const ServerInfo_User *UserListManager::getOnlineUser(const QString &userName) const { - const QString &userNameToMatchLower = userName.toLower(); - - const auto it = - std::find_if(onlineUsers.begin(), onlineUsers.end(), [&userNameToMatchLower](const ServerInfo_User &user) { - return userNameToMatchLower == QString::fromStdString(user.name()).toLower(); - }); - - if (it != onlineUsers.end()) { - return &*it; - } - - return nullptr; + const QString lower = userName.toLower(); + const auto it = std::find_if(onlineUsers.begin(), onlineUsers.end(), [&lower](const ServerInfo_User &user) { + return lower == QString::fromStdString(user.name()).toLower(); + }); + return it != onlineUsers.end() ? &*it : nullptr; } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.h b/cockatrice/src/interface/widgets/server/user/user_list_manager.h index f09284bd0..6238f0799 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.h @@ -47,15 +47,17 @@ public: explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr); ~UserListManager() override; - [[nodiscard]] QMap getAllUsersList() const + [[nodiscard]] const QMap &getAllUsersList() const { return onlineUsers; } - [[nodiscard]] QMap getBuddyList() const + + [[nodiscard]] const QMap &getBuddyList() const { return buddyUsers; } - [[nodiscard]] QMap getIgnoreList() const + + [[nodiscard]] const QMap &getIgnoreList() const { return ignoredUsers; } @@ -71,8 +73,26 @@ public slots: void handleDisconnect(); signals: - void userLeft(const QString &userName); - void userJoined(const ServerInfo_User &userInfo); + /** + * The entire list needs to be rebuilt from scratch. + * Fired on disconnect, reconnect, and initial bulk loads + * (Command_ListUsers response, initial buddy/ignore lists). + */ + void listReset(); + + // ── Online user presence ────────────────────────────────────────────────── + /** A user came online (or joined the room). Full ServerInfo_User available. */ + void userJoinedOnline(const ServerInfo_User &user); + /** A user went offline (or left the room). */ + void userLeftOnline(const QString &userName); + + // ── Buddy list mutations (individual, post-login) ───────────────────────── + void addedToBuddyList(const ServerInfo_User &user); + void removedFromBuddyList(const QString &userName); + + // ── Ignore list mutations (individual, post-login) ──────────────────────── + void addedToIgnoreList(const ServerInfo_User &user); + void removedFromIgnoreList(const QString &userName); }; #endif // COCKATRICE_USER_LIST_MANAGER_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp new file mode 100644 index 000000000..b5541b692 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -0,0 +1,342 @@ +#include "user_list_painter.h" + +#include "../../interface/pixel_map_generator.h" + +#include +#include +#include +#include +#include +#include + +static constexpr int RowHeight = 72; +static constexpr int AvatarSize = 36; +static constexpr int LeftPadding = 14; +static constexpr int TextSpacing = 10; + +QSize UserListPainter::sizeHint() +{ + return QSize(0, RowHeight); +} + +QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online) +{ + QColor accentColor; + + if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { + accentColor = QColor(245, 158, 11); + } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { + accentColor = QColor(59, 130, 246); + } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { + accentColor = QColor(168, 85, 247); + } else { + accentColor = QColor(100, 116, 139); + } + + if (!online) { + accentColor = accentColor.darker(160); + } + + return accentColor; +} + +int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRect &rect) +{ + int scrollBarWidth = 0; + + if (const auto *scrollArea = qobject_cast(option.widget)) { + const QScrollBar *sb = scrollArea->verticalScrollBar(); + if (sb && sb->isVisible()) { + scrollBarWidth = sb->width(); + } + } + + const int viewportRight = option.widget ? option.widget->width() - scrollBarWidth : rect.right(); + + return qMin(rect.right(), viewportRight - 4); +} + +void UserListPainter::drawBackground(QPainter *painter, + const QRectF &cardRect, + const QColor &accentColor, + bool selected) +{ + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); + bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + + painter->setPen(Qt::NoPen); + painter->setBrush(bg); + painter->drawRoundedRect(cardRect, 6, 6); + + painter->setBrush(accentColor); + painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); +} + +static QString makeKey(const QString &user, const QString &card) +{ + return user + u'|' + card; +} + +void UserListPainter::drawCardArt(QPainter *painter, + const QRect &rect, + int cardRight, + const QString &userName, + const QMap *cardArtCache, + const CardArtParams ¶ms, + const QPixmap *overridePixmap = nullptr) +{ + QPixmap art; + + if (overridePixmap && !overridePixmap->isNull()) { + art = *overridePixmap; + } else { + if (!cardArtCache) { + return; + } + + const QString key = makeKey(userName, params.cardName); + + if (!cardArtCache->contains(key)) { + return; + } + + art = cardArtCache->value(key); + } + + if (art.isNull()) { + return; + } + + const int cardH = rect.height() - 4; + const int totalW = cardRight - rect.left(); + const int marginL = qRound(totalW * params.marginPctL); + const int marginR = qRound(totalW * params.marginPctR); + const int drawW = totalW - marginL - marginR; + + const double basescale = qMax(double(drawW) / art.width(), double(cardH) / art.height()); + const double scale = basescale * params.zoom; + + const int scaledW = qRound(art.width() * scale); + const int scaledH = qRound(art.height() * scale); + + const QPixmap scaled = art.scaled(scaledW, scaledH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + + const int srcX = (scaledW - drawW) / 2; + const int srcY = qRound((scaledH - cardH) * params.verticalOffset); + + // Clamp srcY so we never copy outside the pixmap bounds + const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH)); + + QImage img = + scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + + { + QPainter mask(&img); + mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); + + QLinearGradient grad(0, 0, img.width(), 0); + grad.setColorAt(0.00, Qt::transparent); + grad.setColorAt(0.22, Qt::white); + grad.setColorAt(0.78, Qt::white); + grad.setColorAt(1.00, Qt::transparent); + + mask.fillRect(img.rect(), grad); + } + + painter->setOpacity(0.55); + painter->drawImage(rect.left() + marginL, rect.top() + 2, img); + painter->setOpacity(1.0); +} + +QRect UserListPainter::getAvatarRect(const QRect &rect) +{ + const int avatarX = rect.left() + LeftPadding; + const int avatarY = rect.top() + (rect.height() - AvatarSize) / 2; + return QRect(avatarX, avatarY, AvatarSize, AvatarSize); +} + +void UserListPainter::drawAvatar(QPainter *painter, + const QRect &avatarRect, + const QString &userName, + const QColor &accentColor, + const UserLevelFlags &userLevel, + const ServerInfo_User &userInfo, + const QString &privLevel, + const QMap *avatarCache) +{ + QPainterPath clipPath; + clipPath.addEllipse(avatarRect); + + painter->save(); + painter->setClipPath(clipPath); + + bool drewAvatar = false; + + if (avatarCache && avatarCache->contains(userName)) { + const QPixmap &avatar = avatarCache->value(userName); + if (!avatar.isNull()) { + painter->drawPixmap( + avatarRect, avatar.scaled(avatarRect.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + drewAvatar = true; + } + } + + if (!drewAvatar) { + painter->setBrush(accentColor.darker(200)); + painter->setPen(Qt::NoPen); + painter->drawEllipse(avatarRect); + + const QPixmap pawn = + UserLevelPixmapGenerator::generatePixmap(24, userLevel, userInfo.pawn_colors(), false, privLevel); + + painter->drawPixmap(avatarRect.center().x() - 12, avatarRect.center().y() - 12, pawn); + } + + painter->restore(); +} + +void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online) +{ + const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95); + + painter->setPen(QPen(statusColor, 2)); + painter->setBrush(Qt::NoBrush); + painter->drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); +} + +void UserListPainter::drawUserName(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + int textX, + const QString &userName, + bool online, + bool selected) +{ + QFont nameFont = option.font; + nameFont.setBold(true); + painter->setFont(nameFont); + + const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20); + const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10); + + painter->setPen(QColor(0, 0, 0, 200)); + painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + + painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115)); + painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName); +} + +void UserListPainter::drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo) +{ + const QPixmap flag = CountryPixmapGenerator::generatePixmap(13, QString::fromStdString(userInfo.country())); + if (!flag.isNull()) { + painter->drawPixmap(textX, rect.top() + 46, flag); + } +} + +QList UserListPainter::buildBadges(const UserLevelFlags &userLevel, const QString &privLevel) +{ + QList badges; + + if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { + badges << Badge{"ADMIN", QColor(245, 158, 11)}; + } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { + badges << Badge{"MOD", QColor(59, 130, 246)}; + } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { + badges << Badge{"JUDGE", QColor(168, 85, 247)}; + } + + if (privLevel == "VIP") { + badges << Badge{"VIP", QColor(20, 184, 166)}; + } else if (privLevel == "DONATOR") { + badges << Badge{"DONATOR", QColor(249, 115, 22)}; + } + + return badges; +} + +void UserListPainter::drawBadges(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + const QList &badges, + bool online) +{ + if (badges.isEmpty()) { + return; + } + + QFont badgeFont = option.font; + badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68); + badgeFont.setBold(true); + painter->setFont(badgeFont); + + QFontMetrics fm(badgeFont); + + int totalBadgeW = 0; + for (const Badge &b : badges) { + totalBadgeW += fm.horizontalAdvance(b.text) + 8 + 4; + } + totalBadgeW -= 4; + + int bx = cardRight - 6 - totalBadgeW; + + for (const Badge &b : badges) { + const QColor col = online ? b.color : b.color.darker(180); + const int bw = fm.horizontalAdvance(b.text) + 8; + const QRect br(bx, rect.top() + 44, bw, 13); + + painter->setPen(Qt::NoPen); + painter->setBrush(col.darker(online ? 160 : 220)); + painter->drawRoundedRect(br, 3, 3); + + painter->setPen(col.lighter(online ? 160 : 100)); + painter->drawText(br, Qt::AlignCenter, b.text); + + bx += bw + 4; + } +} + +void UserListPainter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index, + const ServerInfo_User &userInfo, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap) +{ + painter->save(); + painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = option.rect; + const bool online = index.data(Qt::UserRole + 1).toBool(); + const bool selected = option.state & QStyle::State_Selected; + const UserLevelFlags userLevel(userInfo.user_level()); + const QString userName = QString::fromStdString(userInfo.name()); + const QString privLevel = QString::fromStdString(userInfo.privlevel()); + const QColor accentColor = getAccentColor(userLevel, online); + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + const int cardRight = getCardRight(option, rect); + + const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(userName)) + ? cardArtParamsMap->value(userName) + : CardArtParams{}; + + drawBackground(painter, cardRect, accentColor, selected); + drawCardArt(painter, rect, cardRight, userName, cardArtCache, params); + + const QRect avatarRect = getAvatarRect(rect); + drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache); + drawStatusRing(painter, avatarRect, online); + + const int textX = avatarRect.right() + TextSpacing; + drawUserName(painter, option, rect, cardRight, textX, userName, online, selected); + drawCountryFlag(painter, rect, textX, userInfo); + + const QList badges = buildBadges(userLevel, privLevel); + drawBadges(painter, option, rect, cardRight, badges, online); + + painter->restore(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.h b/cockatrice/src/interface/widgets/server/user/user_list_painter.h new file mode 100644 index 000000000..95486b75e --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -0,0 +1,86 @@ +#ifndef COCKATRICE_USER_LIST_PAINTER_H +#define COCKATRICE_USER_LIST_PAINTER_H + +#include "user_level.h" + +#include +#include +#include +#include +#include +#include + +class QPainter; +class QModelIndex; +class QStyleOptionViewItem; +class ServerInfo_User; + +struct CardArtParams +{ + QString cardName = ""; + double marginPctL = 0.33; + double marginPctR = 0.02; + double verticalOffset = 0.35; + double zoom = 1.0; +}; + +class UserListPainter +{ +public: + static void paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index, + const ServerInfo_User &userInfo, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap); + + static QSize sizeHint(); + + static void drawCardArt(QPainter *painter, + const QRect &rect, + int cardRight, + const QString &userName, + const QMap *cardArtCache, + const CardArtParams ¶ms, + const QPixmap *overridePixmap); + +private: + struct Badge + { + QString text; + QColor color; + }; + + static QColor getAccentColor(const UserLevelFlags &userLevel, bool online); + static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect); + static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected); + static QRect getAvatarRect(const QRect &rect); + static void drawAvatar(QPainter *painter, + const QRect &avatarRect, + const QString &userName, + const QColor &accentColor, + const UserLevelFlags &userLevel, + const ServerInfo_User &userInfo, + const QString &privLevel, + const QMap *avatarCache); + static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online); + static void drawUserName(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + int textX, + const QString &userName, + bool online, + bool selected); + static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo); + static QList buildBadges(const UserLevelFlags &userLevel, const QString &privLevel); + static void drawBadges(QPainter *painter, + const QStyleOptionViewItem &option, + const QRect &rect, + int cardRight, + const QList &badges, + bool online); +}; + +#endif // COCKATRICE_USER_LIST_PAINTER_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 11c9b60eb..ed06ea941 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,10 +1,13 @@ #include "user_list_widget.h" +#include "../../../../client/settings/cache_settings.h" +#include "../../../card_picture_loader/card_picture_loader.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../game_selector.h" #include "user_context_menu.h" +#include "user_list_painter.h" #include #include @@ -15,13 +18,18 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include +#include +#include #include BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) @@ -308,7 +316,18 @@ QString AdminNotesDialog::getNotes() const return notes->toPlainText(); } -UserListItemDelegate::UserListItemDelegate(QObject *const parent) : QStyledItemDelegate(parent) +namespace UserListRoles +{ +constexpr int Online = Qt::UserRole + 1; +constexpr int UserInfo = Qt::UserRole + 2; +} // namespace UserListRoles + +UserListItemDelegate::UserListItemDelegate(QObject *const parent, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap) + : QStyledItemDelegate(parent), avatarCache(avatarCache), cardArtCache(cardArtCache), + cardArtParamsMap(cardArtParamsMap) { } @@ -331,6 +350,32 @@ bool UserListItemDelegate::editorEvent(QEvent *event, return QStyledItemDelegate::editorEvent(event, model, option, index); } +QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!SettingsCache::instance().getStyleUserList()) { + return QStyledItemDelegate::sizeHint(option, index); + } + return UserListPainter::sizeHint(); +} + +void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!SettingsCache::instance().getStyleUserList()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + const QVariant var = index.data(UserListRoles::UserInfo); + + if (!var.isValid()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + UserListPainter::paint(painter, option, index, var.value(), avatarCache, cardArtCache, + cardArtParamsMap); +} + UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type) { setUserInfo(_userInfo); @@ -347,11 +392,12 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo) setData(2, Qt::UserRole, QString::fromStdString(userInfo.name())); setData(2, Qt::DisplayRole, QString::fromStdString(userInfo.name())); setData(3, Qt::InitialSortOrderRole, QString::fromStdString(userInfo.privlevel())); + setData(0, UserListRoles::UserInfo, QVariant::fromValue(userInfo)); } void UserListTWI::setOnline(bool online) { - setData(0, Qt::UserRole + 1, online); + setData(0, UserListRoles::Online, online); setData(2, Qt::ForegroundRole, online ? qApp->palette().brush(QPalette::WindowText) : QBrush(Qt::gray)); } @@ -370,8 +416,8 @@ void UserListTWI::setOnline(bool online) bool UserListTWI::operator<(const QTreeWidgetItem &other) const { // Sort by online/offline - if (data(0, Qt::UserRole + 1) != other.data(0, Qt::UserRole + 1)) { - return data(0, Qt::UserRole + 1).toBool(); + if (data(0, UserListRoles::Online) != other.data(0, UserListRoles::Online)) { + return data(0, UserListRoles::Online).toBool(); } const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); @@ -418,20 +464,100 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) { - itemDelegate = new UserListItemDelegate(this); + avatarProvider = new UserAvatarProvider(client, this); + cardArtProvider = new UserCardArtProvider(this); + + itemDelegate = + new UserListItemDelegate(this, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); + userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog); userTree = new QTreeWidget; - userTree->setColumnCount(3); - userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + userTree->setColumnCount(4); // 0=display, 1=flag(hidden), 2=name(hidden), 3=privlevel(hidden) + userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); userTree->header()->setMinimumSectionSize(0); userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); + userTree->hideColumn(1); + userTree->hideColumn(2); + userTree->hideColumn(3); connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked); + userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + userTree->header()->setStretchLastSection(true); + + // ── Hover popup ─────────────────────────────────────────────────────────── + m_userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + m_userInfoPopup->hide(); + m_userInfoPopup->setWindowOpacity(0.0); + m_userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + + m_showPopupTimer = new QTimer(this); + m_showPopupTimer->setSingleShot(true); + m_showPopupTimer->setInterval(280); + connect(m_showPopupTimer, &QTimer::timeout, this, [this] { + if (!m_hoveredUser.isEmpty()) { + showPopupForUser(m_hoveredUser); + } + }); + + m_hidePopupTimer = new QTimer(this); + m_hidePopupTimer->setSingleShot(true); + m_hidePopupTimer->setInterval(160); + connect(m_hidePopupTimer, &QTimer::timeout, this, [this] { + if (!m_popupPinned && !m_userInfoPopup->underMouse() && !userTree->underMouse()) { + hidePopup(); + } + }); + + userTree->setMouseTracking(true); + userTree->viewport()->setMouseTracking(true); + userTree->viewport()->installEventFilter(this); + + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + if (!SettingsCache::instance().getStyleUserList()) { + return; + } + const QString name = static_cast(item)->getUserInfo().name().c_str(); + m_popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(name); + m_popupPinned = true; // pin after showing + }); + + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + // if (m_rebuildingTree) return; + if (sel.isEmpty() && m_popupPinned) { + m_popupPinned = false; + hidePopup(); + } + }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + m_showPopupTimer->stop(); + hidePopup(true); + }); + + // Forward join requests from popup upward + connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + + connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, + [this](const QString &) { userTree->viewport()->update(); }); + connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, + [this](const QString &) { userTree->viewport()->update(); }); + + connect(&SettingsCache::instance(), &SettingsCache::styleUserListChanged, this, &UserListWidget::applyDisplayMode); + applyDisplayMode(); QVBoxLayout *vbox = new QVBoxLayout; vbox->addWidget(userTree); @@ -441,6 +567,280 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +void UserListWidget::bind(UserListManager *mgr) +{ + manager = mgr; + + // ── Full rebuild: disconnect / reconnect / bulk initial load ────────────── + connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild); + + // ── Online users list (AllUsersList / RoomList) ─────────────────────────── + if (type == AllUsersList || type == RoomList) { + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { processUserInfo(user, true); }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); + } + + // ── Buddy list ──────────────────────────────────────────────────────────── + if (type == BuddyList) { + connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { deleteUser(name); }); + // Track online presence changes for buddies already in the tree + connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + if (users.contains(name)) { + users[name]->setUserInfo(user); + setUserOnline(name, true); + } + }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { + if (users.contains(name)) { + setUserOnline(name, false); + } + }); + } + + // ── Ignore list ─────────────────────────────────────────────────────────── + if (type == IgnoreList) { + connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { deleteUser(name); }); + } + + // ── Popup button refresh ────────────────────────────────────────────────── + // Any buddy/ignore mutation while the popup is open refreshes its buttons + auto refreshIfPopupOpen = [this](const QString &name) { + if (m_userInfoPopup && m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) { + refreshPopupButtons(name); + } + }; + auto refreshCurrentPopup = [refreshIfPopupOpen](const ServerInfo_User &u) { + refreshIfPopupOpen(QString::fromStdString(u.name())); + }; + + connect(manager, &UserListManager::addedToBuddyList, this, refreshCurrentPopup); + connect(manager, &UserListManager::removedFromBuddyList, this, refreshIfPopupOpen); + connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); + connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); + connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); + connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); + + rebuild(); +} + +void UserListWidget::refreshPopupButtons(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + const UserListProxy *proxy = tabSupervisor->getUserListManager(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = proxy->isUserBuddy(userName); + const bool isIgn = proxy->isUserIgnored(userName); + + m_userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); + positionPopup(userName); // height may have changed β€” reposition +} + +void UserListWidget::hideEvent(QHideEvent *e) +{ + QGroupBox::hideEvent(e); + m_showPopupTimer->stop(); + m_hidePopupTimer->stop(); + hidePopup(true); +} + +void UserListWidget::applyDisplayMode() +{ + const bool styled = SettingsCache::instance().getStyleUserList(); + + if (styled) { + userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + userTree->hideColumn(1); + userTree->hideColumn(2); + userTree->hideColumn(3); + } else { + userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + userTree->showColumn(1); + userTree->showColumn(2); + userTree->hideColumn(3); + } + + userTree->viewport()->update(); +} + +void UserListWidget::connectPopupSignals() +{ + connect(m_userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { + m_popupPinned = false; + hidePopup(true); + }); + connect(m_userInfoPopup, &UserInfoPopup::mouseEnteredPopup, m_hidePopupTimer, &QTimer::stop); + connect(m_userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + }); + + // Wire all action signals to UserContextMenu::exec*() + connect(m_userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(m_userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); + connect(m_userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); + connect(m_userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); + connect(m_userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, + &UserContextMenu::execRemoveFromBuddy); + connect(m_userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); + connect(m_userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, + &UserContextMenu::execRemoveFromIgnore); + connect(m_userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); + connect(m_userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); + connect(m_userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); + connect(m_userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); + connect(m_userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); + connect(m_userInfoPopup, &UserInfoPopup::promoteToModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, true, false); }); + connect(m_userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); }); + connect(m_userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, true); }); + connect(m_userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, + [this](const QString &n) { userContextMenu->execAdjustMod(n, false, false); }); +} + +bool UserListWidget::eventFilter(QObject *obj, QEvent *event) +{ + if (obj == userTree->viewport()) { + if (event->type() == QEvent::MouseMove) { + if (!SettingsCache::instance().getStyleUserList()) { + return QGroupBox::eventFilter(obj, event); + } + auto *me = static_cast(event); + auto *twi = static_cast(userTree->itemAt(me->pos())); + const QString hovName = twi ? QString::fromStdString(twi->getUserInfo().name()) : QString{}; + + if (hovName != m_hoveredUser) { + m_hoveredUser = hovName; + if (!hovName.isEmpty()) { + m_hidePopupTimer->stop(); + if (!m_popupPinned) { + m_showPopupTimer->start(); + } + } else { + m_showPopupTimer->stop(); + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + } + } + } else if (event->type() == QEvent::Leave) { + m_hoveredUser.clear(); + m_showPopupTimer->stop(); + if (!m_popupPinned) { + m_hidePopupTimer->start(); + } + } + } + + return QGroupBox::eventFilter(obj, event); +} + +void UserListWidget::showPopupForUser(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + const ServerInfo_User &info = item->getUserInfo(); + const bool online = item->data(0, UserListRoles::Online).toBool(); + const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); + const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); + + m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + + positionPopup(userName); + + m_userInfoPopup->show(); + m_userInfoPopup->raise(); + + // Fade in + m_userInfoPopup->setWindowOpacity(0.0); + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + fade->setDuration(120); + fade->setStartValue(0.0); + fade->setEndValue(1.0); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +void UserListWidget::positionPopup(const QString &userName) +{ + UserListTWI *item = users.value(userName); + if (!item) { + return; + } + + QWidget *vp = userTree->viewport(); + const QRect itemR = userTree->visualItemRect(item); + const QPoint itemBR = vp->mapToGlobal(itemR.bottomRight()); + const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); + const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); + + // Force a fresh size calculation so popH is accurate + m_userInfoPopup->adjustSize(); + const int popW = m_userInfoPopup->width(); + const int popH = m_userInfoPopup->height(); + const int margin = 12; + + const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + + // ── X: left of the list if there's room, otherwise right ───────────────── + int x = (vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vpTR.x() + margin; + x = qBound(screen.left() + margin, x, screen.right() - popW - margin); + + // ── Y: bottom of popup aligns with bottom of hovered row, grows upward ─── + int y = itemBR.y() - popH; + + // Clamp: never above the screen top + y = qMax(y, screen.top() + margin); + + // Clamp: never below the screen bottom (e.g. if the popup is taller + // than the space above the row, let it spill downward rather than clip) + y = qMin(y, screen.bottom() - popH - margin); + + m_userInfoPopup->move(x, y); +} + +void UserListWidget::hidePopup(bool immediate) +{ + m_showPopupTimer->stop(); + m_hidePopupTimer->stop(); + if (!m_userInfoPopup->isVisible()) { + return; + } + + if (immediate) { + m_userInfoPopup->hide(); + return; + } + + // Fade out + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + fade->setDuration(100); + fade->setStartValue(m_userInfoPopup->windowOpacity()); + fade->setEndValue(0.0); + connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); @@ -461,9 +861,59 @@ void UserListWidget::retranslateUi() updateCount(); } +void UserListWidget::rebuild() +{ + userTree->clear(); + users.clear(); + cardArtParamsMap.clear(); + onlineCount = 0; + + if (!manager) { + return; + } + + const QMap *source = nullptr; + + switch (type) { + case AllUsersList: + case RoomList: + source = &manager->getAllUsersList(); + break; + case BuddyList: + source = &manager->getBuddyList(); + break; + case IgnoreList: + source = &manager->getIgnoreList(); + break; + } + + for (auto it = source->cbegin(); it != source->cend(); ++it) { + processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + + sortItems(); +} + void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) { const QString userName = QString::fromStdString(user.name()); + + // Always update params from the latest ServerInfo_User, whether the + // item is new or existing, so a live server-push refreshes the rendering. + if (user.has_card_art_params()) { + const auto &cap = user.card_art_params(); + CardArtParams params; + params.cardName = QString::fromStdString(cap.card_name()); + params.marginPctL = cap.margin_pct_l(); + params.marginPctR = cap.margin_pct_r(); + params.verticalOffset = cap.vertical_offset(); + params.zoom = cap.zoom(); + cardArtParamsMap.insert(userName, params); + cardArtProvider->requestCardArt(userName, params.cardName); + } else { + cardArtParamsMap.remove(userName); // clear stale params on removal + } + UserListTWI *item = users.value(userName); if (item) { item->setUserInfo(user); @@ -475,25 +925,28 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) ++onlineCount; } updateCount(); + avatarProvider->requestAvatar(userName); } item->setOnline(online); + sortItems(); + userTree->viewport()->update(); } bool UserListWidget::deleteUser(const QString &userName) { UserListTWI *twi = users.value(userName); - if (twi) { - users.remove(userName); - userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); - if (twi->data(0, Qt::UserRole + 1).toBool()) { - --onlineCount; - } - delete twi; - updateCount(); - return true; + if (!twi) { + return false; } - return false; + users.remove(userName); + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); + if (twi->data(0, Qt::UserRole + 1).toBool()) { + --onlineCount; + } + delete twi; + updateCount(); + return true; } void UserListWidget::setUserOnline(const QString &userName, bool online) @@ -537,5 +990,5 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index void UserListWidget::sortItems() { - userTree->sortItems(1, Qt::AscendingOrder); + userTree->sortItems(0, Qt::AscendingOrder); } diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index 5a8c00d10..d70cdfbbd 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -7,9 +7,17 @@ #ifndef USERLIST_H #define USERLIST_H +#include "../../cards/card_info_picture_art_crop_widget.h" +#include "user_avatar_provider.h" +#include "user_card_art_provider.h" +#include "user_info_popup.h" +#include "user_list_manager.h" +#include "user_list_painter.h" + #include #include #include +#include #include #include #include @@ -94,12 +102,21 @@ public: class UserListItemDelegate : public QStyledItemDelegate { + const QMap *avatarCache; + const QMap *cardArtCache; + const QMap *cardArtParamsMap; + public: - explicit UserListItemDelegate(QObject *const parent); + explicit UserListItemDelegate(QObject *const parent, + const QMap *avatarCache, + const QMap *cardArtCache, + const QMap *cardArtParamsMap); bool editorEvent(QEvent *event, QAbstractItemModel *model, const QStyleOptionViewItem &option, const QModelIndex &index) override; + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; }; class UserListTWI : public QTreeWidgetItem @@ -131,6 +148,22 @@ public: }; private: + UserListManager *manager = nullptr; + UserAvatarProvider *avatarProvider = nullptr; + UserCardArtProvider *cardArtProvider = nullptr; + QMap cardArtParamsMap; + // ── Hover popup ─────────────────────────────────────────────────────────── + UserInfoPopup *m_userInfoPopup = nullptr; + QTimer *m_showPopupTimer = nullptr; + QTimer *m_hidePopupTimer = nullptr; + QString m_hoveredUser; + bool m_popupPinned = false; + + void showPopupForUser(const QString &userName); + void hidePopup(bool immediate = false); + void positionPopup(const QString &userName); + void connectPopupSignals(); + QMap users; TabSupervisor *tabSupervisor; AbstractClient *client; @@ -141,6 +174,7 @@ private: int onlineCount; QString titleStr; void updateCount(); + void refreshPopupButtons(const QString &userName); private slots: void userClicked(QTreeWidgetItem *item, int column); signals: @@ -149,13 +183,18 @@ signals: void removeBuddy(const QString &userName); void addIgnore(const QString &userName); void removeIgnore(const QString &userName); + void joinGameRequested(int gameId, int roomId, bool asSpectator); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, QWidget *parent = nullptr); + void bind(UserListManager *mgr); + void applyDisplayMode(); + bool eventFilter(QObject *obj, QEvent *event) override; void retranslateUi(); + void rebuild(); void processUserInfo(const ServerInfo_User &user, bool online); bool deleteUser(const QString &userName); void setUserOnline(const QString &userName, bool online); @@ -165,6 +204,9 @@ public: } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + +protected: + void hideEvent(QHideEvent *e) override; }; #endif diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index 2d32f3ce1..e00484ebf 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -111,6 +111,15 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); + styleUserListCheckBox.setChecked(settings.getStyleUserList()); + connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setStyleUserList); + + auto stylingTabGrid = new QGridLayout; + stylingTabGrid->addWidget(&styleUserListCheckBox, 0, 0, 1, 2); + + stylingGroupBox = new QGroupBox; + stylingGroupBox->setLayout(stylingTabGrid); + // Menu settings showShortcutsCheckBox.setChecked(settings.getShowShortcuts()); connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged); @@ -284,6 +293,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(homeTabGroupBox); + mainLayout->addWidget(stylingGroupBox); mainLayout->addWidget(menuGroupBox); mainLayout->addWidget(printingsGroupBox); mainLayout->addWidget(cardsGroupBox); @@ -398,6 +408,9 @@ void AppearanceSettingsPage::retranslateUi() homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); + stylingGroupBox->setTitle(tr("Styling settings")); + styleUserListCheckBox.setText(tr("Style user list")); + menuGroupBox->setTitle(tr("Menu settings")); showShortcutsCheckBox.setText(tr("Show keyboard shortcuts in right-click menus")); showGameSelectorFilterToolbarCheckBox.setText(tr("Show game filter toolbar above list in room tab")); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 9ed27be4d..e223d70f8 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -37,6 +37,7 @@ private: QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; + QCheckBox styleUserListCheckBox; QLabel minPlayersForMultiColumnLayoutLabel; QLabel maxFontSizeForCardsLabel; QCheckBox showShortcutsCheckBox; @@ -58,6 +59,7 @@ private: QCheckBox invertVerticalCoordinateCheckBox; QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; + QGroupBox *stylingGroupBox; QGroupBox *menuGroupBox; QGroupBox *printingsGroupBox; QGroupBox *cardsGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp new file mode 100644 index 000000000..6e8ab752a --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -0,0 +1,246 @@ +#include "tab_card_art_rules.h" + +#include "libcockatrice/card/database/card_database_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CardArtRulesModel::CardArtRulesModel(AbstractClient *client, QObject *parent) + : QAbstractTableModel(parent), client(client) +{ +} + +int CardArtRulesModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return static_cast(entries.size()); +} + +int CardArtRulesModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return 3; +} + +QVariant CardArtRulesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return {}; + } + + const auto &e = entries.at(index.row()); + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case 0: + return e.cardName; + case 1: + return e.mode; + case 2: + return e.reason; + } + } + + return {}; +} + +QVariant CardArtRulesModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) { + return {}; + } + + switch (section) { + case 0: + return tr("Card"); + case 1: + return tr("Mode"); + case 2: + return tr("Reason"); + default: + return {}; + } +} + +void CardArtRulesModel::refresh() +{ + Command_ListCardArtRules cmd; + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + + connect(pend, &PendingCommand::finished, this, &CardArtRulesModel::onRefreshFinished); + + client->sendCommand(pend); +} + +void CardArtRulesModel::clear() +{ + beginResetModel(); + entries.clear(); + endResetModel(); +} + +QString CardArtRulesModel::cardAt(int row) const +{ + if (row < 0 || row >= static_cast(entries.size())) { + return {}; + } + + return entries[row].cardName; +} + +void CardArtRulesModel::onRefreshFinished(const Response &r) +{ + if (r.response_code() != Response::RespOk) { + return; + } + + const auto &resp = r.GetExtension(Response_ListCardArtRules::ext); + + beginResetModel(); + entries.clear(); + + for (const auto &e : resp.entries()) { + entries.push_back({QString::fromStdString(e.card_name()), QString::fromStdString(e.mode()), + QString::fromStdString(e.reason())}); + } + + endResetModel(); +} + +TabCardArtRules::TabCardArtRules(TabSupervisor *parent, AbstractClient *_client) : Tab(parent), client(_client) +{ + setupUi(); + refresh(); +} + +void TabCardArtRules::setupUi() +{ + auto *central = new QWidget(this); + + initSearchBar(); + + modeBox = new QComboBox; + reasonEdit = new QLineEdit; + + addBtn = new QPushButton; + removeBtn = new QPushButton; + refreshBtn = new QPushButton; + + modeBox->addItems({"ALLOW", "DENY"}); + + tableModel = new CardArtRulesModel(client, this); + + table = new QTableView; + table->setModel(tableModel); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + + auto *form = new QFormLayout; + form->addRow(tr("Card:"), searchEdit); + form->addRow(tr("Mode:"), modeBox); + form->addRow(tr("Reason:"), reasonEdit); + + auto *buttons = new QHBoxLayout; + buttons->addWidget(addBtn); + buttons->addWidget(removeBtn); + buttons->addWidget(refreshBtn); + + auto *layout = new QVBoxLayout; + layout->addLayout(form); + layout->addLayout(buttons); + layout->addWidget(table); + + central->setLayout(layout); + setCentralWidget(central); + + connect(addBtn, &QPushButton::clicked, this, &TabCardArtRules::addRule); + + connect(removeBtn, &QPushButton::clicked, this, &TabCardArtRules::removeSelected); + + connect(refreshBtn, &QPushButton::clicked, this, &TabCardArtRules::refresh); + + retranslateUi(); +} + +void TabCardArtRules::initSearchBar() +{ + searchEdit = new QLineEdit; + searchEdit->setPlaceholderText(tr("Type a card name...")); + + cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDbDisplayModel = new CardDatabaseDisplayModel(this); + cardDbDisplayModel->setSourceModel(cardDbModel); + cardSearchModel = new CardSearchModel(cardDbDisplayModel, this); + + cardProxyModel = new CardCompleterProxyModel(this); + cardProxyModel->setSourceModel(cardSearchModel); + cardProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + + searchCompleter = new QCompleter(cardProxyModel, this); + searchCompleter->setCompletionRole(Qt::DisplayRole); + searchCompleter->setCompletionMode(QCompleter::PopupCompletion); + searchCompleter->setCaseSensitivity(Qt::CaseInsensitive); + searchCompleter->setFilterMode(Qt::MatchContains); + searchCompleter->setMaxVisibleItems(15); + searchEdit->setCompleter(searchCompleter); + + connect(searchEdit, &QLineEdit::textEdited, cardSearchModel, &CardSearchModel::updateSearchResults); + connect(searchEdit, &QLineEdit::textEdited, this, [this](const QString &text) { + const QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; + cardProxyModel->setFilterRegularExpression( + QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); + if (!text.isEmpty()) { + searchCompleter->complete(); + } + }); + connect(searchCompleter, static_cast(&QCompleter::activated), this, + [this](const QString &name) { searchEdit->setText(name); }); +} + +void TabCardArtRules::retranslateUi() +{ + addBtn->setText(tr("Add rule")); + removeBtn->setText(tr("Remove rule")); + refreshBtn->setText(tr("Refresh")); +} + +void TabCardArtRules::refresh() +{ + tableModel->refresh(); +} + +void TabCardArtRules::addRule() +{ + Command_AddCardArtRule cmd; + cmd.set_card_name(searchEdit->text().toStdString()); + cmd.set_mode(modeBox->currentText().toStdString()); + cmd.set_reason(reasonEdit->text().toStdString()); + + client->sendCommand(client->prepareModeratorCommand(cmd)); + + refresh(); +} + +void TabCardArtRules::removeSelected() +{ + QModelIndex idx = table->currentIndex(); + if (!idx.isValid()) { + return; + } + + Command_RemoveCardArtRule cmd; + cmd.set_card_name(tableModel->cardAt(idx.row()).toStdString()); + + client->sendCommand(client->prepareModeratorCommand(cmd)); + + refresh(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h new file mode 100644 index 000000000..a47f1267d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -0,0 +1,89 @@ +#ifndef COCKATRICE_DLG_CARD_ART_RULES_H +#define COCKATRICE_DLG_CARD_ART_RULES_H + +#include "card/card_search_model.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include + +class AbstractClient; + +class CardArtRulesModel : public QAbstractTableModel +{ + Q_OBJECT + +public: + struct Entry + { + QString cardName; + QString mode; + QString reason; + }; + + explicit CardArtRulesModel(AbstractClient *client, QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role) const override; + + void refresh(); + void clear(); + + QString cardAt(int row) const; + +private slots: + void onRefreshFinished(const Response &r); + +private: + AbstractClient *client; + std::vector entries; +}; + +class TabCardArtRules : public Tab +{ + Q_OBJECT + +public: + TabCardArtRules(TabSupervisor *parent, AbstractClient *client); + + QString getTabText() const override + { + return tr("Card Art Rules"); + } + void retranslateUi() override; + +private: + void setupUi(); + +private slots: + void addRule(); + void removeSelected(); + void refresh(); + +private: + AbstractClient *client; + + QLineEdit *searchEdit; + void initSearchBar(); + QCompleter *searchCompleter; + CardDatabaseModel *cardDbModel; + CardDatabaseDisplayModel *cardDbDisplayModel; + CardSearchModel *cardSearchModel; + CardCompleterProxyModel *cardProxyModel; + QComboBox *modeBox; + QLineEdit *reasonEdit; + + QPushButton *addBtn; + QPushButton *removeBtn; + QPushButton *refreshBtn; + + QTableView *table; + CardArtRulesModel *tableModel; +}; + +#endif // COCKATRICE_DLG_CARD_ART_RULES_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 424742e9b..c7495da5a 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -49,10 +49,25 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, QMap tempMap; tempMap.insert(info.room_id(), gameTypes); gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); + + auto *tabs = new QTabWidget(this); + + friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList); + friendsList->bind(tabSupervisor->getUserListManager()); userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList); + userList->bind(tabSupervisor->getUserListManager()); + ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList); + ignoreList->bind(tabSupervisor->getUserListManager()); + + connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this, + SIGNAL(openMessageDialog(const QString &, bool))); connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this, SIGNAL(openMessageDialog(const QString &, bool))); + tabs->addTab(friendsList, tr("Friends")); + tabs->addTab(userList, tr("Online")); + tabs->addTab(ignoreList, tr("Ignored")); + chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); @@ -101,7 +116,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, auto *hbox = new QHBoxLayout; hbox->addWidget(splitter, 3); - hbox->addWidget(userList, 1); + hbox->addWidget(tabs, 1); aLeaveRoom = new QAction(this); connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest); @@ -112,10 +127,8 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, const int userListSize = info.user_list_size(); for (int i = 0; i < userListSize; ++i) { - userList->processUserInfo(info.user_list(i), true); autocompleteUserList.append("@" + QString::fromStdString(info.user_list(i).name())); } - userList->sortItems(); const int gameListSize = info.game_list_size(); for (int i = 0; i < gameListSize; ++i) { @@ -269,8 +282,6 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event) void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) { - userList->processUserInfo(event.user_info(), true); - userList->sortItems(); if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) { autocompleteUserList << "@" + QString::fromStdString(event.user_info().name()); sayEdit->setCompletionList(autocompleteUserList); @@ -279,7 +290,6 @@ void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event) { - userList->deleteUser(QString::fromStdString(event.name())); autocompleteUserList.removeOne("@" + QString::fromStdString(event.name())); sayEdit->setCompletionList(autocompleteUserList); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index eeb5a9e14..d669b6107 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -56,7 +56,9 @@ private: QMap gameTypes; GameSelector *gameSelector; + UserListWidget *friendsList; UserListWidget *userList; + UserListWidget *ignoreList; const UserListProxy *userListProxy; ChatView *chatView; QLabel *sayLabel; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index e7075f78f..52309d94b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -9,6 +9,7 @@ #include "api/edhrec/tab_edhrec_main.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" #include "tab_game.h" @@ -179,6 +180,10 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabAdmin->setCheckable(true); connect(aTabAdmin, &QAction::triggered, this, &TabSupervisor::actTabAdmin); + aTabCardArtRules = new QAction(this); + aTabCardArtRules->setCheckable(true); + connect(aTabCardArtRules, &QAction::triggered, this, &TabSupervisor::actTabCardArtRules); + aTabLog = new QAction(this); aTabLog->setCheckable(true); connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog); @@ -435,6 +440,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addSeparator(); tabsMenu->addAction(aTabAdmin); tabsMenu->addAction(aTabLog); + tabsMenu->addAction(aTabCardArtRules); if (SettingsCache::instance().getTabAdminOpen()) { openTabAdmin(); @@ -442,6 +448,7 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().getTabLogOpen()) { openTabLog(); } + openTabCardArtRules(); } retranslateUi(); @@ -681,6 +688,30 @@ void TabSupervisor::openTabAdmin() aTabAdmin->setChecked(true); } +void TabSupervisor::actTabCardArtRules(bool checked) +{ + if (checked && !tabCardArtRules) { + openTabCardArtRules(); + setCurrentWidget(tabCardArtRules); + } else if (!checked && tabCardArtRules) { + tabCardArtRules->closeRequest(); + } +} + +void TabSupervisor::openTabCardArtRules() +{ + tabCardArtRules = new TabCardArtRules(this, client); + + myAddTab(tabCardArtRules, aTabCardArtRules); + + connect(tabCardArtRules, &QObject::destroyed, this, [this] { + tabCardArtRules = nullptr; + aTabCardArtRules->setChecked(false); + }); + + aTabCardArtRules->setChecked(true); +} + void TabSupervisor::actTabLog(bool checked) { SettingsCache::instance().setTabLogOpen(checked); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index e77fb4f7b..3eac144b7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -24,6 +24,7 @@ #include #include +class TabCardArtRules; inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); class UserListManager; @@ -103,6 +104,7 @@ private: TabDeckStorage *tabDeckStorage; TabReplays *tabReplays; TabAdmin *tabAdmin; + TabCardArtRules *tabCardArtRules; TabLog *tabLog; QMap roomTabs; QMap gameTabs; @@ -112,7 +114,8 @@ private: bool isLocalGame; QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, - *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, *aTabLog; + *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, + *aTabCardArtRules, *aTabLog; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -145,7 +148,7 @@ public: return userInfo; } [[nodiscard]] AbstractClient *getClient() const; - [[nodiscard]] const UserListManager *getUserListManager() const + [[nodiscard]] UserListManager *getUserListManager() const { return userListManager; } @@ -197,6 +200,8 @@ private slots: void openTabDeckStorage(); void openTabReplays(); void openTabAdmin(); + void actTabCardArtRules(bool checked); + void openTabCardArtRules(); void openTabLog(); void updateCurrent(int index); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 3da9ddc73..7abccfca8 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -190,6 +190,25 @@ AuthenticationResult Server::loginUser(Server_ProtocolHandler *session, return authState; } +void Server::broadcastUserInfoUpdate(Server_ProtocolHandler *source) +{ + Event_UserJoined event; + event.mutable_user_info()->CopyFrom(source->copyUserInfo(false)); + + SessionEvent *se = Server_ProtocolHandler::prepareSessionEvent(event); + + clientsLock.lockForRead(); + for (auto &client : clients) { + if (client->getAcceptsUserListChanges()) { + client->sendProtocolItem(*se); + } + } + clientsLock.unlock(); + + sendIsl_SessionEvent(*se); + delete se; +} + void Server::addPersistentPlayer(const QString &userName, int roomId, int gameId, int playerId) { QWriteLocker locker(&persistentPlayersLock); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index ab57fac4e..2fca46593 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -64,6 +64,7 @@ public: QString &clientid, QString &clientVersion, QString &connectionType); + void broadcastUserInfoUpdate(Server_ProtocolHandler *source); const QMap &getRooms() { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index b4c7b6ac8..20a4cb08d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -122,6 +122,7 @@ set(PROTO_FILES response_activate.proto response_adjust_mod.proto response_ban_history.proto + response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto response_deck_upload.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 9d01b51d2..c10b9de22 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -11,6 +11,9 @@ message ModeratorCommand { FORCE_ACTIVATE_USER = 1007; GET_ADMIN_NOTES = 1008; UPDATE_ADMIN_NOTES = 1009; + ADD_CARD_ART_RULE = 1010; + REMOVE_CARD_ART_RULE = 1011; + LIST_CARD_ART_RULES = 1012; } extensions 100 to max; } @@ -106,3 +109,27 @@ message Command_UpdateAdminNotes { optional string user_name = 1; optional string notes = 2; } + +message Command_AddCardArtRule { + extend ModeratorCommand { + optional Command_AddCardArtRule ext = 1010; + } + + optional string card_name = 1; + optional string mode = 2; // "ALLOW" or "DENY" + optional string reason = 3; +} + +message Command_RemoveCardArtRule { + extend ModeratorCommand { + optional Command_RemoveCardArtRule ext = 1011; + } + + optional string card_name = 1; +} + +message Command_ListCardArtRules { + extend ModeratorCommand { + optional Command_ListCardArtRules ext = 1012; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index dece8ae17..e719f3e92 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -68,6 +68,7 @@ message Response { REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_GET_CODE = 1102; + CARD_ART_RULE_LIST = 1200; } required uint64 cmd_id = 1; optional ResponseCode response_code = 2; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto new file mode 100644 index 000000000..b13c79742 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_CardArtRuleEntry { + optional string card_name = 1; + optional string mode = 2; + optional string reason = 3; +} + +message Response_ListCardArtRules { + extend Response { + optional Response_ListCardArtRules ext = 1200; + } + repeated Response_CardArtRuleEntry entries = 1; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 10add611f..4bbbe46bb 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -1,4 +1,5 @@ syntax = "proto2"; + message ServerInfo_User { enum UserLevelFlag { IsNothing = 0; @@ -12,6 +13,13 @@ message ServerInfo_User { optional string left_side = 1; optional string right_side = 2; }; + message CardArtParams { + optional string card_name = 1; + optional double margin_pct_l = 2 [default = 0.33]; + optional double margin_pct_r = 3 [default = 0.02]; + optional double vertical_offset = 4 [default = 0.35]; + optional double zoom = 5 [default = 1.0]; + }; optional string name = 1; optional uint32 user_level = 2; @@ -28,4 +36,5 @@ message ServerInfo_User { optional string clientid = 13; optional string privlevel = 14; optional PawnColorsOverride pawn_colors = 15; -} + optional CardArtParams card_art_params = 16; +} \ No newline at end of file diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index cecf87370..ff5dbff32 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -27,6 +27,7 @@ message SessionCommand { FORGOT_PASSWORD_RESET = 1022; FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; + SET_CARD_ART_PARAMS = 1025; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; @@ -205,3 +206,14 @@ message Command_RequestPasswordSalt { } required string user_name = 1; } + +message Command_SetCardArtParams { + extend SessionCommand { + optional Command_SetCardArtParams ext = 1025; + } + optional string card_name = 1; + optional double margin_pct_l = 2; + optional double margin_pct_r = 3; + optional double vertical_offset = 4; + optional double zoom = 5; +} diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index c0c7d8cc9..df29d6c9f 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -17,6 +17,7 @@ set(UTILITY_HEADERS libcockatrice/utility/passwordhasher.h libcockatrice/utility/trice_limits.h libcockatrice/utility/zone_names.h + libcockatrice/utility/days_years_between.h ) add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) diff --git a/libcockatrice_utility/libcockatrice/utility/days_years_between.h b/libcockatrice_utility/libcockatrice/utility/days_years_between.h index c0f5da23a..4b3b5bc0c 100644 --- a/libcockatrice_utility/libcockatrice/utility/days_years_between.h +++ b/libcockatrice_utility/libcockatrice/utility/days_years_between.h @@ -1,3 +1,6 @@ +#ifndef COCKATRICE_DAYS_YEARS_BETWEEN_H +#define COCKATRICE_DAYS_YEARS_BETWEEN_H + #include inline static QPair getDaysAndYearsBetween(const QDate &then, const QDate &now) @@ -6,3 +9,5 @@ inline static QPair getDaysAndYearsBetween(const QDate &then, const QD int days = then.addYears(years).daysTo(now); return {days, years}; } + +#endif // COCKATRICE_DAYS_YEARS_BETWEEN_H diff --git a/servatrice/migrations/servatrice_0034_to_0035.sql b/servatrice/migrations/servatrice_0034_to_0035.sql new file mode 100644 index 000000000..83502a949 --- /dev/null +++ b/servatrice/migrations/servatrice_0034_to_0035.sql @@ -0,0 +1,18 @@ +ALTER TABLE `cockatrice_users` ADD COLUMN `card_art_params` TEXT DEFAULT NULL, ALGORITHM=INSTANT; + +CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `card_name` varchar(255) NOT NULL, + `mode` enum('ALLOW','DENY') NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_by` int(7) unsigned DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_card_name` (`card_name`), + KEY `idx_mode` (`mode`), + FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; + +UPDATE cockatrice_schema_version SET version=35 WHERE version=34; diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index fa644dbc0..badd82d6d 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(34); +INSERT INTO cockatrice_schema_version VALUES(35); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', `leftPawnColorOverride` varchar(255), `rightPawnColorOverride` varchar(255), + `card_art_params` TEXT DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `name` (`name`), KEY `token` (`token`), @@ -300,3 +301,18 @@ CREATE TABLE IF NOT EXISTS `cockatrice_audit` ( PRIMARY KEY (`id`), KEY `user_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `card_name` varchar(255) NOT NULL, + `mode` enum('ALLOW','DENY') NOT NULL, + `reason` varchar(255) DEFAULT NULL, + `created_by` int(7) unsigned DEFAULT NULL, + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_card_name` (`card_name`), + KEY `idx_mode` (`mode`), + FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 73643825e..92ff80755 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -681,6 +683,30 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer if (!clientid.isEmpty()) { result.set_clientid(clientid.toStdString()); } + + const QString cardArtParamsJson = query->value(12).toString(); + if (!cardArtParamsJson.isEmpty()) { + const QJsonDocument doc = QJsonDocument::fromJson(cardArtParamsJson.toUtf8()); + if (doc.isObject()) { + const QJsonObject obj = doc.object(); + auto *cap = result.mutable_card_art_params(); + if (obj.contains("card_name")) { + cap->set_card_name(obj["card_name"].toString().toStdString()); + } + if (obj.contains("marginPctL")) { + cap->set_margin_pct_l(obj["marginPctL"].toDouble(0.33)); + } + if (obj.contains("marginPctR")) { + cap->set_margin_pct_r(obj["marginPctR"].toDouble(0.02)); + } + if (obj.contains("verticalOffset")) { + cap->set_vertical_offset(obj["verticalOffset"].toDouble(0.35)); + } + if (obj.contains("zoom")) { + cap->set_zoom(obj["zoom"].toDouble(1.0)); + } + } + } } return result; } @@ -698,7 +724,7 @@ ServerInfo_User Servatrice_DatabaseInterface::getUserData(const QString &name, b QSqlQuery *query = prepareQuery("select id, name, admin, country, privlevel, leftPawnColorOverride, " "rightPawnColorOverride, realname, avatar_bmp, registrationDate, " - "email, clientid from {prefix}_users where " + "email, clientid, card_art_params from {prefix}_users where " "name = :name and active = 1"); query->bindValue(":name", name); if (!execSqlQuery(query)) { diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 68080404c..1e3501ec7 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -10,7 +10,7 @@ #include #include -#define DATABASE_SCHEMA_VERSION 34 +#define DATABASE_SCHEMA_VERSION 35 class Servatrice; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index bc90a3ef1..f9d276941 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -31,6 +31,8 @@ #include #include #include +#include +#include #include #include #include @@ -59,8 +61,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -212,6 +216,8 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountEdit(cmd.GetExtension(Command_AccountEdit::ext), rc); case SessionCommand::ACCOUNT_IMAGE: return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); + case SessionCommand::SET_CARD_ART_PARAMS: + return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -247,6 +253,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetAdminNotes(cmd.GetExtension(Command_GetAdminNotes::ext), rc); case ModeratorCommand::UPDATE_ADMIN_NOTES: return cmdUpdateAdminNotes(cmd.GetExtension(Command_UpdateAdminNotes::ext), rc); + case ModeratorCommand::ADD_CARD_ART_RULE: + return cmdAddCardArtRule(cmd.GetExtension((Command_AddCardArtRule::ext)), rc); + case ModeratorCommand::REMOVE_CARD_ART_RULE: + return cmdRemoveCardArtRule(cmd.GetExtension((Command_RemoveCardArtRule::ext)), rc); + case ModeratorCommand::LIST_CARD_ART_RULES: + return cmdListCardArtRules(cmd.GetExtension((Command_ListCardArtRules::ext)), rc); default: return Response::RespFunctionNotAllowed; } @@ -1565,6 +1577,161 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm return Response::RespOk; } +bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName) +{ + QSqlQuery *q = sqlInterface->prepareQuery("SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name"); + + q->bindValue(":name", cardName); + + if (!sqlInterface->execSqlQuery(q)) { + qWarning() << "Card art rule lookup failed; failing open for" << cardName; + return true; + } + + if (!q->next()) { + return true; // default allow + } + + return q->value(0).toString() != "DENY"; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const Command_SetCardArtParams &cmd, + ResponseContainer & /* rc */) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + const QString cardName = QString::fromStdString(cmd.card_name()); + + if (cardName.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + + if (cardName.isEmpty()) { + // Removal path + QSqlQuery *q = sqlInterface->prepareQuery("UPDATE {prefix}_users SET card_art_params = NULL WHERE id = :id"); + q->bindValue(":id", userInfo->id()); + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + userInfo->clear_card_art_params(); + server->broadcastUserInfoUpdate(this); + return Response::RespOk; + } + + if (!isCardNameAllowed(cardName)) { + return Response::RespFunctionNotAllowed; + } + + // Clamp everything to sane ranges server-side so a malicious client + // can't store garbage that breaks other clients' rendering. + const double marginPctL = qBound(0.0, cmd.margin_pct_l(), 0.95); + const double marginPctR = qBound(0.0, cmd.margin_pct_r(), 0.95); + const double verticalOffset = qBound(0.0, cmd.vertical_offset(), 1.0); + const double zoom = qBound(0.1, cmd.zoom(), 4.0); + + QJsonObject obj; + obj["card_name"] = cardName; + obj["marginPctL"] = marginPctL; + obj["marginPctR"] = marginPctR; + obj["verticalOffset"] = verticalOffset; + obj["zoom"] = zoom; + const QString json = QString::fromUtf8(QJsonDocument(obj).toJson(QJsonDocument::Compact)); + + QSqlQuery *query = sqlInterface->prepareQuery("update {prefix}_users set card_art_params=:params where id=:id"); + query->bindValue(":params", json); + query->bindValue(":id", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + // Keep the in-memory userInfo in sync + auto *cap = userInfo->mutable_card_art_params(); + cap->set_card_name(cmd.card_name()); + cap->set_margin_pct_l(marginPctL); + cap->set_margin_pct_r(marginPctR); + cap->set_vertical_offset(verticalOffset); + cap->set_zoom(zoom); + + const QString name = QString::fromStdString(userInfo->name()); + server->broadcastUserInfoUpdate(this); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdAddCardArtRule(const Command_AddCardArtRule &cmd, + ResponseContainer &) +{ + const QString cardName = QString::fromStdString(cmd.card_name()); + const QString mode = QString::fromStdString(cmd.mode()); + + if (mode != "ALLOW" && mode != "DENY") { + return Response::RespInvalidData; + } + if (cardName.isEmpty() || cardName.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + + QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO {prefix}_card_art_name_rules " + "(card_name, mode, reason, created_by) " + "VALUES (:name, :mode, :reason, :uid) " + "ON DUPLICATE KEY UPDATE mode=:mode2, reason=:reason2"); + + q->bindValue(":name", cardName); + q->bindValue(":mode", mode); + q->bindValue(":mode2", mode); + q->bindValue(":reason", QString::fromStdString(cmd.reason())); + q->bindValue(":reason2", QString::fromStdString(cmd.reason())); + q->bindValue(":uid", userInfo->id()); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, + ResponseContainer &) +{ + auto cardName = QString::fromStdString(cmd.card_name()); + if (cardName.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + QSqlQuery *q = sqlInterface->prepareQuery("DELETE FROM {prefix}_card_art_name_rules WHERE card_name=:name"); + + q->bindValue(":name", cardName); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdListCardArtRules(const Command_ListCardArtRules &, + ResponseContainer &rc) +{ + QSqlQuery *q = sqlInterface->prepareQuery("SELECT card_name, mode, reason FROM {prefix}_card_art_name_rules"); + + if (!sqlInterface->execSqlQuery(q)) { + return Response::RespInternalError; + } + + auto *re = new Response_ListCardArtRules; + + while (q->next()) { + auto *entry = re->add_entries(); + entry->set_card_name(q->value(0).toString().toStdString()); + entry->set_mode(q->value(1).toString().toStdString()); + entry->set_reason(q->value(2).toString().toStdString()); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer & /* rc */) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index e10aa0dde..c0732ccd9 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -129,6 +129,11 @@ private: Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); + bool isCardNameAllowed(const QString &cardName); + Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &); + Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &); + Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &); + Response::ResponseCode cmdListCardArtRules(const Command_ListCardArtRules &, ResponseContainer &rc); Response::ResponseCode cmdAccountPassword(const Command_AccountPassword &cmd, ResponseContainer &rc); Response::ResponseCode cmdGrantReplayAccess(const Command_GrantReplayAccess &cmd, ResponseContainer &rc); Response::ResponseCode cmdForceActivateUser(const Command_ForceActivateUser &cmd, ResponseContainer &rc); From 6dc974a05d7bdff5c088044ee28b5fc3427a8c7c Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:23:55 -0400 Subject: [PATCH 3/7] [UserListDelegate] Consider providerId (#7018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lukas BrΓΌbach --- .../server/user/user_card_art_provider.cpp | 13 +-- .../server/user/user_card_art_provider.h | 2 +- .../server/user/user_card_settings_dialog.cpp | 91 +++++++++++++++++-- .../server/user/user_card_settings_dialog.h | 7 ++ .../widgets/server/user/user_info_popup.cpp | 2 +- .../widgets/server/user/user_list_painter.cpp | 6 +- .../widgets/server/user/user_list_painter.h | 1 + .../widgets/server/user/user_list_widget.cpp | 3 +- .../widgets/tabs/tab_card_art_rules.cpp | 59 ++++++++++-- .../widgets/tabs/tab_card_art_rules.h | 4 + .../protocol/pb/moderator_commands.proto | 6 +- .../pb/response_card_art_rule_entry.proto | 5 +- .../protocol/pb/serverinfo_user.proto | 9 +- .../protocol/pb/session_commands.proto | 9 +- .../migrations/servatrice_0034_to_0035.sql | 3 +- servatrice/servatrice.sql | 3 +- .../src/servatrice_database_interface.cpp | 3 + servatrice/src/serversocketinterface.cpp | 36 +++++--- servatrice/src/serversocketinterface.h | 2 +- 19 files changed, 211 insertions(+), 53 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index 70a56375e..67fb4f684 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -5,9 +5,9 @@ #include #include -static QString makeKey(const QString &user, const QString &card) +static QString makeKey(const QString &user, const QString &card, const QString &providerId) { - return user + u'|' + card; + return user + u'|' + card + u'|' + providerId; } UserCardArtProvider::UserCardArtProvider(QObject *parent) : QObject(parent) @@ -31,13 +31,13 @@ const QMap &UserCardArtProvider::cache() const return cardArtCache; } -void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName) +void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName, const QString &providerId) { if (cardName.isEmpty()) { return; } - const QString key = makeKey(userName, cardName); + const QString key = makeKey(userName, cardName, providerId); if (cardArtCache.contains(key) || pending.contains(key)) { return; @@ -83,15 +83,16 @@ void UserCardArtProvider::processQueue() const QString key = queue.dequeue(); const QStringList parts = key.split(u'|'); - if (parts.size() != 2) { + if (parts.size() != 3) { pending.remove(key); continue; } const QString userName = parts.at(0); const QString cardName = parts.at(1); + const QString providerId = parts.at(2); - ExactCard card = CardDatabaseManager::query()->getCard({cardName}); + ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId}); if (!card) { pending.remove(key); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h index a3ab874b7..fb2f37812 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -14,7 +14,7 @@ class UserCardArtProvider : public QObject public: explicit UserCardArtProvider(QObject *parent = nullptr); - void requestCardArt(const QString &userName, const QString &cardName); + void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); const QMap &cache() const; static QPixmap cropCardArt(const QPixmap &fullRes); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index 335ee097e..56c9600a0 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -70,7 +70,7 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) QString(), // userName not needed for override path nullptr, // no cache params, - &sourcePixmap // πŸ‘ˆ direct pixmap + &sourcePixmap // direct pixmap ); // Avatar placeholder so the left-margin interaction is visible @@ -174,6 +174,13 @@ void UserCardArtSettingsDialog::setupUi() { initializeSearchBar(); + providerComboBox = new QComboBox; + connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() { + currentParams.cardProviderId = providerComboBox->currentData().toString(); + reloadPreview(); + onParamChanged(); + }); + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); @@ -181,6 +188,7 @@ void UserCardArtSettingsDialog::setupUi() auto *form = new QFormLayout; form->addRow(tr("Card name:"), searchBar); + form->addRow(tr("Card ProviderId:"), providerComboBox); form->addRow(tr("Left margin (%):"), marginLSpin); form->addRow(tr("Right margin (%):"), marginRSpin); form->addRow(tr("Vertical offset:"), verticalOffsetSpin); @@ -219,6 +227,32 @@ void UserCardArtSettingsDialog::setupUi() connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); } +void UserCardArtSettingsDialog::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } +} + void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) { if (name.isEmpty()) { @@ -231,27 +265,68 @@ void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) if (!card) { currentPixmap = QPixmap(); preview->setPixmap(currentPixmap); + providerComboBox->clear(); return; } currentParams.cardName = name; + populateProviderCombo(name); + + if (providerComboBox->count() == 0) { + // No printings found for this card; nothing to preview. + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + currentParams.cardProviderId.clear(); + return; + } + + currentParams.cardProviderId = providerComboBox->currentData().toString(); + reloadPreview(); +} + +void UserCardArtSettingsDialog::reloadPreview() +{ + if (currentParams.cardName.isEmpty()) { + return; + } + + ExactCard card = CardDatabaseManager::query()->getCard({currentParams.cardName, currentParams.cardProviderId}); + if (!card) { + return; + } + + // CardPictureLoader::getPixmap() is async on a cache miss: it enqueues a + // background download and returns a null pixmap immediately. When that + // download finishes, CardPictureLoader::imageLoaded() caches the result + // and calls card.emitPixmapUpdated(), which emits pixmapUpdated() on the + // underlying CardInfo (see exact_card.h). Listen for that, scoped to + // whichever CardInfo we just asked for, so the preview catches up once + // the image actually arrives instead of staying on the placeholder. + // + // Disconnect any previous listener first -- otherwise switching cards + // repeatedly stacks up connections to old CardInfo objects, each of + // which would still fire reloadPreview() (harmlessly, but wastefully) + // whenever ITS art finishes loading later. + disconnect(pixmapUpdatedConnection); + QPixmap fullRes; CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (fullRes.isNull()) { - connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this, card](const PrintingInfo &) { - disconnect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, nullptr); - QPixmap loaded; - CardPictureLoader::getPixmap(loaded, card, QSize(745, 1040)); - currentPixmap = UserCardArtProvider::cropCardArt(loaded); - preview->setPixmap(currentPixmap); - }); + // Not loaded yet -- wait for the signal instead of giving up. + // card.getCardPtr() is a CardInfoPtr (QSharedPointer); + // .data() gives the raw QObject* needed for connect(). + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); }); + } return; } currentPixmap = UserCardArtProvider::cropCardArt(fullRes); preview->setPixmap(currentPixmap); + preview->setParams(currentParams); } void UserCardArtSettingsDialog::onParamChanged() diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h index cac26c919..018043278 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h @@ -3,6 +3,7 @@ #include "user_list_painter.h" +#include #include #include @@ -43,10 +44,12 @@ public: private slots: void onCardNameChanged(const QString &name); + void reloadPreview(); void onParamChanged(); private: void setupUi(); + void populateProviderCombo(const QString &cardName); void initializeSearchBar(); QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); @@ -57,6 +60,10 @@ private: CardSearchModel *searchModel; CardCompleterProxyModel *proxyModel; + QComboBox *providerComboBox; + + QMetaObject::Connection pixmapUpdatedConnection; + QDoubleSpinBox *marginLSpin; QDoubleSpinBox *marginRSpin; QDoubleSpinBox *verticalOffsetSpin; diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index fd62d5ddf..2b4dcb8ed 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -542,7 +542,7 @@ void UserInfoPopup::showForUser(const QString &userName, const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName)) ? m_cardArtParamsMap->value(userName) : CardArtParams{}; - const QString artKey = userName + u'|' + params.cardName; + const QString artKey = userName + u'|' + params.cardName + u'|' + params.cardProviderId; const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{}; m_header->setUserData(userInfo, online, avatar, cardArt, params); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index b5541b692..8891ff268 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -73,9 +73,9 @@ void UserListPainter::drawBackground(QPainter *painter, painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); } -static QString makeKey(const QString &user, const QString &card) +static QString makeKey(const QString &user, const QString &card, const QString &providerId) { - return user + u'|' + card; + return user + u'|' + card + u'|' + providerId; } void UserListPainter::drawCardArt(QPainter *painter, @@ -95,7 +95,7 @@ void UserListPainter::drawCardArt(QPainter *painter, return; } - const QString key = makeKey(userName, params.cardName); + const QString key = makeKey(userName, params.cardName, params.cardProviderId); if (!cardArtCache->contains(key)) { return; diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.h b/cockatrice/src/interface/widgets/server/user/user_list_painter.h index 95486b75e..28cab9675 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -18,6 +18,7 @@ class ServerInfo_User; struct CardArtParams { QString cardName = ""; + QString cardProviderId = ""; double marginPctL = 0.33; double marginPctR = 0.02; double verticalOffset = 0.35; diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index ed06ea941..a48d95cb5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -904,12 +904,13 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) const auto &cap = user.card_art_params(); CardArtParams params; params.cardName = QString::fromStdString(cap.card_name()); + params.cardProviderId = QString::fromStdString(cap.card_provider_id()); params.marginPctL = cap.margin_pct_l(); params.marginPctR = cap.margin_pct_r(); params.verticalOffset = cap.vertical_offset(); params.zoom = cap.zoom(); cardArtParamsMap.insert(userName, params); - cardArtProvider->requestCardArt(userName, params.cardName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); } else { cardArtParamsMap.remove(userName); // clear stale params on removal } diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp index 6e8ab752a..3dc4de15f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -27,7 +27,7 @@ int CardArtRulesModel::rowCount(const QModelIndex &parent) const int CardArtRulesModel::columnCount(const QModelIndex &parent) const { Q_UNUSED(parent); - return 3; + return 4; } QVariant CardArtRulesModel::data(const QModelIndex &index, int role) const @@ -43,8 +43,10 @@ QVariant CardArtRulesModel::data(const QModelIndex &index, int role) const case 0: return e.cardName; case 1: - return e.mode; + return e.cardProviderId; case 2: + return e.mode; + case 3: return e.reason; } } @@ -62,8 +64,10 @@ QVariant CardArtRulesModel::headerData(int section, Qt::Orientation orientation, case 0: return tr("Card"); case 1: - return tr("Mode"); + return tr("ProviderId"); case 2: + return tr("Mode"); + case 3: return tr("Reason"); default: return {}; @@ -97,6 +101,15 @@ QString CardArtRulesModel::cardAt(int row) const return entries[row].cardName; } +const CardArtRulesModel::Entry *CardArtRulesModel::entryAt(int row) const +{ + if (row < 0 || row >= static_cast(entries.size())) { + return nullptr; + } + + return &entries[row]; +} + void CardArtRulesModel::onRefreshFinished(const Response &r) { if (r.response_code() != Response::RespOk) { @@ -109,8 +122,8 @@ void CardArtRulesModel::onRefreshFinished(const Response &r) entries.clear(); for (const auto &e : resp.entries()) { - entries.push_back({QString::fromStdString(e.card_name()), QString::fromStdString(e.mode()), - QString::fromStdString(e.reason())}); + entries.push_back({QString::fromStdString(e.card_name()), QString::fromStdString(e.card_provider_id()), + QString::fromStdString(e.mode()), QString::fromStdString(e.reason())}); } endResetModel(); @@ -128,6 +141,7 @@ void TabCardArtRules::setupUi() initSearchBar(); + providerComboBox = new QComboBox; modeBox = new QComboBox; reasonEdit = new QLineEdit; @@ -146,6 +160,7 @@ void TabCardArtRules::setupUi() auto *form = new QFormLayout; form->addRow(tr("Card:"), searchEdit); + form->addRow(tr("ProviderId:"), providerComboBox); form->addRow(tr("Mode:"), modeBox); form->addRow(tr("Reason:"), reasonEdit); @@ -204,6 +219,34 @@ void TabCardArtRules::initSearchBar() }); connect(searchCompleter, static_cast(&QCompleter::activated), this, [this](const QString &name) { searchEdit->setText(name); }); + connect(searchEdit, &QLineEdit::editingFinished, this, + [this]() { populateProviderCombo(searchEdit->text().trimmed()); }); +} + +void TabCardArtRules::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } } void TabCardArtRules::retranslateUi() @@ -222,6 +265,7 @@ void TabCardArtRules::addRule() { Command_AddCardArtRule cmd; cmd.set_card_name(searchEdit->text().toStdString()); + cmd.set_card_provider_id(providerComboBox->currentData().toString().toStdString()); cmd.set_mode(modeBox->currentText().toStdString()); cmd.set_reason(reasonEdit->text().toStdString()); @@ -238,7 +282,10 @@ void TabCardArtRules::removeSelected() } Command_RemoveCardArtRule cmd; - cmd.set_card_name(tableModel->cardAt(idx.row()).toStdString()); + const auto e = tableModel->entryAt(idx.row()); + + cmd.set_card_name(e->cardName.toStdString()); + cmd.set_card_provider_id(e->cardProviderId.toStdString()); client->sendCommand(client->prepareModeratorCommand(cmd)); diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h index a47f1267d..b9ea2ca83 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -20,6 +20,7 @@ public: struct Entry { QString cardName; + QString cardProviderId; QString mode; QString reason; }; @@ -35,6 +36,7 @@ public: void clear(); QString cardAt(int row) const; + const Entry *entryAt(int row) const; private slots: void onRefreshFinished(const Response &r); @@ -70,11 +72,13 @@ private: QLineEdit *searchEdit; void initSearchBar(); + void populateProviderCombo(const QString &cardName); QCompleter *searchCompleter; CardDatabaseModel *cardDbModel; CardDatabaseDisplayModel *cardDbDisplayModel; CardSearchModel *cardSearchModel; CardCompleterProxyModel *cardProxyModel; + QComboBox *providerComboBox; QComboBox *modeBox; QLineEdit *reasonEdit; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index c10b9de22..ca46e4dd7 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -116,8 +116,9 @@ message Command_AddCardArtRule { } optional string card_name = 1; - optional string mode = 2; // "ALLOW" or "DENY" - optional string reason = 3; + optional string card_provider_id = 2; + optional string mode = 3; // "ALLOW" or "DENY" + optional string reason = 4; } message Command_RemoveCardArtRule { @@ -126,6 +127,7 @@ message Command_RemoveCardArtRule { } optional string card_name = 1; + optional string card_provider_id = 2; } message Command_ListCardArtRules { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto index b13c79742..25b76e09f 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_card_art_rule_entry.proto @@ -3,8 +3,9 @@ import "response.proto"; message Response_CardArtRuleEntry { optional string card_name = 1; - optional string mode = 2; - optional string reason = 3; + optional string card_provider_id = 2; + optional string mode = 3; + optional string reason = 4; } message Response_ListCardArtRules { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 4bbbe46bb..98cc3ce6a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -15,10 +15,11 @@ message ServerInfo_User { }; message CardArtParams { optional string card_name = 1; - optional double margin_pct_l = 2 [default = 0.33]; - optional double margin_pct_r = 3 [default = 0.02]; - optional double vertical_offset = 4 [default = 0.35]; - optional double zoom = 5 [default = 1.0]; + optional string card_provider_id = 2; + optional double margin_pct_l = 3 [default = 0.33]; + optional double margin_pct_r = 4 [default = 0.02]; + optional double vertical_offset = 5 [default = 0.35]; + optional double zoom = 6 [default = 1.0]; }; optional string name = 1; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index ff5dbff32..9d207c711 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -212,8 +212,9 @@ message Command_SetCardArtParams { optional Command_SetCardArtParams ext = 1025; } optional string card_name = 1; - optional double margin_pct_l = 2; - optional double margin_pct_r = 3; - optional double vertical_offset = 4; - optional double zoom = 5; + optional string card_provider_id = 2; + optional double margin_pct_l = 3; + optional double margin_pct_r = 4; + optional double vertical_offset = 5; + optional double zoom = 6; } diff --git a/servatrice/migrations/servatrice_0034_to_0035.sql b/servatrice/migrations/servatrice_0034_to_0035.sql index 83502a949..acaad9c8b 100644 --- a/servatrice/migrations/servatrice_0034_to_0035.sql +++ b/servatrice/migrations/servatrice_0034_to_0035.sql @@ -3,12 +3,13 @@ ALTER TABLE `cockatrice_users` ADD COLUMN `card_art_params` TEXT DEFAULT NULL, A CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `card_name` varchar(255) NOT NULL, + `card_provider_id` varchar(255) NOT NULL, `mode` enum('ALLOW','DENY') NOT NULL, `reason` varchar(255) DEFAULT NULL, `created_by` int(7) unsigned DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uniq_card_name` (`card_name`), + UNIQUE KEY `uniq_provider_card_name` (`card_provider_id`, `card_name`), KEY `idx_mode` (`mode`), FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index badd82d6d..7f530063c 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -305,12 +305,13 @@ CREATE TABLE IF NOT EXISTS `cockatrice_audit` ( CREATE TABLE IF NOT EXISTS `cockatrice_card_art_name_rules` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `card_name` varchar(255) NOT NULL, + `card_provider_id` varchar(255) NOT NULL, `mode` enum('ALLOW','DENY') NOT NULL, `reason` varchar(255) DEFAULT NULL, `created_by` int(7) unsigned DEFAULT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), - UNIQUE KEY `uniq_card_name` (`card_name`), + UNIQUE KEY `uniq_provider_card_name` (`card_provider_id`, `card_name`), KEY `idx_mode` (`mode`), FOREIGN KEY (`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index 92ff80755..d5e1f13ef 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -693,6 +693,9 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer if (obj.contains("card_name")) { cap->set_card_name(obj["card_name"].toString().toStdString()); } + if (obj.contains("card_provider_id")) { + cap->set_card_provider_id(obj["card_provider_id"].toString().toStdString()); + } if (obj.contains("marginPctL")) { cap->set_margin_pct_l(obj["marginPctL"].toDouble(0.33)); } diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index f9d276941..55f779468 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -1577,11 +1577,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm return Response::RespOk; } -bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName) +bool AbstractServerSocketInterface::isCardNameAllowed(const QString &cardName, const QString &cardProviderId) { - QSqlQuery *q = sqlInterface->prepareQuery("SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name"); + QSqlQuery *q = sqlInterface->prepareQuery( + "SELECT mode FROM {prefix}_card_art_name_rules WHERE card_name = :name AND card_provider_id = :provider"); q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); if (!sqlInterface->execSqlQuery(q)) { qWarning() << "Card art rule lookup failed; failing open for" << cardName; @@ -1603,8 +1605,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const } const QString cardName = QString::fromStdString(cmd.card_name()); + const QString cardProviderId = QString::fromStdString(cmd.card_provider_id()); - if (cardName.length() > MAX_NAME_LENGTH) { + if (cardName.length() > MAX_NAME_LENGTH || cardProviderId.length() > MAX_NAME_LENGTH) { return Response::RespInvalidData; } @@ -1620,7 +1623,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const return Response::RespOk; } - if (!isCardNameAllowed(cardName)) { + if (!isCardNameAllowed(cardName, cardProviderId)) { return Response::RespFunctionNotAllowed; } @@ -1633,6 +1636,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const QJsonObject obj; obj["card_name"] = cardName; + obj["card_provider_id"] = cardProviderId; obj["marginPctL"] = marginPctL; obj["marginPctR"] = marginPctR; obj["verticalOffset"] = verticalOffset; @@ -1649,6 +1653,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdSetCardArtParams(const // Keep the in-memory userInfo in sync auto *cap = userInfo->mutable_card_art_params(); cap->set_card_name(cmd.card_name()); + cap->set_card_provider_id(cmd.card_provider_id()); cap->set_margin_pct_l(marginPctL); cap->set_margin_pct_r(marginPctR); cap->set_vertical_offset(verticalOffset); @@ -1664,21 +1669,23 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAddCardArtRule(const Co ResponseContainer &) { const QString cardName = QString::fromStdString(cmd.card_name()); + const QString cardProviderId = QString::fromStdString(cmd.card_provider_id()); const QString mode = QString::fromStdString(cmd.mode()); if (mode != "ALLOW" && mode != "DENY") { return Response::RespInvalidData; } - if (cardName.isEmpty() || cardName.length() > MAX_NAME_LENGTH) { + if (cardName.isEmpty() || cardName.length() > MAX_NAME_LENGTH || cardProviderId.length() > MAX_NAME_LENGTH) { return Response::RespInvalidData; } QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO {prefix}_card_art_name_rules " - "(card_name, mode, reason, created_by) " - "VALUES (:name, :mode, :reason, :uid) " + "(card_name, card_provider_id, mode, reason, created_by) " + "VALUES (:name, :provider, :mode, :reason, :uid) " "ON DUPLICATE KEY UPDATE mode=:mode2, reason=:reason2"); q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); q->bindValue(":mode", mode); q->bindValue(":mode2", mode); q->bindValue(":reason", QString::fromStdString(cmd.reason())); @@ -1696,12 +1703,15 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRemoveCardArtRule(const ResponseContainer &) { auto cardName = QString::fromStdString(cmd.card_name()); - if (cardName.length() > MAX_NAME_LENGTH) { + auto cardProviderId = QString::fromStdString(cmd.card_provider_id()); + if (cardName.length() > MAX_NAME_LENGTH || cardProviderId.length() > MAX_NAME_LENGTH) { return Response::RespInvalidData; } - QSqlQuery *q = sqlInterface->prepareQuery("DELETE FROM {prefix}_card_art_name_rules WHERE card_name=:name"); + QSqlQuery *q = sqlInterface->prepareQuery( + "DELETE FROM {prefix}_card_art_name_rules WHERE card_name=:name AND card_provider_id=:provider"); q->bindValue(":name", cardName); + q->bindValue(":provider", cardProviderId); if (!sqlInterface->execSqlQuery(q)) { return Response::RespInternalError; @@ -1713,7 +1723,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRemoveCardArtRule(const Response::ResponseCode AbstractServerSocketInterface::cmdListCardArtRules(const Command_ListCardArtRules &, ResponseContainer &rc) { - QSqlQuery *q = sqlInterface->prepareQuery("SELECT card_name, mode, reason FROM {prefix}_card_art_name_rules"); + QSqlQuery *q = sqlInterface->prepareQuery( + "SELECT card_name, card_provider_id, mode, reason FROM {prefix}_card_art_name_rules"); if (!sqlInterface->execSqlQuery(q)) { return Response::RespInternalError; @@ -1724,8 +1735,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdListCardArtRules(const while (q->next()) { auto *entry = re->add_entries(); entry->set_card_name(q->value(0).toString().toStdString()); - entry->set_mode(q->value(1).toString().toStdString()); - entry->set_reason(q->value(2).toString().toStdString()); + entry->set_card_provider_id(q->value(1).toString().toStdString()); + entry->set_mode(q->value(2).toString().toStdString()); + entry->set_reason(q->value(3).toString().toStdString()); } rc.setResponseExtension(re); diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index c0732ccd9..0d66ae78f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -129,7 +129,7 @@ private: Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); - bool isCardNameAllowed(const QString &cardName); + bool isCardNameAllowed(const QString &cardName, const QString &cardProviderId); Response::ResponseCode cmdSetCardArtParams(const Command_SetCardArtParams &cmd, ResponseContainer &); Response::ResponseCode cmdAddCardArtRule(const Command_AddCardArtRule &cmd, ResponseContainer &); Response::ResponseCode cmdRemoveCardArtRule(const Command_RemoveCardArtRule &cmd, ResponseContainer &); From 4cbc00b9c43127c03d60f605549060c50ccb508a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:25:30 +0200 Subject: [PATCH 4/7] Bump actions/cache from 5 to 6 (#7019) --- .github/workflows/desktop-build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 74f905351..a6b9d5340 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -162,7 +162,7 @@ jobs: - name: "Restore compiler cache (ccache)" id: ccache_restore - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} with: @@ -215,7 +215,7 @@ jobs: - name: "Save updated compiler cache (ccache)" if: github.ref == 'refs/heads/master' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: key: ${{ steps.ccache_restore.outputs.cache-primary-key }} path: ${{ env.CACHE }} @@ -365,7 +365,7 @@ jobs: - name: "[macOS] Restore compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 id: ccache_restore - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 env: BRANCH_NAME: ${{ github.head_ref || github.ref_name }} with: @@ -387,7 +387,7 @@ jobs: - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries" if: matrix.os == 'macOS' id: restore_qt - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} path: ${{ github.workspace }}/Qt @@ -410,7 +410,7 @@ jobs: - name: "[macOS] Cache thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} path: ${{ github.workspace }}/Qt @@ -473,7 +473,7 @@ jobs: - name: "[macOS] Save updated compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: key: ${{ steps.ccache_restore.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} From ad4922537ddef6f1f06007b2978dae7e12a7af46 Mon Sep 17 00:00:00 2001 From: BruebachL <44814898+BruebachL@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:44:24 -0400 Subject: [PATCH 5/7] [UserListDelegate] Supply providerid in cmd, position popup correctly (#7020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [UserListDelegate] Transmit providerId in cmd when setting user banner card * [UserListDelegate] Position popup correctly * Lint. --------- Co-authored-by: Lukas BrΓΌbach --- .../widgets/server/user/user_info_box.cpp | 1 + .../widgets/server/user/user_list_widget.cpp | 48 ++++++++++++------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index e6cf38787..416cd42e3 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -335,6 +335,7 @@ void UserInfoBox::actBannerCard() Command_SetCardArtParams cmd; cmd.set_card_name(p.cardName.toStdString()); if (!p.cardName.isEmpty()) { + cmd.set_card_provider_id(p.cardProviderId.toStdString()); cmd.set_margin_pct_l(p.marginPctL); cmd.set_margin_pct_r(p.marginPctR); cmd.set_vertical_offset(p.verticalOffset); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index a48d95cb5..c094f8a6b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -767,13 +767,17 @@ void UserListWidget::showPopupForUser(const QString &userName) m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); - positionPopup(userName); - + // Realize the native window at opacity 0 before positioning so that: + // 1) move() applies to an existing native handle (not overridden by Qt's + // default centering logic on first show) + // 2) adjustSize() inside positionPopup() can measure the final laid-out + // geometry correctly + m_userInfoPopup->setWindowOpacity(0.0); m_userInfoPopup->show(); m_userInfoPopup->raise(); - // Fade in - m_userInfoPopup->setWindowOpacity(0.0); + positionPopup(userName); // geometry is now accurate; move() sticks + auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); fade->setDuration(120); fade->setStartValue(0.0); @@ -790,11 +794,10 @@ void UserListWidget::positionPopup(const QString &userName) QWidget *vp = userTree->viewport(); const QRect itemR = userTree->visualItemRect(item); - const QPoint itemBR = vp->mapToGlobal(itemR.bottomRight()); + const QPoint itemTL = vp->mapToGlobal(itemR.topLeft()); const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); - // Force a fresh size calculation so popH is accurate m_userInfoPopup->adjustSize(); const int popW = m_userInfoPopup->width(); const int popH = m_userInfoPopup->height(); @@ -802,19 +805,32 @@ void UserListWidget::positionPopup(const QString &userName) const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); - // ── X: left of the list if there's room, otherwise right ───────────────── - int x = (vpTL.x() >= popW + margin) ? vpTL.x() - popW - margin : vpTR.x() + margin; + // ── X: prefer the side with more space ─────────────────────────────────── + const int spaceLeft = vpTL.x() - screen.left() - margin; + const int spaceRight = screen.right() - vpTR.x() - margin; + int x; + if (spaceLeft >= spaceRight) { + x = (spaceLeft >= popW) ? (vpTL.x() - margin - popW) : (vpTR.x() + margin); + } else { + x = (spaceRight >= popW) ? (vpTR.x() + margin) : (vpTL.x() - margin - popW); + } x = qBound(screen.left() + margin, x, screen.right() - popW - margin); - // ── Y: bottom of popup aligns with bottom of hovered row, grows upward ─── - int y = itemBR.y() - popH; + // ── Y: grow down if there's room, otherwise grow up ─────────────────────── + const int itemTopY = itemTL.y(); + const int spaceBelow = screen.bottom() - itemTopY - margin; + const int spaceAbove = itemTopY - screen.top() - margin; - // Clamp: never above the screen top - y = qMax(y, screen.top() + margin); - - // Clamp: never below the screen bottom (e.g. if the popup is taller - // than the space above the row, let it spill downward rather than clip) - y = qMin(y, screen.bottom() - popH - margin); + int y; + if (spaceBelow >= popH) { + y = itemTopY; // top edges align, popup grows downward + } else if (spaceAbove >= popH) { + y = itemTopY - popH; // bottom of popup meets top of item, grows upward + } else { + // Neither side fits cleanly β€” pick the roomier side and let clamp handle the rest + y = (spaceBelow >= spaceAbove) ? itemTopY : (itemTopY - popH); + } + y = qBound(screen.top() + margin, y, screen.bottom() - popH - margin); m_userInfoPopup->move(x, y); } From 055ba9a16f9192bd23fc01fe571def0b2581379c Mon Sep 17 00:00:00 2001 From: DawnFire42 Date: Sat, 27 Jun 2026 18:53:21 -0400 Subject: [PATCH 6/7] Add subtype breakdown counter for card selection (#6923) * Add subtype breakdown counter for card selection Display a categorized count of creature subtypes (and other card type subtypes) when multiple cards are selected. The breakdown appears above the total selection counter in the bottom-right corner. Subtypes are grouped by main card type and sorted by frequency, with the most common subtypes positioned adjacent to the total count for quick reference. The feature can be toggled via a new checkbox in Settings > User Interface. * Alignment fix * Computation logic moved to helper funtction in separate file * Rename SubtypeCounter to SubtypeTally * Fix subtype tally alignment by using grid layout instead of character padding * Rename count to tally in the subtype breakdown feature * partial rename * list position fixed * Clean up code and documentation * Rename subtypeCountLabelStyle to subtypeTallyLabelStyle and fix include ordering * Fix include path for selection_subtype_tally.h after file relocation * fixed count to tally rename inconsistencies --- cockatrice/CMakeLists.txt | 1 + .../src/client/settings/cache_settings.cpp | 7 + .../src/client/settings/cache_settings.h | 6 + .../src/game/selection_subtype_tally.cpp | 64 ++++++++ cockatrice/src/game/selection_subtype_tally.h | 36 +++++ cockatrice/src/game_graphics/game_view.cpp | 142 +++++++++++++++--- cockatrice/src/game_graphics/game_view.h | 9 ++ cockatrice/src/interface/theme_manager.cpp | 3 + .../user_interface_settings_page.cpp | 14 +- .../user_interface_settings_page.h | 1 + .../libcockatrice/utility/qt_utils.h | 1 + 11 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 cockatrice/src/game/selection_subtype_tally.cpp create mode 100644 cockatrice/src/game/selection_subtype_tally.h diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 18679664b..166b807d9 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -83,6 +83,7 @@ set(cockatrice_SOURCES src/game/game_state.cpp src/game_graphics/game_view.cpp src/game_graphics/hand_counter.cpp + src/game/selection_subtype_tally.cpp src/game_graphics/log/message_log_widget.cpp src/game/phase.cpp src/game_graphics/phases_toolbar.cpp diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 28e5eb187..b6bc8a47d 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -313,6 +313,7 @@ SettingsCache::SettingsCache() showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool(); showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool(); + showSubtypeSelectionTally = settings->value("interface/showsubtypeselectiontally", true).toBool(); showShortcuts = settings->value("menu/showshortcuts", true).toBool(); showGameSelectorFilterToolbar = settings->value("menu/showgameselectorfiltertoolbar", true).toBool(); @@ -1395,6 +1396,12 @@ void SettingsCache::setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSele settings->setValue("interface/showpersistentselectioncount", showTotalSelectionCount); } +void SettingsCache::setShowSubtypeSelectionTally(QT_STATE_CHANGED_T _showSubtypeSelectionTally) +{ + showSubtypeSelectionTally = static_cast(_showSubtypeSelectionTally); + settings->setValue("interface/showsubtypeselectiontally", showSubtypeSelectionTally); +} + void SettingsCache::loadPaths() { QString dataPath = getDataPath(); diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 5a5e0c546..29af89587 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -355,6 +355,7 @@ private: bool showStatusBar; bool showDragSelectionCount; bool showTotalSelectionCount; + bool showSubtypeSelectionTally; public: SettingsCache(); @@ -478,6 +479,10 @@ public: { return showTotalSelectionCount; } + [[nodiscard]] bool getShowSubtypeSelectionTally() const + { + return showSubtypeSelectionTally; + } [[nodiscard]] bool getNotificationsEnabled() const { return notificationsEnabled; @@ -1176,5 +1181,6 @@ public slots: void setRoundCardCorners(bool _roundCardCorners); void setShowDragSelectionCount(QT_STATE_CHANGED_T _showDragSelectionCount); void setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSelectionCount); + void setShowSubtypeSelectionTally(QT_STATE_CHANGED_T _showSubtypeSelectionTally); }; #endif diff --git a/cockatrice/src/game/selection_subtype_tally.cpp b/cockatrice/src/game/selection_subtype_tally.cpp new file mode 100644 index 000000000..e9f87fab9 --- /dev/null +++ b/cockatrice/src/game/selection_subtype_tally.cpp @@ -0,0 +1,64 @@ +#include "selection_subtype_tally.h" + +#include "../game_graphics/board/card_item.h" + +#include +#include + +namespace +{ + +/** @brief Extracts subtypes from a single card face's type line. */ +QStringList extractSubtypesFromFace(const QString &faceType) +{ + // Card type format: "Creature β€” Goblin Warrior" or "Legendary Enchantment β€” Saga" + QStringList parts = faceType.split(QStringLiteral(" β€” ")); + if (parts.size() > 1) { + return parts[1].split(QStringLiteral(" "), Qt::SkipEmptyParts); + } + return {}; +} + +} // anonymous namespace + +namespace SelectionSubtypeTally +{ + +QList countSubtypes(const QList &cards) +{ + QMap subtypeCounts; + + for (CardItem *card : cards) { + if (card->getFaceDown() || card->getCard().isEmpty()) { + continue; + } + + QString cardType = card->getCardInfo().getCardType(); + // Handle double-faced cards: "Creature β€” Human // Creature β€” Werewolf" + QStringList cardFaces = cardType.split(QStringLiteral(" // ")); + + for (const QString &face : cardFaces) { + QStringList subtypes = extractSubtypesFromFace(face); + for (const QString &subtype : subtypes) { + subtypeCounts[subtype]++; + } + } + } + + QList entries; + for (auto it = subtypeCounts.constBegin(); it != subtypeCounts.constEnd(); ++it) { + entries.append({it.key(), it.value()}); + } + + // Sort by count ascending, then alphabetically (lowest counts at bottom of display) + std::sort(entries.begin(), entries.end(), [](const SubtypeEntry &a, const SubtypeEntry &b) { + if (a.count != b.count) { + return a.count < b.count; + } + return a.name < b.name; + }); + + return entries; +} + +} // namespace SelectionSubtypeTally diff --git a/cockatrice/src/game/selection_subtype_tally.h b/cockatrice/src/game/selection_subtype_tally.h new file mode 100644 index 000000000..9038653f6 --- /dev/null +++ b/cockatrice/src/game/selection_subtype_tally.h @@ -0,0 +1,36 @@ +#ifndef SELECTION_SUBTYPE_TALLY_H +#define SELECTION_SUBTYPE_TALLY_H + +#include +#include + +class CardItem; + +/** @brief A single subtype (e.g., "Goblin", "Warrior") with its occurrence count. */ +struct SubtypeEntry +{ + QString name; ///< The subtype name + int count; ///< Number of selected cards with this subtype + + bool operator==(const SubtypeEntry &other) const + { + return name == other.name && count == other.count; + } +}; + +/** + * @brief Extracts and tallies subtypes from selected cards. + */ +namespace SelectionSubtypeTally +{ +/** + * @brief Parses card type lines and counts each subtype occurrence. + * + * Skips face-down cards and cards without type info. + * @param cards The list of selected card items to analyze. + * @return Entries sorted by count ascending, then alphabetically. + */ +QList countSubtypes(const QList &cards); +} // namespace SelectionSubtypeTally + +#endif diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index 41befd9a4..c2d9b2b3b 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -1,12 +1,16 @@ #include "game_view.h" #include "../client/settings/cache_settings.h" +#include "../game/selection_subtype_tally.h" #include "game_scene.h" #include +#include #include +#include #include #include +#include // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. @@ -55,31 +59,40 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par refreshShortcuts(); rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this); - const QString countLabelStyle = "color: white; " - "font-size: 14px; " - "font-weight: bold; " - "background-color: rgba(0, 0, 0, 160); " - "border-radius: 3px; " - "padding: 1px 2px;"; + 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;"; dragCountLabel = new QLabel(this); - dragCountLabel->setStyleSheet(countLabelStyle); + dragCountLabel->setStyleSheet(dragCountLabelStyle); dragCountLabel->hide(); dragCountLabel->raise(); totalCountLabel = new QLabel(this); - totalCountLabel->setStyleSheet(countLabelStyle); + totalCountLabel->setStyleSheet(totalCountLabelStyle); totalCountLabel->hide(); + + subtypeTallyContainer = new QWidget(this); + subtypeTallyContainer->setStyleSheet(subtypeTallyLabelStyle); + subtypeTallyLayout = new QGridLayout(subtypeTallyContainer); + subtypeTallyLayout->setContentsMargins(2, 2, 2, 2); + subtypeTallyLayout->setSpacing(2); + subtypeTallyContainer->hide(); } void GameView::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); - GameScene *s = dynamic_cast(scene()); - if (s) { - s->processViewSizeChange(event->size()); - } + GameScene *s = static_cast(scene()); + s->processViewSizeChange(event->size()); updateSceneRect(scene()->sceneRect()); updateTotalSelectionCount(event->size()); @@ -164,29 +177,114 @@ void GameView::refreshShortcuts() SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView")); } +void GameView::clearSubtypeLabels() +{ + QtUtils::clearLayoutRec(subtypeTallyLayout); +} + +QSize GameView::rebuildSubtypeLabels(const QList &entries) +{ + clearSubtypeLabels(); + + 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;"); + + int totalHeight = 0; + int maxNameWidth = 0; + int maxCountWidth = 0; + + int row = 0; + for (const SubtypeEntry &entry : entries) { + auto *nameLabel = new QLabel(entry.name, subtypeTallyContainer); + nameLabel->setStyleSheet(nameStyle); + nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + subtypeTallyLayout->addWidget(nameLabel, row, 0); + + auto *countLabel = new QLabel(QString::number(entry.count), subtypeTallyContainer); + countLabel->setStyleSheet(countStyle); + countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + subtypeTallyLayout->addWidget(countLabel, row, 1); + + QSize nameSize = nameLabel->sizeHint(); + QSize countSize = countLabel->sizeHint(); + maxNameWidth = qMax(maxNameWidth, nameSize.width()); + maxCountWidth = qMax(maxCountWidth, countSize.width()); + totalHeight += qMax(nameSize.height(), countSize.height()); + + ++row; + } + + int spacing = subtypeTallyLayout->spacing(); + int margins = subtypeTallyLayout->contentsMargins().left() + subtypeTallyLayout->contentsMargins().right(); + int verticalMargins = subtypeTallyLayout->contentsMargins().top() + subtypeTallyLayout->contentsMargins().bottom(); + + int width = maxNameWidth + spacing + maxCountWidth + margins; + int height = totalHeight + (row - 1) * spacing + verticalMargins; + + return QSize(width, height); +} + void GameView::updateTotalSelectionCount(const QSize &viewSize) { - if (!SettingsCache::instance().getShowTotalSelectionCount()) { - totalCountLabel->hide(); - return; - } + constexpr int kMarginInPixels = 10; + constexpr int kSpacingBetweenLabels = 4; + + int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width(); + int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height(); int count = scene()->selectedItems().count(); - if (count > 1) { + if (!SettingsCache::instance().getShowTotalSelectionCount() || count <= 1) { + totalCountLabel->hide(); + } else { totalCountLabel->setText(QString::number(count)); totalCountLabel->adjustSize(); - constexpr int kMarginInPixels = 10; - int availableWidth = viewSize.isValid() ? viewSize.width() : viewport()->width(); - int availableHeight = viewSize.isValid() ? viewSize.height() : viewport()->height(); int x = availableWidth - totalCountLabel->width() - kMarginInPixels; int y = availableHeight - totalCountLabel->height() - kMarginInPixels; totalCountLabel->move(x, y); totalCountLabel->show(); - } else { - totalCountLabel->hide(); } + + if (!SettingsCache::instance().getShowSubtypeSelectionTally() || count <= 1) { + subtypeTallyContainer->hide(); + cachedSubtypeEntries.clear(); + return; + } + + GameScene *gameScene = static_cast(scene()); + QList entries = SelectionSubtypeTally::countSubtypes(gameScene->selectedCards()); + + if (entries.isEmpty()) { + subtypeTallyContainer->hide(); + cachedSubtypeEntries.clear(); + return; + } + + // Only rebuild labels if entries changed + QSize containerSize; + if (entries != cachedSubtypeEntries) { + cachedSubtypeEntries = entries; + containerSize = rebuildSubtypeLabels(entries); + subtypeTallyContainer->resize(containerSize); + } else { + containerSize = subtypeTallyContainer->size(); + } + + int x = availableWidth - containerSize.width() - kMarginInPixels; + int y; + + if (totalCountLabel->isVisible()) { + y = totalCountLabel->y() - containerSize.height() - kSpacingBetweenLabels; + } else { + y = availableHeight - containerSize.height() - kMarginInPixels; + } + + y = qMax(kMarginInPixels, y); + + subtypeTallyContainer->move(x, y); + subtypeTallyContainer->show(); } /** diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 80e8e96b5..4047c87ab 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -7,9 +7,12 @@ #ifndef GAMEVIEW_H #define GAMEVIEW_H +#include "../game/selection_subtype_tally.h" + #include class GameScene; +class QGridLayout; class QLabel; class QRubberBand; @@ -21,7 +24,13 @@ private: QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; + QWidget *subtypeTallyContainer; + QGridLayout *subtypeTallyLayout; QPointF selectionOrigin; + QList cachedSubtypeEntries; ///< Cached entries to avoid redundant rebuilds + + QSize rebuildSubtypeLabels(const QList &entries); + void clearSubtypeLabels(); protected: void resizeEvent(QResizeEvent *event) override; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 086845fe6..4ba35a00e 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -271,6 +271,9 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, const PaletteConfig &palCfg, const QString &activeScheme) { +#if (QT_VERSION < QT_VERSION_CHECK(6, 5, 0)) + Q_UNUSED(activeScheme) +#endif QString styleName = themeCfg.styleName; if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) { if (themeName == FUSION_THEME_NAME) { diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 6039e3758..44b30d29c 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -68,6 +68,10 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), &SettingsCache::setShowTotalSelectionCount); + showSubtypeSelectionTallyCheckBox.setChecked(SettingsCache::instance().getShowSubtypeSelectionTally()); + connect(&showSubtypeSelectionTallyCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), + &SettingsCache::setShowSubtypeSelectionTally); + useTearOffMenusCheckBox.setChecked(SettingsCache::instance().getUseTearOffMenus()); connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), [](const QT_STATE_CHANGED_T state) { SettingsCache::instance().setUseTearOffMenus(state == Qt::Checked); }); @@ -86,8 +90,9 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() generalGrid->addWidget(&annotateTokensCheckBox, 6, 0); generalGrid->addWidget(&showDragSelectionCountCheckBox, 7, 0); generalGrid->addWidget(&showTotalSelectionCountCheckBox, 8, 0); - generalGrid->addWidget(&useTearOffMenusCheckBox, 9, 0); - generalGrid->addWidget(&keepGameChatFocusCheckBox, 10, 0); + generalGrid->addWidget(&showSubtypeSelectionTallyCheckBox, 9, 0); + generalGrid->addWidget(&useTearOffMenusCheckBox, 10, 0); + generalGrid->addWidget(&keepGameChatFocusCheckBox, 11, 0); generalGroupBox = new QGroupBox; generalGroupBox->setLayout(generalGrid); @@ -209,8 +214,9 @@ void UserInterfaceSettingsPage::retranslateUi() closeEmptyCardViewCheckBox.setText(tr("Close card view window when last card is removed")); focusCardViewSearchBarCheckBox.setText(tr("Auto focus search bar when card view window is opened")); annotateTokensCheckBox.setText(tr("Annotate card text on tokens")); - showDragSelectionCountCheckBox.setText(tr("Show selection counter during drag selection")); - showTotalSelectionCountCheckBox.setText(tr("Show total selection counter")); + showDragSelectionCountCheckBox.setText(tr("Show selection count during drag selection")); + showTotalSelectionCountCheckBox.setText(tr("Show total selection count")); + showSubtypeSelectionTallyCheckBox.setText(tr("Show subtype breakdown in selection tally")); useTearOffMenusCheckBox.setText(tr("Use tear-off menus, allowing right click menus to persist on screen")); keepGameChatFocusCheckBox.setText( tr("Keep game chat focused when clicking in game (Note: disables card view search bar)")); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index e10ed2a06..06f0e6b83 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -29,6 +29,7 @@ private: QCheckBox annotateTokensCheckBox; QCheckBox showDragSelectionCountCheckBox; QCheckBox showTotalSelectionCountCheckBox; + QCheckBox showSubtypeSelectionTallyCheckBox; QCheckBox useTearOffMenusCheckBox; QCheckBox keepGameChatFocusCheckBox; QCheckBox tapAnimationCheckBox; diff --git a/libcockatrice_utility/libcockatrice/utility/qt_utils.h b/libcockatrice_utility/libcockatrice/utility/qt_utils.h index 334e56027..8e5212031 100644 --- a/libcockatrice_utility/libcockatrice/utility/qt_utils.h +++ b/libcockatrice_utility/libcockatrice/utility/qt_utils.h @@ -1,5 +1,6 @@ #ifndef COCKATRICE_QT_UTILS_H #define COCKATRICE_QT_UTILS_H +#include #include namespace QtUtils From fcac7493adbb138366e016fd13ec2543367f4ba0 Mon Sep 17 00:00:00 2001 From: RickyRister <42636155+RickyRister@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:03:07 -0700 Subject: [PATCH 7/7] [Card] Add facedown property to CardRelation (#6997) * [Card] Add facedown property to CardRelation * trailing newline * fix comments * update schema --- cockatrice/src/game/player/player_actions.cpp | 17 +++++++++++------ cockatrice/src/game/player/player_actions.h | 3 ++- doc/carddatabase_v4/cards.xsd | 1 + .../card/database/card_database.cpp | 3 ++- .../card/database/parser/cockatrice_xml_4.cpp | 11 ++++++++++- .../card/relation/card_relation.cpp | 8 +++++--- .../libcockatrice/card/relation/card_relation.h | 15 ++++++++++++++- 7 files changed, 45 insertions(+), 13 deletions(-) diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index fffd23ccf..c9e8628f4 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1018,8 +1018,9 @@ void PlayerActions::actCreateAllRelatedCards() if (!cardRelationAll->getDoesAttach() && !cardRelationAll->getIsVariable()) { dbName = cardRelationAll->getName(); bool persistent = cardRelationAll->getIsPersistent(); + bool faceDown = cardRelationAll->getIsFaceDown(); for (int i = 0; i < cardRelationAll->getDefaultCount(); ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } ++tokensTypesCreated; if (tokensTypesCreated == 1) { @@ -1034,8 +1035,9 @@ void PlayerActions::actCreateAllRelatedCards() if (!cardRelationNotExcluded->getDoesAttach() && !cardRelationNotExcluded->getIsVariable()) { dbName = cardRelationNotExcluded->getName(); bool persistent = cardRelationNotExcluded->getIsPersistent(); + bool faceDown = cardRelationNotExcluded->getIsFaceDown(); for (int i = 0; i < cardRelationNotExcluded->getDefaultCount(); ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } ++tokensTypesCreated; if (tokensTypesCreated == 1) { @@ -1073,6 +1075,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, const QString dbName = cardRelation->getName(); const bool persistent = cardRelation->getIsPersistent(); + const bool faceDown = cardRelation->getIsFaceDown(); // Variable relations always use DoesNotAttach, regardless of the count the user // entered. @@ -1081,7 +1084,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, return false; } for (int i = 0; i < variableCount; ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } return true; } @@ -1090,7 +1093,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, if (count > 1) { for (int i = 0; i < count; ++i) { - createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent); + createCard(sourceCard, dbName, CardRelationType::DoesNotAttach, persistent, faceDown); } return true; } @@ -1110,7 +1113,7 @@ bool PlayerActions::createRelatedFromRelation(const CardItem *sourceCard, playCardToTable(sourceCard, false); } - createCard(sourceCard, dbName, attachType, persistent); + createCard(sourceCard, dbName, attachType, persistent, faceDown); return true; } @@ -1137,7 +1140,8 @@ void PlayerActions::onRelatedCardCreated(const CardItem *sourceCard, const CardR void PlayerActions::createCard(const CardItem *sourceCard, const QString &dbCardName, CardRelationType attachType, - bool persistent) + bool persistent, + bool faceDown) { CardInfoPtr cardInfo = CardDatabaseManager::query()->getCardInfo(dbCardName); @@ -1172,6 +1176,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, cmd.set_destroy_on_zone_change(!persistent); cmd.set_x(gridPoint.x()); cmd.set_y(gridPoint.y()); + cmd.set_face_down(faceDown); ExactCard relatedCard = CardDatabaseManager::query()->getCardFromSameSet(cardInfo->getName(), sourceCard->getCard().getPrinting()); diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index 3f1960892..fa4d54110 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -240,7 +240,8 @@ private: void createCard(const CardItem *sourceCard, const QString &dbCardName, CardRelationType attach = CardRelationType::DoesNotAttach, - bool persistent = false); + bool persistent = false, + bool faceDown = false); void playSelectedCards(QList selectedCards, bool faceDown = false); diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd index 92d30b94f..59ca3e560 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -6,6 +6,7 @@ + diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index edad46174..261b36690 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -85,7 +85,8 @@ void CardDatabase::refreshCachedReverseRelatedCards() for (auto *rel : card->getReverseRelatedCards()) { if (auto target = cards.value(rel->getName())) { auto *newRel = new CardRelation(card->getName(), rel->getAttachType(), rel->getIsCreateAllExclusion(), - rel->getIsVariable(), rel->getDefaultCount(), rel->getIsPersistent()); + rel->getIsVariable(), rel->getDefaultCount(), rel->getIsPersistent(), + rel->getIsFaceDown()); target->addReverseRelatedCards2Me(newRel); } } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 96a5ac104..c242425ab 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -329,6 +329,7 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) bool exclude = false; bool variable = false; bool persistent = false; + bool facedown = false; int count = 1; QXmlStreamAttributes attrs = xml.attributes(); QString cardName = xml.readElementText(QXmlStreamReader::IncludeChildElements); @@ -360,7 +361,12 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) persistent = true; } - auto *relation = new CardRelation(cardName, attachType, exclude, variable, count, persistent); + if (attrs.hasAttribute("facedown")) { + facedown = true; + } + + auto *relation = + new CardRelation(cardName, attachType, exclude, variable, count, persistent, facedown); if (xmlName == "reverse-related") { reverseRelatedCards << relation; } else { @@ -510,6 +516,9 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in if (i->getIsPersistent()) { xml.writeAttribute("persistent", "persistent"); } + if (i->getIsFaceDown()) { + xml.writeAttribute("facedown", "facedown"); + } if (i->getIsVariable()) { if (1 == i->getDefaultCount()) { xml.writeAttribute("count", "x"); diff --git a/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp b/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp index 90e59e439..8903c892d 100644 --- a/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp +++ b/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp @@ -7,8 +7,10 @@ CardRelation::CardRelation(const QString &_name, bool _isCreateAllExclusion, bool _isVariableCount, int _defaultCount, - bool _isPersistent) + bool _isPersistent, + bool _isFaceDown) : name(_name), attachType(_attachType), isCreateAllExclusion(_isCreateAllExclusion), - isVariableCount(_isVariableCount), defaultCount(_defaultCount), isPersistent(_isPersistent) + isVariableCount(_isVariableCount), defaultCount(_defaultCount), isPersistent(_isPersistent), + isFaceDown(_isFaceDown) { -} \ No newline at end of file +} diff --git a/libcockatrice_card/libcockatrice/card/relation/card_relation.h b/libcockatrice_card/libcockatrice/card/relation/card_relation.h index 9ff704097..a1864f5b2 100644 --- a/libcockatrice_card/libcockatrice/card/relation/card_relation.h +++ b/libcockatrice_card/libcockatrice/card/relation/card_relation.h @@ -31,6 +31,7 @@ private: bool isVariableCount; ///< True if the number of creations is variable. int defaultCount; ///< Default number of cards created or involved. bool isPersistent; ///< True if this relation persists (i.e. is not destroyed) on zone change. + bool isFaceDown; ///< True if this relation creates the tokens facedown public: /** @@ -42,13 +43,15 @@ public: * @param _isVariableCount Whether the count is variable. * @param _defaultCount Default number for creations or transformations. * @param _isPersistent Whether the relation persists across zone changes. + * @param _isFaceDown Whether the relation creates the token face down */ explicit CardRelation(const QString &_name = QString(), CardRelationType _attachType = CardRelationType::DoesNotAttach, bool _isCreateAllExclusion = false, bool _isVariableCount = false, int _defaultCount = 1, - bool _isPersistent = false); + bool _isPersistent = false, + bool _isFaceDown = false); /** * @brief Returns the name of the related card. @@ -151,6 +154,16 @@ public: { return isPersistent; } + + /** + * @brief Returns whether the relation creates the token facedown. + * + * @return True if facedown, false otherwise. + */ + [[nodiscard]] bool getIsFaceDown() const + { + return isFaceDown; + } }; #endif // COCKATRICE_CARD_RELATION_H