Compare commits

...

8 commits

Author SHA1 Message Date
tooomm
da45209681
Merge branch 'master' into tooomm-ci_polish 2026-06-28 14:17:30 +02:00
RickyRister
fcac7493ad
[Card] Add facedown property to CardRelation (#6997)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
* [Card] Add facedown property to CardRelation

* trailing newline

* fix comments

* update schema
2026-06-28 02:03:07 -07:00
DawnFire42
055ba9a16f
Add subtype breakdown counter for card selection (#6923)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
* 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
2026-06-27 15:53:21 -07:00
BruebachL
ad4922537d
[UserListDelegate] Supply providerid in cmd, position popup correctly (#7020)
* [UserListDelegate] Transmit providerId in cmd when setting user banner card

* [UserListDelegate] Position popup correctly

* Lint.

---------

Co-authored-by: Lukas Brübach <lukas.bruebach@bdosecurity.de>
2026-06-27 14:44:24 -04:00
dependabot[bot]
4cbc00b9c4
Bump actions/cache from 5 to 6 (#7019)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
2026-06-27 17:25:30 +02:00
BruebachL
6dc974a05d
[UserListDelegate] Consider providerId (#7018)
Co-authored-by: Lukas Brübach <lukas.bruebach@bdosecurity.de>
2026-06-27 11:23:55 -04:00
BruebachL
2914874720
[Room][UserList] Introduce style delegate (#6981)
Some checks are pending
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 15 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker Image / amd64 & arm64 (push) Waiting to run
* [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 <Bruebach.Lukas@bdosecurity.de>
2026-06-26 20:52:24 -04:00
tooomm
c9ebdb451f
CI: Avoid failing tx step if secret can not be accessed (#7012)
Some checks are pending
Build Docker Image / amd64 & arm64 (push) Waiting to run
2026-06-26 16:39:33 +02:00
62 changed files with 3873 additions and 128 deletions

View file

@ -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 == true
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 == true && 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 }}

View file

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

View file

@ -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
@ -236,10 +237,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 +332,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 +355,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)

View file

@ -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();
@ -371,6 +372,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 +1047,13 @@ void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs)
settings->setValue("replay/rewindBufferingMs", rewindBufferingMs);
}
void SettingsCache::setStyleUserList(QT_STATE_CHANGED_T _styleUserList)
{
styleUserList = static_cast<bool>(_styleUserList);
settings->setValue("appearance/styleUserList", styleUserList);
emit styleUserListChanged();
}
void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention)
{
chatMention = static_cast<bool>(_chatMention);
@ -1387,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<bool>(_showSubtypeSelectionTally);
settings->setValue("interface/showsubtypeselectiontally", showSubtypeSelectionTally);
}
void SettingsCache::loadPaths()
{
QString dataPath = getDataPath();

View file

@ -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;
@ -353,6 +355,7 @@ private:
bool showStatusBar;
bool showDragSelectionCount;
bool showTotalSelectionCount;
bool showSubtypeSelectionTally;
public:
SettingsCache();
@ -476,6 +479,10 @@ public:
{
return showTotalSelectionCount;
}
[[nodiscard]] bool getShowSubtypeSelectionTally() const
{
return showSubtypeSelectionTally;
}
[[nodiscard]] bool getNotificationsEnabled() const
{
return notificationsEnabled;
@ -738,6 +745,10 @@ public:
{
return rewindBufferingMs;
}
[[nodiscard]] bool getStyleUserList() const
{
return styleUserList;
}
[[nodiscard]] bool getChatMention() const
{
return chatMention;
@ -1113,6 +1124,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);
@ -1169,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

View file

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

View file

@ -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<CardItem *> selectedCards, bool faceDown = false);

View file

@ -0,0 +1,64 @@
#include "selection_subtype_tally.h"
#include "../game_graphics/board/card_item.h"
#include <QMap>
#include <algorithm>
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<SubtypeEntry> countSubtypes(const QList<CardItem *> &cards)
{
QMap<QString, int> 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<SubtypeEntry> 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

View file

@ -0,0 +1,36 @@
#ifndef SELECTION_SUBTYPE_TALLY_H
#define SELECTION_SUBTYPE_TALLY_H
#include <QList>
#include <QString>
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<SubtypeEntry> countSubtypes(const QList<CardItem *> &cards);
} // namespace SelectionSubtypeTally
#endif

View file

@ -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 <QAction>
#include <QGridLayout>
#include <QLabel>
#include <QLayout>
#include <QResizeEvent>
#include <QRubberBand>
#include <libcockatrice/utility/qt_utils.h>
// 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<GameScene *>(scene());
if (s) {
s->processViewSizeChange(event->size());
}
GameScene *s = static_cast<GameScene *>(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<SubtypeEntry> &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<GameScene *>(scene());
QList<SubtypeEntry> 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();
}
/**

View file

@ -7,9 +7,12 @@
#ifndef GAMEVIEW_H
#define GAMEVIEW_H
#include "../game/selection_subtype_tally.h"
#include <QGraphicsView>
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<SubtypeEntry> cachedSubtypeEntries; ///< Cached entries to avoid redundant rebuilds
QSize rebuildSubtypeLabels(const QList<SubtypeEntry> &entries);
void clearSubtypeLabels();
protected:
void resizeEvent(QResizeEvent *event) override;

View file

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

View file

@ -0,0 +1,48 @@
#include "user_avatar_provider.h"
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client)
{
}
const QMap<QString, QPixmap> &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<const uchar *>(bmp.data()), static_cast<uint>(bmp.size()))) {
avatarCache.insert(userName, avatar);
} else {
avatarCache.insert(userName, QPixmap());
}
emit avatarUpdated(userName);
});
client->sendCommand(pend);
}

View file

@ -0,0 +1,30 @@
#ifndef COCKATRICE_USER_AVATAR_PROVIDER_H
#define COCKATRICE_USER_AVATAR_PROVIDER_H
#include <QMap>
#include <QObject>
#include <QPixmap>
#include <QSet>
class AbstractClient;
class UserAvatarProvider : public QObject
{
Q_OBJECT
public:
explicit UserAvatarProvider(AbstractClient *client, QObject *parent = nullptr);
void requestAvatar(const QString &userName);
const QMap<QString, QPixmap> &cache() const;
signals:
void avatarUpdated(const QString &userName);
private:
AbstractClient *client;
QMap<QString, QPixmap> avatarCache;
QSet<QString> pending;
};
#endif // COCKATRICE_USER_AVATAR_PROVIDER_H

View file

@ -0,0 +1,147 @@
#include "user_card_art_provider.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include <QPointer>
#include <libcockatrice/card/database/card_database_manager.h>
static QString makeKey(const QString &user, const QString &card, const QString &providerId)
{
return user + u'|' + card + u'|' + providerId;
}
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<QString, QPixmap> &UserCardArtProvider::cache() const
{
return cardArtCache;
}
void UserCardArtProvider::requestCardArt(const QString &userName, const QString &cardName, const QString &providerId)
{
if (cardName.isEmpty()) {
return;
}
const QString key = makeKey(userName, cardName, providerId);
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() != 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, providerId});
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<UserCardArtProvider> self(this);
auto conn = std::make_shared<QMetaObject::Connection>();
*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;
}
}

View file

@ -0,0 +1,39 @@
#ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H
#define COCKATRICE_USER_CARD_ART_PROVIDER_H
#include <QMap>
#include <QObject>
#include <QPixmap>
#include <QQueue>
#include <QSet>
class UserCardArtProvider : public QObject
{
Q_OBJECT
public:
explicit UserCardArtProvider(QObject *parent = nullptr);
void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId);
const QMap<QString, QPixmap> &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<QString> cacheInsertionOrder; // FIFO eviction
QMap<QString, QPixmap> cardArtCache;
QSet<QString> pending;
QQueue<QString> queue;
void processQueue();
void insertIntoCache(const QString &key, const QPixmap &pixmap);
};
#endif // COCKATRICE_USER_CARD_ART_PROVIDER_H

View file

@ -0,0 +1,339 @@
#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 <QCompleter>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
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<void (QCompleter::*)(const QString &)>(&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();
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);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
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);
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::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()) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
return;
}
const ExactCard card = CardDatabaseManager::query()->getCard({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()) {
// Not loaded yet -- wait for the signal instead of giving up.
// card.getCardPtr() is a CardInfoPtr (QSharedPointer<CardInfo>);
// .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()
{
currentParams.marginPctL = marginLSpin->value();
currentParams.marginPctR = marginRSpin->value();
currentParams.verticalOffset = verticalOffsetSpin->value();
currentParams.zoom = zoomSpin->value();
preview->setParams(currentParams);
}

View file

@ -0,0 +1,77 @@
#ifndef COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H
#define COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H
#include "user_list_painter.h"
#include <QComboBox>
#include <QDialog>
#include <QPixmap>
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 &params);
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 reloadPreview();
void onParamChanged();
private:
void setupUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
QLineEdit *searchBar;
QCompleter *completer;
CardDatabaseModel *cardDatabaseModel;
CardDatabaseDisplayModel *cardDatabaseDisplayModel;
CardSearchModel *searchModel;
CardCompleterProxyModel *proxyModel;
QComboBox *providerComboBox;
QMetaObject::Connection pixmapUpdatedConnection;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
CardArtPreviewWidget *preview;
QPixmap currentPixmap;
CardArtParams currentParams;
};
#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H

View file

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

View file

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

View file

@ -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 <QDateTime>
#include <QGridLayout>
@ -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,49 @@ 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_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);
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()) {

View file

@ -12,6 +12,7 @@
#include <QPushButton>
#include <QWidget>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/utility/days_years_between.h>
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);

View file

@ -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 <QApplication>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QPropertyAnimation>
#include <QPushButton>
#include <QScreen>
#include <QScrollBar>
#include <QStandardItem>
#include <QStyledItemDelegate>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
// ── 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<ServerInfo_Game>();
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 &params)
{
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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<QPushButton *>(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<ServerInfo_Game>();
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 + u'|' + params.cardProviderId;
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();
}

View file

@ -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 <QFrame>
#include <QListView>
#include <QMap>
#include <QPixmap>
#include <QStandardItemModel>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_game.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
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 &params);
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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<QString, QPixmap> *m_avatarCache;
const QMap<QString, QPixmap> *m_cardArtCache;
const QMap<QString, CardArtParams> *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

View file

@ -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<ServerInfo_User> &_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<ServerInfo_User> &_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<QString, ServerInfo_User> *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<QString, ServerInfo_User> *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;
}

View file

@ -47,15 +47,17 @@ public:
explicit UserListManager(AbstractClient *_client, QObject *parent = nullptr);
~UserListManager() override;
[[nodiscard]] QMap<QString, ServerInfo_User> getAllUsersList() const
[[nodiscard]] const QMap<QString, ServerInfo_User> &getAllUsersList() const
{
return onlineUsers;
}
[[nodiscard]] QMap<QString, ServerInfo_User> getBuddyList() const
[[nodiscard]] const QMap<QString, ServerInfo_User> &getBuddyList() const
{
return buddyUsers;
}
[[nodiscard]] QMap<QString, ServerInfo_User> getIgnoreList() const
[[nodiscard]] const QMap<QString, ServerInfo_User> &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

View file

@ -0,0 +1,342 @@
#include "user_list_painter.h"
#include "../../interface/pixel_map_generator.h"
#include <QAbstractScrollArea>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
#include <QStyle>
#include <QStyleOptionViewItem>
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<const QAbstractScrollArea *>(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, const QString &providerId)
{
return user + u'|' + card + u'|' + providerId;
}
void UserListPainter::drawCardArt(QPainter *painter,
const QRect &rect,
int cardRight,
const QString &userName,
const QMap<QString, QPixmap> *cardArtCache,
const CardArtParams &params,
const QPixmap *overridePixmap = nullptr)
{
QPixmap art;
if (overridePixmap && !overridePixmap->isNull()) {
art = *overridePixmap;
} else {
if (!cardArtCache) {
return;
}
const QString key = makeKey(userName, params.cardName, params.cardProviderId);
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<QString, QPixmap> *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::Badge> UserListPainter::buildBadges(const UserLevelFlags &userLevel, const QString &privLevel)
{
QList<Badge> 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<Badge> &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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<Badge> badges = buildBadges(userLevel, privLevel);
drawBadges(painter, option, rect, cardRight, badges, online);
painter->restore();
}

View file

@ -0,0 +1,87 @@
#ifndef COCKATRICE_USER_LIST_PAINTER_H
#define COCKATRICE_USER_LIST_PAINTER_H
#include "user_level.h"
#include <QColor>
#include <QList>
#include <QMap>
#include <QPixmap>
#include <QRect>
#include <QSize>
class QPainter;
class QModelIndex;
class QStyleOptionViewItem;
class ServerInfo_User;
struct CardArtParams
{
QString cardName = "";
QString cardProviderId = "";
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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
static QSize sizeHint();
static void drawCardArt(QPainter *painter,
const QRect &rect,
int cardRight,
const QString &userName,
const QMap<QString, QPixmap> *cardArtCache,
const CardArtParams &params,
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<QString, QPixmap> *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<Badge> buildBadges(const UserLevelFlags &userLevel, const QString &privLevel);
static void drawBadges(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online);
};
#endif // COCKATRICE_USER_LIST_PAINTER_H

View file

@ -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 <QApplication>
#include <QCheckBox>
@ -15,13 +18,18 @@
#include <QLineEdit>
#include <QMessageBox>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QRadioButton>
#include <QSpinBox>
#include <QWidget>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pb/response_get_user_info.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/utility/trice_limits.h>
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<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<ServerInfo_User>(), 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<UserListTWI *>(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,296 @@ 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<QMouseEvent *>(event);
auto *twi = static_cast<UserListTWI *>(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);
// 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();
positionPopup(userName); // geometry is now accurate; move() sticks
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 itemTL = vp->mapToGlobal(itemR.topLeft());
const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft());
const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight());
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: 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: 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;
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);
}
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 +877,60 @@ void UserListWidget::retranslateUi()
updateCount();
}
void UserListWidget::rebuild()
{
userTree->clear();
users.clear();
cardArtParamsMap.clear();
onlineCount = 0;
if (!manager) {
return;
}
const QMap<QString, ServerInfo_User> *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.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, params.cardProviderId);
} else {
cardArtParamsMap.remove(userName); // clear stale params on removal
}
UserListTWI *item = users.value(userName);
if (item) {
item->setUserInfo(user);
@ -475,25 +942,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 +1007,5 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index
void UserListWidget::sortItems()
{
userTree->sortItems(1, Qt::AscendingOrder);
userTree->sortItems(0, Qt::AscendingOrder);
}

View file

@ -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 <QComboBox>
#include <QDialog>
#include <QGroupBox>
#include <QQueue>
#include <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
@ -94,12 +102,21 @@ public:
class UserListItemDelegate : public QStyledItemDelegate
{
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
public:
explicit UserListItemDelegate(QObject *const parent);
explicit UserListItemDelegate(QObject *const parent,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *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<QString, CardArtParams> 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<QString, UserListTWI *> 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

View file

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

View file

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

View file

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

View file

@ -29,6 +29,7 @@ private:
QCheckBox annotateTokensCheckBox;
QCheckBox showDragSelectionCountCheckBox;
QCheckBox showTotalSelectionCountCheckBox;
QCheckBox showSubtypeSelectionTallyCheckBox;
QCheckBox useTearOffMenusCheckBox;
QCheckBox keepGameChatFocusCheckBox;
QCheckBox tapAnimationCheckBox;

View file

@ -0,0 +1,293 @@
#include "tab_card_art_rules.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QCompleter>
#include <QFormLayout>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pending_command.h>
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<int>(entries.size());
}
int CardArtRulesModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return 4;
}
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.cardProviderId;
case 2:
return e.mode;
case 3:
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("ProviderId");
case 2:
return tr("Mode");
case 3:
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<int>(entries.size())) {
return {};
}
return entries[row].cardName;
}
const CardArtRulesModel::Entry *CardArtRulesModel::entryAt(int row) const
{
if (row < 0 || row >= static_cast<int>(entries.size())) {
return nullptr;
}
return &entries[row];
}
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.card_provider_id()),
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();
providerComboBox = new QComboBox;
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("ProviderId:"), providerComboBox);
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<void (QCompleter::*)(const QString &)>(&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()
{
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_card_provider_id(providerComboBox->currentData().toString().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;
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));
refresh();
}

View file

@ -0,0 +1,93 @@
#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 <QAbstractTableModel>
#include <QComboBox>
#include <QLineEdit>
#include <QPushButton>
#include <QTableView>
class AbstractClient;
class CardArtRulesModel : public QAbstractTableModel
{
Q_OBJECT
public:
struct Entry
{
QString cardName;
QString cardProviderId;
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;
const Entry *entryAt(int row) const;
private slots:
void onRefreshFinished(const Response &r);
private:
AbstractClient *client;
std::vector<Entry> 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();
void populateProviderCombo(const QString &cardName);
QCompleter *searchCompleter;
CardDatabaseModel *cardDbModel;
CardDatabaseDisplayModel *cardDbDisplayModel;
CardSearchModel *cardSearchModel;
CardCompleterProxyModel *cardProxyModel;
QComboBox *providerComboBox;
QComboBox *modeBox;
QLineEdit *reasonEdit;
QPushButton *addBtn;
QPushButton *removeBtn;
QPushButton *refreshBtn;
QTableView *table;
CardArtRulesModel *tableModel;
};
#endif // COCKATRICE_DLG_CARD_ART_RULES_H

View file

@ -49,10 +49,25 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor,
QMap<int, GameTypeMap> tempMap;
tempMap.insert(info.room_id(), gameTypes);
gameSelector = new GameSelector(client, tabSupervisor, this, QMap<int, QString>(), 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);
}

View file

@ -56,7 +56,9 @@ private:
QMap<int, QString> gameTypes;
GameSelector *gameSelector;
UserListWidget *friendsList;
UserListWidget *userList;
UserListWidget *ignoreList;
const UserListProxy *userListProxy;
ChatView *chatView;
QLabel *sayLabel;

View file

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

View file

@ -24,6 +24,7 @@
#include <QProxyStyle>
#include <QTabWidget>
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<int, TabRoom *> roomTabs;
QMap<int, TabGame *> 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);

View file

@ -6,6 +6,7 @@
<xs:attribute type="xs:string" name="exclude" use="optional" />
<xs:attribute type="xs:string" name="attach" use="optional" />
<xs:attribute type="xs:string" name="persistent" use="optional" />
<xs:attribute type="xs:string" name="facedown" use="optional"/>
</xs:extension>
</xs:simpleContent>
</xs:complexType>

View file

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

View file

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

View file

@ -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)
{
}
}

View file

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

View file

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

View file

@ -64,6 +64,7 @@ public:
QString &clientid,
QString &clientVersion,
QString &connectionType);
void broadcastUserInfoUpdate(Server_ProtocolHandler *source);
const QMap<int, Server_Room *> &getRooms()
{

View file

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

View file

@ -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,29 @@ 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 card_provider_id = 2;
optional string mode = 3; // "ALLOW" or "DENY"
optional string reason = 4;
}
message Command_RemoveCardArtRule {
extend ModeratorCommand {
optional Command_RemoveCardArtRule ext = 1011;
}
optional string card_name = 1;
optional string card_provider_id = 2;
}
message Command_ListCardArtRules {
extend ModeratorCommand {
optional Command_ListCardArtRules ext = 1012;
}
}

View file

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

View file

@ -0,0 +1,16 @@
syntax = "proto2";
import "response.proto";
message Response_CardArtRuleEntry {
optional string card_name = 1;
optional string card_provider_id = 2;
optional string mode = 3;
optional string reason = 4;
}
message Response_ListCardArtRules {
extend Response {
optional Response_ListCardArtRules ext = 1200;
}
repeated Response_CardArtRuleEntry entries = 1;
}

View file

@ -1,4 +1,5 @@
syntax = "proto2";
message ServerInfo_User {
enum UserLevelFlag {
IsNothing = 0;
@ -12,6 +13,14 @@ message ServerInfo_User {
optional string left_side = 1;
optional string right_side = 2;
};
message CardArtParams {
optional string card_name = 1;
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;
optional uint32 user_level = 2;
@ -28,4 +37,5 @@ message ServerInfo_User {
optional string clientid = 13;
optional string privlevel = 14;
optional PawnColorsOverride pawn_colors = 15;
}
optional CardArtParams card_art_params = 16;
}

View file

@ -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,15 @@ message Command_RequestPasswordSalt {
}
required string user_name = 1;
}
message Command_SetCardArtParams {
extend SessionCommand {
optional Command_SetCardArtParams ext = 1025;
}
optional string card_name = 1;
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;
}

View file

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

View file

@ -1,3 +1,6 @@
#ifndef COCKATRICE_DAYS_YEARS_BETWEEN_H
#define COCKATRICE_DAYS_YEARS_BETWEEN_H
#include <QDateTime>
inline static QPair<int, int> getDaysAndYearsBetween(const QDate &then, const QDate &now)
@ -6,3 +9,5 @@ inline static QPair<int, int> getDaysAndYearsBetween(const QDate &then, const QD
int days = then.addYears(years).daysTo(now);
return {days, years};
}
#endif // COCKATRICE_DAYS_YEARS_BETWEEN_H

View file

@ -1,5 +1,6 @@
#ifndef COCKATRICE_QT_UTILS_H
#define COCKATRICE_QT_UTILS_H
#include <QLayout>
#include <QObject>
namespace QtUtils

View file

@ -0,0 +1,19 @@
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,
`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_provider_card_name` (`card_provider_id`, `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;

View file

@ -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,19 @@ 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,
`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_provider_card_name` (`card_provider_id`, `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;

View file

@ -7,6 +7,8 @@
#include <QChar>
#include <QDateTime>
#include <QDebug>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QSqlError>
#include <QSqlQuery>
@ -681,6 +683,33 @@ 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("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));
}
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 +727,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)) {

View file

@ -10,7 +10,7 @@
#include <server.h>
#include <server_database_interface.h>
#define DATABASE_SCHEMA_VERSION 34
#define DATABASE_SCHEMA_VERSION 35
class Servatrice;

View file

@ -31,6 +31,8 @@
#include <QDateTime>
#include <QDebug>
#include <QHostAddress>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLoggingCategory>
#include <QRegularExpression>
#include <QSqlError>
@ -59,8 +61,10 @@
#include <libcockatrice/protocol/pb/event_replay_added.pb.h>
#include <libcockatrice/protocol/pb/event_server_identification.pb.h>
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
#include <libcockatrice/protocol/pb/event_user_joined.pb.h>
#include <libcockatrice/protocol/pb/event_user_message.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_card_art_rule_entry.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
@ -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,173 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountImage(const Comm
return Response::RespOk;
}
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 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;
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());
const QString cardProviderId = QString::fromStdString(cmd.card_provider_id());
if (cardName.length() > MAX_NAME_LENGTH || cardProviderId.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, cardProviderId)) {
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["card_provider_id"] = cardProviderId;
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_card_provider_id(cmd.card_provider_id());
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 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 || cardProviderId.length() > MAX_NAME_LENGTH) {
return Response::RespInvalidData;
}
QSqlQuery *q = sqlInterface->prepareQuery("INSERT INTO {prefix}_card_art_name_rules "
"(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()));
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());
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 AND card_provider_id=:provider");
q->bindValue(":name", cardName);
q->bindValue(":provider", cardProviderId);
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, card_provider_id, 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_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);
return Response::RespOk;
}
Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const Command_AccountPassword &cmd,
ResponseContainer & /* rc */)
{

View file

@ -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, 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 &);
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);